glo/routes/rooms.py
2026-11-03 00:22:48 +00:00

527 lines
17 KiB
Python

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/<string:slug>", 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/<string:slug>", 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/<string:slug>/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/<string:slug>/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/<string:slug>/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/<string:slug>/members/<int:user_id>", 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/<string:slug>/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/<string:token>/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)})