import re from .common import ( INVITE_TTL_HOURS, ROOM_SLUG_RE, ROOM_VISIBILITIES, now_iso, parse_iso, utc_now, csrf_valid, rate_limited, current_user, require_admin, require_writable_user, audit, hash_identifier, normalize_username, safe_json, jsonify, get_db, db_write, get_room_by_slug, room_payload, can_access_room, can_manage_room, get_channel_by_legacy_room_id, ) def register_room_routes(app): @app.route("/api/rooms") def list_rooms(): user = current_user() with get_db() as db: rows = db.execute( """ SELECT id, slug, name, description, visibility, created_by_user_id, is_active FROM rooms WHERE is_active = 1 ORDER BY CASE WHEN slug = 'general' THEN 0 ELSE 1 END, 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)}) @app.route("/api/rooms", methods=["POST"]) 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 try: data = safe_json() except ValueError as error: return jsonify({"error": str(error)}), 400 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: return jsonify({"error": "Invalid room visibility."}), 400 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 with get_db() as db: db.execute("BEGIN IMMEDIATE") slug = base_slug suffix = 2 while db.execute("SELECT 1 FROM rooms WHERE slug = ?", (slug,)).fetchone(): slug = f"{base_slug[:44]}-{suffix}" suffix += 1 cursor = db.execute( """ INSERT INTO rooms ( slug, name, description, visibility, created_by_user_id, created_at, is_active ) VALUES (?, ?, ?, ?, ?, ?, 1) """, (slug, name, description, visibility, admin["id"], now_iso()), ) room_id = cursor.lastrowid db.execute( """ INSERT INTO room_members ( room_id, user_id, membership_role, added_at ) VALUES (?, ?, 'owner', ?) """, (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( """ INSERT INTO channels ( team_id, legacy_room_id, slug, name, description, created_by_user_id, created_at, is_active ) VALUES (?, ?, ?, ?, ?, ?, ?, 1) """, ( general_team["id"], room_id, slug, name, description, admin["id"], now_iso(), ), ) db.commit() audit("room_created", admin["id"], "room", str(room_id), f"{slug}:{visibility}") return jsonify( { "room": { "id": room_id, "slug": slug, "name": name, "description": description, "visibility": visibility, "can_read": True, "can_write": True, "can_manage": True, } } ), 201 @app.route("/api/rooms/", methods=["PATCH"]) 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 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 try: data = safe_json() except ValueError as error: return jsonify({"error": str(error)}), 400 name = str(data.get("name", room["name"])).strip() description = str(data.get("description", room["description"] or "")).strip()[:300] visibility = data.get("visibility", room["visibility"]) if not name or len(name) > 60: return jsonify({"error": "Room name must be 1 to 60 characters."}), 400 if visibility not in ROOM_VISIBILITIES: return jsonify({"error": "Invalid room visibility."}), 400 db_write( "UPDATE rooms SET name = ?, description = ?, visibility = ? WHERE id = ?", (name, description, visibility, room["id"]), ) db_write( "UPDATE channels SET name = ?, description = ? WHERE legacy_room_id = ?", (name, description, room["id"]), ) audit("room_updated", user["id"], "room", str(room["id"]), f"name={name};visibility={visibility}") return jsonify({"status": "updated"}) @app.route("/api/rooms/", methods=["DELETE"]) 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 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 db_write("DELETE FROM rooms WHERE id = ?", (room["id"],)) audit("room_deleted", admin["id"], "room", str(room["id"]), room["slug"]) return jsonify({"status": "deleted"}) @app.route("/api/rooms//messages", methods=["DELETE"]) def clear_room_messages(slug: str): if not csrf_valid(): return jsonify({"error": "Security token expired.", "code": "csrf"}), 403 moderator, error = require_admin() if error: return error limited = rate_limited("admin") if limited: return limited room = get_room_by_slug(slug) if not room: return jsonify({"error": "Room not found."}), 404 cursor = db_write( """ DELETE FROM messages WHERE room_id = ? AND direct_conversation_id IS NULL """, (room["id"],), ) audit("room_messages_cleared", moderator["id"], "room", str(room["id"]), str(cursor.rowcount)) return jsonify({"status": "cleared", "deleted_messages": cursor.rowcount}) @app.route("/api/rooms//members") def list_room_members(slug: str): admin, error = require_admin() if error: return error 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 with get_db() as db: rows = db.execute( """ SELECT u.id, u.username, u.role, rm.membership_role, rm.added_at FROM room_members AS rm JOIN users AS u ON u.id = rm.user_id WHERE rm.room_id = ? ORDER BY CASE rm.membership_role WHEN 'owner' THEN 0 ELSE 1 END, u.username COLLATE NOCASE """, (room["id"],), ).fetchall() return jsonify( { "room": room_payload(room, admin), "members": [ { "id": row["id"], "username": row["username"], "role": row["role"], "membership_role": row["membership_role"], "added_at": row["added_at"], } for row in rows ], } ) @app.route("/api/rooms//members", methods=["POST"]) 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 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 try: data = safe_json() except ValueError as error: 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 db_write( """ INSERT OR IGNORE INTO room_members ( room_id, user_id, membership_role, added_at ) VALUES (?, ?, 'member', ?) """, (room["id"], target["id"], now_iso()), ) audit("room_member_added", admin["id"], "room", str(room["id"]), target["username"]) return jsonify({"status": "added"}), 201 @app.route("/api/rooms//members/", methods=["DELETE"]) 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 room = get_room_by_slug(slug) if not room: return jsonify({"error": "Room not found."}), 404 with get_db() as db: member = db.execute( """ SELECT membership_role FROM room_members WHERE room_id = ? AND user_id = ? """, (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 db_write( "DELETE FROM room_members WHERE room_id = ? AND user_id = ?", (room["id"], user_id), ) audit("room_member_removed", admin["id"], "room", str(room["id"]), str(user_id)) return jsonify({"status": "removed"}) @app.route("/api/rooms//invites", methods=["POST"]) 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 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 = app.secrets.token_urlsafe(32) expires_at = (utc_now() + app.timedelta(hours=INVITE_TTL_HOURS)).isoformat() db_write( """ INSERT INTO room_invites ( room_id, token_hash, created_by_user_id, created_at, expires_at ) VALUES (?, ?, ?, ?, ?) """, (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 @app.route("/api/invites//accept", methods=["POST"]) 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( """ SELECT ri.id, ri.room_id, ri.expires_at, ri.used_at, r.slug, r.name, r.description, r.visibility, r.created_by_user_id, r.is_active FROM room_invites AS ri JOIN rooms AS r ON r.id = ri.room_id WHERE ri.token_hash = ? """, (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 with get_db() as db: db.execute("BEGIN IMMEDIATE") fresh = db.execute( "SELECT id, used_at, expires_at FROM room_invites WHERE id = ?", (invite["id"],), ).fetchone() 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 ( room_id, user_id, membership_role, added_at ) VALUES (?, ?, 'member', ?) """, (invite["room_id"], user["id"], now_iso()), ) db.execute( """ UPDATE room_invites SET used_at = ?, used_by_user_id = ? WHERE id = ? """, (now_iso(), user["id"], invite["id"]), ) db.commit() audit("room_invite_accepted", user["id"], "room", str(invite["room_id"])) room = get_room_by_slug(invite["slug"]) return jsonify({"status": "joined", "room": room_payload(room, user)})