@@ -85,6 +85,23 @@ def _ensure_member(user_id:str, room):
8585 return False
8686 return False
8787
88+ def _user_is_viewer (room , user_id ):
89+ """
90+ Check if a user has viewer-only permissions in a room.
91+ Owners always return False (never viewers).
92+ Only non-owner members with role='viewer' return True.
93+ """
94+ # Owners are never viewers
95+ if room .get ("ownerId" ) == user_id :
96+ return False
97+
98+ # Check if member has viewer role
99+ try :
100+ share = shares_coll .find_one ({"roomId" : str (room ["_id" ]), "$or" : [{"userId" : user_id }, {"username" : user_id }]})
101+ return share and share .get ("role" ) == "viewer"
102+ except Exception :
103+ return False
104+
88105def _notification_allowed_for (user_identifier , ntype : str ):
89106 """Check the user's notification preferences. user_identifier may be a userId (string) or username.
90107 If the user has no preferences saved, default to allowing all notifications.
@@ -150,16 +167,10 @@ def create_room():
150167 }
151168 rooms_coll .insert_one (room )
152169
153- shares_coll .update_one (
154- {"roomId" : str (room ["_id" ]), "userId" : claims ["sub" ]},
155- {"$set" : {
156- "roomId" : str (room ["_id" ]),
157- "userId" : claims ["sub" ],
158- "username" : claims ["username" ],
159- "role" :"owner"
160- }},
161- upsert = True
162- )
170+ # Note: Owners do NOT have share records in shares_coll
171+ # Only shared members (non-owners) have records in shares_coll
172+ # The owner is identified by room.ownerId field only
173+
163174 return jsonify ({
164175 "status" :"ok" ,
165176 "room" :{
@@ -245,8 +256,18 @@ def list_rooms():
245256 pipeline .append ({"$match" : match })
246257 pipeline .append ({"$addFields" : {"_id_str" : {"$toString" : "$_id" }}})
247258 pipeline .append ({"$lookup" : {"from" : shares_coll .name , "localField" : "_id_str" , "foreignField" : "roomId" , "as" : "members" }})
248- # memberCount = 1 (owner) + number of shared members
249- pipeline .append ({"$addFields" : {"memberCount" : {"$add" : [1 , {"$size" : {"$ifNull" : ["$members" , []]}}]}}})
259+ # memberCount = 1 (owner) + number of non-owner shared members
260+ # Filter out any owner share records (shouldn't exist but may for old rooms created before fixes)
261+ pipeline .append ({"$addFields" : {
262+ "nonOwnerMembers" : {
263+ "$filter" : {
264+ "input" : "$members" ,
265+ "as" : "member" ,
266+ "cond" : {"$ne" : ["$$member.userId" , "$ownerId" ]}
267+ }
268+ }
269+ }})
270+ pipeline .append ({"$addFields" : {"memberCount" : {"$add" : [1 , {"$size" : {"$ifNull" : ["$nonOwnerMembers" , []]}}]}}})
250271
251272 sort_map = {
252273 'updatedAt' : ('updatedAt' , - 1 ),
@@ -326,8 +347,13 @@ def list_rooms():
326347 shared = list (rooms_coll .find ({"_id" : {"$in" : oids }, "archived" : {"$ne" : True }}))
327348 def _fmt_single (r ):
328349 rid = str (r ["_id" ])
329- # Count includes owner (1) + all shared members
330- member_count = 1 + shares_coll .count_documents ({"roomId" : rid })
350+ owner_id = r .get ("ownerId" )
351+ # Count includes owner (1) + non-owner shared members
352+ # Filter out any legacy owner share records
353+ share_filter = {"roomId" : rid }
354+ if owner_id :
355+ share_filter ["userId" ] = {"$ne" : owner_id }
356+ member_count = 1 + shares_coll .count_documents (share_filter )
331357 my_role = None
332358 try :
333359 if str (r .get ("ownerId" )) == claims ["sub" ]:
@@ -408,9 +434,14 @@ def suggest_rooms():
408434 rooms = []
409435 for r in cursor :
410436 rid = str (r .get ("_id" ))
437+ owner_id = r .get ("ownerId" )
411438 try :
412- # Count includes owner (1) + all shared members
413- member_count = 1 + shares_coll .count_documents ({"roomId" : rid })
439+ # Count includes owner (1) + non-owner shared members
440+ # Filter out any legacy owner share records
441+ share_filter = {"roomId" : rid }
442+ if owner_id :
443+ share_filter ["userId" ] = {"$ne" : owner_id }
444+ member_count = 1 + shares_coll .count_documents (share_filter )
414445 except Exception :
415446 member_count = 1 # At least the owner
416447 rooms .append ({
@@ -688,12 +719,9 @@ def post_stroke(roomId):
688719 claims = g .token_claims
689720 room = g .current_room
690721
691- try :
692- share = shares_coll .find_one ({"roomId" : str (room ["_id" ]), "$or" : [{"userId" : claims ["sub" ]}, {"username" : claims ["sub" ]}]})
693- if share and share .get ("role" ) == "viewer" :
694- return jsonify ({"status" :"error" ,"message" :"Forbidden: viewers cannot modify the canvas" }), 403
695- except Exception :
696- pass
722+ # Check if user is a viewer (owners are never viewers)
723+ if _user_is_viewer (room , claims ["sub" ]):
724+ return jsonify ({"status" :"error" ,"message" :"Forbidden: viewers cannot modify the canvas" }), 403
697725
698726 payload = g .validated_data
699727 stroke = payload ["stroke" ]
@@ -1479,12 +1507,9 @@ def room_undo(roomId):
14791507
14801508 user_id = claims ['sub' ]
14811509
1482- try :
1483- share = shares_coll .find_one ({"roomId" : roomId , "$or" : [{"userId" : user_id }, {"username" : user_id }]})
1484- if share and share .get ('role' ) == 'viewer' :
1485- return jsonify ({"status" :"error" ,"message" :"Forbidden: viewers cannot perform undo" }), 403
1486- except Exception :
1487- pass
1510+ # Check if user is a viewer (owners are never viewers)
1511+ if _user_is_viewer (room , user_id ):
1512+ return jsonify ({"status" :"error" ,"message" :"Forbidden: viewers cannot perform undo" }), 403
14881513 key_base = f"room:{ roomId } :{ user_id } "
14891514 logger .info (f"Using key_base: { key_base } for user { user_id } " )
14901515
@@ -1623,12 +1648,9 @@ def mark_strokes_undone(roomId):
16231648 room = g .current_room
16241649 user_id = claims ['sub' ]
16251650
1626- try :
1627- share = shares_coll .find_one ({"roomId" : roomId , "$or" : [{"userId" : user_id }, {"username" : user_id }]})
1628- if share and share .get ('role' ) == 'viewer' :
1629- return jsonify ({"status" :"error" ,"message" :"Forbidden: viewers cannot mark strokes as undone" }), 403
1630- except Exception :
1631- pass
1651+ # Check if user is a viewer (owners are never viewers)
1652+ if _user_is_viewer (room , user_id ):
1653+ return jsonify ({"status" :"error" ,"message" :"Forbidden: viewers cannot mark strokes as undone" }), 403
16321654
16331655 # Get stroke IDs from request
16341656 data = request .get_json () or {}
@@ -1712,12 +1734,9 @@ def room_redo(roomId):
17121734
17131735 user_id = claims ['sub' ]
17141736
1715- try :
1716- share = shares_coll .find_one ({"roomId" : roomId , "$or" : [{"userId" : user_id }, {"username" : user_id }]})
1717- if share and share .get ('role' ) == 'viewer' :
1718- return jsonify ({"status" :"error" ,"message" :"Forbidden: viewers cannot perform redo" }), 403
1719- except Exception :
1720- pass
1737+ # Check if user is a viewer (owners are never viewers)
1738+ if _user_is_viewer (room , user_id ):
1739+ return jsonify ({"status" :"error" ,"message" :"Forbidden: viewers cannot perform redo" }), 403
17211740
17221741 key_base = f"room:{ roomId } :{ user_id } "
17231742
@@ -1815,12 +1834,9 @@ def reset_my_stacks(roomId):
18151834
18161835 user_id = claims ['sub' ]
18171836
1818- try :
1819- share = shares_coll .find_one ({"roomId" : roomId , "$or" : [{"userId" : user_id }, {"username" : user_id }]})
1820- if share and share .get ('role' ) == 'viewer' :
1821- return jsonify ({"status" :"error" ,"message" :"Forbidden: viewers cannot reset stacks" }), 403
1822- except Exception :
1823- pass
1837+ # Check if user is a viewer (owners are never viewers)
1838+ if _user_is_viewer (room , user_id ):
1839+ return jsonify ({"status" :"error" ,"message" :"Forbidden: viewers cannot reset stacks" }), 403
18241840 key_base = f"room:{ roomId } :{ user_id } "
18251841 try :
18261842 redis_client .delete (f"{ key_base } :undo" )
@@ -1854,12 +1870,10 @@ def room_clear(roomId):
18541870 return jsonify ({"status" :"error" ,"message" :"Room not found" }), 404
18551871 if not _ensure_member (claims ["sub" ], room ):
18561872 return jsonify ({"status" :"error" ,"message" :"Forbidden" }), 403
1857- try :
1858- share = shares_coll .find_one ({"roomId" : str (room ["_id" ]), "$or" : [{"userId" : claims ["sub" ]}, {"username" : claims ["sub" ]}]})
1859- if share and share .get ('role' ) == 'viewer' :
1860- return jsonify ({"status" :"error" ,"message" :"Forbidden: viewers cannot clear the canvas" }), 403
1861- except Exception :
1862- pass
1873+
1874+ # Check if user is a viewer (owners are never viewers)
1875+ if _user_is_viewer (room , claims ["sub" ]):
1876+ return jsonify ({"status" :"error" ,"message" :"Forbidden: viewers cannot clear the canvas" }), 403
18631877
18641878 cleared_at = int (time .time () * 1000 )
18651879
@@ -2313,15 +2327,10 @@ def update_room(roomId):
23132327 return jsonify ({"status" :"error" ,"message" :"No valid fields to update" }), 400
23142328 updates ["updatedAt" ] = datetime .utcnow ()
23152329 rooms_coll .update_one ({"_id" : ObjectId (roomId )}, {"$set" : updates })
2316- try :
2317- if updates .get ("type" ) in ("private" , "secure" ):
2318- shares_coll .update_one (
2319- {"roomId" : str (room ["_id" ]), "userId" : room ["ownerId" ]},
2320- {"$set" : {"roomId" : str (room ["_id" ]), "userId" : room ["ownerId" ], "username" : room .get ("ownerName" , updates .get ("ownerName" )), "role" : "owner" }},
2321- upsert = True
2322- )
2323- except Exception :
2324- logger .exception ("Failed to ensure owner membership after room type change" )
2330+
2331+ # Note: Owners do NOT have share records in shares_coll, even for private/secure rooms
2332+ # The owner is identified by room.ownerId field only
2333+
23252334 try :
23262335 room_refreshed = rooms_coll .find_one ({"_id" : ObjectId (roomId )})
23272336 resp_room = {
@@ -2588,6 +2597,37 @@ def delete_room(roomId):
25882597 redis_client .delete (cut_set_key )
25892598 except Exception :
25902599 pass
2600+
2601+ # Clean up actual stroke data keys (res-canvas-draw-*) that belong to this room
2602+ try :
2603+ all_stroke_keys = redis_client .keys ("res-canvas-draw-*" )
2604+ room_stroke_keys = []
2605+ for key in all_stroke_keys :
2606+ if isinstance (key , bytes ):
2607+ key = key .decode ()
2608+ try :
2609+ val = redis_client .get (key )
2610+ if val :
2611+ if isinstance (val , bytes ):
2612+ val = val .decode ()
2613+ import json
2614+ val_json = json .loads (val )
2615+ if val_json .get ("roomId" ) == rid :
2616+ room_stroke_keys .append (key )
2617+ except Exception :
2618+ pass
2619+
2620+ if room_stroke_keys :
2621+ redis_client .delete (* room_stroke_keys )
2622+ logger .info (f"delete_room: Cleared { len (room_stroke_keys )} Redis stroke keys for room { rid } " )
2623+ except Exception :
2624+ logger .exception ("Failed to cleanup stroke keys for room %s" , rid )
2625+
2626+ # Clean up clear timestamp and related markers
2627+ try :
2628+ redis_client .delete (f"last-clear-ts:{ rid } " )
2629+ except Exception :
2630+ pass
25912631 except Exception :
25922632 logger .exception ("Failed to cleanup redis keys for room %s" , rid )
25932633
@@ -2624,12 +2664,9 @@ def invite_user(roomId):
26242664 claims = g .token_claims
26252665 room = g .current_room
26262666
2627- room = rooms_coll .find_one ({"_id" : ObjectId (roomId )})
2628- if not room :
2629- return jsonify ({"status" :"error" ,"message" :"Room not found" }), 404
2630- inviter_share = shares_coll .find_one ({"roomId" : str (room ["_id" ]), "userId" : claims ["sub" ]})
2631- if not inviter_share or inviter_share .get ("role" ) not in ("owner" , "admin" ):
2632- return jsonify ({"status" :"error" ,"message" :"Forbidden" }), 403
2667+ # @require_room_owner already verified ownership
2668+ # No need for redundant permission check
2669+
26332670 data = request .get_json () or {}
26342671 invited_username = (data .get ("username" ) or "" ).strip ()
26352672 role = (data .get ("role" ) or "editor" ).lower ()
@@ -2695,6 +2732,14 @@ def accept_invite(inviteId):
26952732 return jsonify ({"status" :"error" ,"message" :"Forbidden" }), 403
26962733 if inv .get ("status" ) != "pending" :
26972734 return jsonify ({"status" :"error" ,"message" :"Invite not pending" }), 400
2735+
2736+ # Defensive check: Ensure we don't create share records for room owners
2737+ room = rooms_coll .find_one ({"_id" : ObjectId (inv ["roomId" ])})
2738+ if room and str (room .get ("ownerId" )) == inv ["invitedUserId" ]:
2739+ # Owner doesn't need a share record - they already have full access
2740+ invites_coll .update_one ({"_id" : ObjectId (inviteId )}, {"$set" : {"status" :"accepted" , "respondedAt" : datetime .utcnow ()}})
2741+ return jsonify ({"status" :"ok" ,"message" :"Invite accepted (owner has full access)" ,"roomId" : inv ["roomId" ]})
2742+
26982743 shares_coll .update_one (
26992744 {"roomId" : inv ["roomId" ], "userId" : inv ["invitedUserId" ]},
27002745 {"$set" : {"roomId" : inv ["roomId" ], "userId" : inv ["invitedUserId" ], "username" : inv ["invitedUsername" ], "role" : inv ["role" ]}},
0 commit comments