diff --git a/routes.py b/routes.py index 239ecd5..de37e6d 100644 --- a/routes.py +++ b/routes.py @@ -1,9 +1,7 @@ # routes.py import json import re -import sqlite3 import time - from flask import jsonify, request from features import ( @@ -19,12 +17,14 @@ 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,6 +89,10 @@ 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"] @@ -110,25 +114,12 @@ 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"] ROOM_VISIBILITIES = constants["ROOM_VISIBILITIES"] ROLES = constants["ROLES"] STATUSES = constants["STATUSES"] - PRIVATE_ACCESS_KEY = ctx["PRIVATE_ACCESS_KEY"] - ROLES = constants["ROLES"] - STATUSES = constants["STATUSES"] - - html_module = ctx["html"] - - def private_access_key_valid(candidate: object) -> bool: - configured = (PRIVATE_ACCESS_KEY or "").strip() - if not configured: - return True - if not isinstance(candidate, str): - return False - return candidate.strip() == configured - def build_message_payload(row, user): return richer_message_payload( get_db, @@ -137,6 +128,17 @@ def register_routes(app, ctx): user["id"] if user else None, ) + def _dispatch_due_messages(): + maybe_dispatch_scheduled_messages() + + def private_access_key_valid(candidate: object) -> bool: + configured = (PRIVATE_ACCESS_KEY or "").strip() + if not configured: + return True + if not isinstance(candidate, str): + return False + return candidate.strip() == configured + def build_room_helper_call_id(room) -> str: return f"room-{room['slug']}" @@ -169,10 +171,8 @@ 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,11 +193,9 @@ def register_routes(app, ctx): """, (helper_call_id,), ).fetchone() - if existing and existing["status"] == "active": db.commit() return existing - if existing: db.execute( """ @@ -229,7 +227,6 @@ def register_routes(app, ctx): (existing["id"],), ).fetchone() return refreshed - cursor = db.execute( """ INSERT INTO group_call_sessions ( @@ -286,15 +283,111 @@ 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 _can_pin_message(message_row, user): + if 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)) + return False + + def create_message_share(message_id: int, user_id: int, expires_at: str) -> str: + token = ctx["secrets"].token_urlsafe(32) + db_write( + """ + INSERT INTO message_shares ( + message_id, + token_hash, + created_by_user_id, + created_at, + expires_at + ) VALUES (?, ?, ?, ?, ?) + """, + (message_id, hash_identifier(token), user_id, now_iso(), expires_at), + ) + return token + + def create_attachment_share(attachment_id: int, user_id: int, expires_at: str) -> str: + token = ctx["secrets"].token_urlsafe(32) + db_write( + """ + INSERT INTO attachment_shares ( + attachment_id, + token_hash, + created_by_user_id, + created_at, + expires_at + ) VALUES (?, ?, ?, ?, ?) + """, + (attachment_id, hash_identifier(token), user_id, now_iso(), expires_at), + ) + return token + + def create_page_share(page_id: int, user_id: int, expires_at: str) -> str: + token = ctx["secrets"].token_urlsafe(32) + db_write( + """ + INSERT INTO page_shares ( + page_id, + token_hash, + created_by_user_id, + created_at, + expires_at + ) VALUES (?, ?, ?, ?, ?) + """, + (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"]) + ) + ) + ) + + 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) + ) + ) + + @app.before_request + def _batch_background_dispatch(): + if request.path.startswith("/api/"): + _dispatch_due_messages() + @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 @@ -315,23 +408,19 @@ 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) @@ -340,7 +429,6 @@ 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( """ @@ -360,7 +448,6 @@ def register_routes(app, ctx): raise audit("attachment_uploaded", user["id"], "attachment", str(cursor.lastrowid), original_name) - return jsonify( { "attachment": { @@ -378,7 +465,6 @@ def register_routes(app, ctx): user, error = require_user() if error: return error - return send_attachment_file( get_db, ctx["UPLOAD_DIR"], @@ -389,12 +475,86 @@ 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: @@ -455,10 +615,8 @@ 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 @@ -466,7 +624,6 @@ 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 @@ -478,8 +635,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() + if len(text) > MAX_MESSAGE_LENGTH: return jsonify({"error": "Message is too long."}), 400 @@ -502,7 +659,6 @@ 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 @@ -536,7 +692,6 @@ def register_routes(app, ctx): ), ) message_id = cursor.lastrowid - for position, extra_attachment_id in enumerate(attachment_ids): db.execute( """ @@ -548,7 +703,6 @@ def register_routes(app, ctx): """, (message_id, extra_attachment_id, position), ) - db.commit() with get_db() as db: @@ -561,11 +715,9 @@ 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 @@ -582,7 +734,6 @@ 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}) @@ -591,7 +742,6 @@ 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: @@ -639,7 +789,6 @@ 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"]}, @@ -655,10 +804,8 @@ 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 @@ -666,7 +813,6 @@ 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 @@ -678,7 +824,6 @@ 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 @@ -702,7 +847,6 @@ 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 @@ -733,7 +877,6 @@ def register_routes(app, ctx): ), ) message_id = cursor.lastrowid - for position, extra_attachment_id in enumerate(attachment_ids): db.execute( """ @@ -745,7 +888,6 @@ def register_routes(app, ctx): """, (message_id, extra_attachment_id, position), ) - db.commit() with get_db() as db: @@ -754,15 +896,449 @@ 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 @@ -775,7 +1351,6 @@ 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( @@ -788,13 +1363,11 @@ 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 @@ -810,7 +1383,6 @@ 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( @@ -818,7 +1390,6 @@ 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( """ @@ -830,7 +1401,6 @@ def register_routes(app, ctx): """, (message_id, attachment_id, position), ) - db.execute( """ INSERT INTO message_edits ( @@ -865,11 +1435,9 @@ 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 @@ -888,7 +1456,6 @@ 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: @@ -898,7 +1465,6 @@ 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""" @@ -916,12 +1482,10 @@ 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""" @@ -939,17 +1503,14 @@ 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""" @@ -975,7 +1536,6 @@ 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""" @@ -985,7 +1545,6 @@ def register_routes(app, ctx): """, (message_id,), ).fetchone() - if not root: return jsonify({"error": "Thread root message not found."}), 404 @@ -1000,7 +1559,6 @@ def register_routes(app, ctx): channel = get_channel_by_id(root["channel_id"]) if not channel and root["room_id"]: channel = get_channel_by_legacy_room_id(root["room_id"]) - if not channel or not can_access_channel(channel, user): if not user: return jsonify({"error": "Login required.", "code": "login_required"}), 401 @@ -1021,7 +1579,6 @@ 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: @@ -1032,7 +1589,6 @@ 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"]) @@ -1040,10 +1596,8 @@ 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 @@ -1062,7 +1616,6 @@ 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 @@ -1086,7 +1639,6 @@ 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 @@ -1110,7 +1662,6 @@ 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 @@ -1143,7 +1694,6 @@ def register_routes(app, ctx): ), ) reply_id = cursor.lastrowid - for position, extra_attachment_id in enumerate(attachment_ids): db.execute( """ @@ -1155,7 +1705,6 @@ def register_routes(app, ctx): """, (reply_id, extra_attachment_id, position), ) - db.commit() with get_db() as db: @@ -1169,7 +1718,6 @@ 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)) @@ -1204,7 +1752,10 @@ 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 + us.name_color AS user_name_color, + EXISTS ( + SELECT 1 FROM message_pins mp WHERE mp.message_id = m.id + ) AS is_pinned FROM messages AS m JOIN users AS u ON u.id = m.user_id @@ -1304,10 +1855,8 @@ 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( @@ -1330,7 +1879,6 @@ 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() @@ -1342,15 +1890,12 @@ 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( """ @@ -1381,7 +1926,10 @@ 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 + us.name_color AS user_name_color, + EXISTS ( + SELECT 1 FROM message_pins mp WHERE mp.message_id = m.id + ) AS is_pinned FROM messages AS m JOIN users AS u ON u.id = m.user_id @@ -1413,17 +1961,14 @@ 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 @@ -1450,7 +1995,6 @@ 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( """ @@ -1460,7 +2004,6 @@ def register_routes(app, ctx): """, (message_id,), ).fetchone() - if not row: continue @@ -1473,7 +2016,6 @@ 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 @@ -1490,7 +2032,6 @@ def register_routes(app, ctx): (message_id, user["id"], now_iso()), ) updated += 1 - db.commit() return jsonify({"status": "ok", "updated": updated}) @@ -1510,7 +2051,6 @@ def register_routes(app, ctx): """, (message_id,), ).fetchone() - if not message: return jsonify({"error": "Message not found."}), 404 @@ -1523,7 +2063,6 @@ 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 @@ -1560,7 +2099,6 @@ 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( """ @@ -1574,7 +2112,6 @@ def register_routes(app, ctx): """, (message_id,), ).fetchone() - if not row: return jsonify({"error": "Message not found."}), 404 @@ -1587,7 +2124,6 @@ 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 @@ -1619,7 +2155,6 @@ def register_routes(app, ctx): "created_at": item["created_at"], } ) - summary = [] for emoji, users in grouped.items(): summary.append( @@ -1630,7 +2165,6 @@ def register_routes(app, ctx): "users": users, } ) - summary.sort(key=lambda x: (-x["count"], x["emoji"])) return jsonify({"message_id": message_id, "reactions": summary}) @@ -1639,10 +2173,8 @@ 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 @@ -1669,7 +2201,6 @@ def register_routes(app, ctx): """, (message_id,), ).fetchone() - if not row: return jsonify({"error": "Message not found."}), 404 @@ -1682,7 +2213,6 @@ 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 @@ -1697,7 +2227,6 @@ 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( { @@ -1711,10 +2240,8 @@ 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 @@ -1728,7 +2255,6 @@ def register_routes(app, ctx): """, (message_id, user["id"], emoji), ) - audit("message_reaction_removed", user["id"], "message", str(message_id), emoji) return jsonify( { @@ -1741,7 +2267,6 @@ 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 @@ -1760,12 +2285,10 @@ 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): @@ -1816,11 +2339,9 @@ 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 @@ -1835,7 +2356,6 @@ def register_routes(app, ctx): return jsonify({"error": str(error)}), 400 typing = bool(data.get("typing")) - if typing: db_write( """ @@ -1854,14 +2374,12 @@ 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 @@ -1884,18 +2402,15 @@ 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 @@ -1903,7 +2418,6 @@ 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 @@ -1913,7 +2427,6 @@ def register_routes(app, ctx): return jsonify({"error": str(error)}), 400 typing = bool(data.get("typing")) - if typing: db_write( """ @@ -1932,7 +2445,6 @@ 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") @@ -1940,7 +2452,6 @@ 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: @@ -1966,18 +2477,15 @@ 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 @@ -1991,7 +2499,6 @@ def register_routes(app, ctx): return jsonify({"error": str(error)}), 400 typing = bool(data.get("typing")) - if typing: db_write( """ @@ -2014,7 +2521,6 @@ def register_routes(app, ctx): """, (dm_id, user["id"]), ) - return jsonify({"status": "ok"}) @app.route("/api/dms//typing") @@ -2042,13 +2548,11 @@ 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( """ @@ -2067,7 +2571,6 @@ 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)}) @@ -2075,11 +2578,9 @@ 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 @@ -2092,7 +2593,6 @@ 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: @@ -2100,7 +2600,6 @@ 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 @@ -2111,7 +2610,6 @@ 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 ( @@ -2127,7 +2625,6 @@ def register_routes(app, ctx): (slug, name, description, visibility, admin["id"], now_iso()), ) room_id = cursor.lastrowid - db.execute( """ INSERT INTO room_members ( @@ -2139,11 +2636,9 @@ 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( """ @@ -2168,11 +2663,9 @@ def register_routes(app, ctx): now_iso(), ), ) - db.commit() audit("room_created", admin["id"], "room", str(room_id), f"{slug}:{visibility}") - return jsonify( { "room": { @@ -2192,11 +2685,9 @@ 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 @@ -2204,10 +2695,8 @@ 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 @@ -2233,7 +2722,6 @@ 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"}) @@ -2241,11 +2729,9 @@ 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 @@ -2253,7 +2739,6 @@ 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 @@ -2270,7 +2755,6 @@ 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 @@ -2317,7 +2801,6 @@ 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 @@ -2325,7 +2808,6 @@ 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 @@ -2335,13 +2817,11 @@ 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 @@ -2356,7 +2836,6 @@ 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 @@ -2364,7 +2843,6 @@ 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 @@ -2383,10 +2861,8 @@ 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 @@ -2394,7 +2870,6 @@ 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"}) @@ -2402,7 +2877,6 @@ 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 @@ -2410,13 +2884,11 @@ 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 ( @@ -2429,7 +2901,6 @@ 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 @@ -2437,13 +2908,11 @@ 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( """ @@ -2465,13 +2934,10 @@ 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 @@ -2481,11 +2947,9 @@ 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 ( @@ -2568,11 +3032,9 @@ 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 @@ -2596,10 +3058,8 @@ 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 @@ -2667,7 +3127,6 @@ 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 = ?", @@ -2725,7 +3184,6 @@ 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 @@ -2737,7 +3195,6 @@ 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 @@ -2758,14 +3215,12 @@ 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( """ @@ -2782,7 +3237,6 @@ def register_routes(app, ctx): ORDER BY name COLLATE NOCASE """ ).fetchall() - channels = db.execute( """ SELECT @@ -2806,7 +3260,6 @@ def register_routes(app, ctx): AND t.is_active = 1 """ ).fetchall() - public_page_rows = db.execute( """ SELECT DISTINCT p.team_id @@ -2823,7 +3276,6 @@ 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 = [ @@ -2831,18 +3283,15 @@ 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 @@ -2857,7 +3306,6 @@ 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 @@ -2869,7 +3317,6 @@ 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 @@ -2884,7 +3331,6 @@ 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 ( @@ -2899,7 +3345,6 @@ def register_routes(app, ctx): (slug, name, description, user["id"], now_iso()), ) team_id = cursor.lastrowid - db.execute( """ INSERT INTO team_members ( @@ -2911,11 +3356,9 @@ 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( """ @@ -2932,18 +3375,15 @@ 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 @@ -2951,7 +3391,6 @@ 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 @@ -2962,7 +3401,6 @@ 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 @@ -2970,7 +3408,6 @@ 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)}) @@ -2980,7 +3417,6 @@ 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: @@ -3017,19 +3453,16 @@ 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 @@ -3037,7 +3470,6 @@ 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 @@ -3049,7 +3481,6 @@ 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 @@ -3064,7 +3495,6 @@ 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 ( @@ -3091,11 +3521,9 @@ 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 @@ -3103,7 +3531,6 @@ 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 @@ -3114,7 +3541,6 @@ 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 @@ -3122,7 +3548,6 @@ 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 = ?", @@ -3137,11 +3562,9 @@ 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 @@ -3149,10 +3572,8 @@ 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 @@ -3169,7 +3590,6 @@ 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 @@ -3217,7 +3637,6 @@ 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 @@ -3225,7 +3644,6 @@ 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 @@ -3236,7 +3654,6 @@ 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 @@ -3245,7 +3662,6 @@ def register_routes(app, ctx): "SELECT id, username FROM users WHERE username = ?", (username,), ).fetchone() - if not target: return jsonify({"error": "User not found."}), 404 @@ -3263,7 +3679,6 @@ 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 @@ -3271,7 +3686,6 @@ 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 @@ -3279,7 +3693,6 @@ 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 @@ -3294,13 +3707,10 @@ 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 @@ -3308,7 +3718,6 @@ 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"], @@ -3322,7 +3731,6 @@ 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 @@ -3330,19 +3738,15 @@ 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 @@ -3350,7 +3754,6 @@ 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"}) @@ -3358,7 +3761,6 @@ 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 @@ -3366,7 +3768,6 @@ 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 @@ -3381,7 +3782,6 @@ 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 ( @@ -3395,7 +3795,6 @@ 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( { @@ -3411,13 +3810,11 @@ 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( """ @@ -3438,13 +3835,10 @@ 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 @@ -3454,11 +3848,9 @@ 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 ( @@ -3491,11 +3883,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() + 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: @@ -3505,36 +3897,38 @@ 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 p.updated_at DESC, p.id DESC + ORDER BY + CASE WHEN p.parent_page_id IS NULL THEN 0 ELSE 1 END, + 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(page, user) for page in rows]}) + return jsonify({ + "team": team_payload(team, user), + "pages": [page_payload(ctx, page, user) for page in rows] + }) @app.route("/api/teams//pages", methods=["POST"]) - def api_create_page(): - team_slug = request.view_args["team_slug"] - + def api_create_page(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 @@ -3542,7 +3936,6 @@ 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 @@ -3557,6 +3950,7 @@ 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 @@ -3578,6 +3972,23 @@ 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 @@ -3585,12 +3996,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, @@ -3601,11 +4012,12 @@ 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, @@ -3621,16 +4033,13 @@ 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(page, user)}), 201 + return jsonify({"page": page_payload(ctx, 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""" @@ -3642,27 +4051,22 @@ def register_routes(app, ctx): """, (team_slug, page_slug), ).fetchone() - if not page: return jsonify({"error": "Page not found."}), 404 - permitted = bool( - page["visibility"] == "public" - or (user and (user["role"] in {"moderator", "admin"} or is_team_member(page["team_id"], user["id"]))) - ) - - if not permitted: + if not page_can_read(page, user): 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 - return jsonify({"page": page_payload(page, user)}) + payload = page_payload(ctx, page, user) + payload["versions"] = load_page_versions(get_db, page["id"]) + return jsonify({"page": payload}) @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 @@ -3678,10 +4082,8 @@ 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 @@ -3695,6 +4097,7 @@ 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 @@ -3715,6 +4118,41 @@ 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 @@ -3724,25 +4162,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, user["id"], now_iso(), page["id"]), + (title, body, visibility, page_type, channel_id, parent_page_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() - - return jsonify({"page": page_payload(updated, user)}) + payload = page_payload(ctx, updated, user) + payload["versions"] = load_page_versions(get_db, updated["id"]) + return jsonify({"page": payload}) @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 @@ -3763,10 +4201,8 @@ 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 @@ -3781,10 +4217,392 @@ 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 + 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() @@ -3806,7 +4624,6 @@ def register_routes(app, ctx): """, (user["id"],), ).fetchone() - profile_row = db.execute( """ SELECT @@ -3823,7 +4640,6 @@ def register_routes(app, ctx): """, (user["id"],), ).fetchone() - settings_row = db.execute( """ SELECT @@ -3838,14 +4654,11 @@ def register_routes(app, ctx): @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 @@ -3915,7 +4728,6 @@ def register_routes(app, ctx): """, (user["id"],), ).fetchone() - profile_row = db.execute( """ SELECT @@ -3932,7 +4744,6 @@ def register_routes(app, ctx): """, (user["id"],), ).fetchone() - settings_row = db.execute( "SELECT name_color FROM user_settings WHERE user_id = ?", (user["id"],), @@ -3940,11 +4751,10 @@ def register_routes(app, ctx): 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)}) - + @app.route("/api/profiles/") def api_get_public_profile(username: str): normalized = normalize_username(username) - with get_db() as db: user_row = db.execute( """ @@ -3960,10 +4770,8 @@ def register_routes(app, ctx): """, (normalized,), ).fetchone() - if not user_row: return jsonify({"error": "Profile not found."}), 404 - profile_row = db.execute( """ SELECT @@ -3980,12 +4788,10 @@ def register_routes(app, ctx): """, (user_row["id"],), ).fetchone() - settings_row = db.execute( "SELECT name_color FROM user_settings WHERE user_id = ?", (user_row["id"],), ).fetchone() - return jsonify({"profile": profile_payload(html_module, user_payload, user_row, profile_row, settings_row, current_user())}) @app.route("/api/settings") @@ -4025,11 +4831,9 @@ def register_routes(app, ctx): 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 @@ -4103,7 +4907,6 @@ def register_routes(app, ctx): 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 @@ -4157,7 +4960,6 @@ def register_routes(app, ctx): 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 @@ -4200,7 +5002,6 @@ def register_routes(app, ctx): messages = [build_message_payload(row, user) for row in rows] latest_id = messages[-1]["id"] if messages else after_id - return jsonify( { "dm": dm_payload(get_db, dm_id, user["id"]), @@ -4215,14 +5016,11 @@ 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 - if not validate_dm_access(get_db, dm_id, user): return jsonify({"error": "DM not found."}), 404 @@ -4234,7 +5032,6 @@ 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 @@ -4258,7 +5055,6 @@ 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 @@ -4281,7 +5077,6 @@ def register_routes(app, ctx): (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( """ @@ -4293,7 +5088,6 @@ def register_routes(app, ctx): """, (message_id, extra_attachment_id, position), ) - db.commit() with get_db() as db: @@ -4306,11 +5100,9 @@ def register_routes(app, ctx): 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 @@ -4321,7 +5113,6 @@ def register_routes(app, ctx): room_slug = str(data.get("room", "")).strip() dm_id_raw = data.get("dm_id") - call_id = None room_value = None @@ -4330,27 +5121,21 @@ def register_routes(app, ctx): 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 @@ -4363,9 +5148,7 @@ def register_routes(app, ctx): "can_call": True, "exp": int(time.time()) + CALL_TOKEN_TTL_SECONDS, } - token = sign_call_helper_token(payload) - audit( "call_helper_token_issued", user["id"], @@ -4373,7 +5156,6 @@ def register_routes(app, ctx): call_id, f"room={room_value}", ) - return jsonify( { "token": token, @@ -4386,11 +5168,9 @@ def register_routes(app, ctx): 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 @@ -4412,12 +5192,10 @@ def register_routes(app, ctx): 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() @@ -4426,12 +5204,10 @@ def register_routes(app, ctx): 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 @@ -4452,9 +5228,7 @@ def register_routes(app, ctx): "can_call": True, "exp": int(time.time()) + CALL_TOKEN_TTL_SECONDS, } - token = sign_call_helper_token(payload) - audit( "group_call_token_issued", user["id"], @@ -4462,7 +5236,6 @@ def register_routes(app, ctx): str(session_row["id"]), helper_call_id, ) - return jsonify( { "token": token, @@ -4476,11 +5249,9 @@ def register_routes(app, ctx): 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 @@ -4491,12 +5262,10 @@ def register_routes(app, ctx): 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 @@ -4519,7 +5288,6 @@ def register_routes(app, ctx): """, (session_id,), ).fetchone() - if not session_row or session_row["status"] != "active": return jsonify({"error": "Active group call not found."}), 404 @@ -4530,7 +5298,6 @@ def register_routes(app, ctx): elif session_row["scope_kind"] == "channel": channel = get_channel_by_id(session_row["scope_id"]) allowed = bool(channel and can_access_channel(channel, user)) - if not allowed: return jsonify({"error": "Forbidden.", "code": "forbidden"}), 403 @@ -4547,13 +5314,10 @@ def register_routes(app, ctx): """, (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 @@ -4566,7 +5330,6 @@ def register_routes(app, ctx): "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() @@ -4592,7 +5355,6 @@ def register_routes(app, ctx): expires_at, ), ) - audit( "group_call_invite_created", user["id"], @@ -4600,7 +5362,6 @@ def register_routes(app, ctx): str(session_row["id"]), f"invitee={target['username']}", ) - return jsonify( { "invite": { @@ -4669,7 +5430,6 @@ def register_routes(app, ctx): 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 @@ -4694,10 +5454,8 @@ def register_routes(app, ctx): """, (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 @@ -4711,7 +5469,6 @@ def register_routes(app, ctx): """, (now_iso(), invite_id), ) - audit( "group_call_invite_accepted", user["id"], @@ -4719,7 +5476,6 @@ def register_routes(app, ctx): str(invite_id), row["helper_call_id"], ) - return jsonify( { "token": row["helper_token"], @@ -4732,7 +5488,6 @@ def register_routes(app, ctx): 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 @@ -4749,21 +5504,18 @@ def register_routes(app, ctx): """, (now_iso(), invite_id, user["id"]), ) - audit( "group_call_invite_dismissed", user["id"], "group_call_invite", str(invite_id), ) - return jsonify({"status": "dismissed"}) @app.route("/api/group-calls//end", methods=["POST"]) def api_end_group_call_session(session_id: int): if not csrf_valid(): return jsonify({"error": "Security token expired.", "code": "csrf"}), 403 - user, error = require_writable_user() if error: return error @@ -4783,7 +5535,6 @@ def register_routes(app, ctx): """, (session_id,), ).fetchone() - if not session_row: return jsonify({"error": "Group call session not found."}), 404 @@ -4794,7 +5545,6 @@ def register_routes(app, ctx): elif session_row["scope_kind"] == "channel": channel = get_channel_by_id(session_row["scope_id"]) allowed = bool(channel and can_edit_team(channel["team_id"], user)) - if not allowed and user["role"] not in {"moderator", "admin"}: return jsonify({"error": "Forbidden.", "code": "forbidden"}), 403 @@ -4808,7 +5558,6 @@ def register_routes(app, ctx): """, (now_iso(), session_id), ) - audit( "group_call_session_ended", user["id"], @@ -4816,21 +5565,19 @@ def register_routes(app, ctx): str(session_id), session_row["helper_call_id"], ) - return jsonify({"status": "ended"}) @app.route("/api/dms//calls", methods=["POST"]) def api_start_dm_call(dm_id: int): if not csrf_valid(): 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 @@ -4844,7 +5591,6 @@ def register_routes(app, ctx): 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 @@ -4854,7 +5600,6 @@ def register_routes(app, ctx): 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 @@ -4862,10 +5607,8 @@ def register_routes(app, ctx): 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 @@ -4876,7 +5619,6 @@ def register_routes(app, ctx): 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): @@ -4894,7 +5636,6 @@ def register_routes(app, ctx): 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 @@ -4914,7 +5655,6 @@ def register_routes(app, ctx): 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 @@ -4922,7 +5662,6 @@ def register_routes(app, ctx): 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