527 lines
17 KiB
Python
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)})
|