Skip to content

Commit aa82f61

Browse files
committed
Better UX for cut paste and undo redo
1 parent 7b22efd commit aa82f61

File tree

5 files changed

+616
-97
lines changed

5 files changed

+616
-97
lines changed

backend/routes/rooms.py

Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -885,6 +885,197 @@ def post_stroke(roomId):
885885

886886
return jsonify({"status":"ok"})
887887

888+
@rooms_bp.route("/rooms/<roomId>/strokes/batch", methods=["POST"])
889+
@require_auth
890+
@require_room_access(room_id_param="roomId")
891+
@limiter.limit(f"{RATE_LIMIT_STROKE_MINUTE}/minute")
892+
def post_strokes_batch(roomId):
893+
"""
894+
Add multiple strokes to a room's canvas in a single request.
895+
Optimized for paste operations to reduce network overhead.
896+
897+
Server-side enforcement:
898+
- Authentication required via @require_auth
899+
- Room access required via @require_room_access
900+
- Viewer role cannot post strokes
901+
- Secure rooms require wallet signature for each stroke
902+
- Private/secure rooms encrypt stroke data
903+
"""
904+
user = g.current_user
905+
claims = g.token_claims
906+
room = g.current_room
907+
908+
# Check if user is a viewer (owners are never viewers)
909+
if _user_is_viewer(room, claims["sub"]):
910+
return jsonify({"status":"error","message":"Forbidden: viewers cannot modify the canvas"}), 403
911+
912+
payload = request.get_json() or {}
913+
strokes = payload.get("strokes", [])
914+
915+
if not isinstance(strokes, list):
916+
return jsonify({"status":"error","message":"strokes must be an array"}), 400
917+
918+
if len(strokes) == 0:
919+
return jsonify({"status":"ok", "processed": 0})
920+
921+
if len(strokes) > 200:
922+
return jsonify({"status":"error","message":"Maximum 200 strokes per batch"}), 400
923+
924+
logger.info(f"Processing batch of {len(strokes)} strokes for room {roomId}")
925+
926+
processed_count = 0
927+
failed_count = 0
928+
errors = []
929+
930+
# Get room key once for encrypted rooms
931+
room_key = None
932+
if room["type"] in ("private", "secure"):
933+
if not room.get("wrappedKey"):
934+
try:
935+
enc_count = strokes_coll.count_documents({"roomId": roomId, "$or": [{"blob": {"$exists": True}}, {"asset.data.encrypted": {"$exists": True}}]})
936+
except Exception:
937+
enc_count = 0
938+
939+
if enc_count == 0:
940+
try:
941+
raw = os.urandom(32)
942+
wrapped_new = wrap_room_key(raw)
943+
rooms_coll.update_one({"_id": room["_id"]}, {"$set": {"wrappedKey": wrapped_new}})
944+
room["wrappedKey"] = wrapped_new
945+
logger.info("post_strokes_batch: auto-created wrappedKey for room %s", roomId)
946+
except Exception as e:
947+
logger.exception("post_strokes_batch: failed to auto-create wrappedKey: %s", e)
948+
return jsonify({"status": "error", "message": "Failed to create room encryption key"}), 500
949+
950+
try:
951+
room_key = unwrap_room_key(room["wrappedKey"])
952+
except Exception as e:
953+
logger.exception("post_strokes_batch: failed to unwrap room key: %s", e)
954+
return jsonify({"status": "error", "message": "Invalid room encryption key"}), 500
955+
956+
for idx, stroke in enumerate(strokes):
957+
try:
958+
# Add default fields
959+
stroke["roomId"] = roomId
960+
stroke["user"] = claims["username"]
961+
962+
if "ts" not in stroke:
963+
stroke["ts"] = int(time.time() * 1000) + idx
964+
965+
# Ensure metadata fields
966+
if "brushType" not in stroke or stroke["brushType"] is None:
967+
stroke["brushType"] = "normal"
968+
if "brushParams" not in stroke or stroke["brushParams"] is None:
969+
stroke["brushParams"] = {}
970+
if "metadata" not in stroke or stroke["metadata"] is None:
971+
stroke["metadata"] = {
972+
"brushStyle": stroke.get("brushStyle", "round"),
973+
"brushType": stroke.get("brushType", "normal"),
974+
"brushParams": stroke.get("brushParams", {}),
975+
"drawingType": stroke.get("drawingType", "stroke")
976+
}
977+
978+
if "drawingId" in stroke and "id" not in stroke:
979+
stroke["id"] = stroke["drawingId"]
980+
elif "id" not in stroke and "drawingId" not in stroke:
981+
stroke["id"] = f"stroke_{stroke['ts']}_{claims['username']}_{idx}"
982+
983+
# Handle secure room signatures
984+
if room["type"] == "secure":
985+
sig = stroke.get("signature") or payload.get("signature")
986+
spk = stroke.get("signerPubKey") or payload.get("signerPubKey")
987+
if not (sig and spk):
988+
errors.append(f"Stroke {idx}: Signature required for secure room")
989+
failed_count += 1
990+
continue
991+
992+
try:
993+
import nacl.signing, nacl.encoding
994+
vk = nacl.signing.VerifyKey(spk, encoder=nacl.encoding.HexEncoder)
995+
msg_data = {
996+
"roomId": roomId, "user": stroke["user"], "color": stroke["color"],
997+
"lineWidth": stroke["lineWidth"], "pathData": stroke["pathData"],
998+
"timestamp": stroke.get("timestamp", stroke["ts"])
999+
}
1000+
msg = json.dumps(msg_data, separators=(',', ':'), sort_keys=True).encode()
1001+
vk.verify(msg, bytes.fromhex(sig))
1002+
stroke["walletSignature"] = sig
1003+
stroke["walletPubKey"] = spk
1004+
except Exception as e:
1005+
logger.error(f"Batch stroke {idx} signature verification failed: {str(e)}")
1006+
errors.append(f"Stroke {idx}: Bad signature")
1007+
failed_count += 1
1008+
continue
1009+
1010+
# Store stroke
1011+
if room["type"] in ("private", "secure"):
1012+
enc = encrypt_for_room(room_key, json.dumps(stroke).encode())
1013+
asset_data = {"roomId": roomId, "type": room["type"], "encrypted": enc}
1014+
strokes_coll.insert_one({"roomId": roomId, "ts": stroke["ts"], "blob": enc})
1015+
else:
1016+
asset_data = {"roomId": roomId, "type": "public", "stroke": stroke}
1017+
strokes_coll.insert_one({"roomId": roomId, "ts": stroke["ts"], "stroke": stroke})
1018+
1019+
# Cache in Redis
1020+
try:
1021+
stroke_cache_key = f"stroke:{roomId}:{stroke['id']}"
1022+
stroke_cache_value = {
1023+
"id": stroke["id"],
1024+
"roomId": roomId,
1025+
"ts": stroke["ts"],
1026+
"user": stroke["user"],
1027+
"stroke": stroke,
1028+
"undone": False
1029+
}
1030+
redis_client.set(stroke_cache_key, json.dumps(stroke_cache_value))
1031+
except Exception as e:
1032+
logger.warning(f"Failed to cache stroke {idx} in Redis: {e}")
1033+
1034+
# Commit to ResilientDB
1035+
prep = {
1036+
"operation": "CREATE",
1037+
"amount": 1,
1038+
"signerPublicKey": SIGNER_PUBLIC_KEY,
1039+
"signerPrivateKey": SIGNER_PRIVATE_KEY,
1040+
"recipientPublicKey": RECIPIENT_PUBLIC_KEY,
1041+
"asset": {"data": asset_data}
1042+
}
1043+
commit_transaction_via_graphql(prep)
1044+
1045+
# Update undo stack if not skipped
1046+
skip_undo_stack = payload.get("skipUndoStack", False) or stroke.get("skipUndoStack", False)
1047+
if not skip_undo_stack:
1048+
key_base = f"room:{roomId}:{claims['sub']}"
1049+
redis_client.lpush(f"{key_base}:undo", json.dumps(stroke))
1050+
redis_client.delete(f"{key_base}:redo")
1051+
1052+
processed_count += 1
1053+
1054+
except Exception as e:
1055+
logger.exception(f"Failed to process batch stroke {idx}: {e}")
1056+
errors.append(f"Stroke {idx}: {str(e)}")
1057+
failed_count += 1
1058+
1059+
# Update room timestamp
1060+
rooms_coll.update_one({"_id": room["_id"]}, {"$set": {"updatedAt": datetime.utcnow()}})
1061+
1062+
# Broadcast batch completion
1063+
push_to_room(roomId, "batch_strokes_added", {
1064+
"roomId": roomId,
1065+
"count": processed_count,
1066+
"user": claims["username"],
1067+
"timestamp": int(time.time() * 1000)
1068+
})
1069+
1070+
logger.info(f"Batch complete: {processed_count} processed, {failed_count} failed")
1071+
1072+
return jsonify({
1073+
"status": "ok" if failed_count == 0 else "partial",
1074+
"processed": processed_count,
1075+
"failed": failed_count,
1076+
"errors": errors[:10] # Limit error list
1077+
})
1078+
8881079
@rooms_bp.route("/rooms/<roomId>/strokes", methods=["GET"])
8891080
@require_auth
8901081
@require_room_access(room_id_param="roomId")

frontend/src/api/rooms.js

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,30 @@ export async function postRoomStroke(token, roomId, stroke, signature, signerPub
146146
return await handleApiResponse(r);
147147
}
148148

149+
/**
150+
* Submit multiple strokes in a single batch request
151+
* Backend: POST /rooms/{id}/strokes/batch
152+
* Middleware: @require_auth + @require_room_access
153+
* Optimized for paste operations to reduce network overhead
154+
* Max 200 strokes per batch
155+
*/
156+
export async function postRoomStrokesBatch(token, roomId, strokes, options = {}) {
157+
const headers = withTK({ "Content-Type": "application/json", ...(token ? { Authorization: `Bearer ${token}` } : {}) });
158+
const body = {
159+
strokes,
160+
skipUndoStack: options.skipUndoStack || false
161+
};
162+
if (options.signature) body.signature = options.signature;
163+
if (options.signerPubKey) body.signerPubKey = options.signerPubKey;
164+
165+
const r = await authFetch(`${API_BASE}/rooms/${roomId}/strokes/batch`, {
166+
method: "POST",
167+
headers,
168+
body: JSON.stringify(body)
169+
});
170+
return await handleApiResponse(r);
171+
}
172+
149173
export async function undoRoomAction(token, roomId) {
150174
const r = await authFetch(`${API_BASE}/rooms/${roomId}/undo`, {
151175
method: "POST",

0 commit comments

Comments
 (0)