From af19566309bd21ef1fce25276914b117f65c7d18 Mon Sep 17 00:00:00 2001 From: dev Date: Fri, 25 Sep 2026 02:24:38 +0000 Subject: [PATCH] Update routes.py --- routes.py | 1120 ++++++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 1031 insertions(+), 89 deletions(-) diff --git a/routes.py b/routes.py index e1cdd0f..dcd4f3a 100644 --- a/routes.py +++ b/routes.py @@ -14,11 +14,8 @@ from features import ( TEAM_MEMBER_ROLES, THEME_MODES, append_call_signal, - attachment_payload_from_row, call_payload, channel_presence_count, - cleanup_orphaned_attachments, - create_call_session, dm_payload, dm_presence_count, end_call, @@ -62,6 +59,10 @@ def register_routes(app, ctx): normalize_username = ctx["normalize_username"] audit = ctx["audit"] hash_identifier = ctx["hash_identifier"] + sign_call_helper_token = ctx["sign_call_helper_token"] + CALLS_BASE = ctx["CALLS_BASE"] + CALL_TOKEN_TTL_SECONDS = ctx["CALL_TOKEN_TTL_SECONDS"] + CALL_HELPER_SHARED_SECRET = ctx["CALL_HELPER_SHARED_SECRET"] room_by_id = ctx["room_by_id"] get_room_by_slug = ctx["get_room_by_slug"] can_access_room = ctx["can_access_room"] @@ -84,6 +85,7 @@ def register_routes(app, ctx): team_payload = ctx["team_payload"] channel_payload = ctx["channel_payload"] user_payload = ctx["user_payload"] + get_dm_other_user = ctx["get_dm_other_user"] MESSAGE_SELECT = ctx["MESSAGE_SELECT"] team_page_select = ctx["team_page_select"] constants = ctx["constants"] @@ -110,6 +112,7 @@ def register_routes(app, ctx): ROOM_VISIBILITIES = constants["ROOM_VISIBILITIES"] ROLES = constants["ROLES"] STATUSES = constants["STATUSES"] + html_module = ctx["html"] def build_message_payload(row, user): @@ -120,6 +123,155 @@ def register_routes(app, ctx): user["id"] if user else None, ) + def build_room_helper_call_id(room) -> str: + return f"room-{room['slug']}" + + def build_channel_helper_call_id(channel) -> str: + return f"channel-{channel['team_slug']}--{channel['slug']}" + + def get_or_create_group_call_session( + scope_kind: str, + scope_id: int, + scope_slug: str, + helper_call_id: str, + created_by_user_id: int, + ): + with get_db() as db: + existing = db.execute( + """ + SELECT + id, + scope_kind, + scope_id, + scope_slug, + helper_call_id, + created_by_user_id, + status, + created_at, + ended_at + FROM group_call_sessions + WHERE helper_call_id = ? + LIMIT 1 + """, + (helper_call_id,), + ).fetchone() + + if existing and existing["status"] == "active": + return existing + + with get_db() as db: + db.execute("BEGIN IMMEDIATE") + existing = db.execute( + """ + SELECT + id, + scope_kind, + scope_id, + scope_slug, + helper_call_id, + created_by_user_id, + status, + created_at, + ended_at + FROM group_call_sessions + WHERE helper_call_id = ? + LIMIT 1 + """, + (helper_call_id,), + ).fetchone() + + if existing and existing["status"] == "active": + db.commit() + return existing + + if existing: + db.execute( + """ + UPDATE group_call_sessions + SET + status = 'active', + ended_at = NULL, + created_at = ? + WHERE id = ? + """, + (now_iso(), existing["id"]), + ) + db.commit() + refreshed = db.execute( + """ + SELECT + id, + scope_kind, + scope_id, + scope_slug, + helper_call_id, + created_by_user_id, + status, + created_at, + ended_at + FROM group_call_sessions + WHERE id = ? + """, + (existing["id"],), + ).fetchone() + return refreshed + + cursor = db.execute( + """ + INSERT INTO group_call_sessions ( + scope_kind, + scope_id, + scope_slug, + helper_call_id, + created_by_user_id, + status, + created_at, + ended_at + ) VALUES (?, ?, ?, ?, ?, 'active', ?, NULL) + """, + ( + scope_kind, + scope_id, + scope_slug, + helper_call_id, + created_by_user_id, + now_iso(), + ), + ) + session_id = cursor.lastrowid + db.commit() + created = db.execute( + """ + SELECT + id, + scope_kind, + scope_id, + scope_slug, + helper_call_id, + created_by_user_id, + status, + created_at, + ended_at + FROM group_call_sessions + WHERE id = ? + """, + (session_id,), + ).fetchone() + return created + + def group_call_session_payload(row): + return { + "id": row["id"], + "scope_kind": row["scope_kind"], + "scope_id": row["scope_id"], + "scope_slug": row["scope_slug"], + "helper_call_id": row["helper_call_id"], + "created_by_user_id": row["created_by_user_id"], + "status": row["status"], + "created_at": row["created_at"], + "ended_at": row["ended_at"], + } + @app.route("/api/attachments", methods=["POST"]) def upload_attachment(): if not csrf_valid(): @@ -237,7 +389,6 @@ def register_routes(app, ctx): after_raw = request.args.get("after_id", "").strip() after_id = 0 - if after_raw: try: after_id = max(0, int(after_raw)) @@ -277,8 +428,13 @@ def register_routes(app, ctx): messages = [build_message_payload(row, user) for row in rows] latest_id = messages[-1]["id"] if messages else after_id - - return jsonify({"messages": messages, "latest_id": latest_id, "has_more": len(messages) == MAX_INCREMENTAL_MESSAGES}) + return jsonify( + { + "messages": messages, + "latest_id": latest_id, + "has_more": len(messages) == MAX_INCREMENTAL_MESSAGES, + } + ) @app.route("/api/rooms//messages", methods=["POST"]) def send_room_message(slug: str): @@ -308,22 +464,31 @@ def register_routes(app, ctx): text = data.get("text", "") if not isinstance(text, str): return jsonify({"error": "Message must be text."}), 400 - text = text.strip() + text = text.strip() if len(text) > MAX_MESSAGE_LENGTH: return jsonify({"error": "Message is too long."}), 400 - attachment_ids, attachment_error = resolve_owned_attachment_ids(get_db, data.get("attachment_ids", []), user["id"]) + attachment_ids, attachment_error = resolve_owned_attachment_ids( + get_db, + data.get("attachment_ids", []), + user["id"], + ) if attachment_error: return attachment_error if not attachment_ids and data.get("attachment_id") is not None: - single_attachment_id, single_attachment_error = resolve_owned_attachment(get_db, data.get("attachment_id"), user["id"]) + single_attachment_id, single_attachment_error = resolve_owned_attachment( + get_db, + data.get("attachment_id"), + user["id"], + ) if single_attachment_error: return single_attachment_error attachment_ids = [single_attachment_id] if single_attachment_id else [] attachment_id = attachment_ids[0] if attachment_ids else None + if not text and not attachment_id: return jsonify({"error": "Message cannot be empty."}), 400 @@ -346,7 +511,15 @@ def register_routes(app, ctx): created_at ) VALUES (?, ?, NULL, ?, ?, ?, ?, NULL, ?) """, - (room["id"], channel_id, user["id"], user["username"], text, attachment_id, now_iso()), + ( + room["id"], + channel_id, + user["id"], + user["username"], + text, + attachment_id, + now_iso(), + ), ) message_id = cursor.lastrowid @@ -413,7 +586,6 @@ def register_routes(app, ctx): after_raw = request.args.get("after_id", "").strip() after_id = 0 - if after_raw: try: after_id = max(0, int(after_raw)) @@ -492,22 +664,31 @@ def register_routes(app, ctx): text = data.get("text", "") if not isinstance(text, str): return jsonify({"error": "Message must be text."}), 400 - text = text.strip() + text = text.strip() if len(text) > MAX_MESSAGE_LENGTH: return jsonify({"error": "Message is too long."}), 400 - attachment_ids, attachment_error = resolve_owned_attachment_ids(get_db, data.get("attachment_ids", []), user["id"]) + attachment_ids, attachment_error = resolve_owned_attachment_ids( + get_db, + data.get("attachment_ids", []), + user["id"], + ) if attachment_error: return attachment_error if not attachment_ids and data.get("attachment_id") is not None: - single_attachment_id, single_attachment_error = resolve_owned_attachment(get_db, data.get("attachment_id"), user["id"]) + single_attachment_id, single_attachment_error = resolve_owned_attachment( + get_db, + data.get("attachment_id"), + user["id"], + ) if single_attachment_error: return single_attachment_error attachment_ids = [single_attachment_id] if single_attachment_id else [] attachment_id = attachment_ids[0] if attachment_ids else None + if not text and not attachment_id: return jsonify({"error": "Message cannot be empty."}), 400 @@ -527,7 +708,15 @@ def register_routes(app, ctx): created_at ) VALUES (?, ?, NULL, ?, ?, ?, ?, NULL, ?) """, - (channel["legacy_room_id"], channel["id"], user["id"], user["username"], text, attachment_id, now_iso()), + ( + channel["legacy_room_id"], + channel["id"], + user["id"], + user["username"], + text, + attachment_id, + now_iso(), + ), ) message_id = cursor.lastrowid @@ -572,9 +761,14 @@ def register_routes(app, ctx): new_text = data.get("text", "") if not isinstance(new_text, str): return jsonify({"error": "Message must be text."}), 400 + new_text = new_text.strip() - attachment_ids, attachment_error = resolve_owned_attachment_ids(get_db, data.get("attachment_ids", []), user["id"]) + attachment_ids, attachment_error = resolve_owned_attachment_ids( + get_db, + data.get("attachment_ids", []), + user["id"], + ) if attachment_error: return attachment_error @@ -592,7 +786,6 @@ def register_routes(app, ctx): is_owner = message["user_id"] == user["id"] is_moderator = user["role"] in {"moderator", "admin"} - if not is_owner and not is_moderator: return jsonify({"error": "You cannot edit this message.", "code": "forbidden"}), 403 @@ -606,7 +799,10 @@ def register_routes(app, ctx): with get_db() as db: db.execute("BEGIN IMMEDIATE") - db.execute("UPDATE messages SET text = ?, attachment_id = ? WHERE id = ?", (new_text, attachment_ids[0] if attachment_ids else None, message_id)) + db.execute( + "UPDATE messages SET text = ?, attachment_id = ? WHERE id = ?", + (new_text, attachment_ids[0] if attachment_ids else None, message_id), + ) db.execute("DELETE FROM message_attachments WHERE message_id = ?", (message_id,)) for position, attachment_id in enumerate(attachment_ids): @@ -729,6 +925,7 @@ def register_routes(app, ctx): """, (channel["id"], before_id, MAX_HISTORY_MESSAGES), ).fetchall() + elif kind == "dm": dm_id_raw = str(request.args.get("dm_id", "")).strip() try: @@ -814,7 +1011,11 @@ def register_routes(app, ctx): if root["direct_conversation_id"]: payload["dm"] = dm_payload(get_db, root["direct_conversation_id"], user["id"]) else: - channel = get_channel_by_id(root["channel_id"]) if root["channel_id"] else get_channel_by_legacy_room_id(root["room_id"]) + channel = ( + get_channel_by_id(root["channel_id"]) + if root["channel_id"] + else get_channel_by_legacy_room_id(root["room_id"]) + ) payload["team"] = {"slug": channel["team_slug"], "name": channel["team_name"]} payload["channel"] = channel_payload(channel, user) @@ -860,7 +1061,6 @@ def register_routes(app, ctx): channel = get_channel_by_id(root["channel_id"]) if not channel and root["room_id"]: channel = get_channel_by_legacy_room_id(root["room_id"]) - if not channel or not can_write_channel(channel, user): return jsonify({"error": "You cannot reply in this thread.", "code": "forbidden"}), 403 @@ -872,22 +1072,31 @@ def register_routes(app, ctx): text = data.get("text", "") if not isinstance(text, str): return jsonify({"error": "Reply must be text."}), 400 - text = text.strip() + text = text.strip() if len(text) > MAX_MESSAGE_LENGTH: return jsonify({"error": "Reply is too long."}), 400 - attachment_ids, attachment_error = resolve_owned_attachment_ids(get_db, data.get("attachment_ids", []), user["id"]) + attachment_ids, attachment_error = resolve_owned_attachment_ids( + get_db, + data.get("attachment_ids", []), + user["id"], + ) if attachment_error: return attachment_error if not attachment_ids and data.get("attachment_id") is not None: - single_attachment_id, single_attachment_error = resolve_owned_attachment(get_db, data.get("attachment_id"), user["id"]) + single_attachment_id, single_attachment_error = resolve_owned_attachment( + get_db, + data.get("attachment_id"), + user["id"], + ) if single_attachment_error: return single_attachment_error attachment_ids = [single_attachment_id] if single_attachment_id else [] attachment_id = attachment_ids[0] if attachment_ids else None + if not text and not attachment_id: return jsonify({"error": "Reply cannot be empty."}), 400 @@ -932,6 +1141,7 @@ def register_routes(app, ctx): """, (reply_id, extra_attachment_id, position), ) + db.commit() with get_db() as db: @@ -1080,11 +1290,19 @@ def register_routes(app, ctx): LIMIT ? """ query_params = [*params, after_id, MAX_FEED_MESSAGES] + with get_db() as db: rows = db.execute(query, tuple(query_params)).fetchall() + entries = [feed_message_payload(get_db, row, user) for row in rows] latest_id = entries[-1]["id"] if entries else after_id - return jsonify({"entries": entries, "latest_id": latest_id, "has_more": len(entries) == MAX_FEED_MESSAGES}) + return jsonify( + { + "entries": entries, + "latest_id": latest_id, + "has_more": len(entries) == MAX_FEED_MESSAGES, + } + ) query = f""" SELECT * @@ -1098,6 +1316,7 @@ def register_routes(app, ctx): ORDER BY id ASC """ query_params = [*params, MAX_FEED_MESSAGES] + with get_db() as db: rows = db.execute(query, tuple(query_params)).fetchall() @@ -1215,9 +1434,9 @@ def register_routes(app, ctx): return jsonify({"status": "ok", "updated": 0}) updated = 0 - with get_db() as db: db.execute("BEGIN IMMEDIATE") + for message_id in parsed_ids: row = db.execute( """ @@ -1257,6 +1476,7 @@ def register_routes(app, ctx): (message_id, user["id"], now_iso()), ) updated += 1 + db.commit() return jsonify({"status": "ok", "updated": updated}) @@ -1312,7 +1532,14 @@ def register_routes(app, ctx): return jsonify( { "message_id": message_id, - "receipts": [{"user_id": row["id"], "username": row["username"], "seen_at": row["seen_at"]} for row in rows], + "receipts": [ + { + "user_id": row["id"], + "username": row["username"], + "seen_at": row["seen_at"], + } + for row in rows + ], } ) @@ -1458,7 +1685,12 @@ def register_routes(app, ctx): ) audit("message_reaction_added", user["id"], "message", str(message_id), emoji) - return jsonify({"message_id": message_id, "reactions": reaction_summary_for_message(get_db, message_id, user)}), 201 + return jsonify( + { + "message_id": message_id, + "reactions": reaction_summary_for_message(get_db, message_id, user), + } + ), 201 @app.route("/api/messages//reactions/", methods=["DELETE"]) def api_remove_reaction(message_id: int, emoji: str): @@ -1484,7 +1716,12 @@ def register_routes(app, ctx): ) audit("message_reaction_removed", user["id"], "message", str(message_id), emoji) - return jsonify({"message_id": message_id, "reactions": reaction_summary_for_message(get_db, message_id, user)}) + return jsonify( + { + "message_id": message_id, + "reactions": reaction_summary_for_message(get_db, message_id, user), + } + ) @app.route("/api/presence", methods=["POST"]) def api_presence_ping(): @@ -1529,7 +1766,12 @@ def register_routes(app, ctx): user = current_user() if not room or not can_access_room(room, user): return jsonify({"error": "Room not found."}), 404 - return jsonify({"room_id": room["id"], "online_count": room_presence_count(get_db, PRESENCE_TTL_SECONDS, room["id"])}) + return jsonify( + { + "room_id": room["id"], + "online_count": room_presence_count(get_db, PRESENCE_TTL_SECONDS, room["id"]), + } + ) @app.route("/api/teams//channels//presence") def api_channel_presence(team_slug: str, channel_slug: str): @@ -1537,14 +1779,24 @@ def register_routes(app, ctx): user = current_user() if not channel or not can_access_channel(channel, user): return jsonify({"error": "Channel not found."}), 404 - return jsonify({"channel_id": channel["id"], "online_count": channel_presence_count(get_db, PRESENCE_TTL_SECONDS, channel["id"])}) + return jsonify( + { + "channel_id": channel["id"], + "online_count": channel_presence_count(get_db, PRESENCE_TTL_SECONDS, channel["id"]), + } + ) @app.route("/api/dms//presence") def api_dm_presence(dm_id: int): user = current_user() if not validate_dm_access(get_db, dm_id, user): return jsonify({"error": "DM not found."}), 404 - return jsonify({"dm_id": dm_id, "online_count": dm_presence_count(get_db, PRESENCE_TTL_SECONDS, dm_id)}) + return jsonify( + { + "dm_id": dm_id, + "online_count": dm_presence_count(get_db, PRESENCE_TTL_SECONDS, dm_id), + } + ) @app.route("/api/rooms//typing", methods=["POST"]) def set_typing(slug: str): @@ -1569,6 +1821,7 @@ def register_routes(app, ctx): return jsonify({"error": str(error)}), 400 typing = bool(data.get("typing")) + if typing: db_write( """ @@ -1583,7 +1836,10 @@ def register_routes(app, ctx): (room["id"], user["id"], time.time()), ) else: - db_write("DELETE FROM typing_events WHERE room_id = ? AND user_id = ?", (room["id"], user["id"])) + db_write( + "DELETE FROM typing_events WHERE room_id = ? AND user_id = ?", + (room["id"], user["id"]), + ) return jsonify({"status": "ok"}) @@ -1596,7 +1852,6 @@ def register_routes(app, ctx): return jsonify({"error": "Room not found."}), 404 cutoff = time.time() - TYPING_TTL_SECONDS - with get_db() as db: db.execute("DELETE FROM typing_events WHERE updated_at < ?", (cutoff,)) rows = db.execute( @@ -1644,6 +1899,7 @@ def register_routes(app, ctx): return jsonify({"error": str(error)}), 400 typing = bool(data.get("typing")) + if typing: db_write( """ @@ -1658,7 +1914,10 @@ def register_routes(app, ctx): (channel["id"], user["id"], time.time()), ) else: - db_write("DELETE FROM channel_typing_events WHERE channel_id = ? AND user_id = ?", (channel["id"], user["id"])) + db_write( + "DELETE FROM channel_typing_events WHERE channel_id = ? AND user_id = ?", + (channel["id"], user["id"]), + ) return jsonify({"status": "ok"}) @@ -1718,6 +1977,7 @@ def register_routes(app, ctx): return jsonify({"error": str(error)}), 400 typing = bool(data.get("typing")) + if typing: db_write( """ @@ -1774,6 +2034,7 @@ def register_routes(app, ctx): @app.route("/api/rooms") def list_rooms(): user = current_user() + with get_db() as db: rows = db.execute( """ @@ -1820,7 +2081,6 @@ def register_routes(app, ctx): if not name or len(name) > 60: return jsonify({"error": "Room name must be 1 to 60 characters."}), 400 - if visibility not in ROOM_VISIBILITIES: return jsonify({"error": "Invalid room visibility."}), 400 @@ -1834,7 +2094,6 @@ def register_routes(app, ctx): db.execute("BEGIN IMMEDIATE") slug = base_slug suffix = 2 - while db.execute("SELECT 1 FROM rooms WHERE slug = ?", (slug,)).fetchone(): slug = f"{base_slug[:44]}-{suffix}" suffix += 1 @@ -1867,7 +2126,10 @@ def register_routes(app, ctx): (room_id, admin["id"], now_iso()), ) - general_team = db.execute("SELECT id FROM teams WHERE slug = 'general' AND is_active = 1").fetchone() + general_team = db.execute( + "SELECT id FROM teams WHERE slug = 'general' AND is_active = 1" + ).fetchone() + if general_team: db.execute( """ @@ -1882,13 +2144,35 @@ def register_routes(app, ctx): is_active ) VALUES (?, ?, ?, ?, ?, ?, ?, 1) """, - (general_team["id"], room_id, slug, name, description, admin["id"], now_iso()), + ( + general_team["id"], + room_id, + slug, + name, + description, + admin["id"], + now_iso(), + ), ) db.commit() audit("room_created", admin["id"], "room", str(room_id), f"{slug}:{visibility}") - return jsonify({"room": {"id": room_id, "slug": slug, "name": name, "description": description, "visibility": visibility, "can_read": True, "can_write": True, "can_manage": True}}), 201 + + return jsonify( + { + "room": { + "id": room_id, + "slug": slug, + "name": name, + "description": description, + "visibility": visibility, + "can_read": True, + "can_write": True, + "can_manage": True, + } + } + ), 201 @app.route("/api/rooms/", methods=["PATCH"]) def update_room(slug: str): @@ -1924,12 +2208,17 @@ def register_routes(app, ctx): if not name or len(name) > 60: return jsonify({"error": "Room name must be 1 to 60 characters."}), 400 - if visibility not in ROOM_VISIBILITIES: return jsonify({"error": "Invalid room visibility."}), 400 - db_write("UPDATE rooms SET name = ?, description = ?, visibility = ? WHERE id = ?", (name, description, visibility, room["id"])) - db_write("UPDATE channels SET name = ?, description = ? WHERE legacy_room_id = ?", (name, description, room["id"])) + db_write( + "UPDATE rooms SET name = ?, description = ?, visibility = ? WHERE id = ?", + (name, description, visibility, room["id"]), + ) + db_write( + "UPDATE channels SET name = ?, description = ? WHERE legacy_room_id = ?", + (name, description, room["id"]), + ) audit("room_updated", user["id"], "room", str(room["id"]), f"name={name};visibility={visibility}") return jsonify({"status": "updated"}) @@ -1997,7 +2286,16 @@ def register_routes(app, ctx): return jsonify( { "room": room_payload(room, admin), - "members": [{"id": row["id"], "username": row["username"], "role": row["role"], "membership_role": row["membership_role"], "added_at": row["added_at"]} for row in rows], + "members": [ + { + "id": row["id"], + "username": row["username"], + "role": row["role"], + "membership_role": row["membership_role"], + "added_at": row["added_at"], + } + for row in rows + ], } ) @@ -2023,8 +2321,12 @@ def register_routes(app, ctx): return jsonify({"error": str(error)}), 400 username = normalize_username(data.get("username")) + with get_db() as db: - target = db.execute("SELECT id, username FROM users WHERE username = ?", (username,)).fetchone() + target = db.execute( + "SELECT id, username FROM users WHERE username = ?", + (username,), + ).fetchone() if not target: return jsonify({"error": "User not found."}), 404 @@ -2074,7 +2376,11 @@ def register_routes(app, ctx): if member["membership_role"] == "owner": return jsonify({"error": "The room owner cannot be removed."}), 400 - db_write("DELETE FROM room_members WHERE room_id = ? AND user_id = ?", (room["id"], user_id)) + db_write( + "DELETE FROM room_members WHERE room_id = ? AND user_id = ?", + (room["id"], user_id), + ) + audit("room_member_removed", admin["id"], "room", str(room["id"]), str(user_id)) return jsonify({"status": "removed"}) @@ -2157,7 +2463,10 @@ def register_routes(app, ctx): with get_db() as db: db.execute("BEGIN IMMEDIATE") - fresh = db.execute("SELECT id, used_at, expires_at FROM room_invites WHERE id = ?", (invite["id"],)).fetchone() + fresh = db.execute( + "SELECT id, used_at, expires_at FROM room_invites WHERE id = ?", + (invite["id"],), + ).fetchone() if not fresh or fresh["used_at"] or parse_iso(fresh["expires_at"]) <= utc_now(): db.rollback() @@ -2174,7 +2483,6 @@ def register_routes(app, ctx): """, (invite["room_id"], user["id"], now_iso()), ) - db.execute( """ UPDATE room_invites @@ -2287,7 +2595,6 @@ def register_routes(app, ctx): if role not in ROLES: return jsonify({"error": "Invalid role."}), 400 - if status not in STATUSES: return jsonify({"error": "Invalid account status."}), 400 @@ -2308,7 +2615,6 @@ def register_routes(app, ctx): AND status = 'active' """ ).fetchone()["count"] - if count <= 1: return jsonify({"error": "At least one active administrator is required."}), 400 @@ -2340,13 +2646,27 @@ def register_routes(app, ctx): created_by_user_id ) VALUES ('user', ?, ?, ?, ?) """, - (hash_identifier(str(user_id)), "Account blacklisted by administrator", now_iso(), admin["id"]), + ( + hash_identifier(str(user_id)), + "Account blacklisted by administrator", + now_iso(), + admin["id"], + ), ) if target["status"] == "blacklisted" and status != "blacklisted": - db_write("DELETE FROM blacklist_entries WHERE kind = 'user' AND value_hash = ?", (hash_identifier(str(user_id)),)) + db_write( + "DELETE FROM blacklist_entries WHERE kind = 'user' AND value_hash = ?", + (hash_identifier(str(user_id)),), + ) - audit("user_updated", admin["id"], "user", str(user_id), f"role={role};status={status};restricted_until={restricted_until}") + audit( + "user_updated", + admin["id"], + "user", + str(user_id), + f"role={role};status={status};restricted_until={restricted_until}", + ) return jsonify({"status": "updated"}) @app.route("/api/admin/rooms") @@ -2372,7 +2692,20 @@ def register_routes(app, ctx): """ ).fetchall() - return jsonify({"rooms": [{"id": row["id"], "slug": row["slug"], "name": row["name"], "description": row["description"], "visibility": row["visibility"]} for row in rows]}) + return jsonify( + { + "rooms": [ + { + "id": row["id"], + "slug": row["slug"], + "name": row["name"], + "description": row["description"], + "visibility": row["visibility"], + } + for row in rows + ] + } + ) @app.route("/api/admin/blacklist/ip", methods=["POST"]) def blacklist_ip(): @@ -2404,7 +2737,12 @@ def register_routes(app, ctx): created_by_user_id ) VALUES ('ip', ?, ?, ?, ?) """, - (hash_identifier(ip), reason or "IP blacklisted by administrator", now_iso(), admin["id"]), + ( + hash_identifier(ip), + reason or "IP blacklisted by administrator", + now_iso(), + admin["id"], + ), ) audit("ip_blacklisted", admin["id"], "ip", hash_identifier(ip), reason) @@ -2529,7 +2867,6 @@ def register_routes(app, ctx): db.execute("BEGIN IMMEDIATE") slug = base_slug suffix = 2 - while db.execute("SELECT 1 FROM teams WHERE slug = ?", (slug,)).fetchone(): slug = f"{base_slug[:44]}-{suffix}" suffix += 1 @@ -2560,6 +2897,7 @@ def register_routes(app, ctx): """, (team_id, user["id"], now_iso()), ) + db.commit() audit("team_created", user["id"], "team", str(team_id), slug) @@ -2614,9 +2952,12 @@ def register_routes(app, ctx): if not name or len(name) > 60: return jsonify({"error": "Team name must be 1 to 60 characters."}), 400 - db_write("UPDATE teams SET name = ?, description = ? WHERE id = ?", (name, description, team["id"])) - updated = get_team_by_slug(team_slug) + db_write( + "UPDATE teams SET name = ?, description = ? WHERE id = ?", + (name, description, team["id"]), + ) + updated = get_team_by_slug(team_slug) audit("team_updated", user["id"], "team", str(team["id"]), f"name={name}") return jsonify({"team": team_payload(updated, user)}) @@ -2706,7 +3047,6 @@ def register_routes(app, ctx): db.execute("BEGIN IMMEDIATE") slug = base_slug suffix = 2 - while db.execute("SELECT 1 FROM channels WHERE team_id = ? AND slug = ?", (team["id"], slug)).fetchone(): slug = f"{base_slug[:44]}-{suffix}" suffix += 1 @@ -2764,10 +3104,16 @@ def register_routes(app, ctx): if not name or len(name) > 60: return jsonify({"error": "Channel name must be 1 to 60 characters."}), 400 - db_write("UPDATE channels SET name = ?, description = ? WHERE id = ?", (name, description, channel["id"])) + db_write( + "UPDATE channels SET name = ?, description = ? WHERE id = ?", + (name, description, channel["id"]), + ) if channel["legacy_room_id"]: - db_write("UPDATE rooms SET name = ?, description = ? WHERE id = ?", (name, description, channel["legacy_room_id"])) + db_write( + "UPDATE rooms SET name = ?, description = ? WHERE id = ?", + (name, description, channel["legacy_room_id"]), + ) updated = get_channel_by_id(channel["id"]) audit("channel_updated", user["id"], "channel", str(channel["id"]), f"{team_slug}/{channel_slug}") @@ -2837,7 +3183,21 @@ def register_routes(app, ctx): (team["id"],), ).fetchall() - return jsonify({"team": team_payload(team, user), "members": [{"id": row["id"], "username": row["username"], "role": row["role"], "membership_role": row["membership_role"], "added_at": row["added_at"]} for row in rows]}) + return jsonify( + { + "team": team_payload(team, user), + "members": [ + { + "id": row["id"], + "username": row["username"], + "role": row["role"], + "membership_role": row["membership_role"], + "added_at": row["added_at"], + } + for row in rows + ], + } + ) @app.route("/api/teams//members", methods=["POST"]) def api_add_team_member(team_slug: str): @@ -2867,7 +3227,10 @@ def register_routes(app, ctx): return jsonify({"error": "Invalid membership role."}), 400 with get_db() as db: - target = db.execute("SELECT id, username FROM users WHERE username = ?", (username,)).fetchone() + target = db.execute( + "SELECT id, username FROM users WHERE username = ?", + (username,), + ).fetchone() if not target: return jsonify({"error": "User not found."}), 404 @@ -2927,8 +3290,18 @@ def register_routes(app, ctx): if membership_role == "owner" and actor_role != "owner": return jsonify({"error": "Only an owner can assign owner role."}), 403 - db_write("UPDATE team_members SET membership_role = ? WHERE team_id = ? AND user_id = ?", (membership_role, team["id"], member_user_id)) - audit("team_member_role_updated", user["id"], "team", str(team["id"]), f"user_id={member_user_id};role={membership_role}") + db_write( + "UPDATE team_members SET membership_role = ? WHERE team_id = ? AND user_id = ?", + (membership_role, team["id"], member_user_id), + ) + + audit( + "team_member_role_updated", + user["id"], + "team", + str(team["id"]), + f"user_id={member_user_id};role={membership_role}", + ) return jsonify({"status": "updated"}) @app.route("/api/teams//members/", methods=["DELETE"]) @@ -2959,7 +3332,11 @@ def register_routes(app, ctx): if target_role == "admin" and actor_role not in {"owner", "admin"}: return jsonify({"error": "Forbidden.", "code": "forbidden"}), 403 - db_write("DELETE FROM team_members WHERE team_id = ? AND user_id = ?", (team["id"], member_user_id)) + db_write( + "DELETE FROM team_members WHERE team_id = ? AND user_id = ?", + (team["id"], member_user_id), + ) + audit("team_member_removed", user["id"], "team", str(team["id"]), str(member_user_id)) return jsonify({"status": "removed"}) @@ -3006,7 +3383,15 @@ def register_routes(app, ctx): ) audit("team_invite_created", user["id"], "team", str(team["id"]), membership_role) - return jsonify({"invite": {"url": f"/?team_invite={token}", "expires_at": expires_at, "membership_role": membership_role}}), 201 + return jsonify( + { + "invite": { + "url": f"/?team_invite={token}", + "expires_at": expires_at, + "membership_role": membership_role, + } + } + ), 201 @app.route("/api/team-invites//accept", methods=["POST"]) def api_accept_team_invite(token: str): @@ -3051,7 +3436,10 @@ def register_routes(app, ctx): with get_db() as db: db.execute("BEGIN IMMEDIATE") - fresh = db.execute("SELECT id, used_at, expires_at FROM team_invites WHERE id = ?", (invite["id"],)).fetchone() + fresh = db.execute( + "SELECT id, used_at, expires_at FROM team_invites WHERE id = ?", + (invite["id"],), + ).fetchone() if not fresh or fresh["used_at"] or parse_iso(fresh["expires_at"]) <= utc_now(): db.rollback() @@ -3070,7 +3458,6 @@ def register_routes(app, ctx): """, (invite["team_id"], user["id"], invite["membership_role"], now_iso()), ) - db.execute( """ UPDATE team_invites @@ -3097,7 +3484,6 @@ def register_routes(app, ctx): if page_type and page_type not in PAGE_TYPES: return jsonify({"error": "Invalid page type."}), 400 - if visibility and visibility not in PAGE_VISIBILITIES: return jsonify({"error": "Invalid page visibility."}), 400 @@ -3131,6 +3517,7 @@ def register_routes(app, ctx): @app.route("/api/teams//pages", methods=["POST"]) def api_create_page(): team_slug = request.view_args["team_slug"] + if not csrf_valid(): return jsonify({"error": "Security token expired.", "code": "csrf"}), 403 @@ -3181,7 +3568,6 @@ def register_routes(app, ctx): db.execute("BEGIN IMMEDIATE") slug = base_slug suffix = 2 - while db.execute("SELECT 1 FROM pages WHERE team_id = ? AND slug = ?", (team["id"], slug)).fetchone(): slug = f"{base_slug[:44]}-{suffix}" suffix += 1 @@ -3203,7 +3589,19 @@ def register_routes(app, ctx): is_active ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1) """, - (team["id"], channel_id, slug, title, body, visibility, page_type, user["id"], user["id"], now_iso(), now_iso()), + ( + team["id"], + channel_id, + slug, + title, + body, + visibility, + page_type, + user["id"], + user["id"], + now_iso(), + now_iso(), + ), ) page_id = cursor.lastrowid db.commit() @@ -3218,6 +3616,7 @@ def register_routes(app, ctx): @app.route("/api/teams//pages/") def api_get_page(team_slug: str, page_slug: str): user = current_user() + with get_db() as db: page = db.execute( f""" @@ -3233,7 +3632,10 @@ def register_routes(app, ctx): if not page: return jsonify({"error": "Page not found."}), 404 - permitted = bool(page["visibility"] == "public" or (user and (user["role"] in {"moderator", "admin"} or is_team_member(page["team_id"], user["id"])))) + permitted = bool( + page["visibility"] == "public" + or (user and (user["role"] in {"moderator", "admin"} or is_team_member(page["team_id"], user["id"]))) + ) if not permitted: if not user: @@ -3466,7 +3868,15 @@ def register_routes(app, ctx): updated_at = ? WHERE user_id = ? """, - (display_name or user["username"], bio, bio_mode, website, location, now_iso(), user["id"]), + ( + display_name or user["username"], + bio, + bio_mode, + website, + location, + now_iso(), + user["id"], + ), ) with get_db() as db: @@ -3501,7 +3911,10 @@ def register_routes(app, ctx): (user["id"],), ).fetchone() - settings_row = db.execute("SELECT name_color FROM user_settings WHERE user_id = ?", (user["id"],)).fetchone() + settings_row = db.execute( + "SELECT name_color FROM user_settings WHERE user_id = ?", + (user["id"],), + ).fetchone() audit("profile_updated", user["id"], "user", str(user["id"])) return jsonify({"profile": profile_payload(html_module, user_payload, user_row, profile_row, settings_row, user)}) @@ -3545,7 +3958,10 @@ def register_routes(app, ctx): (user_row["id"],), ).fetchone() - settings_row = db.execute("SELECT name_color FROM user_settings WHERE user_id = ?", (user_row["id"],)).fetchone() + settings_row = db.execute( + "SELECT name_color FROM user_settings WHERE user_id = ?", + (user_row["id"],), + ).fetchone() return jsonify({"profile": profile_payload(html_module, user_payload, user_row, profile_row, settings_row, current_user())}) @@ -3607,13 +4023,10 @@ def register_routes(app, ctx): if not valid_hex_color(accent_color): return jsonify({"error": "Invalid accent color."}), 400 - - if name_color not in (None, "",) and not valid_hex_color(name_color): + if name_color not in (None, "") and not valid_hex_color(name_color): return jsonify({"error": "Invalid name color."}), 400 - if len(custom_css) > MAX_CUSTOM_CSS_LENGTH: return jsonify({"error": "Custom CSS is too long."}), 400 - if theme_mode not in THEME_MODES: return jsonify({"error": "Invalid theme mode."}), 400 @@ -3661,7 +4074,6 @@ def register_routes(app, ctx): user, error = require_user() if error: return error - return jsonify({"dms": list_dms_for_user(get_db, user["id"])}) @app.route("/api/dms", methods=["POST"]) @@ -3708,16 +4120,13 @@ def register_routes(app, ctx): if not target: return jsonify({"error": "User not found."}), 404 - if int(target["id"]) == int(user["id"]): return jsonify({"error": "You cannot DM yourself."}), 400 - if target["status"] in {"disabled", "blacklisted"}: return jsonify({"error": "User not available."}), 400 dm_id = find_or_create_dm(get_db, db_write, now_iso, user["id"], target["id"]) audit("dm_opened", user["id"], "dm", str(dm_id), str(target["id"])) - return jsonify({"dm": dm_payload(get_db, dm_id, user["id"])}), 201 @app.route("/api/dms//messages") @@ -3731,7 +4140,6 @@ def register_routes(app, ctx): after_raw = request.args.get("after_id", "").strip() after_id = 0 - if after_raw: try: after_id = max(0, int(after_raw)) @@ -3770,7 +4178,14 @@ def register_routes(app, ctx): messages = [build_message_payload(row, user) for row in rows] latest_id = messages[-1]["id"] if messages else after_id - return jsonify({"dm": dm_payload(get_db, dm_id, user["id"]), "messages": messages, "latest_id": latest_id, "has_more": len(messages) == MAX_INCREMENTAL_MESSAGES}) + return jsonify( + { + "dm": dm_payload(get_db, dm_id, user["id"]), + "messages": messages, + "latest_id": latest_id, + "has_more": len(messages) == MAX_INCREMENTAL_MESSAGES, + } + ) @app.route("/api/dms//messages", methods=["POST"]) def api_send_dm_message(dm_id: int): @@ -3796,22 +4211,31 @@ def register_routes(app, ctx): text = data.get("text", "") if not isinstance(text, str): return jsonify({"error": "Message must be text."}), 400 - text = text.strip() + text = text.strip() if len(text) > MAX_MESSAGE_LENGTH: return jsonify({"error": "Message is too long."}), 400 - attachment_ids, attachment_error = resolve_owned_attachment_ids(get_db, data.get("attachment_ids", []), user["id"]) + attachment_ids, attachment_error = resolve_owned_attachment_ids( + get_db, + data.get("attachment_ids", []), + user["id"], + ) if attachment_error: return attachment_error if not attachment_ids and data.get("attachment_id") is not None: - single_attachment_id, single_attachment_error = resolve_owned_attachment(get_db, data.get("attachment_id"), user["id"]) + single_attachment_id, single_attachment_error = resolve_owned_attachment( + get_db, + data.get("attachment_id"), + user["id"], + ) if single_attachment_error: return single_attachment_error attachment_ids = [single_attachment_id] if single_attachment_id else [] attachment_id = attachment_ids[0] if attachment_ids else None + if not text and not attachment_id: return jsonify({"error": "Message cannot be empty."}), 400 @@ -3846,6 +4270,7 @@ def register_routes(app, ctx): """, (message_id, extra_attachment_id, position), ) + db.commit() with get_db() as db: @@ -3854,6 +4279,523 @@ def register_routes(app, ctx): audit("dm_message_sent", user["id"], "dm", str(dm_id), str(message_id)) return jsonify({"message": build_message_payload(row, user)}), 201 + @app.route("/api/call-token", methods=["POST"]) + def api_call_token(): + if not csrf_valid(): + return jsonify({"error": "Security token expired.", "code": "csrf"}), 403 + + user, error = require_writable_user() + if error: + return error + + if not CALL_HELPER_SHARED_SECRET: + return jsonify({"error": "Call helper is not configured."}), 503 + + try: + data = safe_json() + except ValueError as error: + return jsonify({"error": str(error)}), 400 + + room_slug = str(data.get("room", "")).strip() + dm_id_raw = data.get("dm_id") + + call_id = None + room_value = None + + if dm_id_raw is not None: + try: + dm_id = int(dm_id_raw) + except (TypeError, ValueError): + return jsonify({"error": "Invalid dm_id."}), 400 + + if not validate_dm_access(get_db, dm_id, user): + return jsonify({"error": "DM not found."}), 404 + + peer = get_dm_other_user(dm_id, user["id"]) + if not peer: + return jsonify({"error": "Direct-message peer not found."}), 404 + + call_id = f"dm-{dm_id}" + room_value = call_id + + elif room_slug: + room = get_room_by_slug(room_slug) + if not room: + return jsonify({"error": "Room not found."}), 404 + if not can_access_room(room, user): + return jsonify({"error": "Forbidden.", "code": "forbidden"}), 403 + + call_id = f"room-{room['slug']}" + room_value = room["slug"] + + else: + return jsonify({"error": "room or dm_id is required."}), 400 + + payload = { + "sub": f"user-{user['id']}", + "username": user["username"], + "room": room_value, + "call_id": call_id, + "role": user["role"], + "can_call": True, + "exp": int(time.time()) + CALL_TOKEN_TTL_SECONDS, + } + + token = sign_call_helper_token(payload) + + audit( + "call_helper_token_issued", + user["id"], + "call", + call_id, + f"room={room_value}", + ) + + return jsonify( + { + "token": token, + "calls_base": CALLS_BASE, + "call_id": call_id, + } + ) + + @app.route("/api/group-calls/token", methods=["POST"]) + def api_group_call_token(): + if not csrf_valid(): + return jsonify({"error": "Security token expired.", "code": "csrf"}), 403 + + user, error = require_writable_user() + if error: + return error + + if not CALL_HELPER_SHARED_SECRET: + return jsonify({"error": "Call helper is not configured."}), 503 + + try: + data = safe_json() + except ValueError as error: + return jsonify({"error": str(error)}), 400 + + scope_kind = str(data.get("scope_kind", "")).strip().lower() + helper_call_id = None + scope_id = None + scope_slug = None + room_value = None + + if scope_kind == "room": + room_slug = str(data.get("room_slug", "")).strip() + room = get_room_by_slug(room_slug) + if not room: + return jsonify({"error": "Room not found."}), 404 + if not can_access_room(room, user): + return jsonify({"error": "Forbidden.", "code": "forbidden"}), 403 + + helper_call_id = build_room_helper_call_id(room) + scope_id = room["id"] + scope_slug = room["slug"] + room_value = room["slug"] + + elif scope_kind == "channel": + team_slug = str(data.get("team_slug", "")).strip() + channel_slug = str(data.get("channel_slug", "")).strip() + channel = get_channel_by_slug(team_slug, channel_slug) + if not channel: + return jsonify({"error": "Channel not found."}), 404 + if not can_access_channel(channel, user): + return jsonify({"error": "Forbidden.", "code": "forbidden"}), 403 + + helper_call_id = build_channel_helper_call_id(channel) + scope_id = channel["id"] + scope_slug = f"{channel['team_slug']}/{channel['slug']}" + room_value = scope_slug + + else: + return jsonify({"error": "scope_kind must be room or channel."}), 400 + + session_row = get_or_create_group_call_session( + scope_kind=scope_kind, + scope_id=scope_id, + scope_slug=scope_slug, + helper_call_id=helper_call_id, + created_by_user_id=user["id"], + ) + + payload = { + "sub": f"user-{user['id']}", + "username": user["username"], + "room": room_value, + "call_id": helper_call_id, + "role": user["role"], + "can_call": True, + "exp": int(time.time()) + CALL_TOKEN_TTL_SECONDS, + } + + token = sign_call_helper_token(payload) + + audit( + "group_call_token_issued", + user["id"], + "group_call_session", + str(session_row["id"]), + helper_call_id, + ) + + return jsonify( + { + "token": token, + "calls_base": CALLS_BASE, + "call_id": helper_call_id, + "session": group_call_session_payload(session_row), + } + ) + + @app.route("/api/group-calls/invite", methods=["POST"]) + def api_group_call_invite(): + if not csrf_valid(): + return jsonify({"error": "Security token expired.", "code": "csrf"}), 403 + + user, error = require_writable_user() + if error: + return error + + if not CALL_HELPER_SHARED_SECRET: + return jsonify({"error": "Call helper is not configured."}), 503 + + try: + data = safe_json() + except ValueError as error: + return jsonify({"error": str(error)}), 400 + + session_id_raw = data.get("group_call_session_id") + invite_username = normalize_username(data.get("username")) + + try: + session_id = int(session_id_raw) + except (TypeError, ValueError): + return jsonify({"error": "Invalid group_call_session_id."}), 400 + + if not invite_username: + return jsonify({"error": "username is required."}), 400 + + with get_db() as db: + session_row = db.execute( + """ + SELECT + id, + scope_kind, + scope_id, + scope_slug, + helper_call_id, + created_by_user_id, + status, + created_at, + ended_at + FROM group_call_sessions + WHERE id = ? + LIMIT 1 + """, + (session_id,), + ).fetchone() + + if not session_row or session_row["status"] != "active": + return jsonify({"error": "Active group call not found."}), 404 + + allowed = False + if session_row["scope_kind"] == "room": + room = room_by_id(session_row["scope_id"]) + allowed = bool(room and can_access_room(room, user)) + elif session_row["scope_kind"] == "channel": + channel = get_channel_by_id(session_row["scope_id"]) + allowed = bool(channel and can_access_channel(channel, user)) + + if not allowed: + return jsonify({"error": "Forbidden.", "code": "forbidden"}), 403 + + with get_db() as db: + target = db.execute( + """ + SELECT + id, + username, + role, + status + FROM users + WHERE username = ? + """, + (invite_username,), + ).fetchone() + + if not target: + return jsonify({"error": "User not found."}), 404 + + if int(target["id"]) == int(user["id"]): + return jsonify({"error": "You are already in the call."}), 400 + + if target["status"] in {"disabled", "blacklisted"}: + return jsonify({"error": "User is not available."}), 400 + + payload = { + "sub": f"user-{target['id']}", + "username": target["username"], + "room": session_row["scope_slug"], + "call_id": session_row["helper_call_id"], + "role": target["role"], + "can_call": True, + "exp": int(time.time()) + CALL_TOKEN_TTL_SECONDS, + } + + helper_token = sign_call_helper_token(payload) + expires_at = (utc_now() + ctx["timedelta"](minutes=10)).isoformat() + + cursor = db_write( + """ + INSERT INTO group_call_invites ( + group_call_session_id, + inviter_user_id, + invitee_user_id, + helper_token, + status, + created_at, + expires_at, + responded_at + ) VALUES (?, ?, ?, ?, 'pending', ?, ?, NULL) + """, + ( + session_row["id"], + user["id"], + target["id"], + helper_token, + now_iso(), + expires_at, + ), + ) + + audit( + "group_call_invite_created", + user["id"], + "group_call_session", + str(session_row["id"]), + f"invitee={target['username']}", + ) + + return jsonify( + { + "invite": { + "id": cursor.lastrowid, + "invitee_username": target["username"], + "group_call_session_id": session_row["id"], + "status": "pending", + "expires_at": expires_at, + } + } + ), 201 + + @app.route("/api/group-calls/invites") + def api_list_group_call_invites(): + user, error = require_user() + if error: + return error + + with get_db() as db: + rows = db.execute( + """ + SELECT + i.id, + i.group_call_session_id, + i.status, + i.created_at, + i.expires_at, + i.responded_at, + s.scope_kind, + s.scope_slug, + s.helper_call_id, + inviter.username AS inviter_username + FROM group_call_invites AS i + JOIN group_call_sessions AS s + ON s.id = i.group_call_session_id + JOIN users AS inviter + ON inviter.id = i.inviter_user_id + WHERE i.invitee_user_id = ? + AND i.status = 'pending' + AND i.expires_at > ? + ORDER BY i.id DESC + """, + (user["id"], now_iso()), + ).fetchall() + + return jsonify( + { + "invites": [ + { + "id": row["id"], + "group_call_session_id": row["group_call_session_id"], + "scope_kind": row["scope_kind"], + "scope_slug": row["scope_slug"], + "helper_call_id": row["helper_call_id"], + "inviter_username": row["inviter_username"], + "status": row["status"], + "created_at": row["created_at"], + "expires_at": row["expires_at"], + } + for row in rows + ] + } + ) + + @app.route("/api/group-calls/invites//accept", methods=["POST"]) + def api_accept_group_call_invite(invite_id: int): + if not csrf_valid(): + return jsonify({"error": "Security token expired.", "code": "csrf"}), 403 + + user, error = require_writable_user() + if error: + return error + + with get_db() as db: + row = db.execute( + """ + SELECT + i.id, + i.group_call_session_id, + i.invitee_user_id, + i.helper_token, + i.status, + i.expires_at, + s.helper_call_id + FROM group_call_invites AS i + JOIN group_call_sessions AS s + ON s.id = i.group_call_session_id + WHERE i.id = ? + AND i.invitee_user_id = ? + LIMIT 1 + """, + (invite_id, user["id"]), + ).fetchone() + + if not row: + return jsonify({"error": "Invite not found."}), 404 + + if row["status"] != "pending" or parse_iso(row["expires_at"]) <= utc_now(): + return jsonify({"error": "Invite expired or unavailable."}), 400 + + db_write( + """ + UPDATE group_call_invites + SET + status = 'accepted', + responded_at = ? + WHERE id = ? + """, + (now_iso(), invite_id), + ) + + audit( + "group_call_invite_accepted", + user["id"], + "group_call_invite", + str(invite_id), + row["helper_call_id"], + ) + + return jsonify( + { + "token": row["helper_token"], + "calls_base": CALLS_BASE, + "call_id": row["helper_call_id"], + } + ) + + @app.route("/api/group-calls/invites//dismiss", methods=["POST"]) + def api_dismiss_group_call_invite(invite_id: int): + if not csrf_valid(): + return jsonify({"error": "Security token expired.", "code": "csrf"}), 403 + + user, error = require_user() + if error: + return error + + db_write( + """ + UPDATE group_call_invites + SET + status = 'dismissed', + responded_at = ? + WHERE id = ? + AND invitee_user_id = ? + AND status = 'pending' + """, + (now_iso(), invite_id, user["id"]), + ) + + audit( + "group_call_invite_dismissed", + user["id"], + "group_call_invite", + str(invite_id), + ) + + return jsonify({"status": "dismissed"}) + + @app.route("/api/group-calls//end", methods=["POST"]) + def api_end_group_call_session(session_id: int): + if not csrf_valid(): + return jsonify({"error": "Security token expired.", "code": "csrf"}), 403 + + user, error = require_writable_user() + if error: + return error + + with get_db() as db: + session_row = db.execute( + """ + SELECT + id, + scope_kind, + scope_id, + helper_call_id, + status + FROM group_call_sessions + WHERE id = ? + LIMIT 1 + """, + (session_id,), + ).fetchone() + + if not session_row: + return jsonify({"error": "Group call session not found."}), 404 + + allowed = False + if session_row["scope_kind"] == "room": + room = room_by_id(session_row["scope_id"]) + allowed = bool(room and can_manage_room(room, user)) + elif session_row["scope_kind"] == "channel": + channel = get_channel_by_id(session_row["scope_id"]) + allowed = bool(channel and can_edit_team(channel["team_id"], user)) + + if not allowed and user["role"] not in {"moderator", "admin"}: + return jsonify({"error": "Forbidden.", "code": "forbidden"}), 403 + + db_write( + """ + UPDATE group_call_sessions + SET + status = 'ended', + ended_at = ? + WHERE id = ? + """, + (now_iso(), session_id), + ) + + audit( + "group_call_session_ended", + user["id"], + "group_call_session", + str(session_id), + session_row["helper_call_id"], + ) + + return jsonify({"status": "ended"}) + @app.route("/api/dms//calls", methods=["POST"]) def api_start_dm_call(dm_id: int): if not csrf_valid(): @@ -3963,4 +4905,4 @@ def register_routes(app, ctx): end_call(db_write, now_iso, call_id) audit("call_ended", user["id"], "call", str(call_id)) - return jsonify({"status": "ended", "call": call_payload(get_db, call_id)}) + return jsonify({"status": "ended", "call": call_payload(get_db, call_id)}) \ No newline at end of file