Skip to content

Commit e969b19

Browse files
committed
Lots of UX and UI fixes and tweaks for notifications and user permissions
1 parent f6620bb commit e969b19

5 files changed

Lines changed: 224 additions & 74 deletions

File tree

backend/cleanup_owner_shares.py

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
#!/usr/bin/env python3
2+
"""
3+
Cleanup script to remove owner share records that were incorrectly created before the bug fixes.
4+
Owners should only be identified by room.ownerId, not by share records in shares_coll.
5+
6+
This script:
7+
1. Finds all rooms
8+
2. For each room, checks if the owner has a share record
9+
3. Deletes any owner share records found
10+
4. Reports statistics
11+
"""
12+
13+
import sys
14+
from services.db import rooms_coll, shares_coll
15+
from bson import ObjectId
16+
17+
def cleanup_owner_shares():
18+
"""Remove any share records where userId matches the room's ownerId."""
19+
20+
print("=" * 60)
21+
print("Cleanup Owner Share Records")
22+
print("=" * 60)
23+
print()
24+
25+
# Get all rooms
26+
rooms = list(rooms_coll.find({}))
27+
print(f"Found {len(rooms)} total rooms")
28+
print()
29+
30+
total_deleted = 0
31+
rooms_affected = []
32+
33+
for room in rooms:
34+
room_id = str(room['_id'])
35+
owner_id = room.get('ownerId')
36+
room_name = room.get('name', 'Unnamed')
37+
38+
if not owner_id:
39+
print(f"⚠️ Room '{room_name}' ({room_id}) has no ownerId, skipping")
40+
continue
41+
42+
# Check if owner has a share record
43+
owner_shares = list(shares_coll.find({
44+
'roomId': room_id,
45+
'userId': owner_id
46+
}))
47+
48+
if owner_shares:
49+
print(f"🔍 Room '{room_name}' ({room_id})")
50+
print(f" Owner: {owner_id}")
51+
print(f" Found {len(owner_shares)} owner share record(s) to delete")
52+
53+
# Delete the owner share records
54+
result = shares_coll.delete_many({
55+
'roomId': room_id,
56+
'userId': owner_id
57+
})
58+
59+
deleted_count = result.deleted_count
60+
total_deleted += deleted_count
61+
rooms_affected.append(room_name)
62+
63+
print(f" ✅ Deleted {deleted_count} owner share record(s)")
64+
print()
65+
66+
print()
67+
print("=" * 60)
68+
print("Cleanup Summary")
69+
print("=" * 60)
70+
print(f"Total rooms checked: {len(rooms)}")
71+
print(f"Rooms with owner shares: {len(rooms_affected)}")
72+
print(f"Total owner shares deleted: {total_deleted}")
73+
74+
if rooms_affected:
75+
print()
76+
print("Affected rooms:")
77+
for room_name in rooms_affected:
78+
print(f" - {room_name}")
79+
80+
print()
81+
print("✅ Cleanup complete!")
82+
print()
83+
84+
return total_deleted
85+
86+
if __name__ == '__main__':
87+
try:
88+
deleted = cleanup_owner_shares()
89+
sys.exit(0)
90+
except Exception as e:
91+
print(f"\n❌ Error during cleanup: {e}")
92+
import traceback
93+
traceback.print_exc()
94+
sys.exit(1)

backend/routes/export.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -300,9 +300,12 @@ def import_canvas(room_id):
300300

301301
# Check member permissions
302302
user_id = str(user['_id'])
303-
member = shares_coll.find_one({"roomId": str(room["_id"]), "userId": user_id})
304-
if member and member.get("role") == "viewer":
305-
return jsonify({"status": "error", "message": "Viewers cannot import data"}), 403
303+
is_owner = room.get("ownerId") == user_id
304+
# Owners can always import, check viewer role for members only
305+
if not is_owner:
306+
member = shares_coll.find_one({"roomId": str(room["_id"]), "userId": user_id})
307+
if member and member.get("role") == "viewer":
308+
return jsonify({"status": "error", "message": "Viewers cannot import data"}), 403
306309

307310
# Import configuration
308311
clear_existing = data.get("clearExisting", False)

backend/routes/rooms.py

Lines changed: 112 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
88105
def _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

Comments
 (0)