@@ -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" )
0 commit comments