diff --git a/routes.py b/routes.py index 1c34708..daac70a 100644 --- a/routes.py +++ b/routes.py @@ -1,7 +1,9 @@ # routes.py import json import re +import sqlite3 import time + from flask import jsonify, request from features import ( @@ -17,14 +19,12 @@ from features import ( dm_payload, dm_presence_count, end_call, - extract_page_reference_slugs, feed_message_payload, find_or_create_dm, get_call_with_dm, + get_dm_for_user, list_call_signals, list_dms_for_user, - load_page_version_detail, - load_page_versions, normalized_hex_color, page_payload, profile_payload, @@ -34,11 +34,11 @@ from features import ( richer_message_payload, room_presence_count, send_attachment_file, - snapshot_page_version, update_presence, validate_dm_access, valid_hex_color, ) + ROOM_SLUG_RE = re.compile(r"^[a-z0-9](?:[a-z0-9-]{0,46}[a-z0-9])?$") @@ -89,10 +89,6 @@ def register_routes(app, ctx): MESSAGE_SELECT = ctx["MESSAGE_SELECT"] team_page_select = ctx["team_page_select"] constants = ctx["constants"] - html_module = ctx["html"] - maybe_dispatch_scheduled_messages = ctx["maybe_dispatch_scheduled_messages"] - SHARE_TOKEN_TTL_SECONDS = ctx["SHARE_TOKEN_TTL_SECONDS"] - MAX_PAGE_SHARE_TTL_SECONDS = ctx["MAX_PAGE_SHARE_TTL_SECONDS"] MAX_MESSAGE_LENGTH = constants["MAX_MESSAGE_LENGTH"] MAX_HISTORY_MESSAGES = constants["MAX_HISTORY_MESSAGES"] @@ -114,22 +110,14 @@ def register_routes(app, ctx): MAX_CUSTOM_CSS_LENGTH = constants["MAX_CUSTOM_CSS_LENGTH"] MAX_PROFILE_BADGE_LENGTH = constants["MAX_PROFILE_BADGE_LENGTH"] MAX_OWNED_TEAMS_PER_USER = constants["MAX_OWNED_TEAMS_PER_USER"] - MAX_PAGE_COMMENT_LENGTH = constants["MAX_PAGE_COMMENT_LENGTH"] + GROUP_CALL_PARTICIPANT_STALE_SECONDS = constants["GROUP_CALL_PARTICIPANT_STALE_SECONDS"] ROOM_VISIBILITIES = constants["ROOM_VISIBILITIES"] ROLES = constants["ROLES"] STATUSES = constants["STATUSES"] + PRIVATE_ACCESS_KEY = ctx["PRIVATE_ACCESS_KEY"] - def build_message_payload(row, user): - return richer_message_payload( - get_db, - row, - user, - user["id"] if user else None, - ) - - def _dispatch_due_messages(): - maybe_dispatch_scheduled_messages() + html_module = ctx["html"] def private_access_key_valid(candidate: object) -> bool: configured = (PRIVATE_ACCESS_KEY or "").strip() @@ -139,6 +127,14 @@ def register_routes(app, ctx): return False return candidate.strip() == configured + def build_message_payload(row, user): + return richer_message_payload( + get_db, + row, + user, + user["id"] if user else None, + ) + def build_room_helper_call_id(room) -> str: return f"room-{room['slug']}" @@ -171,8 +167,10 @@ def register_routes(app, ctx): """, (helper_call_id,), ).fetchone() + if existing and existing["status"] == "active": return existing + with get_db() as db: db.execute("BEGIN IMMEDIATE") existing = db.execute( @@ -193,9 +191,11 @@ def register_routes(app, ctx): """, (helper_call_id,), ).fetchone() + if existing and existing["status"] == "active": db.commit() return existing + if existing: db.execute( """ @@ -227,6 +227,7 @@ def register_routes(app, ctx): (existing["id"],), ).fetchone() return refreshed + cursor = db.execute( """ INSERT INTO group_call_sessions ( @@ -271,6 +272,8 @@ def register_routes(app, ctx): return created def group_call_session_payload(row): + if not row: + return None return { "id": row["id"], "scope_kind": row["scope_kind"], @@ -283,111 +286,251 @@ def register_routes(app, ctx): "ended_at": row["ended_at"], } - def _message_accessible(message_row, user): - if message_row["direct_conversation_id"]: - return bool(user and validate_dm_access(get_db, message_row["direct_conversation_id"], user)) - if message_row["channel_id"]: - channel = get_channel_by_id(message_row["channel_id"]) - return bool(channel and can_access_channel(channel, user)) - if message_row["room_id"]: - room = room_by_id(message_row["room_id"]) - return bool(room and can_access_room(room, user)) - return False + def get_group_call_session_by_id(session_id: int): + with get_db() as db: + return 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() - def _can_pin_message(message_row, user): - if not user: + def can_access_group_call_session(session_row, user: dict) -> bool: + if not session_row or not user: return False - if message_row["direct_conversation_id"]: - return validate_dm_access(get_db, message_row["direct_conversation_id"], user) - if message_row["channel_id"]: - channel = get_channel_by_id(message_row["channel_id"]) - return bool(channel and can_write_channel(channel, user)) - if message_row["room_id"]: - room = room_by_id(message_row["room_id"]) - return bool(room and can_write_room(room, user)) + + if session_row["scope_kind"] == "room": + room = room_by_id(session_row["scope_id"]) + return bool(room and can_access_room(room, user)) + + if session_row["scope_kind"] == "channel": + channel = get_channel_by_id(session_row["scope_id"]) + return bool(channel and can_access_channel(channel, user)) + return False - def create_message_share(message_id: int, user_id: int, expires_at: str) -> str: - token = ctx["secrets"].token_urlsafe(32) + def can_manage_group_call_session(session_row, user: dict) -> bool: + if not session_row or not user: + return False + + if user["role"] in {"moderator", "admin"}: + return True + + if int(session_row["created_by_user_id"]) == int(user["id"]): + return True + + if session_row["scope_kind"] == "room": + room = room_by_id(session_row["scope_id"]) + return bool(room and can_manage_room(room, user)) + + if session_row["scope_kind"] == "channel": + channel = get_channel_by_id(session_row["scope_id"]) + return bool(channel and can_edit_team(channel["team_id"], user)) + + return False + + def cleanup_stale_group_call_participants(session_id: int): + cutoff = utc_now().timestamp() - GROUP_CALL_PARTICIPANT_STALE_SECONDS db_write( """ - INSERT INTO message_shares ( - message_id, - token_hash, - created_by_user_id, - created_at, - expires_at - ) VALUES (?, ?, ?, ?, ?) + UPDATE group_call_participants + SET + left_at = COALESCE(left_at, ?) + WHERE group_call_session_id = ? + AND left_at IS NULL + AND ( + updated_at IS NULL + OR strftime('%s', updated_at) < ? + ) """, - (message_id, hash_identifier(token), user_id, now_iso(), expires_at), + (now_iso(), session_id, int(cutoff)), ) - return token - def create_attachment_share(attachment_id: int, user_id: int, expires_at: str) -> str: - token = ctx["secrets"].token_urlsafe(32) + def upsert_group_call_participant(session_id: int, user_id: int): db_write( """ - INSERT INTO attachment_shares ( - attachment_id, - token_hash, - created_by_user_id, - created_at, - expires_at - ) VALUES (?, ?, ?, ?, ?) + INSERT INTO group_call_participants ( + group_call_session_id, + user_id, + joined_at, + updated_at, + left_at + ) VALUES (?, ?, ?, ?, NULL) + ON CONFLICT(group_call_session_id, user_id) + DO UPDATE SET + updated_at = excluded.updated_at, + left_at = NULL """, - (attachment_id, hash_identifier(token), user_id, now_iso(), expires_at), + (session_id, user_id, now_iso(), now_iso()), ) - return token - def create_page_share(page_id: int, user_id: int, expires_at: str) -> str: - token = ctx["secrets"].token_urlsafe(32) + def heartbeat_group_call_participant(session_id: int, user_id: int): db_write( """ - INSERT INTO page_shares ( - page_id, - token_hash, - created_by_user_id, - created_at, - expires_at - ) VALUES (?, ?, ?, ?, ?) + UPDATE group_call_participants + SET + updated_at = ?, + left_at = NULL + WHERE group_call_session_id = ? + AND user_id = ? """, - (page_id, hash_identifier(token), user_id, now_iso(), expires_at), - ) - return token - - def page_can_read(page_row, user): - return bool( - page_row["visibility"] == "public" - or ( - user - and ( - user["role"] in {"moderator", "admin"} - or is_team_member(page_row["team_id"], user["id"]) - ) - ) + (now_iso(), session_id, user_id), ) - def page_can_edit(page_row, user): - return bool( - user - and ( - user["role"] in {"moderator", "admin"} - or is_team_admin(page_row["team_id"], user) - ) + def mark_group_call_participant_left(session_id: int, user_id: int): + db_write( + """ + UPDATE group_call_participants + SET + updated_at = ?, + left_at = ? + WHERE group_call_session_id = ? + AND user_id = ? + AND left_at IS NULL + """, + (now_iso(), now_iso(), session_id, user_id), ) - @app.before_request - def _batch_background_dispatch(): - if request.path.startswith("/api/"): - _dispatch_due_messages() + def list_group_call_participants(session_id: int) -> list[dict]: + cleanup_stale_group_call_participants(session_id) + + with get_db() as db: + rows = db.execute( + """ + SELECT + gcp.group_call_session_id, + gcp.user_id, + gcp.joined_at, + gcp.updated_at, + gcp.left_at, + u.username, + u.role, + up.display_name, + us.name_color + FROM group_call_participants AS gcp + JOIN users AS u + ON u.id = gcp.user_id + LEFT JOIN user_profiles AS up + ON up.user_id = u.id + LEFT JOIN user_settings AS us + ON us.user_id = u.id + WHERE gcp.group_call_session_id = ? + AND gcp.left_at IS NULL + ORDER BY gcp.joined_at ASC, u.username COLLATE NOCASE + """, + (session_id,), + ).fetchall() + + return [ + { + "user_id": row["user_id"], + "username": row["username"], + "display_name": row["display_name"] or row["username"], + "role": row["role"], + "name_color": row["name_color"], + "joined_at": row["joined_at"], + "updated_at": row["updated_at"], + } + for row in rows + ] + + def active_group_call_participant_count(session_id: int) -> int: + cleanup_stale_group_call_participants(session_id) + + with get_db() as db: + row = db.execute( + """ + SELECT COUNT(*) AS count + FROM group_call_participants + WHERE group_call_session_id = ? + AND left_at IS NULL + """, + (session_id,), + ).fetchone() + return int(row["count"]) if row else 0 + + def maybe_end_group_call_if_empty(session_id: int): + if active_group_call_participant_count(session_id) > 0: + return + + db_write( + """ + UPDATE group_call_sessions + SET + status = 'ended', + ended_at = ? + WHERE id = ? + AND status != 'ended' + """, + (now_iso(), session_id), + ) + + db_write( + """ + UPDATE group_call_invites + SET + status = 'dismissed', + responded_at = ? + WHERE group_call_session_id = ? + AND status = 'pending' + """, + (now_iso(), session_id), + ) + + def group_call_scope_payload(session_row, user): + payload = { + "scope_kind": session_row["scope_kind"], + "scope_id": session_row["scope_id"], + "scope_slug": session_row["scope_slug"], + } + + if session_row["scope_kind"] == "room": + room = room_by_id(session_row["scope_id"]) + if room: + payload["room"] = room_payload(room, user) + payload["scope_label"] = room["name"] + elif session_row["scope_kind"] == "channel": + channel = get_channel_by_id(session_row["scope_id"]) + if channel: + payload["channel"] = channel_payload(channel, user) + payload["team"] = { + "slug": channel["team_slug"], + "name": channel["team_name"], + } + payload["scope_label"] = f"# {channel['name']}" + + return payload + + def group_call_permissions_payload(session_row, user): + can_manage = can_manage_group_call_session(session_row, user) + can_access = can_access_group_call_session(session_row, user) + return { + "can_manage": can_manage, + "can_end": can_manage, + "can_invite": can_access, + } @app.route("/api/attachments", methods=["POST"]) def upload_attachment(): if not csrf_valid(): return jsonify({"error": "Security token expired.", "code": "csrf"}), 403 + user, error = require_writable_user() if error: return error + limited = rate_limited("attachment") if limited: return limited @@ -408,19 +551,23 @@ def register_routes(app, ctx): declared_mime = (uploaded.mimetype or "").lower() guessed_mime = ctx["mimetypes"].guess_type(original_name)[0] or "" + if declared_mime and declared_mime not in constants["ALLOWED_ATTACHMENT_MIME_TYPES"]: return jsonify({"error": "This file MIME type is not allowed."}), 400 + if guessed_mime and guessed_mime not in constants["ALLOWED_ATTACHMENT_MIME_TYPES"]: return jsonify({"error": "This file extension is not allowed."}), 400 content = uploaded.stream.read(constants["MAX_ATTACHMENT_BYTES"] + 1) if not content: return jsonify({"error": "Attachment cannot be empty."}), 400 + if len(content) > constants["MAX_ATTACHMENT_BYTES"]: return jsonify({"error": "Attachments must be 100 MB or smaller."}), 400 stored_name = f"{ctx['secrets'].token_urlsafe(24)}.{extension}" destination = ctx["UPLOAD_DIR"] / stored_name + try: destination.write_bytes(content) ctx["os"].chmod(destination, 0o600) @@ -429,6 +576,7 @@ def register_routes(app, ctx): return jsonify({"error": "Could not save attachment."}), 500 mime_type = declared_mime or guessed_mime or "application/octet-stream" + try: cursor = db_write( """ @@ -448,6 +596,7 @@ def register_routes(app, ctx): raise audit("attachment_uploaded", user["id"], "attachment", str(cursor.lastrowid), original_name) + return jsonify( { "attachment": { @@ -465,6 +614,7 @@ def register_routes(app, ctx): user, error = require_user() if error: return error + return send_attachment_file( get_db, ctx["UPLOAD_DIR"], @@ -475,86 +625,12 @@ def register_routes(app, ctx): get_channel_by_id, ) - @app.route("/api/attachments//share", methods=["POST"]) - def api_create_attachment_share(attachment_id: int): - if not csrf_valid(): - return jsonify({"error": "Security token expired.", "code": "csrf"}), 403 - limited = rate_limited("share") - if limited: - return limited - user, error = require_user() - if error: - return error - - from features import can_download_attachment - if not can_download_attachment( - get_db, - attachment_id, - user, - can_access_room, - can_access_channel, - get_channel_by_id, - ): - return jsonify({"error": "Forbidden.", "code": "forbidden"}), 403 - - expires_at = (utc_now() + ctx["timedelta"](seconds=SHARE_TOKEN_TTL_SECONDS)).isoformat() - token = create_attachment_share(attachment_id, user["id"], expires_at) - audit("attachment_share_created", user["id"], "attachment", str(attachment_id)) - return jsonify( - { - "share": { - "url": f"/api/attachment-shares/{token}", - "expires_at": expires_at, - } - } - ), 201 - - @app.route("/api/attachment-shares/") - def api_open_attachment_share(token: str): - token_hash = hash_identifier(token) - with get_db() as db: - share = db.execute( - """ - SELECT - s.attachment_id, - s.expires_at, - s.revoked_at - FROM attachment_shares s - WHERE s.token_hash = ? - """, - (token_hash,), - ).fetchone() - if not share: - return jsonify({"error": "Share not found."}), 404 - if share["revoked_at"] or parse_iso(share["expires_at"]) <= utc_now(): - return jsonify({"error": "Share expired."}), 410 - - with get_db() as db: - attachment = db.execute( - """ - SELECT id, stored_name, original_name, mime_type - FROM attachments - WHERE id = ? - """, - (share["attachment_id"],), - ).fetchone() - if not attachment: - return jsonify({"error": "Attachment not found."}), 404 - - from flask import send_from_directory - return send_from_directory( - ctx["UPLOAD_DIR"], - attachment["stored_name"], - as_attachment=True, - download_name=attachment["original_name"], - mimetype=attachment["mime_type"], - ) - @app.route("/api/rooms//messages") def get_room_messages(slug: str): room = get_room_by_slug(slug) if not room: return jsonify({"error": "Room not found."}), 404 + user = current_user() if not can_access_room(room, user): if not user: @@ -615,8 +691,10 @@ def register_routes(app, ctx): limited = rate_limited("send") if limited: return limited + if not csrf_valid(): return jsonify({"error": "Security token expired.", "code": "csrf"}), 403 + user, error = require_writable_user() if error: return error @@ -624,6 +702,7 @@ def register_routes(app, ctx): room = get_room_by_slug(slug) if not room: return jsonify({"error": "Room not found."}), 404 + if not can_write_room(room, user): return jsonify({"error": "You cannot post in this room.", "code": "forbidden"}), 403 @@ -635,8 +714,8 @@ 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 @@ -659,6 +738,7 @@ def register_routes(app, ctx): 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 @@ -692,6 +772,7 @@ def register_routes(app, ctx): ), ) message_id = cursor.lastrowid + for position, extra_attachment_id in enumerate(attachment_ids): db.execute( """ @@ -703,6 +784,7 @@ def register_routes(app, ctx): """, (message_id, extra_attachment_id, position), ) + db.commit() with get_db() as db: @@ -715,9 +797,11 @@ def register_routes(app, ctx): def clear_room_messages(slug: str): if not csrf_valid(): return jsonify({"error": "Security token expired.", "code": "csrf"}), 403 + moderator, error = require_moderator() if error: return error + limited = rate_limited("admin") if limited: return limited @@ -734,6 +818,7 @@ def register_routes(app, ctx): """, (room["id"],), ) + audit("room_messages_cleared", moderator["id"], "room", str(room["id"]), str(cursor.rowcount)) return jsonify({"status": "cleared", "deleted_messages": cursor.rowcount}) @@ -742,6 +827,7 @@ def register_routes(app, ctx): channel = get_channel_by_slug(team_slug, channel_slug) if not channel: return jsonify({"error": "Channel not found."}), 404 + user = current_user() if not can_access_channel(channel, user): if not user: @@ -789,6 +875,7 @@ 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( { "team": {"slug": channel["team_slug"], "name": channel["team_name"]}, @@ -804,8 +891,10 @@ def register_routes(app, ctx): limited = rate_limited("send") if limited: return limited + if not csrf_valid(): return jsonify({"error": "Security token expired.", "code": "csrf"}), 403 + user, error = require_writable_user() if error: return error @@ -813,6 +902,7 @@ def register_routes(app, ctx): channel = get_channel_by_slug(team_slug, channel_slug) if not channel: return jsonify({"error": "Channel not found."}), 404 + if not can_write_channel(channel, user): return jsonify({"error": "You cannot post in this channel.", "code": "forbidden"}), 403 @@ -824,6 +914,7 @@ 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() if len(text) > MAX_MESSAGE_LENGTH: return jsonify({"error": "Message is too long."}), 400 @@ -847,6 +938,7 @@ def register_routes(app, ctx): 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 @@ -877,6 +969,7 @@ def register_routes(app, ctx): ), ) message_id = cursor.lastrowid + for position, extra_attachment_id in enumerate(attachment_ids): db.execute( """ @@ -888,6 +981,7 @@ def register_routes(app, ctx): """, (message_id, extra_attachment_id, position), ) + db.commit() with get_db() as db: @@ -896,449 +990,15 @@ def register_routes(app, ctx): audit("channel_message_sent", user["id"], "channel", str(channel["id"]), str(message_id)) return jsonify({"message": build_message_payload(row, user)}), 201 - @app.route("/api/messages//pin", methods=["POST"]) - def api_pin_message(message_id: int): - if not csrf_valid(): - return jsonify({"error": "Security token expired.", "code": "csrf"}), 403 - limited = rate_limited("pin") - if limited: - return limited - user, error = require_writable_user() - if error: - return error - - with get_db() as db: - row = db.execute( - """ - SELECT id, room_id, channel_id, direct_conversation_id - FROM messages - WHERE id = ? - """, - (message_id,), - ).fetchone() - if not row: - return jsonify({"error": "Message not found."}), 404 - if not _can_pin_message(row, user): - return jsonify({"error": "Forbidden.", "code": "forbidden"}), 403 - - db_write( - """ - INSERT OR REPLACE INTO message_pins ( - message_id, - pinned_by_user_id, - pinned_at - ) VALUES (?, ?, ?) - """, - (message_id, user["id"], now_iso()), - ) - audit("message_pinned", user["id"], "message", str(message_id)) - return jsonify({"status": "pinned"}) - - @app.route("/api/messages//pin", methods=["DELETE"]) - def api_unpin_message(message_id: int): - if not csrf_valid(): - return jsonify({"error": "Security token expired.", "code": "csrf"}), 403 - limited = rate_limited("pin") - if limited: - return limited - user, error = require_writable_user() - if error: - return error - - with get_db() as db: - row = db.execute( - """ - SELECT id, room_id, channel_id, direct_conversation_id - FROM messages - WHERE id = ? - """, - (message_id,), - ).fetchone() - if not row: - return jsonify({"error": "Message not found."}), 404 - if not _can_pin_message(row, user): - return jsonify({"error": "Forbidden.", "code": "forbidden"}), 403 - - db_write("DELETE FROM message_pins WHERE message_id = ?", (message_id,)) - audit("message_unpinned", user["id"], "message", str(message_id)) - return jsonify({"status": "unpinned"}) - - @app.route("/api/pins") - def api_list_pins(): - user, error = require_user() - if error: - return error - - scope = str(request.args.get("scope", "")).strip().lower() - scope_id_raw = request.args.get("scope_id") - try: - scope_id = int(scope_id_raw) - except (TypeError, ValueError): - return jsonify({"error": "scope_id is required."}), 400 - - if scope == "room": - room = room_by_id(scope_id) - if not room or not can_access_room(room, user): - return jsonify({"error": "Room not found."}), 404 - where_clause = "m.room_id = ? AND m.direct_conversation_id IS NULL" - elif scope == "channel": - channel = get_channel_by_id(scope_id) - if not channel or not can_access_channel(channel, user): - return jsonify({"error": "Channel not found."}), 404 - where_clause = "m.channel_id = ? AND m.direct_conversation_id IS NULL" - elif scope == "dm": - if not validate_dm_access(get_db, scope_id, user): - return jsonify({"error": "DM not found."}), 404 - where_clause = "m.direct_conversation_id = ?" - else: - return jsonify({"error": "Invalid scope."}), 400 - - with get_db() as db: - rows = db.execute( - f""" - {MESSAGE_SELECT} - JOIN message_pins mp - ON mp.message_id = m.id - WHERE {where_clause} - ORDER BY mp.pinned_at DESC - LIMIT 100 - """, - (scope_id,), - ).fetchall() - - return jsonify({"messages": [build_message_payload(row, user) for row in rows]}) - - @app.route("/api/messages//bookmark", methods=["POST"]) - def api_bookmark_message(message_id: int): - if not csrf_valid(): - return jsonify({"error": "Security token expired.", "code": "csrf"}), 403 - limited = rate_limited("bookmark") - if limited: - return limited - user, error = require_user() - if error: - return error - - with get_db() as db: - message = db.execute( - f"{MESSAGE_SELECT} WHERE m.id = ?", - (message_id,), - ).fetchone() - if not message: - return jsonify({"error": "Message not found."}), 404 - if not _message_accessible(message, user): - return jsonify({"error": "Forbidden.", "code": "forbidden"}), 403 - - db_write( - """ - INSERT OR IGNORE INTO message_bookmarks ( - user_id, - message_id, - created_at - ) VALUES (?, ?, ?) - """, - (user["id"], message_id, now_iso()), - ) - audit("message_bookmarked", user["id"], "message", str(message_id)) - return jsonify({"status": "bookmarked"}) - - @app.route("/api/messages//bookmark", methods=["DELETE"]) - def api_unbookmark_message(message_id: int): - if not csrf_valid(): - return jsonify({"error": "Security token expired.", "code": "csrf"}), 403 - limited = rate_limited("bookmark") - if limited: - return limited - user, error = require_user() - if error: - return error - - db_write( - """ - DELETE FROM message_bookmarks - WHERE user_id = ? - AND message_id = ? - """, - (user["id"], message_id), - ) - audit("message_unbookmarked", user["id"], "message", str(message_id)) - return jsonify({"status": "unbookmarked"}) - - @app.route("/api/bookmarks") - def api_list_bookmarks(): - user, error = require_user() - if error: - return error - - with get_db() as db: - rows = db.execute( - f""" - {MESSAGE_SELECT} - JOIN message_bookmarks mb - ON mb.message_id = m.id - WHERE mb.user_id = ? - ORDER BY mb.created_at DESC - LIMIT 200 - """, - (user["id"],), - ).fetchall() - - return jsonify({"messages": [build_message_payload(row, user) for row in rows]}) - - @app.route("/api/messages//share", methods=["POST"]) - def api_create_message_share(message_id: int): - if not csrf_valid(): - return jsonify({"error": "Security token expired.", "code": "csrf"}), 403 - limited = rate_limited("share") - if limited: - return limited - user, error = require_user() - if error: - return error - - with get_db() as db: - message = db.execute( - f"{MESSAGE_SELECT} WHERE m.id = ?", - (message_id,), - ).fetchone() - if not message: - return jsonify({"error": "Message not found."}), 404 - if not _message_accessible(message, user): - return jsonify({"error": "Forbidden.", "code": "forbidden"}), 403 - - expires_at = (utc_now() + ctx["timedelta"](seconds=SHARE_TOKEN_TTL_SECONDS)).isoformat() - token = create_message_share(message_id, user["id"], expires_at) - audit("message_share_created", user["id"], "message", str(message_id)) - return jsonify( - { - "share": { - "url": f"/api/message-shares/{token}", - "expires_at": expires_at, - } - } - ), 201 - - @app.route("/api/message-shares/") - def api_open_message_share(token: str): - token_hash = hash_identifier(token) - with get_db() as db: - share = db.execute( - """ - SELECT - s.message_id, - s.expires_at, - s.revoked_at - FROM message_shares s - WHERE s.token_hash = ? - """, - (token_hash,), - ).fetchone() - if not share: - return jsonify({"error": "Share not found."}), 404 - if share["revoked_at"] or parse_iso(share["expires_at"]) <= utc_now(): - return jsonify({"error": "Share expired."}), 410 - - with get_db() as db: - message = db.execute( - f"{MESSAGE_SELECT} WHERE m.id = ?", - (share["message_id"],), - ).fetchone() - if not message: - return jsonify({"error": "Message not found."}), 404 - - return jsonify({"message": build_message_payload(message, current_user())}) - - @app.route("/api/scheduled-messages") - def api_list_scheduled_messages(): - user, error = require_user() - if error: - return error - - with get_db() as db: - rows = db.execute( - """ - SELECT - id, - room_id, - channel_id, - direct_conversation_id, - text, - attachment_ids_json, - deliver_at, - created_at, - sent_at, - cancelled_at - FROM scheduled_messages - WHERE user_id = ? - AND sent_at IS NULL - AND cancelled_at IS NULL - ORDER BY deliver_at ASC - LIMIT 200 - """, - (user["id"],), - ).fetchall() - - messages = [] - for row in rows: - try: - attachment_ids = json.loads(row["attachment_ids_json"] or "[]") - except json.JSONDecodeError: - attachment_ids = [] - messages.append( - { - "id": row["id"], - "room_id": row["room_id"], - "channel_id": row["channel_id"], - "direct_conversation_id": row["direct_conversation_id"], - "text": row["text"], - "attachment_ids": attachment_ids, - "deliver_at": row["deliver_at"], - "created_at": row["created_at"], - } - ) - - return jsonify({"scheduled_messages": messages}) - - @app.route("/api/scheduled-messages", methods=["POST"]) - def api_create_scheduled_message(): - if not csrf_valid(): - return jsonify({"error": "Security token expired.", "code": "csrf"}), 403 - limited = rate_limited("schedule") - if limited: - return limited - user, error = require_writable_user() - if error: - return error - - try: - data = safe_json() - except ValueError as error: - return jsonify({"error": str(error)}), 400 - - text = str(data.get("text", "")).strip() - deliver_at_raw = str(data.get("deliver_at", "")).strip() - scope_kind = str(data.get("scope_kind", "")).strip().lower() - scope_id_raw = data.get("scope_id") - - if not text: - return jsonify({"error": "Scheduled message cannot be empty."}), 400 - if len(text) > MAX_MESSAGE_LENGTH: - return jsonify({"error": "Message is too long."}), 400 - - deliver_at = parse_iso(deliver_at_raw) - if not deliver_at or deliver_at <= utc_now(): - return jsonify({"error": "deliver_at must be a future ISO timestamp."}), 400 - - try: - scope_id = int(scope_id_raw) - except (TypeError, ValueError): - return jsonify({"error": "scope_id is required."}), 400 - - attachment_ids, attachment_error = resolve_owned_attachment_ids( - get_db, - data.get("attachment_ids", []), - user["id"], - ) - if attachment_error: - return attachment_error - - room_id = None - channel_id = None - dm_id = None - - if scope_kind == "room": - room = room_by_id(scope_id) - if not room or not can_write_room(room, user): - return jsonify({"error": "Room not found or not writable."}), 404 - room_id = room["id"] - channel = get_channel_by_legacy_room_id(room_id) - channel_id = channel["id"] if channel else None - elif scope_kind == "channel": - channel = get_channel_by_id(scope_id) - if not channel or not can_write_channel(channel, user): - return jsonify({"error": "Channel not found or not writable."}), 404 - channel_id = channel["id"] - room_id = channel["legacy_room_id"] - elif scope_kind == "dm": - if not validate_dm_access(get_db, scope_id, user): - return jsonify({"error": "DM not found."}), 404 - dm_id = scope_id - else: - return jsonify({"error": "Invalid scope_kind."}), 400 - - cursor = db_write( - """ - INSERT INTO scheduled_messages ( - room_id, - channel_id, - direct_conversation_id, - user_id, - username, - text, - attachment_ids_json, - deliver_at, - created_at, - sent_at, - cancelled_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, NULL) - """, - ( - room_id, - channel_id, - dm_id, - user["id"], - user["username"], - text, - json.dumps(attachment_ids), - deliver_at.isoformat(), - now_iso(), - ), - ) - audit("scheduled_message_created", user["id"], "scheduled_message", str(cursor.lastrowid)) - return jsonify({"status": "scheduled", "id": cursor.lastrowid}), 201 - - @app.route("/api/scheduled-messages/", methods=["DELETE"]) - def api_cancel_scheduled_message(scheduled_id: int): - if not csrf_valid(): - return jsonify({"error": "Security token expired.", "code": "csrf"}), 403 - user, error = require_user() - if error: - return error - - with get_db() as db: - row = db.execute( - """ - SELECT id, user_id, sent_at, cancelled_at - FROM scheduled_messages - WHERE id = ? - """, - (scheduled_id,), - ).fetchone() - if not row: - return jsonify({"error": "Scheduled message not found."}), 404 - if row["user_id"] != user["id"] and user["role"] not in {"moderator", "admin"}: - return jsonify({"error": "Forbidden.", "code": "forbidden"}), 403 - if row["sent_at"] or row["cancelled_at"]: - return jsonify({"error": "Scheduled message is no longer cancelable."}), 400 - - db_write( - """ - UPDATE scheduled_messages - SET cancelled_at = ? - WHERE id = ? - """, - (now_iso(), scheduled_id), - ) - audit("scheduled_message_cancelled", user["id"], "scheduled_message", str(scheduled_id)) - return jsonify({"status": "cancelled"}) - @app.route("/api/messages/", methods=["PATCH"]) def edit_message(message_id: int): if not csrf_valid(): return jsonify({"error": "Security token expired.", "code": "csrf"}), 403 + limited = rate_limited("send") if limited: return limited + user, error = require_writable_user() if error: return error @@ -1351,6 +1011,7 @@ 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( @@ -1363,11 +1024,13 @@ def register_routes(app, ctx): if len(new_text) > MAX_MESSAGE_LENGTH: return jsonify({"error": "Message is too long."}), 400 + if not new_text and not attachment_ids: return jsonify({"error": "Message cannot be empty."}), 400 with get_db() as db: message = db.execute(f"{MESSAGE_SELECT} WHERE m.id = ?", (message_id,)).fetchone() + if not message: return jsonify({"error": "Message not found."}), 404 @@ -1383,6 +1046,7 @@ def register_routes(app, ctx): return jsonify({"error": "Edit window has expired."}), 403 old_attachments = ctx["attachment_list_for_message"](message_id) + with get_db() as db: db.execute("BEGIN IMMEDIATE") db.execute( @@ -1390,6 +1054,7 @@ def register_routes(app, ctx): (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): db.execute( """ @@ -1401,6 +1066,7 @@ def register_routes(app, ctx): """, (message_id, attachment_id, position), ) + db.execute( """ INSERT INTO message_edits ( @@ -1435,9 +1101,11 @@ def register_routes(app, ctx): def delete_message(message_id: int): if not csrf_valid(): return jsonify({"error": "Security token expired.", "code": "csrf"}), 403 + moderator, error = require_moderator() if error: return error + limited = rate_limited("admin") if limited: return limited @@ -1456,6 +1124,7 @@ def register_routes(app, ctx): slug = str(request.args.get("slug", "")).strip() team_slug = str(request.args.get("team_slug", "")).strip() before_id_raw = str(request.args.get("before_id", "")).strip() + try: before_id = int(before_id_raw) except ValueError: @@ -1465,6 +1134,7 @@ def register_routes(app, ctx): room = get_room_by_slug(slug) if not room or not can_access_room(room, user): return jsonify({"error": "Room not found."}), 404 + with get_db() as db: rows = db.execute( f""" @@ -1482,10 +1152,12 @@ def register_routes(app, ctx): """, (room["id"], before_id, MAX_HISTORY_MESSAGES), ).fetchall() + elif kind == "channel": channel = get_channel_by_slug(team_slug, slug) if not channel or not can_access_channel(channel, user): return jsonify({"error": "Channel not found."}), 404 + with get_db() as db: rows = db.execute( f""" @@ -1503,14 +1175,17 @@ 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: dm_id = int(dm_id_raw) except ValueError: return jsonify({"error": "dm_id is required for DM history."}), 400 + if not validate_dm_access(get_db, dm_id, user): return jsonify({"error": "DM not found."}), 404 + with get_db() as db: rows = db.execute( f""" @@ -1536,6 +1211,7 @@ def register_routes(app, ctx): @app.route("/api/messages//thread") def api_get_message_thread(message_id: int): user = current_user() + with get_db() as db: root = db.execute( f""" @@ -1545,6 +1221,7 @@ def register_routes(app, ctx): """, (message_id,), ).fetchone() + if not root: return jsonify({"error": "Thread root message not found."}), 404 @@ -1559,6 +1236,7 @@ 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_access_channel(channel, user): if not user: return jsonify({"error": "Login required.", "code": "login_required"}), 401 @@ -1579,6 +1257,7 @@ def register_routes(app, ctx): "root": build_message_payload(root, user), "replies": [build_message_payload(reply, user) for reply in replies], } + if root["direct_conversation_id"]: payload["dm"] = dm_payload(get_db, root["direct_conversation_id"], user["id"]) else: @@ -1589,6 +1268,7 @@ def register_routes(app, ctx): ) payload["team"] = {"slug": channel["team_slug"], "name": channel["team_name"]} payload["channel"] = channel_payload(channel, user) + return jsonify(payload) @app.route("/api/messages//thread", methods=["POST"]) @@ -1596,8 +1276,10 @@ def register_routes(app, ctx): limited = rate_limited("send") if limited: return limited + if not csrf_valid(): return jsonify({"error": "Security token expired.", "code": "csrf"}), 403 + user, error = require_writable_user() if error: return error @@ -1616,6 +1298,7 @@ def register_routes(app, ctx): """, (message_id,), ).fetchone() + if not root or root["parent_message_id"] is not None: return jsonify({"error": "Thread root message not found."}), 404 @@ -1639,6 +1322,7 @@ 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() if len(text) > MAX_MESSAGE_LENGTH: return jsonify({"error": "Reply is too long."}), 400 @@ -1662,6 +1346,7 @@ def register_routes(app, ctx): 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 @@ -1694,6 +1379,7 @@ def register_routes(app, ctx): ), ) reply_id = cursor.lastrowid + for position, extra_attachment_id in enumerate(attachment_ids): db.execute( """ @@ -1705,6 +1391,7 @@ def register_routes(app, ctx): """, (reply_id, extra_attachment_id, position), ) + db.commit() with get_db() as db: @@ -1718,6 +1405,7 @@ def register_routes(app, ctx): user = current_user() after_raw = request.args.get("after_id", "").strip() after_id = 0 + if after_raw: try: after_id = max(0, int(after_raw)) @@ -1752,10 +1440,7 @@ def register_routes(app, ctx): a.mime_type AS attachment_mime_type, a.size_bytes AS attachment_size_bytes, u.role AS user_role, - us.name_color AS user_name_color, - EXISTS ( - SELECT 1 FROM message_pins mp WHERE mp.message_id = m.id - ) AS is_pinned + us.name_color AS user_name_color FROM messages AS m JOIN users AS u ON u.id = m.user_id @@ -1855,8 +1540,10 @@ 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( @@ -1879,6 +1566,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() @@ -1890,12 +1578,15 @@ def register_routes(app, ctx): def api_search(): user = current_user() query = str(request.args.get("q", "")).strip() + if not query: return jsonify({"results": [], "query": ""}) + if len(query) > MAX_SEARCH_QUERY_LENGTH: return jsonify({"error": "Search query is too long."}), 400 pattern = f"%{query.lower()}%" + with get_db() as db: rows = db.execute( """ @@ -1926,10 +1617,7 @@ def register_routes(app, ctx): a.mime_type AS attachment_mime_type, a.size_bytes AS attachment_size_bytes, u.role AS user_role, - us.name_color AS user_name_color, - EXISTS ( - SELECT 1 FROM message_pins mp WHERE mp.message_id = m.id - ) AS is_pinned + us.name_color AS user_name_color FROM messages AS m JOIN users AS u ON u.id = m.user_id @@ -1961,14 +1649,17 @@ def register_routes(app, ctx): elif row["room_id"]: room = room_by_id(row["room_id"]) allowed = bool(room and can_access_room(room, user)) + if allowed: visible.append(feed_message_payload(get_db, row, user)) + return jsonify({"query": query, "results": visible}) @app.route("/api/read-receipts", methods=["POST"]) def api_mark_read_receipts(): if not csrf_valid(): return jsonify({"error": "Security token expired.", "code": "csrf"}), 403 + user, error = require_user() if error: return error @@ -1995,6 +1686,7 @@ def register_routes(app, ctx): updated = 0 with get_db() as db: db.execute("BEGIN IMMEDIATE") + for message_id in parsed_ids: row = db.execute( """ @@ -2004,6 +1696,7 @@ def register_routes(app, ctx): """, (message_id,), ).fetchone() + if not row: continue @@ -2016,6 +1709,7 @@ def register_routes(app, ctx): elif row["room_id"]: room = room_by_id(row["room_id"]) allowed = bool(room and can_access_room(room, user)) + if not allowed: continue @@ -2032,6 +1726,7 @@ def register_routes(app, ctx): (message_id, user["id"], now_iso()), ) updated += 1 + db.commit() return jsonify({"status": "ok", "updated": updated}) @@ -2051,6 +1746,7 @@ def register_routes(app, ctx): """, (message_id,), ).fetchone() + if not message: return jsonify({"error": "Message not found."}), 404 @@ -2063,6 +1759,7 @@ def register_routes(app, ctx): elif message["room_id"]: room = room_by_id(message["room_id"]) allowed = bool(room and can_access_room(room, user)) + if not allowed: return jsonify({"error": "Forbidden.", "code": "forbidden"}), 403 @@ -2099,6 +1796,7 @@ def register_routes(app, ctx): @app.route("/api/messages//reactions") def api_get_message_reactions(message_id: int): user = current_user() + with get_db() as db: row = db.execute( """ @@ -2112,6 +1810,7 @@ def register_routes(app, ctx): """, (message_id,), ).fetchone() + if not row: return jsonify({"error": "Message not found."}), 404 @@ -2124,6 +1823,7 @@ def register_routes(app, ctx): elif row["room_id"]: room = room_by_id(row["room_id"]) allowed = bool(room and can_access_room(room, user)) + if not allowed: if not user: return jsonify({"error": "Login required.", "code": "login_required"}), 401 @@ -2155,6 +1855,7 @@ def register_routes(app, ctx): "created_at": item["created_at"], } ) + summary = [] for emoji, users in grouped.items(): summary.append( @@ -2165,6 +1866,7 @@ def register_routes(app, ctx): "users": users, } ) + summary.sort(key=lambda x: (-x["count"], x["emoji"])) return jsonify({"message_id": message_id, "reactions": summary}) @@ -2173,8 +1875,10 @@ def register_routes(app, ctx): limited = rate_limited("reaction") if limited: return limited + if not csrf_valid(): return jsonify({"error": "Security token expired.", "code": "csrf"}), 403 + user, error = require_writable_user() if error: return error @@ -2201,6 +1905,7 @@ def register_routes(app, ctx): """, (message_id,), ).fetchone() + if not row: return jsonify({"error": "Message not found."}), 404 @@ -2213,6 +1918,7 @@ def register_routes(app, ctx): elif row["room_id"]: room = room_by_id(row["room_id"]) allowed = bool(room and can_access_room(room, user)) + if not allowed: return jsonify({"error": "Forbidden.", "code": "forbidden"}), 403 @@ -2227,6 +1933,7 @@ def register_routes(app, ctx): """, (message_id, user["id"], emoji, now_iso()), ) + audit("message_reaction_added", user["id"], "message", str(message_id), emoji) return jsonify( { @@ -2240,8 +1947,10 @@ def register_routes(app, ctx): limited = rate_limited("reaction") if limited: return limited + if not csrf_valid(): return jsonify({"error": "Security token expired.", "code": "csrf"}), 403 + user, error = require_writable_user() if error: return error @@ -2255,6 +1964,7 @@ def register_routes(app, ctx): """, (message_id, user["id"], emoji), ) + audit("message_reaction_removed", user["id"], "message", str(message_id), emoji) return jsonify( { @@ -2267,6 +1977,7 @@ def register_routes(app, ctx): def api_presence_ping(): if not csrf_valid(): return jsonify({"error": "Security token expired.", "code": "csrf"}), 403 + user, error = require_user() if error: return error @@ -2285,10 +1996,12 @@ def register_routes(app, ctx): room_id = int(room_id) if room_id is not None else None except (TypeError, ValueError): room_id = None + try: channel_id = int(channel_id) if channel_id is not None else None except (TypeError, ValueError): channel_id = None + try: dm_id = int(dm_id) if dm_id is not None else None except (TypeError, ValueError): @@ -2339,9 +2052,11 @@ def register_routes(app, ctx): def set_typing(slug: str): if not csrf_valid(): return jsonify({"error": "Security token expired.", "code": "csrf"}), 403 + user, error = require_writable_user() if error: return error + limited = rate_limited("typing") if limited: return limited @@ -2356,6 +2071,7 @@ def register_routes(app, ctx): return jsonify({"error": str(error)}), 400 typing = bool(data.get("typing")) + if typing: db_write( """ @@ -2374,12 +2090,14 @@ def register_routes(app, ctx): "DELETE FROM typing_events WHERE room_id = ? AND user_id = ?", (room["id"], user["id"]), ) + return jsonify({"status": "ok"}) @app.route("/api/rooms//typing") def get_typing(slug: str): room = get_room_by_slug(slug) user = current_user() + if not room or not can_access_room(room, user): return jsonify({"error": "Room not found."}), 404 @@ -2402,15 +2120,18 @@ def register_routes(app, ctx): (room["id"], user["id"] if user else -1, cutoff), ).fetchall() db.commit() + return jsonify({"users": [{"id": row["id"], "username": row["username"]} for row in rows]}) @app.route("/api/teams//channels//typing", methods=["POST"]) def api_set_channel_typing(team_slug: str, channel_slug: str): if not csrf_valid(): return jsonify({"error": "Security token expired.", "code": "csrf"}), 403 + user, error = require_writable_user() if error: return error + limited = rate_limited("typing") if limited: return limited @@ -2418,6 +2139,7 @@ def register_routes(app, ctx): channel = get_channel_by_slug(team_slug, channel_slug) if not channel: return jsonify({"error": "Channel not found."}), 404 + if not can_write_channel(channel, user): return jsonify({"error": "You cannot use typing indicators here.", "code": "forbidden"}), 403 @@ -2427,6 +2149,7 @@ def register_routes(app, ctx): return jsonify({"error": str(error)}), 400 typing = bool(data.get("typing")) + if typing: db_write( """ @@ -2445,6 +2168,7 @@ def register_routes(app, ctx): "DELETE FROM channel_typing_events WHERE channel_id = ? AND user_id = ?", (channel["id"], user["id"]), ) + return jsonify({"status": "ok"}) @app.route("/api/teams//channels//typing") @@ -2452,6 +2176,7 @@ def register_routes(app, ctx): channel = get_channel_by_slug(team_slug, channel_slug) if not channel: return jsonify({"error": "Channel not found."}), 404 + user = current_user() if not can_access_channel(channel, user): if not user: @@ -2477,15 +2202,18 @@ def register_routes(app, ctx): (channel["id"], user["id"] if user else -1, cutoff), ).fetchall() db.commit() + return jsonify({"users": [{"id": row["id"], "username": row["username"]} for row in rows]}) @app.route("/api/dms//typing", methods=["POST"]) def api_set_dm_typing(dm_id: int): if not csrf_valid(): return jsonify({"error": "Security token expired.", "code": "csrf"}), 403 + user, error = require_writable_user() if error: return error + limited = rate_limited("typing") if limited: return limited @@ -2499,6 +2227,7 @@ def register_routes(app, ctx): return jsonify({"error": str(error)}), 400 typing = bool(data.get("typing")) + if typing: db_write( """ @@ -2521,6 +2250,7 @@ def register_routes(app, ctx): """, (dm_id, user["id"]), ) + return jsonify({"status": "ok"}) @app.route("/api/dms//typing") @@ -2548,11 +2278,13 @@ def register_routes(app, ctx): (dm_id, user["id"], cutoff), ).fetchall() db.commit() + return jsonify({"users": [{"id": row["id"], "username": row["username"]} for row in rows]}) @app.route("/api/rooms") def list_rooms(): user = current_user() + with get_db() as db: rows = db.execute( """ @@ -2571,6 +2303,7 @@ def register_routes(app, ctx): name COLLATE NOCASE """ ).fetchall() + rooms = [room_payload(room, user) for room in rows if can_access_room(room, user)] return jsonify({"rooms": rooms, "authenticated": bool(user)}) @@ -2578,9 +2311,11 @@ def register_routes(app, ctx): def create_room(): if not csrf_valid(): return jsonify({"error": "Security token expired.", "code": "csrf"}), 403 + admin, error = require_admin() if error: return error + limited = rate_limited("admin") if limited: return limited @@ -2593,6 +2328,7 @@ def register_routes(app, ctx): name = str(data.get("name", "")).strip() description = str(data.get("description", "")).strip()[:300] visibility = data.get("visibility", "public") + 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: @@ -2600,6 +2336,7 @@ def register_routes(app, ctx): base_slug = re.sub(r"[^a-z0-9-]+", "-", name.lower()).strip("-") base_slug = base_slug[:48].strip("-") + if not base_slug or not ROOM_SLUG_RE.fullmatch(base_slug): return jsonify({"error": "Room name cannot create a valid room slug."}), 400 @@ -2610,6 +2347,7 @@ def register_routes(app, ctx): while db.execute("SELECT 1 FROM rooms WHERE slug = ?", (slug,)).fetchone(): slug = f"{base_slug[:44]}-{suffix}" suffix += 1 + cursor = db.execute( """ INSERT INTO rooms ( @@ -2625,6 +2363,7 @@ def register_routes(app, ctx): (slug, name, description, visibility, admin["id"], now_iso()), ) room_id = cursor.lastrowid + db.execute( """ INSERT INTO room_members ( @@ -2636,9 +2375,11 @@ 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() + if general_team: db.execute( """ @@ -2663,9 +2404,11 @@ def register_routes(app, ctx): now_iso(), ), ) + db.commit() audit("room_created", admin["id"], "room", str(room_id), f"{slug}:{visibility}") + return jsonify( { "room": { @@ -2685,9 +2428,11 @@ def register_routes(app, ctx): def update_room(slug: str): if not csrf_valid(): return jsonify({"error": "Security token expired.", "code": "csrf"}), 403 + user, error = require_writable_user() if error: return error + limited = rate_limited("admin") if limited: return limited @@ -2695,8 +2440,10 @@ def register_routes(app, ctx): room = get_room_by_slug(slug) if not room: return jsonify({"error": "Room not found."}), 404 + if room["slug"] == "general" and user["role"] != "admin": return jsonify({"error": "Only an administrator can modify the default room."}), 403 + if not can_manage_room(room, user): return jsonify({"error": "Room owner or administrator access required.", "code": "forbidden"}), 403 @@ -2722,6 +2469,7 @@ def register_routes(app, ctx): "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"}) @@ -2729,9 +2477,11 @@ def register_routes(app, ctx): def delete_room(slug: str): if not csrf_valid(): return jsonify({"error": "Security token expired.", "code": "csrf"}), 403 + admin, error = require_admin() if error: return error + limited = rate_limited("admin") if limited: return limited @@ -2739,6 +2489,7 @@ def register_routes(app, ctx): room = get_room_by_slug(slug) if not room: return jsonify({"error": "Room not found."}), 404 + if room["slug"] == "general": return jsonify({"error": "The default room cannot be deleted."}), 400 @@ -2755,6 +2506,7 @@ def register_routes(app, ctx): room = get_room_by_slug(slug) if not room: return jsonify({"error": "Room not found."}), 404 + if room["visibility"] != "private": return jsonify({"error": "This room is public."}), 400 @@ -2801,6 +2553,7 @@ def register_routes(app, ctx): def add_room_member(slug: str): if not csrf_valid(): return jsonify({"error": "Security token expired.", "code": "csrf"}), 403 + admin, error = require_admin() if error: return error @@ -2808,6 +2561,7 @@ def register_routes(app, ctx): room = get_room_by_slug(slug) if not room: return jsonify({"error": "Room not found."}), 404 + if room["visibility"] != "private": return jsonify({"error": "Members can only be managed for private rooms."}), 400 @@ -2817,11 +2571,13 @@ 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() + if not target: return jsonify({"error": "User not found."}), 404 @@ -2836,6 +2592,7 @@ def register_routes(app, ctx): """, (room["id"], target["id"], now_iso()), ) + audit("room_member_added", admin["id"], "room", str(room["id"]), target["username"]) return jsonify({"status": "added"}), 201 @@ -2843,6 +2600,7 @@ def register_routes(app, ctx): def remove_room_member(slug: str, user_id: int): if not csrf_valid(): return jsonify({"error": "Security token expired.", "code": "csrf"}), 403 + admin, error = require_admin() if error: return error @@ -2861,8 +2619,10 @@ def register_routes(app, ctx): """, (room["id"], user_id), ).fetchone() + if not member: return jsonify({"error": "Room member not found."}), 404 + if member["membership_role"] == "owner": return jsonify({"error": "The room owner cannot be removed."}), 400 @@ -2870,6 +2630,7 @@ def register_routes(app, ctx): "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"}) @@ -2877,6 +2638,7 @@ def register_routes(app, ctx): def create_room_invite(slug: str): if not csrf_valid(): return jsonify({"error": "Security token expired.", "code": "csrf"}), 403 + admin, error = require_admin() if error: return error @@ -2884,11 +2646,13 @@ def register_routes(app, ctx): room = get_room_by_slug(slug) if not room: return jsonify({"error": "Room not found."}), 404 + if room["visibility"] != "private": return jsonify({"error": "Invite links are only available for private rooms."}), 400 token = ctx["secrets"].token_urlsafe(32) expires_at = (utc_now() + ctx["timedelta"](hours=INVITE_TTL_HOURS)).isoformat() + db_write( """ INSERT INTO room_invites ( @@ -2901,6 +2665,7 @@ def register_routes(app, ctx): """, (room["id"], hash_identifier(token), admin["id"], now_iso(), expires_at), ) + audit("room_invite_created", admin["id"], "room", str(room["id"])) return jsonify({"invite": {"url": f"/?invite={token}", "expires_at": expires_at}}), 201 @@ -2908,11 +2673,13 @@ def register_routes(app, ctx): def accept_room_invite(token: str): if not csrf_valid(): return jsonify({"error": "Security token expired.", "code": "csrf"}), 403 + user, error = require_writable_user() if error: return error token_hash = hash_identifier(token) + with get_db() as db: invite = db.execute( """ @@ -2934,10 +2701,13 @@ def register_routes(app, ctx): """, (token_hash,), ).fetchone() + if not invite: return jsonify({"error": "Invite link is invalid."}), 404 + if invite["used_at"] or parse_iso(invite["expires_at"]) <= utc_now(): return jsonify({"error": "Invite link has expired or was already used."}), 400 + if invite["visibility"] != "private" or not invite["is_active"]: return jsonify({"error": "This room is no longer available."}), 404 @@ -2947,9 +2717,11 @@ def register_routes(app, ctx): "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() return jsonify({"error": "Invite link has expired or was already used."}), 400 + db.execute( """ INSERT OR IGNORE INTO room_members ( @@ -3032,9 +2804,11 @@ def register_routes(app, ctx): def admin_update_user(user_id: int): if not csrf_valid(): return jsonify({"error": "Security token expired.", "code": "csrf"}), 403 + admin, error = require_admin() if error: return error + limited = rate_limited("admin") if limited: return limited @@ -3058,8 +2832,10 @@ def register_routes(app, ctx): """, (user_id,), ).fetchone() + if not target: return jsonify({"error": "User not found."}), 404 + if target["id"] == admin["id"]: return jsonify({"error": "You cannot modify your own account here."}), 400 @@ -3127,6 +2903,7 @@ def register_routes(app, ctx): admin["id"], ), ) + if target["status"] == "blacklisted" and status != "blacklisted": db_write( "DELETE FROM blacklist_entries WHERE kind = 'user' AND value_hash = ?", @@ -3184,6 +2961,7 @@ def register_routes(app, ctx): def blacklist_ip(): if not csrf_valid(): return jsonify({"error": "Security token expired.", "code": "csrf"}), 403 + admin, error = require_admin() if error: return error @@ -3195,6 +2973,7 @@ def register_routes(app, ctx): ip = str(data.get("ip", "")).strip() reason = str(data.get("reason", "")).strip()[:300] + if not ip or len(ip) > 128: return jsonify({"error": "A valid IP value is required."}), 400 @@ -3215,12 +2994,14 @@ def register_routes(app, ctx): admin["id"], ), ) + audit("ip_blacklisted", admin["id"], "ip", hash_identifier(ip), reason) return jsonify({"status": "blacklisted"}) @app.route("/api/teams") def api_list_teams(): user = current_user() + with get_db() as db: teams = db.execute( """ @@ -3237,6 +3018,7 @@ def register_routes(app, ctx): ORDER BY name COLLATE NOCASE """ ).fetchall() + channels = db.execute( """ SELECT @@ -3260,6 +3042,7 @@ def register_routes(app, ctx): AND t.is_active = 1 """ ).fetchall() + public_page_rows = db.execute( """ SELECT DISTINCT p.team_id @@ -3276,6 +3059,7 @@ def register_routes(app, ctx): for channel in channels: if can_access_channel(channel, user): channels_by_team[channel["team_id"]] = True + public_page_team_ids = {row["team_id"] for row in public_page_rows} visible_teams = [ @@ -3283,15 +3067,18 @@ def register_routes(app, ctx): for team in teams if can_access_team(team, user) or channels_by_team.get(team["id"]) or team["id"] in public_page_team_ids ] + return jsonify({"teams": visible_teams, "authenticated": bool(user)}) @app.route("/api/teams", methods=["POST"]) def api_create_team(): if not csrf_valid(): return jsonify({"error": "Security token expired.", "code": "csrf"}), 403 + user, error = require_writable_user() if error: return error + limited = rate_limited("admin") if limited: return limited @@ -3306,6 +3093,7 @@ def register_routes(app, ctx): """, (user["id"],), ).fetchone()["count"] + if owned_count >= MAX_OWNED_TEAMS_PER_USER: return jsonify({"error": "You can own at most 3 teams."}), 400 @@ -3317,6 +3105,7 @@ def register_routes(app, ctx): name = str(data.get("name", "")).strip() description = str(data.get("description", "")).strip()[:300] requested_slug = str(data.get("slug", "")).strip().lower() + if not name or len(name) > 60: return jsonify({"error": "Team name must be 1 to 60 characters."}), 400 @@ -3331,6 +3120,7 @@ def register_routes(app, ctx): while db.execute("SELECT 1 FROM teams WHERE slug = ?", (slug,)).fetchone(): slug = f"{base_slug[:44]}-{suffix}" suffix += 1 + cursor = db.execute( """ INSERT INTO teams ( @@ -3345,6 +3135,7 @@ def register_routes(app, ctx): (slug, name, description, user["id"], now_iso()), ) team_id = cursor.lastrowid + db.execute( """ INSERT INTO team_members ( @@ -3356,9 +3147,11 @@ def register_routes(app, ctx): """, (team_id, user["id"], now_iso()), ) + db.commit() audit("team_created", user["id"], "team", str(team_id), slug) + with get_db() as db: team = db.execute( """ @@ -3375,15 +3168,18 @@ def register_routes(app, ctx): """, (team_id,), ).fetchone() + return jsonify({"team": team_payload(team, user)}), 201 @app.route("/api/teams/", methods=["PATCH"]) def api_update_team(team_slug: str): if not csrf_valid(): return jsonify({"error": "Security token expired.", "code": "csrf"}), 403 + user, error = require_writable_user() if error: return error + limited = rate_limited("admin") if limited: return limited @@ -3391,6 +3187,7 @@ def register_routes(app, ctx): team = get_team_by_slug(team_slug) if not team: return jsonify({"error": "Team not found."}), 404 + if not can_edit_team(team["id"], user): return jsonify({"error": "Team admin access required.", "code": "forbidden"}), 403 @@ -3401,6 +3198,7 @@ def register_routes(app, ctx): name = str(data.get("name", team["name"])).strip() description = str(data.get("description", team["description"] or "")).strip()[:300] + if not name or len(name) > 60: return jsonify({"error": "Team name must be 1 to 60 characters."}), 400 @@ -3408,6 +3206,7 @@ def register_routes(app, ctx): "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)}) @@ -3417,6 +3216,7 @@ def register_routes(app, ctx): team = get_team_by_slug(team_slug) if not team: return jsonify({"error": "Team not found."}), 404 + user = current_user() with get_db() as db: @@ -3453,16 +3253,19 @@ def register_routes(app, ctx): ).fetchall() channels = [channel_payload(channel, user) for channel in rows if can_access_channel(channel, user)] + if not channels and not can_access_team(team, user): if not user: return jsonify({"error": "Login required.", "code": "login_required"}), 401 return jsonify({"error": "You do not have access to this team.", "code": "forbidden"}), 403 + return jsonify({"team": team_payload(team, user), "channels": channels}) @app.route("/api/teams//channels", methods=["POST"]) def api_create_channel(team_slug: str): if not csrf_valid(): return jsonify({"error": "Security token expired.", "code": "csrf"}), 403 + user, error = require_writable_user() if error: return error @@ -3470,6 +3273,7 @@ def register_routes(app, ctx): team = get_team_by_slug(team_slug) if not team: return jsonify({"error": "Team not found."}), 404 + if not is_team_admin(team["id"], user): return jsonify({"error": "Team admin access required.", "code": "forbidden"}), 403 @@ -3481,6 +3285,7 @@ def register_routes(app, ctx): name = str(data.get("name", "")).strip() description = str(data.get("description", "")).strip()[:300] requested_slug = str(data.get("slug", "")).strip().lower() + if not name or len(name) > 60: return jsonify({"error": "Channel name must be 1 to 60 characters."}), 400 @@ -3495,6 +3300,7 @@ def register_routes(app, ctx): while db.execute("SELECT 1 FROM channels WHERE team_id = ? AND slug = ?", (team["id"], slug)).fetchone(): slug = f"{base_slug[:44]}-{suffix}" suffix += 1 + cursor = db.execute( """ INSERT INTO channels ( @@ -3521,9 +3327,11 @@ def register_routes(app, ctx): def api_update_channel(team_slug: str, channel_slug: str): if not csrf_valid(): return jsonify({"error": "Security token expired.", "code": "csrf"}), 403 + user, error = require_writable_user() if error: return error + limited = rate_limited("admin") if limited: return limited @@ -3531,6 +3339,7 @@ def register_routes(app, ctx): channel = get_channel_by_slug(team_slug, channel_slug) if not channel: return jsonify({"error": "Channel not found."}), 404 + if not can_edit_team(channel["team_id"], user): return jsonify({"error": "Team admin access required.", "code": "forbidden"}), 403 @@ -3541,6 +3350,7 @@ def register_routes(app, ctx): name = str(data.get("name", channel["name"])).strip() description = str(data.get("description", channel["description"] or "")).strip()[:300] + if not name or len(name) > 60: return jsonify({"error": "Channel name must be 1 to 60 characters."}), 400 @@ -3548,6 +3358,7 @@ def register_routes(app, ctx): "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 = ?", @@ -3562,9 +3373,11 @@ def register_routes(app, ctx): def api_delete_channel(team_slug: str, channel_slug: str): if not csrf_valid(): return jsonify({"error": "Security token expired.", "code": "csrf"}), 403 + user, error = require_writable_user() if error: return error + limited = rate_limited("admin") if limited: return limited @@ -3572,8 +3385,10 @@ def register_routes(app, ctx): channel = get_channel_by_slug(team_slug, channel_slug) if not channel: return jsonify({"error": "Channel not found."}), 404 + if not can_edit_team(channel["team_id"], user): return jsonify({"error": "Team admin access required.", "code": "forbidden"}), 403 + if channel["legacy_room_id"]: return jsonify({"error": "Legacy channels linked to rooms cannot be deleted here."}), 400 @@ -3590,6 +3405,7 @@ def register_routes(app, ctx): team = get_team_by_slug(team_slug) if not team: return jsonify({"error": "Team not found."}), 404 + if not is_team_admin(team["id"], user): return jsonify({"error": "Team admin access required.", "code": "forbidden"}), 403 @@ -3637,6 +3453,7 @@ def register_routes(app, ctx): def api_add_team_member(team_slug: str): if not csrf_valid(): return jsonify({"error": "Security token expired.", "code": "csrf"}), 403 + user, error = require_writable_user() if error: return error @@ -3644,6 +3461,7 @@ def register_routes(app, ctx): team = get_team_by_slug(team_slug) if not team: return jsonify({"error": "Team not found."}), 404 + if not is_team_admin(team["id"], user): return jsonify({"error": "Team admin access required.", "code": "forbidden"}), 403 @@ -3654,6 +3472,7 @@ def register_routes(app, ctx): username = normalize_username(data.get("username")) membership_role = str(data.get("membership_role", "member")).strip().lower() + if membership_role not in TEAM_MEMBER_ROLES: return jsonify({"error": "Invalid membership role."}), 400 @@ -3662,6 +3481,7 @@ def register_routes(app, ctx): "SELECT id, username FROM users WHERE username = ?", (username,), ).fetchone() + if not target: return jsonify({"error": "User not found."}), 404 @@ -3679,6 +3499,7 @@ def register_routes(app, ctx): """, (team["id"], target["id"], membership_role, now_iso()), ) + audit("team_member_added", user["id"], "team", str(team["id"]), f"{target['username']}:{membership_role}") return jsonify({"status": "added"}), 201 @@ -3686,6 +3507,7 @@ def register_routes(app, ctx): def api_update_team_member(team_slug: str, member_user_id: int): if not csrf_valid(): return jsonify({"error": "Security token expired.", "code": "csrf"}), 403 + user, error = require_writable_user() if error: return error @@ -3693,6 +3515,7 @@ def register_routes(app, ctx): team = get_team_by_slug(team_slug) if not team: return jsonify({"error": "Team not found."}), 404 + if not is_team_admin(team["id"], user): return jsonify({"error": "Team admin access required.", "code": "forbidden"}), 403 @@ -3707,10 +3530,13 @@ def register_routes(app, ctx): actor_role = team_member_role(team["id"], user["id"]) target_role = team_member_role(team["id"], member_user_id) + if not target_role: return jsonify({"error": "Team member not found."}), 404 + if target_role == "owner" and actor_role != "owner": return jsonify({"error": "Only an owner can modify another owner."}), 403 + if membership_role == "owner" and actor_role != "owner": return jsonify({"error": "Only an owner can assign owner role."}), 403 @@ -3718,6 +3544,7 @@ def register_routes(app, ctx): "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"], @@ -3731,6 +3558,7 @@ def register_routes(app, ctx): def api_remove_team_member(team_slug: str, member_user_id: int): if not csrf_valid(): return jsonify({"error": "Security token expired.", "code": "csrf"}), 403 + user, error = require_writable_user() if error: return error @@ -3738,15 +3566,19 @@ def register_routes(app, ctx): team = get_team_by_slug(team_slug) if not team: return jsonify({"error": "Team not found."}), 404 + if not is_team_admin(team["id"], user): return jsonify({"error": "Team admin access required.", "code": "forbidden"}), 403 actor_role = team_member_role(team["id"], user["id"]) target_role = team_member_role(team["id"], member_user_id) + if not target_role: return jsonify({"error": "Team member not found."}), 404 + if target_role == "owner": return jsonify({"error": "The team owner cannot be removed."}), 400 + if target_role == "admin" and actor_role not in {"owner", "admin"}: return jsonify({"error": "Forbidden.", "code": "forbidden"}), 403 @@ -3754,6 +3586,7 @@ def register_routes(app, ctx): "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"}) @@ -3761,6 +3594,7 @@ def register_routes(app, ctx): def api_create_team_invite(team_slug: str): if not csrf_valid(): return jsonify({"error": "Security token expired.", "code": "csrf"}), 403 + user, error = require_writable_user() if error: return error @@ -3768,6 +3602,7 @@ def register_routes(app, ctx): team = get_team_by_slug(team_slug) if not team: return jsonify({"error": "Team not found."}), 404 + if not is_team_admin(team["id"], user): return jsonify({"error": "Team admin access required.", "code": "forbidden"}), 403 @@ -3782,6 +3617,7 @@ def register_routes(app, ctx): token = ctx["secrets"].token_urlsafe(32) expires_at = (utc_now() + ctx["timedelta"](hours=TEAM_INVITE_TTL_HOURS)).isoformat() + db_write( """ INSERT INTO team_invites ( @@ -3795,6 +3631,7 @@ def register_routes(app, ctx): """, (team["id"], hash_identifier(token), user["id"], membership_role, now_iso(), expires_at), ) + audit("team_invite_created", user["id"], "team", str(team["id"]), membership_role) return jsonify( { @@ -3810,11 +3647,13 @@ def register_routes(app, ctx): def api_accept_team_invite(token: str): if not csrf_valid(): return jsonify({"error": "Security token expired.", "code": "csrf"}), 403 + user, error = require_writable_user() if error: return error token_hash = hash_identifier(token) + with get_db() as db: invite = db.execute( """ @@ -3835,10 +3674,13 @@ def register_routes(app, ctx): """, (token_hash,), ).fetchone() + if not invite: return jsonify({"error": "Invite link is invalid."}), 404 + if invite["used_at"] or parse_iso(invite["expires_at"]) <= utc_now(): return jsonify({"error": "Invite link has expired or was already used."}), 400 + if not invite["is_active"]: return jsonify({"error": "This team is no longer available."}), 404 @@ -3848,9 +3690,11 @@ def register_routes(app, ctx): "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() return jsonify({"error": "Invite link has expired or was already used."}), 400 + db.execute( """ INSERT INTO team_members ( @@ -3883,11 +3727,11 @@ def register_routes(app, ctx): team = get_team_by_slug(team_slug) if not team: return jsonify({"error": "Team not found."}), 404 - user = current_user() + user = current_user() page_type = request.args.get("type", "").strip().lower() visibility = request.args.get("visibility", "").strip().lower() - parent_slug = request.args.get("parent_slug", "").strip().lower() + 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: @@ -3897,38 +3741,36 @@ def register_routes(app, ctx): conditions = ["p.team_id = ?", "p.is_active = 1", "t.is_active = 1"] params = [team["id"]] + if page_type: conditions.append("p.page_type = ?") params.append(page_type) + if visibility: conditions.append("p.visibility = ?") params.append(visibility) + if not is_member: conditions.append("p.visibility = 'public'") - if parent_slug: - conditions.append("parent.slug = ?") - params.append(parent_slug) query = f""" {team_page_select()} WHERE {" AND ".join(conditions)} - ORDER BY - CASE WHEN p.parent_page_id IS NULL THEN 0 ELSE 1 END, - p.updated_at DESC, - p.id DESC + ORDER BY p.updated_at DESC, p.id DESC """ + with get_db() as db: rows = db.execute(query, tuple(params)).fetchall() - return jsonify({ - "team": team_payload(team, user), - "pages": [page_payload(ctx, page, user) for page in rows] - }) + return jsonify({"team": team_payload(team, user), "pages": [page_payload(page, user) for page in rows]}) @app.route("/api/teams//pages", methods=["POST"]) - def api_create_page(team_slug: str): + def api_create_page(): + team_slug = request.view_args["team_slug"] + if not csrf_valid(): return jsonify({"error": "Security token expired.", "code": "csrf"}), 403 + user, error = require_writable_user() if error: return error @@ -3936,6 +3778,7 @@ def register_routes(app, ctx): team = get_team_by_slug(team_slug) if not team: return jsonify({"error": "Team not found."}), 404 + if not is_team_admin(team["id"], user): return jsonify({"error": "Team admin access required.", "code": "forbidden"}), 403 @@ -3950,7 +3793,6 @@ def register_routes(app, ctx): page_type = str(data.get("page_type", "document")).strip().lower() requested_slug = str(data.get("slug", "")).strip().lower() channel_slug = str(data.get("channel_slug", "")).strip().lower() - parent_page_slug = str(data.get("parent_page_slug", "")).strip().lower() if not title or len(title) > 160: return jsonify({"error": "Page title must be 1 to 160 characters."}), 400 @@ -3972,23 +3814,6 @@ def register_routes(app, ctx): return jsonify({"error": "Channel not found."}), 404 channel_id = channel["id"] - parent_page_id = None - if parent_page_slug: - with get_db() as db: - parent_page = db.execute( - """ - SELECT id - FROM pages - WHERE team_id = ? - AND slug = ? - AND is_active = 1 - """, - (team["id"], parent_page_slug), - ).fetchone() - if not parent_page: - return jsonify({"error": "Parent page not found."}), 404 - parent_page_id = parent_page["id"] - with get_db() as db: db.execute("BEGIN IMMEDIATE") slug = base_slug @@ -3996,12 +3821,12 @@ def register_routes(app, ctx): while db.execute("SELECT 1 FROM pages WHERE team_id = ? AND slug = ?", (team["id"], slug)).fetchone(): slug = f"{base_slug[:44]}-{suffix}" suffix += 1 + cursor = db.execute( """ INSERT INTO pages ( team_id, channel_id, - parent_page_id, slug, title, body, @@ -4012,12 +3837,11 @@ def register_routes(app, ctx): created_at, updated_at, is_active - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1) + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1) """, ( team["id"], channel_id, - parent_page_id, slug, title, body, @@ -4033,13 +3857,16 @@ def register_routes(app, ctx): db.commit() audit("page_created", user["id"], "page", str(page_id), f"{team_slug}/{slug}") + with get_db() as db: page = db.execute(f"{team_page_select()} WHERE p.id = ?", (page_id,)).fetchone() - return jsonify({"page": page_payload(ctx, page, user)}), 201 + + return jsonify({"page": page_payload(page, user)}), 201 @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""" @@ -4051,22 +3878,27 @@ def register_routes(app, ctx): """, (team_slug, page_slug), ).fetchone() + if not page: return jsonify({"error": "Page not found."}), 404 - if not page_can_read(page, user): + 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: return jsonify({"error": "Login required.", "code": "login_required"}), 401 return jsonify({"error": "You do not have access to this page.", "code": "forbidden"}), 403 - payload = page_payload(ctx, page, user) - payload["versions"] = load_page_versions(get_db, page["id"]) - return jsonify({"page": payload}) + return jsonify({"page": page_payload(page, user)}) @app.route("/api/teams//pages/", methods=["PATCH"]) def api_update_page(team_slug: str, page_slug: str): if not csrf_valid(): return jsonify({"error": "Security token expired.", "code": "csrf"}), 403 + user, error = require_writable_user() if error: return error @@ -4082,8 +3914,10 @@ def register_routes(app, ctx): """, (team_slug, page_slug), ).fetchone() + if not page: return jsonify({"error": "Page not found."}), 404 + if not is_team_admin(page["team_id"], user): return jsonify({"error": "Team admin access required.", "code": "forbidden"}), 403 @@ -4097,7 +3931,6 @@ def register_routes(app, ctx): visibility = str(data.get("visibility", page["visibility"])).strip().lower() page_type = str(data.get("page_type", page["page_type"])).strip().lower() channel_slug = str(data.get("channel_slug", "")).strip().lower() - parent_page_slug = str(data.get("parent_page_slug", "")).strip().lower() if not title or len(title) > 160: return jsonify({"error": "Page title must be 1 to 160 characters."}), 400 @@ -4118,41 +3951,6 @@ def register_routes(app, ctx): else: channel_id = None - parent_page_id = page["parent_page_id"] - if "parent_page_slug" in data: - if parent_page_slug: - with get_db() as db: - parent_page = db.execute( - """ - SELECT id - FROM pages - WHERE team_id = ? - AND slug = ? - AND is_active = 1 - """, - (page["team_id"], parent_page_slug), - ).fetchone() - if not parent_page: - return jsonify({"error": "Parent page not found."}), 404 - if int(parent_page["id"]) == int(page["id"]): - return jsonify({"error": "A page cannot be its own parent."}), 400 - parent_page_id = parent_page["id"] - else: - parent_page_id = None - - snapshot_page_version( - get_db, - page["id"], - page["title"], - page["body"], - page["visibility"], - page["page_type"], - page["channel_id"], - page["parent_page_id"], - user["id"], - now_iso(), - ) - db_write( """ UPDATE pages @@ -4162,25 +3960,25 @@ def register_routes(app, ctx): visibility = ?, page_type = ?, channel_id = ?, - parent_page_id = ?, updated_by_user_id = ?, updated_at = ? WHERE id = ? """, - (title, body, visibility, page_type, channel_id, parent_page_id, user["id"], now_iso(), page["id"]), + (title, body, visibility, page_type, channel_id, user["id"], now_iso(), page["id"]), ) + audit("page_updated", user["id"], "page", str(page["id"]), f"{team_slug}/{page_slug}") with get_db() as db: updated = db.execute(f"{team_page_select()} WHERE p.id = ?", (page["id"],)).fetchone() - payload = page_payload(ctx, updated, user) - payload["versions"] = load_page_versions(get_db, updated["id"]) - return jsonify({"page": payload}) + + return jsonify({"page": page_payload(updated, user)}) @app.route("/api/teams//pages/", methods=["DELETE"]) def api_delete_page(team_slug: str, page_slug: str): if not csrf_valid(): return jsonify({"error": "Security token expired.", "code": "csrf"}), 403 + user, error = require_writable_user() if error: return error @@ -4201,8 +3999,10 @@ def register_routes(app, ctx): """, (team_slug, page_slug), ).fetchone() + if not page: return jsonify({"error": "Page not found."}), 404 + if not is_team_admin(page["team_id"], user): return jsonify({"error": "Team admin access required.", "code": "forbidden"}), 403 @@ -4217,708 +4017,213 @@ def register_routes(app, ctx): """, (user["id"], now_iso(), page["id"]), ) + audit("page_deleted", user["id"], "page", str(page["id"]), f"{team_slug}/{page_slug}") return jsonify({"status": "deleted"}) - @app.route("/api/teams//pages//comments") - def api_list_page_comments(team_slug: str, page_slug: str): - user = current_user() - with get_db() as db: - page = db.execute( - f""" - {team_page_select()} - WHERE t.slug = ? - AND p.slug = ? - AND p.is_active = 1 - AND t.is_active = 1 - """, - (team_slug, page_slug), - ).fetchone() - if not page: - return jsonify({"error": "Page not found."}), 404 - if not page_can_read(page, user): - return jsonify({"error": "Forbidden.", "code": "forbidden"}), 403 - - from features import load_page_comments - return jsonify({"comments": load_page_comments(get_db, page["id"])}) - - @app.route("/api/teams//pages//comments", methods=["POST"]) - def api_create_page_comment(team_slug: str, page_slug: str): - if not csrf_valid(): - return jsonify({"error": "Security token expired.", "code": "csrf"}), 403 - limited = rate_limited("page_comment") - if limited: - return limited - user, error = require_writable_user() - if error: - return error - - with get_db() as db: - page = db.execute( - f""" - {team_page_select()} - WHERE t.slug = ? - AND p.slug = ? - AND p.is_active = 1 - AND t.is_active = 1 - """, - (team_slug, page_slug), - ).fetchone() - if not page: - return jsonify({"error": "Page not found."}), 404 - if not is_team_member(page["team_id"], user["id"]) and user["role"] not in {"moderator", "admin"}: - return jsonify({"error": "Only team members can comment.", "code": "forbidden"}), 403 - - try: - data = safe_json() - except ValueError as ex: - return jsonify({"error": str(ex)}), 400 - - body = str(data.get("body", "")).strip() - if not body: - return jsonify({"error": "Comment cannot be empty."}), 400 - if len(body) > MAX_PAGE_COMMENT_LENGTH: - return jsonify({"error": "Comment is too long."}), 400 - - cursor = db_write( - """ - INSERT INTO page_comments ( - page_id, - user_id, - username, - body, - created_at, - updated_at, - deleted_at - ) VALUES (?, ?, ?, ?, ?, NULL, NULL) - """, - (page["id"], user["id"], user["username"], body, now_iso()), - ) - audit("page_comment_created", user["id"], "page_comment", str(cursor.lastrowid), str(page["id"])) - - with get_db() as db: - row = db.execute( - """ - SELECT - pc.id, - pc.page_id, - pc.user_id, - pc.username, - pc.body, - pc.created_at, - pc.updated_at, - u.role AS user_role, - us.name_color AS user_name_color - FROM page_comments AS pc - JOIN users AS u - ON u.id = pc.user_id - LEFT JOIN user_settings AS us - ON us.user_id = u.id - WHERE pc.id = ? - """, - (cursor.lastrowid,), - ).fetchone() - - return jsonify({ - "comment": { - "id": row["id"], - "page_id": row["page_id"], - "user_id": row["user_id"], - "username": row["username"], - "body": row["body"], - "created_at": row["created_at"], - "updated_at": row["updated_at"], - "user_role": row["user_role"], - "user_name_color": row["user_name_color"], - } - }), 201 - - @app.route("/api/page-comments/", methods=["DELETE"]) - def api_delete_page_comment(comment_id: int): - if not csrf_valid(): - return jsonify({"error": "Security token expired.", "code": "csrf"}), 403 + @app.route("/api/profile") + def api_get_my_profile(): user, error = require_user() if error: return error with get_db() as db: - row = db.execute( - """ - SELECT - pc.id, - pc.page_id, - pc.user_id, - p.team_id - FROM page_comments AS pc - JOIN pages AS p - ON p.id = pc.page_id - WHERE pc.id = ? - AND pc.deleted_at IS NULL - AND p.is_active = 1 - """, - (comment_id,), - ).fetchone() - if not row: - return jsonify({"error": "Comment not found."}), 404 - - allowed = ( - int(row["user_id"]) == int(user["id"]) - or user["role"] in {"moderator", "admin"} - or is_team_admin(row["team_id"], user) - ) - if not allowed: - return jsonify({"error": "Forbidden.", "code": "forbidden"}), 403 - - db_write( - """ - UPDATE page_comments - SET deleted_at = ? - WHERE id = ? - """, - (now_iso(), comment_id), - ) - audit("page_comment_deleted", user["id"], "page_comment", str(comment_id)) - return jsonify({"status": "deleted"}) - - @app.route("/api/teams//pages//versions") - def api_list_page_versions(team_slug: str, page_slug: str): - user = current_user() - with get_db() as db: - page = db.execute( - f""" - {team_page_select()} - WHERE t.slug = ? - AND p.slug = ? - AND p.is_active = 1 - AND t.is_active = 1 - """, - (team_slug, page_slug), - ).fetchone() - if not page: - return jsonify({"error": "Page not found."}), 404 - if not page_can_read(page, user): - return jsonify({"error": "Forbidden.", "code": "forbidden"}), 403 - - return jsonify({"versions": load_page_versions(get_db, page["id"])}) - - @app.route("/api/page-versions/") - def api_get_page_version(version_id: int): - user = current_user() - row = load_page_version_detail(get_db, version_id) - if not row: - return jsonify({"error": "Version not found."}), 404 - - with get_db() as db: - page = db.execute( - """ - SELECT - p.id, - p.team_id, - p.visibility - FROM pages p - WHERE p.id = ? - """, - (row["page_id"],), - ).fetchone() - if not page: - return jsonify({"error": "Page not found."}), 404 - - allowed = bool( - page["visibility"] == "public" - or ( - user - and ( - user["role"] in {"moderator", "admin"} - or is_team_member(page["team_id"], user["id"]) - ) - ) - ) - if not allowed: - return jsonify({"error": "Forbidden.", "code": "forbidden"}), 403 - - return jsonify({ - "version": { - "id": row["id"], - "page_id": row["page_id"], - "version_number": row["version_number"], - "title": row["title"], - "body": row["body"], - "body_html": __import__("features").render_markdown_to_safe_html(row["body"]), - "visibility": row["visibility"], - "page_type": row["page_type"], - "channel_id": row["channel_id"], - "parent_page_id": row["parent_page_id"], - "created_by_user_id": row["created_by_user_id"], - "created_by_username": row["created_by_username"], - "created_at": row["created_at"], - } - }) - - @app.route("/api/page-versions//restore", methods=["POST"]) - def api_restore_page_version(version_id: int): - if not csrf_valid(): - return jsonify({"error": "Security token expired.", "code": "csrf"}), 403 - limited = rate_limited("page_restore") - if limited: - return limited - user, error = require_writable_user() - if error: - return error - - row = load_page_version_detail(get_db, version_id) - if not row: - return jsonify({"error": "Version not found."}), 404 - - with get_db() as db: - page = db.execute( - f"{team_page_select()} WHERE p.id = ? AND p.is_active = 1", - (row["page_id"],), - ).fetchone() - if not page: - return jsonify({"error": "Page not found."}), 404 - if not is_team_admin(page["team_id"], user): - return jsonify({"error": "Team admin access required.", "code": "forbidden"}), 403 - - snapshot_page_version( - get_db, - page["id"], - page["title"], - page["body"], - page["visibility"], - page["page_type"], - page["channel_id"], - page["parent_page_id"], - user["id"], - now_iso(), - ) - - db_write( - """ - UPDATE pages - SET - title = ?, - body = ?, - visibility = ?, - page_type = ?, - channel_id = ?, - parent_page_id = ?, - updated_by_user_id = ?, - updated_at = ? - WHERE id = ? - """, - ( - row["title"], - row["body"], - row["visibility"], - row["page_type"], - row["channel_id"], - row["parent_page_id"], - user["id"], - now_iso(), - row["page_id"], - ), - ) - audit("page_version_restored", user["id"], "page_version", str(version_id), str(row["page_id"])) - - with get_db() as db: - updated = db.execute(f"{team_page_select()} WHERE p.id = ?", (row["page_id"],)).fetchone() - payload = page_payload(ctx, updated, user) - payload["versions"] = load_page_versions(get_db, updated["id"]) - return jsonify({"page": payload}) - - @app.route("/api/teams//pages//share", methods=["POST"]) - def api_create_page_share(team_slug: str, page_slug: str): - if not csrf_valid(): - return jsonify({"error": "Security token expired.", "code": "csrf"}), 403 - limited = rate_limited("page_share") - if limited: - return limited - user, error = require_user() - if error: - return error - - with get_db() as db: - page = db.execute( - f""" - {team_page_select()} - WHERE t.slug = ? - AND p.slug = ? - AND p.is_active = 1 - AND t.is_active = 1 - """, - (team_slug, page_slug), - ).fetchone() - if not page: - return jsonify({"error": "Page not found."}), 404 - if not page_can_read(page, user): - return jsonify({"error": "Forbidden.", "code": "forbidden"}), 403 - - requested_ttl = request.args.get("ttl_seconds", "").strip() - ttl = SHARE_TOKEN_TTL_SECONDS - if requested_ttl: - try: - ttl = max(60, min(int(requested_ttl), MAX_PAGE_SHARE_TTL_SECONDS)) - except ValueError: - return jsonify({"error": "ttl_seconds must be an integer."}), 400 - - expires_at = (utc_now() + ctx["timedelta"](seconds=ttl)).isoformat() - token = create_page_share(page["id"], user["id"], expires_at) - audit("page_share_created", user["id"], "page", str(page["id"])) - return jsonify({ - "share": { - "url": f"/api/page-shares/{token}", - "expires_at": expires_at, - } - }), 201 - - @app.route("/api/page-shares/") - def api_open_page_share(token: str): - token_hash = hash_identifier(token) - with get_db() as db: - share = db.execute( - """ - SELECT - ps.page_id, - ps.expires_at, - ps.revoked_at - FROM page_shares ps - WHERE ps.token_hash = ? - """, - (token_hash,), - ).fetchone() - if not share: - return jsonify({"error": "Share not found."}), 404 - if share["revoked_at"] or parse_iso(share["expires_at"]) <= utc_now(): - return jsonify({"error": "Share expired."}), 410 - - with get_db() as db: - page = db.execute( - f"{team_page_select()} WHERE p.id = ? AND p.is_active = 1", - (share["page_id"],), - ).fetchone() - if not page: - return jsonify({"error": "Page not found."}), 404 - - payload = page_payload(ctx, page, current_user()) - payload["versions"] = [] - return jsonify({"page": payload, "shared": True}) - -@app.route("/api/profile") -def api_get_my_profile(): - user, error = require_user() - if error: - return error - - with get_db() as db: - user_row = db.execute( - """ - SELECT - id, - username, - role, - status, - restricted_until, - created_at, - last_login_at - FROM users - WHERE id = ? - """, - (user["id"],), - ).fetchone() - - profile_row = db.execute( - """ - SELECT - user_id, - display_name, - badge_text, - bio, - bio_mode, - website, - location, - status_message, - banner_attachment_id, - updated_at - FROM user_profiles - WHERE user_id = ? - """, - (user["id"],), - ).fetchone() - - settings_row = db.execute( - """ - SELECT - name_color, - profile_background_color, - profile_banner_color, - profile_visibility, - last_active_visibility, - presence_mode - FROM user_settings - WHERE user_id = ? - """, - (user["id"],), - ).fetchone() - - return jsonify({ - "profile": profile_payload( - html_module, - user_payload, - user_row, - profile_row, - settings_row, - user, - ) - }) - - -@app.route("/api/profile", methods=["PATCH"]) -def api_update_my_profile(): - if not csrf_valid(): - return jsonify({"error": "Security token expired.", "code": "csrf"}), 403 - - limited = rate_limited("profile") - if limited: - return limited - - user, error = require_writable_user() - if error: - return error - - try: - data = safe_json() - except ValueError as exc: - return jsonify({"error": str(exc)}), 400 - - display_name = str(data.get("display_name", "")).strip() - badge_text = str(data.get("badge_text", "")).strip() - bio = str(data.get("bio", "")).strip() - bio_mode = str(data.get("bio_mode", "txt")).strip().lower() - website = str(data.get("website", "")).strip() - location = str(data.get("location", "")).strip() - status_message = str(data.get("status_message", "")).strip() - banner_attachment_id = data.get("banner_attachment_id") - - if len(display_name) > MAX_DISPLAY_NAME_LENGTH: - return jsonify({"error": "Display name is too long."}), 400 - if len(badge_text) > MAX_PROFILE_BADGE_LENGTH: - return jsonify({"error": "Badge text is too long."}), 400 - if len(bio) > MAX_PROFILE_BIO_LENGTH: - return jsonify({"error": "Bio is too long."}), 400 - if bio_mode not in PROFILE_BIO_MODES: - return jsonify({"error": "Invalid bio mode."}), 400 - if len(website) > MAX_WEBSITE_LENGTH: - return jsonify({"error": "Website is too long."}), 400 - if len(location) > MAX_LOCATION_LENGTH: - return jsonify({"error": "Location is too long."}), 400 - if len(status_message) > constants["MAX_STATUS_MESSAGE_LENGTH"]: - return jsonify({"error": "Status message is too long."}), 400 - - if banner_attachment_id in ("", None): - banner_attachment_id = None - else: - try: - banner_attachment_id = int(banner_attachment_id) - except (TypeError, ValueError): - return jsonify({"error": "Invalid banner attachment."}), 400 - - with get_db() as db: - attachment = db.execute( + user_row = db.execute( """ SELECT id, - uploader_user_id - FROM attachments + username, + role, + status, + restricted_until, + created_at + FROM users WHERE id = ? """, - (banner_attachment_id,), + (user["id"],), ).fetchone() - if not attachment: - return jsonify({"error": "Banner attachment not found."}), 404 - if int(attachment["uploader_user_id"]) != int(user["id"]): - return jsonify({"error": "You can only use your own uploaded image as banner."}), 403 + profile_row = db.execute( + """ + SELECT + user_id, + display_name, + badge_text, + bio, + bio_mode, + website, + location, + updated_at + FROM user_profiles + WHERE user_id = ? + """, + (user["id"],), + ).fetchone() - db_write( - """ - UPDATE user_profiles - SET - display_name = ?, - badge_text = ?, - bio = ?, - bio_mode = ?, - website = ?, - location = ?, - status_message = ?, - banner_attachment_id = ?, - updated_at = ? - WHERE user_id = ? - """, - ( - display_name or user["username"], - badge_text, - bio, - bio_mode, - website, - location, - status_message, - banner_attachment_id, - now_iso(), - user["id"], - ), - ) + settings_row = db.execute( + """ + SELECT + name_color + FROM user_settings + WHERE user_id = ? + """, + (user["id"],), + ).fetchone() - with get_db() as db: - user_row = db.execute( + return jsonify({"profile": profile_payload(html_module, user_payload, user_row, profile_row, settings_row, user)}) + + @app.route("/api/profile", methods=["PATCH"]) + def api_update_my_profile(): + + if not csrf_valid(): + return jsonify({"error": "Security token expired.", "code": "csrf"}), 403 + + limited = rate_limited("profile") + if limited: + return limited + + user, error = require_writable_user() + if error: + return error + + try: + data = safe_json() + except ValueError as error: + return jsonify({"error": str(error)}), 400 + + display_name = str(data.get("display_name", "")).strip() + badge_text = str(data.get("badge_text", "")).strip() + bio = str(data.get("bio", "")).strip() + bio_mode = str(data.get("bio_mode", "txt")).strip().lower() + website = str(data.get("website", "")).strip() + location = str(data.get("location", "")).strip() + + if len(display_name) > MAX_DISPLAY_NAME_LENGTH: + return jsonify({"error": "Display name is too long."}), 400 + if len(badge_text) > MAX_PROFILE_BADGE_LENGTH: + return jsonify({"error": "Badge text is too long."}), 400 + if len(bio) > MAX_PROFILE_BIO_LENGTH: + return jsonify({"error": "Bio is too long."}), 400 + if bio_mode not in PROFILE_BIO_MODES: + return jsonify({"error": "Invalid bio mode."}), 400 + if len(website) > MAX_WEBSITE_LENGTH: + return jsonify({"error": "Website is too long."}), 400 + if len(location) > MAX_LOCATION_LENGTH: + return jsonify({"error": "Location is too long."}), 400 + + db_write( """ - SELECT - id, - username, - role, - status, - restricted_until, - created_at, - last_login_at - FROM users - WHERE id = ? + UPDATE user_profiles + SET + display_name = ?, + badge_text = ?, + bio = ?, + bio_mode = ?, + website = ?, + location = ?, + updated_at = ? + WHERE user_id = ? """, - (user["id"],), - ).fetchone() - - profile_row = db.execute( - """ - SELECT - user_id, - display_name, + ( + display_name or user["username"], badge_text, bio, bio_mode, website, location, - status_message, - banner_attachment_id, - updated_at - FROM user_profiles - WHERE user_id = ? - """, - (user["id"],), - ).fetchone() - - settings_row = db.execute( - """ - SELECT - name_color, - profile_background_color, - profile_banner_color, - profile_visibility, - last_active_visibility, - presence_mode - 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, + now_iso(), + user["id"], + ), ) - }) + with get_db() as db: + user_row = db.execute( + """ + SELECT + id, + username, + role, + status, + restricted_until, + created_at + FROM users + WHERE id = ? + """, + (user["id"],), + ).fetchone() -@app.route("/api/profiles/") -def api_get_public_profile(username: str): - normalized = normalize_username(username) - viewer = current_user() + profile_row = db.execute( + """ + SELECT + user_id, + display_name, + badge_text, + bio, + bio_mode, + website, + location, + updated_at + FROM user_profiles + WHERE user_id = ? + """, + (user["id"],), + ).fetchone() - with get_db() as db: - user_row = db.execute( - """ - SELECT - id, - username, - role, - status, - restricted_until, - created_at, - last_login_at - FROM users - WHERE username = ? - """, - (normalized,), - ).fetchone() + settings_row = db.execute( + "SELECT name_color FROM user_settings WHERE user_id = ?", + (user["id"],), + ).fetchone() - if not user_row: - return jsonify({"error": "Profile not found."}), 404 + 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)}) - profile_row = db.execute( - """ - SELECT - user_id, - display_name, - badge_text, - bio, - bio_mode, - website, - location, - status_message, - banner_attachment_id, - updated_at - FROM user_profiles - WHERE user_id = ? - """, - (user_row["id"],), - ).fetchone() + @app.route("/api/profiles/") + def api_get_public_profile(username: str): + normalized = normalize_username(username) - settings_row = db.execute( - """ - SELECT - name_color, - profile_background_color, - profile_banner_color, - profile_visibility, - last_active_visibility, - presence_mode - FROM user_settings - WHERE user_id = ? - """, - (user_row["id"],), - ).fetchone() + with get_db() as db: + user_row = db.execute( + """ + SELECT + id, + username, + role, + status, + restricted_until, + created_at + FROM users + WHERE username = ? + """, + (normalized,), + ).fetchone() - profile_visibility = ( - settings_row["profile_visibility"] - if settings_row and "profile_visibility" in settings_row.keys() - else "public" - ) + if not user_row: + return jsonify({"error": "Profile not found."}), 404 - is_self = bool(viewer and int(viewer["id"]) == int(user_row["id"])) + profile_row = db.execute( + """ + SELECT + user_id, + display_name, + badge_text, + bio, + bio_mode, + website, + location, + updated_at + FROM user_profiles + WHERE user_id = ? + """, + (user_row["id"],), + ).fetchone() - if profile_visibility == "private": - if not is_self and not (viewer and viewer["role"] in {"moderator", "admin"}): - return jsonify({"error": "Profile not available."}), 403 + settings_row = db.execute( + "SELECT name_color FROM user_settings WHERE user_id = ?", + (user_row["id"],), + ).fetchone() - if profile_visibility == "members": - if not is_self and not viewer: - return jsonify({"error": "Profile not available."}), 403 + return jsonify({"profile": profile_payload(html_module, user_payload, user_row, profile_row, settings_row, current_user())}) - return jsonify({ - "profile": profile_payload( - html_module, - user_payload, - user_row, - profile_row, - settings_row, - viewer, - ) - }) - @app.route("/api/settings") def api_get_settings(): user, error = require_user() @@ -4956,9 +4261,11 @@ def api_get_public_profile(username: str): def api_update_settings(): if not csrf_valid(): return jsonify({"error": "Security token expired.", "code": "csrf"}), 403 + limited = rate_limited("settings") if limited: return limited + user, error = require_writable_user() if error: return error @@ -5032,6 +4339,7 @@ def api_get_public_profile(username: str): def api_create_or_open_dm(): if not csrf_valid(): return jsonify({"error": "Security token expired.", "code": "csrf"}), 403 + user, error = require_writable_user() if error: return error @@ -5085,6 +4393,7 @@ def api_get_public_profile(username: str): user, error = require_user() if error: return error + if not validate_dm_access(get_db, dm_id, user): return jsonify({"error": "DM not found."}), 404 @@ -5127,6 +4436,7 @@ def api_get_public_profile(username: str): 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"]), @@ -5141,11 +4451,14 @@ def api_get_public_profile(username: str): limited = rate_limited("send") if limited: return limited + if not csrf_valid(): return jsonify({"error": "Security token expired.", "code": "csrf"}), 403 + user, error = require_writable_user() if error: return error + if not validate_dm_access(get_db, dm_id, user): return jsonify({"error": "DM not found."}), 404 @@ -5157,6 +4470,7 @@ def api_get_public_profile(username: str): text = data.get("text", "") if not isinstance(text, str): return jsonify({"error": "Message must be text."}), 400 + text = text.strip() if len(text) > MAX_MESSAGE_LENGTH: return jsonify({"error": "Message is too long."}), 400 @@ -5180,6 +4494,7 @@ def api_get_public_profile(username: str): 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 @@ -5202,6 +4517,7 @@ def api_get_public_profile(username: str): (dm_id, user["id"], user["username"], text, attachment_id, now_iso()), ) message_id = cursor.lastrowid + for position, extra_attachment_id in enumerate(attachment_ids): db.execute( """ @@ -5213,6 +4529,7 @@ def api_get_public_profile(username: str): """, (message_id, extra_attachment_id, position), ) + db.commit() with get_db() as db: @@ -5221,81 +4538,15 @@ def api_get_public_profile(username: str): 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 @@ -5317,10 +4568,12 @@ def api_get_public_profile(username: str): 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() @@ -5329,10 +4582,12 @@ def api_get_public_profile(username: str): 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 @@ -5344,6 +4599,8 @@ def api_get_public_profile(username: str): created_by_user_id=user["id"], ) + upsert_group_call_participant(session_row["id"], user["id"]) + payload = { "sub": f"user-{user['id']}", "username": user["username"], @@ -5353,7 +4610,9 @@ def api_get_public_profile(username: str): "can_call": True, "exp": int(time.time()) + CALL_TOKEN_TTL_SECONDS, } + token = sign_call_helper_token(payload) + audit( "group_call_token_issued", user["id"], @@ -5361,12 +4620,16 @@ def api_get_public_profile(username: str): 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), + "participants": list_group_call_participants(session_row["id"]), + "permissions": group_call_permissions_payload(session_row, user), + "scope": group_call_scope_payload(session_row, user), } ) @@ -5374,9 +4637,11 @@ def api_get_public_profile(username: str): 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 @@ -5387,45 +4652,45 @@ def api_get_public_profile(username: str): 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() + session_row = get_group_call_session_by_id(session_id) 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: + if not can_access_group_call_session(session_row, user): return jsonify({"error": "Forbidden.", "code": "forbidden"}), 403 + is_active_participant = False + with get_db() as db: + is_active_participant = bool( + db.execute( + """ + SELECT 1 + FROM group_call_participants + WHERE group_call_session_id = ? + AND user_id = ? + AND left_at IS NULL + """, + (session_row["id"], user["id"]), + ).fetchone() + ) + + if not (is_active_participant or can_manage_group_call_session(session_row, user)): + return jsonify( + { + "error": "Only active participants or scope managers can invite users.", + "code": "forbidden", + } + ), 403 + with get_db() as db: target = db.execute( """ @@ -5439,10 +4704,13 @@ def api_get_public_profile(username: str): """, (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 @@ -5455,6 +4723,7 @@ def api_get_public_profile(username: str): "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() @@ -5480,6 +4749,7 @@ def api_get_public_profile(username: str): expires_at, ), ) + audit( "group_call_invite_created", user["id"], @@ -5487,6 +4757,7 @@ def api_get_public_profile(username: str): str(session_row["id"]), f"invitee={target['username']}", ) + return jsonify( { "invite": { @@ -5518,6 +4789,7 @@ def api_get_public_profile(username: str): s.scope_kind, s.scope_slug, s.helper_call_id, + s.status AS session_status, inviter.username AS inviter_username FROM group_call_invites AS i JOIN group_call_sessions AS s @@ -5527,6 +4799,7 @@ def api_get_public_profile(username: str): WHERE i.invitee_user_id = ? AND i.status = 'pending' AND i.expires_at > ? + AND s.status = 'active' ORDER BY i.id DESC """, (user["id"], now_iso()), @@ -5555,6 +4828,7 @@ def api_get_public_profile(username: str): 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 @@ -5579,8 +4853,10 @@ def api_get_public_profile(username: str): """, (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 @@ -5594,6 +4870,9 @@ def api_get_public_profile(username: str): """, (now_iso(), invite_id), ) + + upsert_group_call_participant(row["group_call_session_id"], user["id"]) + audit( "group_call_invite_accepted", user["id"], @@ -5601,11 +4880,18 @@ def api_get_public_profile(username: str): str(invite_id), row["helper_call_id"], ) + + session_row = get_group_call_session_by_id(row["group_call_session_id"]) + return jsonify( { "token": row["helper_token"], "calls_base": CALLS_BASE, "call_id": row["helper_call_id"], + "session": group_call_session_payload(session_row), + "participants": list_group_call_participants(row["group_call_session_id"]), + "permissions": group_call_permissions_payload(session_row, user), + "scope": group_call_scope_payload(session_row, user), } ) @@ -5613,10 +4899,28 @@ def api_get_public_profile(username: str): 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 + with get_db() as db: + row = db.execute( + """ + SELECT + id, + group_call_session_id + FROM group_call_invites + WHERE id = ? + AND invitee_user_id = ? + LIMIT 1 + """, + (invite_id, user["id"]), + ).fetchone() + + if not row: + return jsonify({"error": "Invite not found."}), 404 + db_write( """ UPDATE group_call_invites @@ -5629,48 +4933,184 @@ def api_get_public_profile(username: str): """, (now_iso(), invite_id, user["id"]), ) + audit( "group_call_invite_dismissed", user["id"], "group_call_invite", str(invite_id), ) - return jsonify({"status": "dismissed"}) + + return jsonify( + { + "status": "dismissed", + "group_call_session_id": row["group_call_session_id"], + } + ) + + @app.route("/api/group-calls/") + def api_get_group_call_session(session_id: int): + user, error = require_user() + if error: + return error + + session_row = get_group_call_session_by_id(session_id) + if not session_row: + return jsonify({"error": "Group call session not found."}), 404 + + if not can_access_group_call_session(session_row, user): + return jsonify({"error": "Forbidden.", "code": "forbidden"}), 403 + + return jsonify( + { + "session": group_call_session_payload(session_row), + "scope": group_call_scope_payload(session_row, user), + "participants": list_group_call_participants(session_id), + "permissions": group_call_permissions_payload(session_row, user), + } + ) + + @app.route("/api/group-calls//participants") + def api_get_group_call_participants(session_id: int): + user, error = require_user() + if error: + return error + + session_row = get_group_call_session_by_id(session_id) + if not session_row: + return jsonify({"error": "Group call session not found."}), 404 + + if not can_access_group_call_session(session_row, user): + return jsonify({"error": "Forbidden.", "code": "forbidden"}), 403 + + return jsonify( + { + "session_id": session_id, + "participants": list_group_call_participants(session_id), + "permissions": group_call_permissions_payload(session_row, user), + "scope": group_call_scope_payload(session_row, user), + } + ) + + @app.route("/api/group-calls//join", methods=["POST"]) + def api_join_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 + + session_row = get_group_call_session_by_id(session_id) + if not session_row: + return jsonify({"error": "Group call session not found."}), 404 + + if session_row["status"] != "active": + return jsonify({"error": "Group call session is not active."}), 400 + + if not can_access_group_call_session(session_row, user): + return jsonify({"error": "Forbidden.", "code": "forbidden"}), 403 + + upsert_group_call_participant(session_id, user["id"]) + + audit( + "group_call_session_joined", + user["id"], + "group_call_session", + str(session_id), + session_row["helper_call_id"], + ) + + return jsonify( + { + "status": "joined", + "session": group_call_session_payload(session_row), + "participants": list_group_call_participants(session_id), + "permissions": group_call_permissions_payload(session_row, user), + "scope": group_call_scope_payload(session_row, user), + } + ) + + @app.route("/api/group-calls//heartbeat", methods=["POST"]) + def api_heartbeat_group_call_session(session_id: int): + if not csrf_valid(): + return jsonify({"error": "Security token expired.", "code": "csrf"}), 403 + + user, error = require_user() + if error: + return error + + session_row = get_group_call_session_by_id(session_id) + if not session_row: + return jsonify({"error": "Group call session not found."}), 404 + + if session_row["status"] != "active": + return jsonify({"error": "Group call session is not active."}), 400 + + if not can_access_group_call_session(session_row, user): + return jsonify({"error": "Forbidden.", "code": "forbidden"}), 403 + + heartbeat_group_call_participant(session_id, user["id"]) + + return jsonify( + { + "status": "ok", + "session": group_call_session_payload(session_row), + "participants": list_group_call_participants(session_id), + } + ) + + @app.route("/api/group-calls//leave", methods=["POST"]) + def api_leave_group_call_session(session_id: int): + if not csrf_valid(): + return jsonify({"error": "Security token expired.", "code": "csrf"}), 403 + + user, error = require_user() + if error: + return error + + session_row = get_group_call_session_by_id(session_id) + if not session_row: + return jsonify({"error": "Group call session not found."}), 404 + + if not can_access_group_call_session(session_row, user): + return jsonify({"error": "Forbidden.", "code": "forbidden"}), 403 + + mark_group_call_participant_left(session_id, user["id"]) + maybe_end_group_call_if_empty(session_id) + + refreshed = get_group_call_session_by_id(session_id) + + audit( + "group_call_session_left", + user["id"], + "group_call_session", + str(session_id), + session_row["helper_call_id"], + ) + + return jsonify( + { + "status": "left", + "session": group_call_session_payload(refreshed) if refreshed else None, + "participants": list_group_call_participants(session_id), + } + ) @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() + session_row = get_group_call_session_by_id(session_id) 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"}: + if not can_manage_group_call_session(session_row, user): return jsonify({"error": "Forbidden.", "code": "forbidden"}), 403 db_write( @@ -5683,6 +5123,33 @@ def api_get_public_profile(username: str): """, (now_iso(), session_id), ) + + db_write( + """ + UPDATE group_call_participants + SET + updated_at = ?, + left_at = COALESCE(left_at, ?) + WHERE group_call_session_id = ? + AND left_at IS NULL + """, + (now_iso(), now_iso(), session_id), + ) + + db_write( + """ + UPDATE group_call_invites + SET + status = 'dismissed', + responded_at = COALESCE(responded_at, ?) + WHERE group_call_session_id = ? + AND status = 'pending' + """, + (now_iso(), session_id), + ) + + refreshed = get_group_call_session_by_id(session_id) + audit( "group_call_session_ended", user["id"], @@ -5690,19 +5157,107 @@ def api_get_public_profile(username: str): str(session_id), session_row["helper_call_id"], ) - return jsonify({"status": "ended"}) + + return jsonify( + { + "status": "ended", + "session": group_call_session_payload(refreshed) if refreshed else None, + "participants": [], + } + ) + + @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/dms//calls", methods=["POST"]) def api_start_dm_call(dm_id: int): if not csrf_valid(): return jsonify({"error": "Security token expired.", "code": "csrf"}), 403 + user, error = require_writable_user() if error: return error + if not validate_dm_access(get_db, dm_id, user): return jsonify({"error": "DM not found."}), 404 - from features import create_call_session call_id = create_call_session(db_write, now_iso, dm_id, user["id"]) audit("call_started", user["id"], "call", str(call_id), str(dm_id)) return jsonify({"call": call_payload(get_db, call_id)}), 201 @@ -5716,6 +5271,7 @@ def api_get_public_profile(username: str): call = get_call_with_dm(get_db, call_id) if not call: return jsonify({"error": "Call not found."}), 404 + if not validate_dm_access(get_db, call["direct_conversation_id"], user): return jsonify({"error": "Call not found."}), 404 @@ -5725,6 +5281,7 @@ def api_get_public_profile(username: str): def api_post_call_signal(call_id: int): if not csrf_valid(): return jsonify({"error": "Security token expired.", "code": "csrf"}), 403 + user, error = require_writable_user() if error: return error @@ -5732,8 +5289,10 @@ def api_get_public_profile(username: str): call = get_call_with_dm(get_db, call_id) if not call: return jsonify({"error": "Call not found."}), 404 + if not validate_dm_access(get_db, call["direct_conversation_id"], user): return jsonify({"error": "Call not found."}), 404 + if call["status"] != "active": return jsonify({"error": "Call is not active."}), 400 @@ -5744,6 +5303,7 @@ def api_get_public_profile(username: str): signal_type = str(data.get("signal_type", "")).strip()[:40] payload = data.get("payload", {}) + if not signal_type: return jsonify({"error": "signal_type is required."}), 400 if not isinstance(payload, dict): @@ -5761,6 +5321,7 @@ def api_get_public_profile(username: str): call = get_call_with_dm(get_db, call_id) if not call: return jsonify({"error": "Call not found."}), 404 + if not validate_dm_access(get_db, call["direct_conversation_id"], user): return jsonify({"error": "Call not found."}), 404 @@ -5780,6 +5341,7 @@ def api_get_public_profile(username: str): def api_end_call(call_id: int): if not csrf_valid(): return jsonify({"error": "Security token expired.", "code": "csrf"}), 403 + user, error = require_writable_user() if error: return error @@ -5787,6 +5349,7 @@ def api_get_public_profile(username: str): call = get_call_with_dm(get_db, call_id) if not call: return jsonify({"error": "Call not found."}), 404 + if not validate_dm_access(get_db, call["direct_conversation_id"], user): return jsonify({"error": "Call not found."}), 404