From 216ca382b3c7b3e9f04fab297623a8cd4f5bed7f Mon Sep 17 00:00:00 2001 From: admin Date: Mon, 2 Nov 2026 22:40:59 +0000 Subject: [PATCH] v1.6 backend refactor and api compatibility --- app.py | 2155 ++---------------------------------------------- attachments.py | 335 ++++++++ auth.py | 498 +++++++++++ calls.py | 464 +++++++++++ config.py | 231 ++++++ db.py | 948 +++++++++++++++++++++ features.py | 1537 ---------------------------------- models.py | 642 +++++++++++++++ profiles.py | 658 +++++++++++++++ routes.py | 850 +++++++------------ 10 files changed, 4122 insertions(+), 4196 deletions(-) create mode 100644 attachments.py create mode 100644 auth.py create mode 100644 calls.py create mode 100644 config.py create mode 100644 db.py delete mode 100644 features.py create mode 100644 models.py create mode 100644 profiles.py diff --git a/app.py b/app.py index f9aee2b..ffa8d9a 100644 --- a/app.py +++ b/app.py @@ -1,1011 +1,58 @@ -import base64 -import hashlib import html -import hmac -import json import mimetypes import os -import re import secrets -import sqlite3 -import time -from datetime import datetime, timedelta, timezone from pathlib import Path -from typing import Optional from flask import Flask, g, jsonify, render_template, request, session -from werkzeug.exceptions import BadRequest, RequestEntityTooLarge +from werkzeug.exceptions import RequestEntityTooLarge from werkzeug.middleware.proxy_fix import ProxyFix -from werkzeug.security import check_password_hash, generate_password_hash from werkzeug.utils import secure_filename -from features import maybe_cleanup_orphaned_attachments +from config import ( + APP_ROOT, + CALLS_BASE, + CALL_HELPER_SHARED_SECRET, + CALL_TOKEN_TTL_SECONDS, + CLIENT_HASH_KEY, + CONSTANTS, + DB_PATH, + PRIVATE_ACCESS_KEY, + RUNTIME_STATE, + SECRET_KEY, + TRUSTED_PROXY_HOPS, + UPLOAD_DIR, + ATTACHMENT_CLEANUP_INTERVAL_SECONDS, + ATTACHMENT_ORPHAN_TTL_HOURS, + flask_config, + get_asset_version, + now_iso, + parse_iso, + utc_now, +) +from db import get_db, db_write, init_db, bootstrap_admin +from auth import csrf_token, request_blacklisted +from attachments import maybe_cleanup_orphaned_attachments, cleanup_orphaned_attachments from routes import register_routes -APP_ROOT = Path(__file__).resolve().parent - -DB_PATH = os.environ.get("CHAT_DB_PATH", str(APP_ROOT / "chat.db")) -UPLOAD_DIR = Path(os.environ.get("CHAT_UPLOAD_DIR", str(APP_ROOT / "uploads"))) - -SECRET_KEY_PATH = os.environ.get( - "CHAT_SECRET_KEY_PATH", - str(Path(DB_PATH).resolve().parent / "flask_secret.key"), -) -CLIENT_HASH_KEY_PATH = os.environ.get( - "CHAT_CLIENT_HASH_KEY_PATH", - str(Path(DB_PATH).resolve().parent / "client_hash.key"), -) - -MAX_ATTACHMENT_BYTES = 100 * 1024 * 1024 -MAX_REQUEST_BYTES = max(110 * 1024 * 1024, MAX_ATTACHMENT_BYTES + 10 * 1024 * 1024) - -MAX_MESSAGE_LENGTH = 1000 -MAX_USERNAME_LENGTH = 30 -MIN_USERNAME_LENGTH = 3 -MIN_PASSWORD_LENGTH = 7 -MAX_PASSWORD_LENGTH = 256 -MAX_HISTORY_MESSAGES = 100 -MAX_INCREMENTAL_MESSAGES = 100 -MAX_FEED_MESSAGES = 50 -MAX_THREAD_REPLIES = 100 -MAX_ATTACHMENT_NAME_LENGTH = 150 -ATTACHMENT_ORPHAN_TTL_HOURS = 24 -ATTACHMENT_CLEANUP_INTERVAL_SECONDS = 60 * 60 -INVITE_TTL_HOURS = 72 -TEAM_INVITE_TTL_HOURS = 72 -TYPING_TTL_SECONDS = 7 -PRESENCE_TTL_SECONDS = 40 -MAX_PROFILE_BIO_LENGTH = 5000 -MAX_DISPLAY_NAME_LENGTH = 80 -MAX_LOCATION_LENGTH = 120 -MAX_WEBSITE_LENGTH = 300 -MAX_CUSTOM_CSS_LENGTH = 20000 -MAX_PROFILE_BADGE_LENGTH = 48 -MAX_OWNED_TEAMS_PER_USER = 3 -MAX_MESSAGE_EDIT_WINDOW_SECONDS = 15 * 60 -MAX_SEARCH_QUERY_LENGTH = 120 -MAX_SEARCH_RESULTS = 100 -READ_RECEIPT_BATCH_LIMIT = 200 -GROUP_CALL_PARTICIPANT_STALE_SECONDS = 45 - -PRIVATE_ACCESS_KEY = os.environ.get("PRIVATE_ACCESS_KEY", "").strip() - -CALLS_BASE = os.environ.get("CALLS_BASE", "https://calls.example.com").rstrip("/") -CALL_HELPER_SHARED_SECRET = os.environ.get("CALL_HELPER_SHARED_SECRET", "").strip() -CALL_TOKEN_TTL_SECONDS = int(os.environ.get("CALL_TOKEN_TTL_SECONDS", "60")) - -ALLOWED_ATTACHMENT_EXTENSIONS = { - "txt", - "pdf", - "png", - "jpg", - "jpeg", - "gif", - "webp", - "mp3", - "wav", - "ogg", - "m4a", - "aac", - "flac", - "mp4", - "webm", - "mov", - "m4v", - "ogv", -} - -ALLOWED_ATTACHMENT_MIME_TYPES = { - "text/plain", - "application/pdf", - "image/png", - "image/jpeg", - "image/gif", - "image/webp", - "audio/mpeg", - "audio/mp3", - "audio/wav", - "audio/x-wav", - "audio/ogg", - "audio/mp4", - "audio/aac", - "audio/flac", - "video/mp4", - "video/webm", - "video/quicktime", - "video/ogg", -} - -USERNAME_RE = re.compile(r"^[a-z0-9](?:[a-z0-9._-]{1,28}[a-z0-9])?$") -ROOM_SLUG_RE = re.compile(r"^[a-z0-9](?:[a-z0-9-]{0,46}[a-z0-9])?$") - -ROLES = {"user", "moderator", "admin"} -STATUSES = {"active", "disabled", "restricted", "blacklisted"} -ROOM_VISIBILITIES = {"public", "private"} - -RATE_LIMITS = { - "login": (8, 10 * 60), - "register": (5, 60 * 60), - "send": (30, 60), - "admin": (60, 60), - "attachment": (20, 60), - "typing": (60, 60), - "reaction": (120, 60), - "profile": (30, 60), - "settings": (30, 60), -} - -PLACEHOLDER_SECRETS = { - "", - "change-this-secret-key", - "replace-this-with-a-long-random-secret", - "replace-me", - "secret", - "changeme", -} - -_runtime_state = { - "last_attachment_cleanup": 0.0, -} - - -def utc_now() -> datetime: - return datetime.now(timezone.utc) - - -def now_iso() -> str: - return utc_now().isoformat() - - -def parse_iso(value: Optional[str]) -> Optional[datetime]: - if not value: - return None - try: - parsed = datetime.fromisoformat(value) - except ValueError: - return None - if not parsed.tzinfo: - return parsed.replace(tzinfo=timezone.utc) - return parsed - - -def load_or_create_key(path_value: str, env_value: str, min_length: int = 32) -> str: - configured = os.environ.get(env_value, "").strip() - if configured and configured.lower() not in PLACEHOLDER_SECRETS and len(configured) >= min_length: - return configured - - path = Path(path_value) - path.parent.mkdir(parents=True, exist_ok=True) - - if path.exists(): - existing = path.read_text(encoding="utf-8").strip() - if len(existing) >= min_length: - return existing - - generated = secrets.token_urlsafe(64) - try: - path.write_text(generated, encoding="utf-8") - os.chmod(path, 0o600) - except OSError: - pass - return generated - - -SECRET_KEY = load_or_create_key(SECRET_KEY_PATH, "FLASK_SECRET_KEY") -CLIENT_HASH_KEY = load_or_create_key(CLIENT_HASH_KEY_PATH, "CHAT_CLIENT_HASH_KEY") - app = Flask(__name__) app.secret_key = SECRET_KEY -trusted_proxy_hops = int(os.environ.get("TRUSTED_PROXY_HOPS", "0")) -if trusted_proxy_hops > 0: +if TRUSTED_PROXY_HOPS > 0: app.wsgi_app = ProxyFix( app.wsgi_app, - x_for=trusted_proxy_hops, - x_proto=trusted_proxy_hops, - x_host=trusted_proxy_hops, + x_for=TRUSTED_PROXY_HOPS, + x_proto=TRUSTED_PROXY_HOPS, + x_host=TRUSTED_PROXY_HOPS, ) -app.config.update( - MAX_CONTENT_LENGTH=MAX_REQUEST_BYTES, - SESSION_COOKIE_NAME="chat_session", - SESSION_COOKIE_HTTPONLY=True, - SESSION_COOKIE_SAMESITE="Lax", - SESSION_COOKIE_SECURE=(os.environ.get("SESSION_COOKIE_SECURE", "1") == "1"), - PERMANENT_SESSION_LIFETIME=timedelta(days=14), -) +app.config.update(flask_config()) - -def get_asset_version() -> str: - try: - js_dir = APP_ROOT / "static" / "js" - values = [int((APP_ROOT / "static" / "style.css").stat().st_mtime)] - values.extend(int(path.stat().st_mtime) for path in js_dir.glob("*.js")) - return str(max(values)) - except OSError: - return "1" - - -def get_db() -> sqlite3.Connection: - db_dir = os.path.dirname(DB_PATH) - if db_dir: - os.makedirs(db_dir, exist_ok=True) - - conn = sqlite3.connect(DB_PATH, timeout=8, isolation_level=None, check_same_thread=False) - conn.row_factory = sqlite3.Row - conn.execute("PRAGMA foreign_keys = ON") - conn.execute("PRAGMA journal_mode = WAL") - conn.execute("PRAGMA busy_timeout = 8000") - conn.execute("PRAGMA synchronous = NORMAL") - return conn - - -def db_write(query: str, params: tuple = ()) -> sqlite3.Cursor: - last_error = None - for attempt in range(5): - try: - with get_db() as db: - db.execute("BEGIN IMMEDIATE") - cursor = db.execute(query, params) - db.commit() - return cursor - except sqlite3.OperationalError as error: - last_error = error - if "locked" not in str(error).lower() and "busy" not in str(error).lower(): - raise - if attempt == 4: - raise - time.sleep(0.05 * (2 ** attempt)) - raise last_error - - -def column_exists(db: sqlite3.Connection, table: str, column: str) -> bool: - rows = db.execute(f"PRAGMA table_info({table})").fetchall() - return any(row["name"] == column for row in rows) - - -def get_room_by_slug(slug: str): - with get_db() as db: - return db.execute( - """ - SELECT - id, - slug, - name, - description, - visibility, - created_by_user_id, - is_active - FROM rooms - WHERE slug = ? - AND is_active = 1 - """, - (slug,), - ).fetchone() - - -def room_by_id(room_id: int): - with get_db() as db: - return db.execute( - """ - SELECT - id, - slug, - name, - description, - visibility, - created_by_user_id, - is_active - FROM rooms - WHERE id = ? - AND is_active = 1 - """, - (room_id,), - ).fetchone() - - -def room_member(room_id: int, user_id: int) -> bool: - with get_db() as db: - row = db.execute( - """ - SELECT 1 - FROM room_members - WHERE room_id = ? - AND user_id = ? - """, - (room_id, user_id), - ).fetchone() - return bool(row) - - -def room_owner(room_id: int, user_id: int) -> bool: - with get_db() as db: - row = db.execute( - """ - SELECT 1 - FROM room_members - WHERE room_id = ? - AND user_id = ? - AND membership_role = 'owner' - """, - (room_id, user_id), - ).fetchone() - return bool(row) - - -def can_access_room(room, user: Optional[dict]) -> bool: - if room["visibility"] == "public": - return True - if not user: - return False - if user["role"] in {"moderator", "admin"}: - return True - return room_member(room["id"], user["id"]) - - -def can_write_room(room, user: Optional[dict]) -> bool: - if not user or user["status"] != "active": - return False - return can_access_room(room, user) - - -def can_manage_room(room, user: Optional[dict]) -> bool: - if not user: - return False - if user["role"] == "admin": - return True - return room_owner(room["id"], user["id"]) - - -def room_payload(room, user: Optional[dict]) -> dict: - return { - "id": room["id"], - "slug": room["slug"], - "name": room["name"], - "description": room["description"], - "visibility": room["visibility"], - "can_read": can_access_room(room, user), - "can_write": can_write_room(room, user), - "can_manage": can_manage_room(room, user), - } - - -def get_team_by_slug(slug: str): - with get_db() as db: - return db.execute( - """ - SELECT - id, - slug, - name, - description, - created_by_user_id, - created_at, - is_active - FROM teams - WHERE slug = ? - AND is_active = 1 - """, - (slug,), - ).fetchone() - - -def get_channel_by_id(channel_id: int): - with get_db() as db: - return db.execute( - """ - SELECT - c.id, - c.team_id, - c.legacy_room_id, - c.slug, - c.name, - c.description, - c.created_by_user_id, - c.created_at, - c.is_active, - t.slug AS team_slug, - t.name AS team_name, - t.description AS team_description, - t.is_active AS team_is_active - FROM channels AS c - JOIN teams AS t - ON t.id = c.team_id - WHERE c.id = ? - AND c.is_active = 1 - AND t.is_active = 1 - """, - (channel_id,), - ).fetchone() - - -def get_channel_by_slug(team_slug: str, channel_slug: str): - with get_db() as db: - return db.execute( - """ - SELECT - c.id, - c.team_id, - c.legacy_room_id, - c.slug, - c.name, - c.description, - c.created_by_user_id, - c.created_at, - c.is_active, - t.slug AS team_slug, - t.name AS team_name, - t.description AS team_description, - t.is_active AS team_is_active - FROM channels AS c - JOIN teams AS t - ON t.id = c.team_id - WHERE t.slug = ? - AND c.slug = ? - AND t.is_active = 1 - AND c.is_active = 1 - """, - (team_slug, channel_slug), - ).fetchone() - - -def get_channel_by_legacy_room_id(room_id: int): - with get_db() as db: - return db.execute( - """ - SELECT - c.id, - c.team_id, - c.legacy_room_id, - c.slug, - c.name, - c.description, - c.created_by_user_id, - c.created_at, - c.is_active, - t.slug AS team_slug, - t.name AS team_name, - t.description AS team_description, - t.is_active AS team_is_active - FROM channels AS c - JOIN teams AS t - ON t.id = c.team_id - WHERE c.legacy_room_id = ? - AND c.is_active = 1 - AND t.is_active = 1 - """, - (room_id,), - ).fetchone() - - -def team_member_role(team_id: int, user_id: int) -> Optional[str]: - with get_db() as db: - row = db.execute( - """ - SELECT membership_role - FROM team_members - WHERE team_id = ? - AND user_id = ? - """, - (team_id, user_id), - ).fetchone() - return row["membership_role"] if row else None - - -def is_team_member(team_id: int, user_id: int) -> bool: - return team_member_role(team_id, user_id) is not None - - -def is_team_admin(team_id: int, user: Optional[dict]) -> bool: - if not user: - return False - if user["role"] == "admin": - return True - return team_member_role(team_id, user["id"]) in {"owner", "admin"} - - -def can_edit_team(team_id: int, user: Optional[dict]) -> bool: - return is_team_admin(team_id, user) - - -def can_access_team(team, user: Optional[dict]) -> bool: - if not team["is_active"]: - return False - if user and user["role"] in {"moderator", "admin"}: - return True - return bool(user and is_team_member(team["id"], user["id"])) - - -def legacy_room_for_channel(channel): - if not channel["legacy_room_id"]: - return None - return room_by_id(channel["legacy_room_id"]) - - -def can_access_channel(channel, user: Optional[dict]) -> bool: - if user and user["role"] in {"moderator", "admin"}: - return True - legacy_room = legacy_room_for_channel(channel) - if legacy_room: - return can_access_room(legacy_room, user) - return bool(user and is_team_member(channel["team_id"], user["id"])) - - -def can_write_channel(channel, user: Optional[dict]) -> bool: - return bool(user and user["status"] == "active" and can_access_channel(channel, user)) - - -def team_payload(team, user: Optional[dict]) -> dict: - membership_role = team_member_role(team["id"], user["id"]) if user else None - return { - "id": team["id"], - "slug": team["slug"], - "name": team["name"], - "description": team["description"], - "membership_role": membership_role, - "can_read": can_access_team(team, user), - "can_manage": is_team_admin(team["id"], user), - } - - -def channel_payload(channel, user: Optional[dict]) -> dict: - return { - "id": channel["id"], - "team_id": channel["team_id"], - "team_slug": channel["team_slug"], - "team_name": channel["team_name"], - "legacy_room_id": channel["legacy_room_id"], - "slug": channel["slug"], - "name": channel["name"], - "description": channel["description"], - "can_read": can_access_channel(channel, user), - "can_write": can_write_channel(channel, user), - "can_manage": is_team_admin(channel["team_id"], user), - } - - -MESSAGE_SELECT = """ - SELECT - m.id, - m.room_id, - m.channel_id, - m.direct_conversation_id, - m.user_id, - m.username, - m.text, - m.created_at, - m.attachment_id, - m.parent_message_id, - ( - SELECT COUNT(*) - FROM messages AS thread_reply - WHERE thread_reply.parent_message_id = m.id - ) AS thread_reply_count, - a.original_name AS attachment_original_name, - 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 - FROM messages AS m - JOIN users AS u - ON u.id = m.user_id - LEFT JOIN user_settings AS us - ON us.user_id = u.id - LEFT JOIN attachments AS a - ON a.id = m.attachment_id -""" - - -def team_page_select() -> str: - has_parent_page = False - try: - with get_db() as db: - has_parent_page = column_exists(db, "pages", "parent_page_id") - except Exception: - has_parent_page = False - - if has_parent_page: - return """ - SELECT - p.id, - p.team_id, - p.channel_id, - p.parent_page_id, - parent.slug AS parent_page_slug, - parent.title AS parent_page_title, - p.slug, - p.title, - p.body, - p.visibility, - p.page_type, - p.created_by_user_id, - p.updated_by_user_id, - p.created_at, - p.updated_at, - p.is_active, - t.slug AS team_slug, - t.name AS team_name, - c.slug AS channel_slug, - c.name AS channel_name - FROM pages AS p - JOIN teams AS t - ON t.id = p.team_id - LEFT JOIN channels AS c - ON c.id = p.channel_id - LEFT JOIN pages AS parent - ON parent.id = p.parent_page_id - """ - - return """ - SELECT - p.id, - p.team_id, - p.channel_id, - NULL AS parent_page_id, - NULL AS parent_page_slug, - NULL AS parent_page_title, - p.slug, - p.title, - p.body, - p.visibility, - p.page_type, - p.created_by_user_id, - p.updated_by_user_id, - p.created_at, - p.updated_at, - p.is_active, - t.slug AS team_slug, - t.name AS team_name, - c.slug AS channel_slug, - c.name AS channel_name - FROM pages AS p - JOIN teams AS t - ON t.id = p.team_id - LEFT JOIN channels AS c - ON c.id = p.channel_id - """ - - -def team_slug_from_name(name: str) -> str: - return re.sub(r"[^a-z0-9-]+", "-", name.lower()).strip("-")[:48].strip("-") - - -def page_slug_from_title(title: str) -> str: - return re.sub(r"[^a-z0-9-]+", "-", title.lower()).strip("-")[:48].strip("-") - - -def normalize_username(value: object) -> str: - return value.strip().lower() if isinstance(value, str) else "" - - -def validate_username(username: str) -> Optional[str]: - if not username: - return "Username is required." - if len(username) < MIN_USERNAME_LENGTH: - return f"Username must be at least {MIN_USERNAME_LENGTH} characters." - if len(username) > MAX_USERNAME_LENGTH: - return f"Username must be {MAX_USERNAME_LENGTH} characters or less." - if not USERNAME_RE.fullmatch(username): - return "Username contains invalid characters." - return None - - -def validate_password(password: object, username: str = "") -> Optional[str]: - if not isinstance(password, str) or not password: - return "Password is required." - if len(password) < MIN_PASSWORD_LENGTH: - return f"Password must be at least {MIN_PASSWORD_LENGTH} characters." - if len(password) > MAX_PASSWORD_LENGTH: - return f"Password must be {MAX_PASSWORD_LENGTH} characters or less." - if not re.search(r"[a-z]", password): - return "Password must include a lowercase letter." - if not re.search(r"[A-Z]", password): - return "Password must include an uppercase letter." - if not re.search(r"\d", password): - return "Password must include a number." - if not re.search(r"[^A-Za-z0-9]", password): - return "Password must include a symbol." - if username and username in password.lower(): - return "Password must not contain your username." - return None - - -def client_ip() -> str: - return (request.remote_addr or "unknown")[:128] - - -def ctx_hash(): - return hashlib.sha256 - - -def hash_identifier(value: str) -> str: - return hmac.new( - CLIENT_HASH_KEY.encode("utf-8"), - value.encode("utf-8"), - ctx_hash(), - ).hexdigest() - - -def b64url_encode_bytes(data: bytes) -> str: - return base64.urlsafe_b64encode(data).decode("ascii").rstrip("=") - - -def sign_call_helper_token(payload: dict) -> str: - payload_json = json.dumps(payload, separators=(",", ":"), ensure_ascii=False) - payload_bytes = payload_json.encode("utf-8") - signature = hmac.new( - CALL_HELPER_SHARED_SECRET.encode("utf-8"), - payload_bytes, - hashlib.sha256, - ).digest() - return f"{b64url_encode_bytes(payload_bytes)}.{b64url_encode_bytes(signature)}" - - -def current_client_hash() -> str: - return hash_identifier(client_ip()) - - -def audit( - action: str, - actor_user_id: Optional[int] = None, - target_type: Optional[str] = None, - target_id: Optional[str] = None, - details: Optional[str] = None, -) -> None: - db_write( - """ - INSERT INTO audit_log ( - actor_user_id, - action, - target_type, - target_id, - details, - client_hash, - created_at - ) VALUES (?, ?, ?, ?, ?, ?, ?) - """, - ( - actor_user_id, - action, - target_type, - target_id, - details, - current_client_hash(), - now_iso(), - ), - ) - - -def active_blacklist_match(kind: str, value_hash: str) -> bool: - with get_db() as db: - row = db.execute( - """ - SELECT id - FROM blacklist_entries - WHERE kind = ? - AND value_hash = ? - AND ( - expires_at IS NULL - OR expires_at > ? - ) - """, - (kind, value_hash, now_iso()), - ).fetchone() - return bool(row) - - -def request_blacklisted() -> bool: - if request.endpoint in {"static"}: - return False - if active_blacklist_match("ip", current_client_hash()): - return True - user_id = session.get("user_id") - if user_id and active_blacklist_match("user", hash_identifier(str(user_id))): - return True - return False - - -def check_rate_limit(scope: str) -> Optional[int]: - limit, window_seconds = RATE_LIMITS[scope] - now = time.time() - cutoff = now - window_seconds - subject_hash = current_client_hash() - - with get_db() as db: - db.execute("DELETE FROM rate_limit_events WHERE created_at < ?", (cutoff - 60,)) - rows = db.execute( - """ - SELECT created_at - FROM rate_limit_events - WHERE scope = ? - AND subject_hash = ? - AND created_at >= ? - ORDER BY created_at ASC - """, - (scope, subject_hash, cutoff), - ).fetchall() - - if len(rows) >= limit: - oldest = rows[0]["created_at"] - return max(1, int(window_seconds - (now - oldest)) + 1) - - db.execute( - """ - INSERT INTO rate_limit_events ( - scope, - subject_hash, - created_at - ) VALUES (?, ?, ?) - """, - (scope, subject_hash, now), - ) - db.commit() - - return None - - -def rate_limited(scope: str): - retry_after = check_rate_limit(scope) - if retry_after is None: - return None - return ( - jsonify( - { - "error": "Too many requests. Please wait and try again.", - "code": "rate_limited", - "retry_after": retry_after, - } - ), - 429, - ) - - -def safe_json() -> dict: - if not request.is_json: - raise ValueError("Request body must be JSON.") - try: - data = request.get_json(silent=False) - except BadRequest as error: - raise ValueError("Invalid JSON request body.") from error - if not isinstance(data, dict): - raise ValueError("JSON body must be an object.") - return data - - -def csrf_token() -> str: - token = session.get("csrf_token") - if not token: - token = secrets.token_urlsafe(32) - session["csrf_token"] = token - return token - - -def csrf_valid() -> bool: - supplied = request.headers.get("X-CSRF-Token", "") - expected = session.get("csrf_token", "") - return bool(supplied and expected and hmac.compare_digest(supplied, expected)) - - -def user_payload(row: sqlite3.Row, settings_row: sqlite3.Row | None = None) -> dict: - status = row["status"] - restricted_until = parse_iso(row["restricted_until"]) - if status == "restricted" and restricted_until and restricted_until <= utc_now(): - status = "active" - return { - "id": row["id"], - "username": row["username"], - "role": row["role"], - "status": status, - "restricted_until": row["restricted_until"], - } - - -def current_user() -> Optional[dict]: - user_id = session.get("user_id") - if not user_id: - return None - - with get_db() as db: - row = db.execute( - """ - SELECT - id, - username, - role, - status, - restricted_until - FROM users - WHERE id = ? - """, - (user_id,), - ).fetchone() - - if not row: - session.clear() - return None - - user = user_payload(row) - - if user["status"] == "restricted": - expires = parse_iso(user["restricted_until"]) - if expires and expires <= utc_now(): - db_write( - """ - UPDATE users - SET - status = 'active', - restricted_until = NULL, - restriction_reason = NULL, - is_active = 1 - WHERE id = ? - """, - (user["id"],), - ) - user["status"] = "active" - user["restricted_until"] = None - - if user["status"] in {"disabled", "blacklisted"}: - session.clear() - return None - - return user - - -def login_user(row: sqlite3.Row | dict) -> dict: - session.clear() - session.permanent = True - session["user_id"] = row["id"] - session["csrf_token"] = secrets.token_urlsafe(32) - return { - "id": row["id"], - "username": row["username"], - "role": row["role"], - "status": row["status"], - "restricted_until": row.get("restricted_until") if isinstance(row, dict) else row["restricted_until"], - } - - -def require_user(): - user = current_user() - if not user: - return None, (jsonify({"error": "Login required.", "code": "login_required"}), 401) - return user, None - - -def require_writable_user(): - user, error = require_user() - if error: - return None, error - if user["status"] == "restricted": - return None, ( - jsonify( - { - "error": "Your account is temporarily restricted.", - "code": "restricted", - "restricted_until": user["restricted_until"], - } - ), - 403, - ) - return user, None - - -def require_moderator(): - user, error = require_writable_user() - if error: - return None, error - if user["role"] not in {"moderator", "admin"}: - return None, (jsonify({"error": "Moderator access required.", "code": "forbidden"}), 403) - return user, None - - -def require_admin(): - user, error = require_writable_user() - if error: - return None, error - if user["role"] != "admin": - return None, (jsonify({"error": "Administrator access required.", "code": "forbidden"}), 403) - return user, None +app.os = os +app.secrets = secrets +app.mimetypes = mimetypes +app.secure_filename = secure_filename +app.timedelta = __import__("datetime").timedelta def get_asset_template_values(): @@ -1017,7 +64,7 @@ def get_asset_template_values(): @app.before_request def setup_request_security(): - g.csp_nonce = base64.b64encode(secrets.token_bytes(18)).decode("ascii") + g.csp_nonce = __import__("base64").b64encode(secrets.token_bytes(18)).decode("ascii") if request_blacklisted(): if request.path.startswith("/api/"): return jsonify({"error": "Access to this service has been blocked.", "code": "blacklisted"}), 403 @@ -1074,912 +121,12 @@ def request_too_large(_error): return jsonify({"error": "Request is too large."}), 413 -@app.errorhandler(sqlite3.OperationalError) +@app.errorhandler(__import__("sqlite3").OperationalError) def sqlite_error(error): app.logger.exception("Database error: %s", error) return jsonify({"error": "A database error occurred."}), 500 -def init_db() -> None: - UPLOAD_DIR.mkdir(parents=True, exist_ok=True) - - with get_db() as db: - db.execute( - """ - CREATE TABLE IF NOT EXISTS users ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - username TEXT COLLATE NOCASE UNIQUE NOT NULL, - password_hash TEXT NOT NULL, - is_admin INTEGER NOT NULL DEFAULT 0, - is_active INTEGER NOT NULL DEFAULT 1, - role TEXT NOT NULL DEFAULT 'user', - status TEXT NOT NULL DEFAULT 'active', - restricted_until TEXT, - restriction_reason TEXT, - created_at TEXT NOT NULL, - last_login_at TEXT - ) - """ - ) - - db.execute( - """ - CREATE TABLE IF NOT EXISTS rooms ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - slug TEXT COLLATE NOCASE UNIQUE NOT NULL, - name TEXT NOT NULL, - description TEXT, - visibility TEXT NOT NULL DEFAULT 'public', - created_by_user_id INTEGER, - created_at TEXT NOT NULL, - is_active INTEGER NOT NULL DEFAULT 1, - FOREIGN KEY (created_by_user_id) REFERENCES users(id) - ) - """ - ) - - db.execute( - """ - CREATE TABLE IF NOT EXISTS room_members ( - room_id INTEGER NOT NULL, - user_id INTEGER NOT NULL, - membership_role TEXT NOT NULL DEFAULT 'member', - added_at TEXT NOT NULL, - PRIMARY KEY (room_id, user_id), - FOREIGN KEY (room_id) REFERENCES rooms(id) ON DELETE CASCADE, - FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE - ) - """ - ) - - db.execute( - """ - CREATE TABLE IF NOT EXISTS attachments ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - uploader_user_id INTEGER NOT NULL, - stored_name TEXT UNIQUE NOT NULL, - original_name TEXT NOT NULL, - mime_type TEXT NOT NULL, - size_bytes INTEGER NOT NULL, - created_at TEXT NOT NULL, - FOREIGN KEY (uploader_user_id) REFERENCES users(id) ON DELETE CASCADE - ) - """ - ) - - db.execute( - """ - CREATE TABLE IF NOT EXISTS teams ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - slug TEXT COLLATE NOCASE UNIQUE NOT NULL, - name TEXT NOT NULL, - description TEXT, - created_by_user_id INTEGER, - created_at TEXT NOT NULL, - is_active INTEGER NOT NULL DEFAULT 1, - FOREIGN KEY (created_by_user_id) REFERENCES users(id) ON DELETE SET NULL - ) - """ - ) - - db.execute( - """ - CREATE TABLE IF NOT EXISTS team_members ( - team_id INTEGER NOT NULL, - user_id INTEGER NOT NULL, - membership_role TEXT NOT NULL DEFAULT 'member', - added_at TEXT NOT NULL, - PRIMARY KEY (team_id, user_id), - FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE, - FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE - ) - """ - ) - - db.execute( - """ - CREATE TABLE IF NOT EXISTS team_invites ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - team_id INTEGER NOT NULL, - token_hash TEXT UNIQUE NOT NULL, - created_by_user_id INTEGER NOT NULL, - membership_role TEXT NOT NULL DEFAULT 'member', - created_at TEXT NOT NULL, - expires_at TEXT NOT NULL, - used_at TEXT, - used_by_user_id INTEGER, - FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE, - FOREIGN KEY (created_by_user_id) REFERENCES users(id) ON DELETE CASCADE, - FOREIGN KEY (used_by_user_id) REFERENCES users(id) ON DELETE SET NULL - ) - """ - ) - - db.execute( - """ - CREATE TABLE IF NOT EXISTS channels ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - team_id INTEGER NOT NULL, - legacy_room_id INTEGER UNIQUE, - slug TEXT COLLATE NOCASE NOT NULL, - name TEXT NOT NULL, - description TEXT, - created_by_user_id INTEGER, - created_at TEXT NOT NULL, - is_active INTEGER NOT NULL DEFAULT 1, - UNIQUE(team_id, slug), - FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE, - FOREIGN KEY (legacy_room_id) REFERENCES rooms(id) ON DELETE SET NULL, - FOREIGN KEY (created_by_user_id) REFERENCES users(id) ON DELETE SET NULL - ) - """ - ) - - db.execute( - """ - CREATE TABLE IF NOT EXISTS messages ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - room_id INTEGER, - channel_id INTEGER, - direct_conversation_id INTEGER, - user_id INTEGER NOT NULL, - username TEXT NOT NULL, - text TEXT NOT NULL, - attachment_id INTEGER, - parent_message_id INTEGER, - created_at TEXT NOT NULL, - FOREIGN KEY (room_id) REFERENCES rooms(id) ON DELETE CASCADE, - FOREIGN KEY (channel_id) REFERENCES channels(id) ON DELETE CASCADE, - FOREIGN KEY (direct_conversation_id) REFERENCES direct_conversations(id) ON DELETE CASCADE, - FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, - FOREIGN KEY (attachment_id) REFERENCES attachments(id) ON DELETE SET NULL, - FOREIGN KEY (parent_message_id) REFERENCES messages(id) ON DELETE CASCADE - ) - """ - ) - - db.execute( - """ - CREATE TABLE IF NOT EXISTS room_invites ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - room_id INTEGER NOT NULL, - token_hash TEXT UNIQUE NOT NULL, - created_by_user_id INTEGER NOT NULL, - created_at TEXT NOT NULL, - expires_at TEXT NOT NULL, - used_at TEXT, - used_by_user_id INTEGER, - FOREIGN KEY (room_id) REFERENCES rooms(id) ON DELETE CASCADE, - FOREIGN KEY (created_by_user_id) REFERENCES users(id) ON DELETE CASCADE, - FOREIGN KEY (used_by_user_id) REFERENCES users(id) ON DELETE SET NULL - ) - """ - ) - - db.execute( - """ - CREATE TABLE IF NOT EXISTS typing_events ( - room_id INTEGER NOT NULL, - user_id INTEGER NOT NULL, - updated_at REAL NOT NULL, - PRIMARY KEY (room_id, user_id), - FOREIGN KEY (room_id) REFERENCES rooms(id) ON DELETE CASCADE, - FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE - ) - """ - ) - - db.execute( - """ - CREATE TABLE IF NOT EXISTS channel_typing_events ( - channel_id INTEGER NOT NULL, - user_id INTEGER NOT NULL, - updated_at REAL NOT NULL, - PRIMARY KEY (channel_id, user_id), - FOREIGN KEY (channel_id) REFERENCES channels(id) ON DELETE CASCADE, - FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE - ) - """ - ) - - db.execute( - """ - CREATE TABLE IF NOT EXISTS dm_typing_events ( - direct_conversation_id INTEGER NOT NULL, - user_id INTEGER NOT NULL, - updated_at REAL NOT NULL, - PRIMARY KEY (direct_conversation_id, user_id), - FOREIGN KEY (direct_conversation_id) REFERENCES direct_conversations(id) ON DELETE CASCADE, - FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE - ) - """ - ) - - db.execute( - """ - CREATE TABLE IF NOT EXISTS blacklist_entries ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - kind TEXT NOT NULL, - value_hash TEXT NOT NULL, - reason TEXT, - created_at TEXT NOT NULL, - expires_at TEXT, - created_by_user_id INTEGER, - UNIQUE(kind, value_hash), - FOREIGN KEY (created_by_user_id) REFERENCES users(id) - ) - """ - ) - - db.execute( - """ - CREATE TABLE IF NOT EXISTS rate_limit_events ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - scope TEXT NOT NULL, - subject_hash TEXT NOT NULL, - created_at REAL NOT NULL - ) - """ - ) - - db.execute( - """ - CREATE TABLE IF NOT EXISTS audit_log ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - actor_user_id INTEGER, - action TEXT NOT NULL, - target_type TEXT, - target_id TEXT, - details TEXT, - client_hash TEXT, - created_at TEXT NOT NULL, - FOREIGN KEY (actor_user_id) REFERENCES users(id) ON DELETE SET NULL - ) - """ - ) - - db.execute( - """ - CREATE TABLE IF NOT EXISTS pages ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - team_id INTEGER NOT NULL, - channel_id INTEGER, - slug TEXT COLLATE NOCASE NOT NULL, - title TEXT NOT NULL, - body TEXT NOT NULL DEFAULT '', - visibility TEXT NOT NULL DEFAULT 'team', - page_type TEXT NOT NULL DEFAULT 'document', - created_by_user_id INTEGER NOT NULL, - updated_by_user_id INTEGER NOT NULL, - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL, - is_active INTEGER NOT NULL DEFAULT 1, - UNIQUE(team_id, slug), - FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE, - FOREIGN KEY (channel_id) REFERENCES channels(id) ON DELETE SET NULL, - FOREIGN KEY (created_by_user_id) REFERENCES users(id) ON DELETE RESTRICT, - FOREIGN KEY (updated_by_user_id) REFERENCES users(id) ON DELETE RESTRICT - ) - """ - ) - - db.execute( - """ - CREATE TABLE IF NOT EXISTS user_profiles ( - user_id INTEGER PRIMARY KEY, - display_name TEXT, - badge_text TEXT, - bio TEXT NOT NULL DEFAULT '', - bio_mode TEXT NOT NULL DEFAULT 'txt', - website TEXT, - location TEXT, - status_message TEXT NOT NULL DEFAULT '', - banner_attachment_id INTEGER, - updated_at TEXT NOT NULL, - FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, - FOREIGN KEY (banner_attachment_id) REFERENCES attachments(id) ON DELETE SET NULL - ) - """ - ) - - db.execute( - """ - CREATE TABLE IF NOT EXISTS user_settings ( - user_id INTEGER PRIMARY KEY, - accent_color TEXT, - name_color TEXT, - custom_css TEXT NOT NULL DEFAULT '', - theme_mode TEXT NOT NULL DEFAULT 'dark', - profile_background_color TEXT, - profile_banner_color TEXT, - profile_visibility TEXT NOT NULL DEFAULT 'public', - last_active_visibility TEXT NOT NULL DEFAULT 'everyone', - presence_mode TEXT NOT NULL DEFAULT 'online', - dm_layout_mode TEXT NOT NULL DEFAULT 'cards', - show_presence_badge INTEGER NOT NULL DEFAULT 1, - updated_at TEXT NOT NULL, - FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE - ) - """ - ) - - db.execute( - """ - CREATE TABLE IF NOT EXISTS message_reactions ( - message_id INTEGER NOT NULL, - user_id INTEGER NOT NULL, - emoji TEXT NOT NULL, - created_at TEXT NOT NULL, - PRIMARY KEY (message_id, user_id, emoji), - FOREIGN KEY (message_id) REFERENCES messages(id) ON DELETE CASCADE, - FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE - ) - """ - ) - - db.execute( - """ - CREATE TABLE IF NOT EXISTS message_edits ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - message_id INTEGER NOT NULL, - edited_by_user_id INTEGER NOT NULL, - previous_text TEXT NOT NULL, - new_text TEXT NOT NULL, - previous_attachments_json TEXT NOT NULL DEFAULT '[]', - new_attachments_json TEXT NOT NULL DEFAULT '[]', - created_at TEXT NOT NULL, - FOREIGN KEY (message_id) REFERENCES messages(id) ON DELETE CASCADE, - FOREIGN KEY (edited_by_user_id) REFERENCES users(id) ON DELETE CASCADE - ) - """ - ) - - db.execute( - """ - CREATE TABLE IF NOT EXISTS message_receipts ( - message_id INTEGER NOT NULL, - user_id INTEGER NOT NULL, - seen_at TEXT NOT NULL, - PRIMARY KEY (message_id, user_id), - FOREIGN KEY (message_id) REFERENCES messages(id) ON DELETE CASCADE, - FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE - ) - """ - ) - - db.execute( - """ - CREATE TABLE IF NOT EXISTS message_attachments ( - message_id INTEGER NOT NULL, - attachment_id INTEGER NOT NULL, - position INTEGER NOT NULL DEFAULT 0, - PRIMARY KEY (message_id, attachment_id), - FOREIGN KEY (message_id) REFERENCES messages(id) ON DELETE CASCADE, - FOREIGN KEY (attachment_id) REFERENCES attachments(id) ON DELETE CASCADE - ) - """ - ) - - db.execute( - """ - CREATE TABLE IF NOT EXISTS presence_events ( - user_id INTEGER PRIMARY KEY, - updated_at REAL NOT NULL, - view_name TEXT, - room_id INTEGER, - channel_id INTEGER, - direct_conversation_id INTEGER, - FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE - ) - """ - ) - - db.execute( - """ - CREATE TABLE IF NOT EXISTS direct_conversations ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - member_pair_key TEXT UNIQUE NOT NULL, - created_at TEXT NOT NULL - ) - """ - ) - - db.execute( - """ - CREATE TABLE IF NOT EXISTS direct_conversation_members ( - direct_conversation_id INTEGER NOT NULL, - user_id INTEGER NOT NULL, - added_at TEXT NOT NULL, - PRIMARY KEY (direct_conversation_id, user_id), - FOREIGN KEY (direct_conversation_id) REFERENCES direct_conversations(id) ON DELETE CASCADE, - FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE - ) - """ - ) - - db.execute( - """ - CREATE TABLE IF NOT EXISTS call_sessions ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - direct_conversation_id INTEGER NOT NULL, - started_by_user_id INTEGER NOT NULL, - status TEXT NOT NULL DEFAULT 'active', - created_at TEXT NOT NULL, - ended_at TEXT, - FOREIGN KEY (direct_conversation_id) REFERENCES direct_conversations(id) ON DELETE CASCADE, - FOREIGN KEY (started_by_user_id) REFERENCES users(id) ON DELETE CASCADE - ) - """ - ) - - db.execute( - """ - CREATE TABLE IF NOT EXISTS call_signals ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - call_session_id INTEGER NOT NULL, - from_user_id INTEGER NOT NULL, - signal_type TEXT NOT NULL, - payload_json TEXT NOT NULL DEFAULT '{}', - created_at TEXT NOT NULL, - FOREIGN KEY (call_session_id) REFERENCES call_sessions(id) ON DELETE CASCADE, - FOREIGN KEY (from_user_id) REFERENCES users(id) ON DELETE CASCADE - ) - """ - ) - - db.execute( - """ - CREATE TABLE IF NOT EXISTS group_call_sessions ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - scope_kind TEXT NOT NULL, - scope_id INTEGER NOT NULL, - scope_slug TEXT NOT NULL, - helper_call_id TEXT NOT NULL UNIQUE, - created_by_user_id INTEGER NOT NULL, - status TEXT NOT NULL DEFAULT 'active', - created_at TEXT NOT NULL, - ended_at TEXT, - FOREIGN KEY (created_by_user_id) REFERENCES users(id) ON DELETE CASCADE - ) - """ - ) - - db.execute( - """ - CREATE TABLE IF NOT EXISTS group_call_invites ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - group_call_session_id INTEGER NOT NULL, - inviter_user_id INTEGER NOT NULL, - invitee_user_id INTEGER NOT NULL, - helper_token TEXT NOT NULL, - status TEXT NOT NULL DEFAULT 'pending', - created_at TEXT NOT NULL, - expires_at TEXT NOT NULL, - responded_at TEXT, - FOREIGN KEY (group_call_session_id) REFERENCES group_call_sessions(id) ON DELETE CASCADE, - FOREIGN KEY (inviter_user_id) REFERENCES users(id) ON DELETE CASCADE, - FOREIGN KEY (invitee_user_id) REFERENCES users(id) ON DELETE CASCADE - ) - """ - ) - - db.execute( - """ - CREATE TABLE IF NOT EXISTS group_call_participants ( - group_call_session_id INTEGER NOT NULL, - user_id INTEGER NOT NULL, - joined_at TEXT NOT NULL, - updated_at TEXT NOT NULL, - left_at TEXT, - PRIMARY KEY (group_call_session_id, user_id), - FOREIGN KEY (group_call_session_id) REFERENCES group_call_sessions(id) ON DELETE CASCADE, - FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE - ) - """ - ) - - if not column_exists(db, "messages", "room_id"): - db.execute("ALTER TABLE messages ADD COLUMN room_id INTEGER") - if not column_exists(db, "messages", "attachment_id"): - db.execute("ALTER TABLE messages ADD COLUMN attachment_id INTEGER") - if not column_exists(db, "messages", "parent_message_id"): - db.execute("ALTER TABLE messages ADD COLUMN parent_message_id INTEGER") - if not column_exists(db, "messages", "channel_id"): - db.execute("ALTER TABLE messages ADD COLUMN channel_id INTEGER") - if not column_exists(db, "messages", "direct_conversation_id"): - db.execute("ALTER TABLE messages ADD COLUMN direct_conversation_id INTEGER") - - if not column_exists(db, "user_profiles", "badge_text"): - db.execute("ALTER TABLE user_profiles ADD COLUMN badge_text TEXT") - if not column_exists(db, "user_profiles", "status_message"): - db.execute("ALTER TABLE user_profiles ADD COLUMN status_message TEXT NOT NULL DEFAULT ''") - if not column_exists(db, "user_profiles", "banner_attachment_id"): - db.execute("ALTER TABLE user_profiles ADD COLUMN banner_attachment_id INTEGER") - - if not column_exists(db, "user_settings", "name_color"): - db.execute("ALTER TABLE user_settings ADD COLUMN name_color TEXT") - if not column_exists(db, "user_settings", "profile_background_color"): - db.execute("ALTER TABLE user_settings ADD COLUMN profile_background_color TEXT") - if not column_exists(db, "user_settings", "profile_banner_color"): - db.execute("ALTER TABLE user_settings ADD COLUMN profile_banner_color TEXT") - if not column_exists(db, "user_settings", "profile_visibility"): - db.execute("ALTER TABLE user_settings ADD COLUMN profile_visibility TEXT NOT NULL DEFAULT 'public'") - if not column_exists(db, "user_settings", "last_active_visibility"): - db.execute("ALTER TABLE user_settings ADD COLUMN last_active_visibility TEXT NOT NULL DEFAULT 'everyone'") - if not column_exists(db, "user_settings", "presence_mode"): - db.execute("ALTER TABLE user_settings ADD COLUMN presence_mode TEXT NOT NULL DEFAULT 'online'") - if not column_exists(db, "user_settings", "dm_layout_mode"): - db.execute("ALTER TABLE user_settings ADD COLUMN dm_layout_mode TEXT NOT NULL DEFAULT 'cards'") - if not column_exists(db, "user_settings", "show_presence_badge"): - db.execute("ALTER TABLE user_settings ADD COLUMN show_presence_badge INTEGER NOT NULL DEFAULT 1") - - if not column_exists(db, "presence_events", "direct_conversation_id"): - db.execute("ALTER TABLE presence_events ADD COLUMN direct_conversation_id INTEGER") - - db.execute("CREATE INDEX IF NOT EXISTS idx_messages_room_id_id ON messages(room_id, id)") - db.execute("CREATE INDEX IF NOT EXISTS idx_messages_channel_id_id ON messages(channel_id, id)") - db.execute("CREATE INDEX IF NOT EXISTS idx_messages_dm_id_id ON messages(direct_conversation_id, id)") - db.execute("CREATE INDEX IF NOT EXISTS idx_messages_parent_message_id_id ON messages(parent_message_id, id)") - db.execute("CREATE INDEX IF NOT EXISTS idx_messages_attachment_id ON messages(attachment_id)") - db.execute("CREATE INDEX IF NOT EXISTS idx_room_members_user ON room_members(user_id)") - db.execute("CREATE INDEX IF NOT EXISTS idx_team_members_user_id ON team_members(user_id)") - db.execute("CREATE INDEX IF NOT EXISTS idx_channels_team_id_slug ON channels(team_id, slug)") - db.execute("CREATE INDEX IF NOT EXISTS idx_channels_legacy_room_id ON channels(legacy_room_id)") - db.execute("CREATE INDEX IF NOT EXISTS idx_pages_team_updated_at ON pages(team_id, updated_at DESC)") - db.execute("CREATE INDEX IF NOT EXISTS idx_channel_typing_events_updated_at ON channel_typing_events(channel_id, updated_at)") - db.execute("CREATE INDEX IF NOT EXISTS idx_dm_typing_events_updated_at ON dm_typing_events(direct_conversation_id, updated_at)") - db.execute("CREATE INDEX IF NOT EXISTS idx_invites_token_hash ON room_invites(token_hash)") - db.execute("CREATE INDEX IF NOT EXISTS idx_team_invites_token_hash ON team_invites(token_hash)") - db.execute("CREATE INDEX IF NOT EXISTS idx_typing_room_updated ON typing_events(room_id, updated_at)") - db.execute("CREATE INDEX IF NOT EXISTS idx_blacklist_lookup ON blacklist_entries(kind, value_hash)") - db.execute("CREATE INDEX IF NOT EXISTS idx_rate_limit_lookup ON rate_limit_events(scope, subject_hash, created_at)") - db.execute("CREATE INDEX IF NOT EXISTS idx_message_reactions_message ON message_reactions(message_id, emoji)") - db.execute("CREATE INDEX IF NOT EXISTS idx_message_receipts_user_seen ON message_receipts(user_id, seen_at)") - db.execute("CREATE INDEX IF NOT EXISTS idx_message_attachments_message_position ON message_attachments(message_id, position)") - db.execute("CREATE INDEX IF NOT EXISTS idx_presence_events_updated_at ON presence_events(updated_at)") - db.execute("CREATE INDEX IF NOT EXISTS idx_presence_events_dm_id ON presence_events(direct_conversation_id, updated_at)") - db.execute("CREATE INDEX IF NOT EXISTS idx_direct_conversation_members_user ON direct_conversation_members(user_id, direct_conversation_id)") - db.execute("CREATE INDEX IF NOT EXISTS idx_call_signals_lookup ON call_signals(call_session_id, id)") - db.execute("CREATE INDEX IF NOT EXISTS idx_user_settings_name_color ON user_settings(user_id, name_color)") - db.execute("CREATE INDEX IF NOT EXISTS idx_group_call_sessions_scope ON group_call_sessions(scope_kind, scope_id, status)") - db.execute("CREATE INDEX IF NOT EXISTS idx_group_call_invites_invitee_status ON group_call_invites(invitee_user_id, status, expires_at)") - db.execute("CREATE INDEX IF NOT EXISTS idx_group_call_participants_session_active ON group_call_participants(group_call_session_id, left_at)") - db.execute("CREATE INDEX IF NOT EXISTS idx_group_call_participants_user_active ON group_call_participants(user_id, left_at)") - db.execute("CREATE INDEX IF NOT EXISTS idx_group_call_participants_session_updated ON group_call_participants(group_call_session_id, updated_at)") - - general = db.execute("SELECT id FROM rooms WHERE slug = 'general'").fetchone() - if not general: - cursor = db.execute( - """ - INSERT INTO rooms ( - slug, - name, - description, - visibility, - created_at, - is_active - ) VALUES (?, ?, ?, ?, ?, 1) - """, - ("general", "general", "Default public chat room", "public", now_iso()), - ) - general_id = cursor.lastrowid - else: - general_id = general["id"] - - db.execute( - "UPDATE messages SET room_id = ? WHERE room_id IS NULL AND direct_conversation_id IS NULL", - (general_id,), - ) - - db.execute( - """ - UPDATE users - SET role = CASE - WHEN is_admin = 1 THEN 'admin' - ELSE 'user' - END - WHERE role IS NULL - OR role = '' - """ - ) - - db.execute( - """ - UPDATE users - SET status = CASE - WHEN is_active = 1 THEN 'active' - ELSE 'disabled' - END - WHERE status IS NULL - OR status = '' - """ - ) - - general_team = db.execute("SELECT id FROM teams WHERE slug = 'general'").fetchone() - if general_team: - general_team_id = general_team["id"] - else: - cursor = db.execute( - """ - INSERT INTO teams ( - slug, - name, - description, - created_by_user_id, - created_at, - is_active - ) VALUES (?, ?, ?, NULL, ?, 1) - """, - ("general", "General", "Default team for existing chat rooms.", now_iso()), - ) - general_team_id = cursor.lastrowid - - legacy_rooms = db.execute( - """ - SELECT - id, - slug, - name, - description, - created_by_user_id, - created_at - FROM rooms - WHERE is_active = 1 - """ - ).fetchall() - - for room in legacy_rooms: - existing_channel = db.execute( - "SELECT id FROM channels WHERE legacy_room_id = ?", - (room["id"],), - ).fetchone() - if existing_channel: - continue - 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"], - room["slug"], - room["name"], - room["description"], - room["created_by_user_id"], - room["created_at"], - ), - ) - - db.execute( - """ - UPDATE messages - SET channel_id = ( - SELECT c.id - FROM channels AS c - WHERE c.legacy_room_id = messages.room_id - LIMIT 1 - ) - WHERE channel_id IS NULL - AND room_id IS NOT NULL - AND direct_conversation_id IS NULL - """ - ) - - admin_users = db.execute( - """ - SELECT id - FROM users - WHERE role = 'admin' - AND status = 'active' - """ - ).fetchall() - - for admin_user in admin_users: - db.execute( - """ - INSERT OR IGNORE INTO team_members ( - team_id, - user_id, - membership_role, - added_at - ) VALUES (?, ?, 'admin', ?) - """, - (general_team_id, admin_user["id"], now_iso()), - ) - - db.execute( - """ - INSERT OR IGNORE INTO user_profiles ( - user_id, - display_name, - badge_text, - bio, - bio_mode, - website, - location, - status_message, - banner_attachment_id, - updated_at - ) - SELECT - id, - username, - '', - '', - 'txt', - '', - '', - '', - NULL, - ? - FROM users - """, - (now_iso(),), - ) - - db.execute( - """ - INSERT OR IGNORE INTO user_settings ( - user_id, - accent_color, - name_color, - custom_css, - theme_mode, - profile_background_color, - profile_banner_color, - profile_visibility, - last_active_visibility, - presence_mode, - dm_layout_mode, - show_presence_badge, - updated_at - ) - SELECT - id, - '#f7dce8', - NULL, - '', - 'dark', - NULL, - NULL, - 'public', - 'everyone', - 'online', - 'cards', - 1, - ? - FROM users - """, - (now_iso(),), - ) - - existing_message_attachment_counts = db.execute( - "SELECT COUNT(*) AS count FROM message_attachments" - ).fetchone()["count"] - - if existing_message_attachment_counts == 0: - rows = db.execute( - """ - SELECT id, attachment_id - FROM messages - WHERE attachment_id IS NOT NULL - ORDER BY id ASC - """ - ).fetchall() - for row in rows: - db.execute( - """ - INSERT OR IGNORE INTO message_attachments ( - message_id, - attachment_id, - position - ) VALUES (?, ?, 0) - """, - (row["id"], row["attachment_id"]), - ) - - db.commit() - - -def bootstrap_admin() -> None: - username = normalize_username(os.environ.get("ADMIN_USERNAME", "")) - password = os.environ.get("ADMIN_PASSWORD", "") - - if not username or not password: - return - - if validate_username(username) or validate_password(password, username): - print("warning: admin bootstrap credentials failed validation.") - return - - with get_db() as db: - existing = db.execute( - "SELECT id FROM users WHERE username = ?", - (username,), - ).fetchone() - - if existing: - db.execute( - """ - UPDATE users - SET - role = 'admin', - status = 'active', - is_admin = 1, - is_active = 1 - WHERE id = ? - """, - (existing["id"],), - ) - user_id = existing["id"] - else: - cursor = db.execute( - """ - INSERT INTO users ( - username, - password_hash, - role, - status, - is_admin, - is_active, - created_at - ) VALUES (?, ?, 'admin', 'active', 1, 1, ?) - """, - (username, generate_password_hash(password), now_iso()), - ) - user_id = cursor.lastrowid - - db.execute( - """ - INSERT OR IGNORE INTO user_profiles ( - user_id, - display_name, - badge_text, - bio, - bio_mode, - website, - location, - status_message, - banner_attachment_id, - updated_at - ) VALUES (?, ?, '', '', 'txt', '', '', '', NULL, ?) - """, - (user_id, username, now_iso()), - ) - - db.execute( - """ - INSERT OR IGNORE INTO user_settings ( - user_id, - accent_color, - name_color, - custom_css, - theme_mode, - profile_background_color, - profile_banner_color, - profile_visibility, - last_active_visibility, - presence_mode, - dm_layout_mode, - show_presence_badge, - updated_at - ) VALUES (?, '#f7dce8', NULL, '', 'dark', NULL, NULL, 'public', 'everyone', 'online', 'cards', 1, ?) - """, - (user_id, now_iso()), - ) - - db.commit() - - @app.route("/") def index(): return render_template("index.html") @@ -1992,6 +139,8 @@ def api_csrf(): @app.route("/api/me") def api_me(): + from auth import current_user + user = current_user() return jsonify( { @@ -2004,6 +153,8 @@ def api_me(): @app.route("/api/register", methods=["POST"]) def register(): + from auth import rate_limited, csrf_valid, safe_json, authenticate_account, audit, login_user, register_account + limited = rate_limited("register") if limited: return limited @@ -2016,88 +167,9 @@ def register(): except ValueError as error: return jsonify({"error": str(error)}), 400 - access_key = data.get("access_key", "") - if not PRIVATE_ACCESS_KEY or not isinstance(access_key, str) or access_key.strip() != PRIVATE_ACCESS_KEY: - return jsonify({"error": "Invalid private access key."}), 403 - - username = normalize_username(data.get("username")) - password = data.get("password", "") - - username_error = validate_username(username) - password_error = validate_password(password, username) - - if username_error: - return jsonify({"error": username_error}), 400 - if password_error: - return jsonify({"error": password_error}), 400 - - try: - cursor = db_write( - """ - INSERT INTO users ( - username, - password_hash, - role, - status, - is_admin, - is_active, - created_at - ) VALUES (?, ?, 'user', 'active', 0, 1, ?) - """, - (username, generate_password_hash(password), now_iso()), - ) - except sqlite3.IntegrityError: - return jsonify({"error": "Unable to create account with those details."}), 409 - - user = { - "id": cursor.lastrowid, - "username": username, - "role": "user", - "status": "active", - "restricted_until": None, - } - - db_write( - """ - INSERT OR IGNORE INTO user_profiles ( - user_id, - display_name, - badge_text, - bio, - bio_mode, - website, - location, - status_message, - banner_attachment_id, - updated_at - ) VALUES (?, ?, '', '', 'txt', '', '', '', NULL, ?) - """, - (user["id"], username, now_iso()), - ) - - db_write( - """ - INSERT OR IGNORE INTO user_settings ( - user_id, - accent_color, - name_color, - custom_css, - theme_mode, - profile_background_color, - profile_banner_color, - profile_visibility, - last_active_visibility, - presence_mode, - dm_layout_mode, - show_presence_badge, - updated_at - ) VALUES (?, '#f7dce8', NULL, '', 'dark', NULL, NULL, 'public', 'everyone', 'online', 'cards', 1, ?) - """, - (user["id"], now_iso()), - ) - - authenticated = login_user(user) - audit("account_registered", user["id"], "user", str(user["id"])) + authenticated, error = register_account(data) + if error: + return error return jsonify( { @@ -2110,6 +182,8 @@ def register(): @app.route("/api/login", methods=["POST"]) def login(): + from auth import rate_limited, csrf_valid, safe_json, authenticate_account + limited = rate_limited("login") if limited: return limited @@ -2122,38 +196,9 @@ def login(): except ValueError as error: return jsonify({"error": str(error)}), 400 - access_key = data.get("access_key", "") - if not PRIVATE_ACCESS_KEY or not isinstance(access_key, str) or access_key.strip() != PRIVATE_ACCESS_KEY: - return jsonify({"error": "Invalid private access key."}), 403 - - username = normalize_username(data.get("username")) - password = data.get("password", "") - - with get_db() as db: - row = db.execute( - """ - SELECT - id, - username, - password_hash, - role, - status, - restricted_until - FROM users - WHERE username = ? - """, - (username,), - ).fetchone() - - if not row or not isinstance(password, str) or not check_password_hash(row["password_hash"], password): - return jsonify({"error": "Invalid username or password."}), 401 - - if row["status"] in {"disabled", "blacklisted"}: - return jsonify({"error": "Invalid username or password."}), 401 - - db_write("UPDATE users SET last_login_at = ? WHERE id = ?", (now_iso(), row["id"])) - authenticated = login_user(row) - audit("account_logged_in", row["id"], "user", str(row["id"])) + authenticated, error = authenticate_account(data) + if error: + return error return jsonify( { @@ -2166,6 +211,8 @@ def login(): @app.route("/api/logout", methods=["POST"]) def logout(): + from auth import csrf_valid + if not csrf_valid(): return jsonify({"error": "Security token expired.", "code": "csrf"}), 403 session.clear() @@ -2174,10 +221,9 @@ def logout(): def _maybe_cleanup_orphaned_attachments(): maybe_cleanup_orphaned_attachments( - _runtime_state, + RUNTIME_STATE, ATTACHMENT_CLEANUP_INTERVAL_SECONDS, - lambda: __import__("features").cleanup_orphaned_attachments( - get_db, + lambda: cleanup_orphaned_attachments( UPLOAD_DIR, utc_now, ATTACHMENT_ORPHAN_TTL_HOURS, @@ -2187,96 +233,9 @@ def _maybe_cleanup_orphaned_attachments(): ) -register_routes( - app, - { - "get_db": get_db, - "db_write": db_write, - "safe_json": safe_json, - "csrf_valid": csrf_valid, - "rate_limited": rate_limited, - "current_user": current_user, - "require_user": require_user, - "require_writable_user": require_writable_user, - "require_moderator": require_moderator, - "require_admin": require_admin, - "now_iso": now_iso, - "utc_now": utc_now, - "parse_iso": parse_iso, - "normalize_username": normalize_username, - "audit": audit, - "hash_identifier": hash_identifier, - "sign_call_helper_token": sign_call_helper_token, - "CALLS_BASE": CALLS_BASE, - "CALL_TOKEN_TTL_SECONDS": CALL_TOKEN_TTL_SECONDS, - "CALL_HELPER_SHARED_SECRET": CALL_HELPER_SHARED_SECRET, - "PRIVATE_ACCESS_KEY": PRIVATE_ACCESS_KEY, - "room_by_id": room_by_id, - "get_room_by_slug": get_room_by_slug, - "can_access_room": can_access_room, - "can_write_room": can_write_room, - "can_manage_room": can_manage_room, - "room_payload": room_payload, - "team_slug_from_name": team_slug_from_name, - "page_slug_from_title": page_slug_from_title, - "get_team_by_slug": get_team_by_slug, - "get_channel_by_id": get_channel_by_id, - "get_channel_by_slug": get_channel_by_slug, - "get_channel_by_legacy_room_id": get_channel_by_legacy_room_id, - "team_member_role": team_member_role, - "is_team_member": is_team_member, - "is_team_admin": is_team_admin, - "can_edit_team": can_edit_team, - "can_access_team": can_access_team, - "can_access_channel": can_access_channel, - "can_write_channel": can_write_channel, - "team_payload": team_payload, - "channel_payload": channel_payload, - "user_payload": user_payload, - "get_dm_other_user": lambda dm_id, user_id: __import__("features").get_dm_other_user(get_db, dm_id, user_id), - "MESSAGE_SELECT": MESSAGE_SELECT, - "team_page_select": team_page_select, - "UPLOAD_DIR": UPLOAD_DIR, - "secure_filename": secure_filename, - "mimetypes": mimetypes, - "secrets": secrets, - "os": os, - "timedelta": timedelta, - "html": html, - "attachment_list_for_message": lambda message_id: __import__("features").attachment_list_for_message(get_db, message_id), - "maybe_cleanup_orphaned_attachments": _maybe_cleanup_orphaned_attachments, - "constants": { - "MAX_MESSAGE_LENGTH": MAX_MESSAGE_LENGTH, - "MAX_HISTORY_MESSAGES": MAX_HISTORY_MESSAGES, - "MAX_INCREMENTAL_MESSAGES": MAX_INCREMENTAL_MESSAGES, - "MAX_FEED_MESSAGES": MAX_FEED_MESSAGES, - "MAX_THREAD_REPLIES": MAX_THREAD_REPLIES, - "MAX_ATTACHMENT_BYTES": MAX_ATTACHMENT_BYTES, - "MAX_ATTACHMENT_NAME_LENGTH": MAX_ATTACHMENT_NAME_LENGTH, - "ALLOWED_ATTACHMENT_EXTENSIONS": ALLOWED_ATTACHMENT_EXTENSIONS, - "ALLOWED_ATTACHMENT_MIME_TYPES": ALLOWED_ATTACHMENT_MIME_TYPES, - "INVITE_TTL_HOURS": INVITE_TTL_HOURS, - "TEAM_INVITE_TTL_HOURS": TEAM_INVITE_TTL_HOURS, - "TYPING_TTL_SECONDS": TYPING_TTL_SECONDS, - "PRESENCE_TTL_SECONDS": PRESENCE_TTL_SECONDS, - "MAX_SEARCH_QUERY_LENGTH": MAX_SEARCH_QUERY_LENGTH, - "MAX_SEARCH_RESULTS": MAX_SEARCH_RESULTS, - "READ_RECEIPT_BATCH_LIMIT": READ_RECEIPT_BATCH_LIMIT, - "MAX_MESSAGE_EDIT_WINDOW_SECONDS": MAX_MESSAGE_EDIT_WINDOW_SECONDS, - "MAX_DISPLAY_NAME_LENGTH": MAX_DISPLAY_NAME_LENGTH, - "MAX_PROFILE_BIO_LENGTH": MAX_PROFILE_BIO_LENGTH, - "MAX_LOCATION_LENGTH": MAX_LOCATION_LENGTH, - "MAX_WEBSITE_LENGTH": MAX_WEBSITE_LENGTH, - "MAX_CUSTOM_CSS_LENGTH": MAX_CUSTOM_CSS_LENGTH, - "MAX_PROFILE_BADGE_LENGTH": MAX_PROFILE_BADGE_LENGTH, - "MAX_OWNED_TEAMS_PER_USER": MAX_OWNED_TEAMS_PER_USER, - "ROOM_VISIBILITIES": ROOM_VISIBILITIES, - "ROLES": ROLES, - "STATUSES": STATUSES, - "GROUP_CALL_PARTICIPANT_STALE_SECONDS": GROUP_CALL_PARTICIPANT_STALE_SECONDS, - }, - }, -) +app.maybe_cleanup_orphaned_attachments = _maybe_cleanup_orphaned_attachments + +register_routes(app) init_db() bootstrap_admin() @@ -2286,4 +245,4 @@ if __name__ == "__main__": host="0.0.0.0", port=int(os.environ.get("PORT", 5000)), debug=os.environ.get("FLASK_DEBUG", "0") == "1", - ) \ No newline at end of file + ) diff --git a/attachments.py b/attachments.py new file mode 100644 index 0000000..709c330 --- /dev/null +++ b/attachments.py @@ -0,0 +1,335 @@ +from datetime import timedelta +from pathlib import Path +from typing import Optional + +from flask import abort, jsonify, send_from_directory + +from db import get_db + + +IMAGE_MIME_PREFIX = "image/" +AUDIO_MIME_PREFIX = "audio/" +VIDEO_MIME_PREFIX = "video/" + +IMAGE_EXTENSIONS = {"png", "jpg", "jpeg", "gif", "webp"} +AUDIO_EXTENSIONS = {"mp3", "wav", "ogg", "m4a", "aac", "flac"} +VIDEO_EXTENSIONS = {"mp4", "webm", "mov", "m4v", "ogv"} + + +def attachment_kind_from_name_and_mime( + original_name: Optional[str], + mime_type: Optional[str], +) -> str: + mime_type = str(mime_type or "").lower() + name = str(original_name or "") + ext = name.rsplit(".", 1)[-1].lower() if "." in name else "" + if mime_type.startswith(IMAGE_MIME_PREFIX) or ext in IMAGE_EXTENSIONS: + return "image" + if mime_type.startswith(AUDIO_MIME_PREFIX) or ext in AUDIO_EXTENSIONS: + return "audio" + if mime_type.startswith(VIDEO_MIME_PREFIX) or ext in VIDEO_EXTENSIONS: + return "video" + return "file" + + +def attachment_payload_from_row(row) -> Optional[dict]: + if not row or not row["id"]: + return None + kind = attachment_kind_from_name_and_mime( + row["original_name"], + row["mime_type"], + ) + return { + "id": row["id"], + "original_name": row["original_name"], + "mime_type": row["mime_type"], + "size_bytes": row["size_bytes"], + "kind": kind, + "url": f"/api/attachments/{row['id']}/download", + } + + +def attachment_payload_legacy_from_message_select_row(row) -> Optional[dict]: + if not row["attachment_id"]: + return None + kind = attachment_kind_from_name_and_mime( + row["attachment_original_name"], + row["attachment_mime_type"], + ) + return { + "id": row["attachment_id"], + "original_name": row["attachment_original_name"], + "mime_type": row["attachment_mime_type"], + "size_bytes": row["attachment_size_bytes"], + "kind": kind, + "url": f"/api/attachments/{row['attachment_id']}/download", + } + + +def attachment_list_for_message(message_id: int) -> list[dict]: + with get_db() as db: + rows = db.execute( + """ + SELECT + a.id, + a.original_name, + a.mime_type, + a.size_bytes + FROM message_attachments AS ma + JOIN attachments AS a + ON a.id = ma.attachment_id + WHERE ma.message_id = ? + ORDER BY ma.position ASC, a.id ASC + """, + (message_id,), + ).fetchall() + return [attachment_payload_from_row(row) for row in rows] + + +def cleanup_orphaned_attachments( + upload_dir: Path, + utc_now_func, + orphan_ttl_hours: int, + app_logger, +) -> None: + cutoff = (utc_now_func() - timedelta(hours=orphan_ttl_hours)).isoformat() + with get_db() as db: + orphaned = db.execute( + """ + SELECT + a.id, + a.stored_name + FROM attachments AS a + WHERE a.created_at < ? + AND NOT EXISTS ( + SELECT 1 + FROM messages AS m + WHERE m.attachment_id = a.id + ) + AND NOT EXISTS ( + SELECT 1 + FROM message_attachments AS ma + WHERE ma.attachment_id = a.id + ) + """, + (cutoff,), + ).fetchall() + for attachment in orphaned: + db.execute( + """ + DELETE FROM attachments + WHERE id = ? + """, + (attachment["id"],), + ) + db.commit() + + for attachment in orphaned: + try: + (upload_dir / attachment["stored_name"]).unlink(missing_ok=True) + except OSError: + app_logger.warning( + "Could not remove orphaned attachment file: %s", + attachment["stored_name"], + ) + + +def maybe_cleanup_orphaned_attachments( + state: dict, + cleanup_interval_seconds: int, + cleanup_func, + app_logger, +) -> None: + import time + + now_value = time.time() + last = state.get("last_attachment_cleanup", 0.0) + if now_value - last < cleanup_interval_seconds: + return + state["last_attachment_cleanup"] = now_value + try: + cleanup_func() + except Exception: + app_logger.exception("Could not clean orphaned attachments.") + + +def resolve_owned_attachment_ids( + attachment_ids: object, + user_id: int, +) -> tuple[Optional[list[int]], Optional[tuple]]: + if attachment_ids is None: + return [], None + if not isinstance(attachment_ids, list): + return None, (jsonify({"error": "attachment_ids must be an array."}), 400) + + parsed = [] + seen = set() + for item in attachment_ids[:10]: + try: + attachment_id = int(item) + except (TypeError, ValueError): + return None, (jsonify({"error": "Invalid attachment."}), 400) + if attachment_id in seen: + continue + seen.add(attachment_id) + with get_db() as db: + row = db.execute( + """ + SELECT id, uploader_user_id + FROM attachments + WHERE id = ? + """, + (attachment_id,), + ).fetchone() + if not row: + return None, (jsonify({"error": "Attachment not found."}), 404) + if row["uploader_user_id"] != user_id: + return None, ( + jsonify({"error": "You can only attach files you uploaded."}), + 403, + ) + parsed.append(attachment_id) + return parsed, None + + +def resolve_owned_attachment( + attachment_id: object, + user_id: int, +) -> tuple[Optional[int], Optional[tuple]]: + if attachment_id is None: + return None, None + try: + parsed_id = int(attachment_id) + except (TypeError, ValueError): + return None, (jsonify({"error": "Invalid attachment."}), 400) + + with get_db() as db: + attachment = db.execute( + """ + SELECT + id, + uploader_user_id + FROM attachments + WHERE id = ? + """, + (parsed_id,), + ).fetchone() + if not attachment: + return None, (jsonify({"error": "Attachment not found."}), 404) + if attachment["uploader_user_id"] != user_id: + return None, ( + jsonify({"error": "You can only attach files you uploaded."}), + 403, + ) + return parsed_id, None + + +def can_download_attachment( + attachment_id: int, + user: Optional[dict], + can_access_room, + can_access_channel, + get_channel_by_id, +) -> bool: + if not user: + return False + with get_db() as db: + room_rows = db.execute( + """ + SELECT DISTINCT + r.id, + r.slug, + r.name, + r.description, + r.visibility, + r.created_by_user_id, + r.is_active + FROM message_attachments AS ma + JOIN messages AS m + ON m.id = ma.message_id + JOIN rooms AS r + ON r.id = m.room_id + WHERE ma.attachment_id = ? + AND r.is_active = 1 + """, + (attachment_id,), + ).fetchall() + channel_rows = db.execute( + """ + SELECT DISTINCT + m.channel_id + FROM message_attachments AS ma + JOIN messages AS m + ON m.id = ma.message_id + WHERE ma.attachment_id = ? + AND m.channel_id IS NOT NULL + """, + (attachment_id,), + ).fetchall() + dm_rows = db.execute( + """ + SELECT DISTINCT dc.id + FROM message_attachments AS ma + JOIN messages AS m + ON m.id = ma.message_id + JOIN direct_conversations AS dc + ON dc.id = m.direct_conversation_id + JOIN direct_conversation_members AS dcm + ON dcm.direct_conversation_id = dc.id + WHERE ma.attachment_id = ? + AND dcm.user_id = ? + """, + (attachment_id, user["id"]), + ).fetchall() + + if dm_rows: + return True + if any(can_access_room(room, user) for room in room_rows): + return True + for item in channel_rows: + channel = get_channel_by_id(item["channel_id"]) + if channel and can_access_channel(channel, user): + return True + return False + + +def send_attachment_file( + upload_dir: Path, + attachment_id: int, + user: Optional[dict], + can_access_room, + can_access_channel, + get_channel_by_id, +): + with get_db() as db: + attachment = db.execute( + """ + SELECT + id, + stored_name, + original_name, + mime_type + FROM attachments + WHERE id = ? + """, + (attachment_id,), + ).fetchone() + if not attachment: + abort(404) + + if not can_download_attachment( + attachment_id, + user, + can_access_room, + can_access_channel, + get_channel_by_id, + ): + abort(403) + + return send_from_directory( + upload_dir, + attachment["stored_name"], + as_attachment=True, + download_name=attachment["original_name"], + mimetype=attachment["mime_type"], + ) diff --git a/auth.py b/auth.py new file mode 100644 index 0000000..15238ff --- /dev/null +++ b/auth.py @@ -0,0 +1,498 @@ +import base64 +import hashlib +import hmac +import json +import re +import secrets +import time +from typing import Optional + +from flask import jsonify, request, session +from werkzeug.exceptions import BadRequest +from werkzeug.security import check_password_hash, generate_password_hash + +from config import ( + CLIENT_HASH_KEY, + PLACEHOLDER_SECRETS, + PRIVATE_ACCESS_KEY, + RATE_LIMITS, + ROLES, + STATUSES, + MAX_PASSWORD_LENGTH, + MIN_PASSWORD_LENGTH, + MAX_USERNAME_LENGTH, + MIN_USERNAME_LENGTH, + now_iso, + parse_iso, + utc_now, +) +from db import db_write, get_db + +USERNAME_RE = re.compile(r"^[a-z0-9](?:[a-z0-9._-]{1,28}[a-z0-9])?$") + + +def normalize_username(value: object) -> str: + return value.strip().lower() if isinstance(value, str) else "" + + +def validate_username(username: str) -> Optional[str]: + if not username: + return "Username is required." + if len(username) < MIN_USERNAME_LENGTH: + return f"Username must be at least {MIN_USERNAME_LENGTH} characters." + if len(username) > MAX_USERNAME_LENGTH: + return f"Username must be {MAX_USERNAME_LENGTH} characters or less." + if not USERNAME_RE.fullmatch(username): + return "Username contains invalid characters." + return None + + +def validate_password(password: object, username: str = "") -> Optional[str]: + if not isinstance(password, str) or not password: + return "Password is required." + if len(password) < MIN_PASSWORD_LENGTH: + return f"Password must be at least {MIN_PASSWORD_LENGTH} characters." + if len(password) > MAX_PASSWORD_LENGTH: + return f"Password must be {MAX_PASSWORD_LENGTH} characters or less." + if not re.search(r"[a-z]", password): + return "Password must include a lowercase letter." + if not re.search(r"[A-Z]", password): + return "Password must include an uppercase letter." + if not re.search(r"\d", password): + return "Password must include a number." + if not re.search(r"[^A-Za-z0-9]", password): + return "Password must include a symbol." + if username and username in password.lower(): + return "Password must not contain your username." + return None + + +def safe_json() -> dict: + if not request.is_json: + raise ValueError("Request body must be JSON.") + try: + data = request.get_json(silent=False) + except BadRequest as error: + raise ValueError("Invalid JSON request body.") from error + if not isinstance(data, dict): + raise ValueError("JSON body must be an object.") + return data + + +def csrf_token() -> str: + token = session.get("csrf_token") + if not token: + token = secrets.token_urlsafe(32) + session["csrf_token"] = token + return token + + +def csrf_valid() -> bool: + supplied = request.headers.get("X-CSRF-Token", "") + expected = session.get("csrf_token", "") + return bool(supplied and expected and hmac.compare_digest(supplied, expected)) + + +def client_ip() -> str: + return (request.remote_addr or "unknown")[:128] + + +def ctx_hash(): + return hashlib.sha256 + + +def hash_identifier(value: str) -> str: + return hmac.new( + CLIENT_HASH_KEY.encode("utf-8"), + value.encode("utf-8"), + ctx_hash(), + ).hexdigest() + + +def b64url_encode_bytes(data: bytes) -> str: + return base64.urlsafe_b64encode(data).decode("ascii").rstrip("=") + + +def sign_call_helper_token(payload: dict, shared_secret: str) -> str: + payload_json = json.dumps(payload, separators=(",", ":"), ensure_ascii=False) + payload_bytes = payload_json.encode("utf-8") + signature = hmac.new( + shared_secret.encode("utf-8"), + payload_bytes, + hashlib.sha256, + ).digest() + return f"{b64url_encode_bytes(payload_bytes)}.{b64url_encode_bytes(signature)}" + + +def current_client_hash() -> str: + return hash_identifier(client_ip()) + + +def audit( + action: str, + actor_user_id: Optional[int] = None, + target_type: Optional[str] = None, + target_id: Optional[str] = None, + details: Optional[str] = None, +) -> None: + db_write( + """ + INSERT INTO audit_log ( + actor_user_id, + action, + target_type, + target_id, + details, + client_hash, + created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?) + """, + ( + actor_user_id, + action, + target_type, + target_id, + details, + current_client_hash(), + now_iso(), + ), + ) + + +def active_blacklist_match(kind: str, value_hash: str) -> bool: + with get_db() as db: + row = db.execute( + """ + SELECT id + FROM blacklist_entries + WHERE kind = ? + AND value_hash = ? + AND ( + expires_at IS NULL + OR expires_at > ? + ) + """, + (kind, value_hash, now_iso()), + ).fetchone() + return bool(row) + + +def request_blacklisted() -> bool: + if request.endpoint in {"static"}: + return False + if active_blacklist_match("ip", current_client_hash()): + return True + user_id = session.get("user_id") + if user_id and active_blacklist_match("user", hash_identifier(str(user_id))): + return True + return False + + +def check_rate_limit(scope: str) -> Optional[int]: + limit, window_seconds = RATE_LIMITS[scope] + now_value = time.time() + cutoff = now_value - window_seconds + subject_hash = current_client_hash() + + with get_db() as db: + db.execute("DELETE FROM rate_limit_events WHERE created_at < ?", (cutoff - 60,)) + rows = db.execute( + """ + SELECT created_at + FROM rate_limit_events + WHERE scope = ? + AND subject_hash = ? + AND created_at >= ? + ORDER BY created_at ASC + """, + (scope, subject_hash, cutoff), + ).fetchall() + + if len(rows) >= limit: + oldest = rows[0]["created_at"] + return max(1, int(window_seconds - (now_value - oldest)) + 1) + + db.execute( + """ + INSERT INTO rate_limit_events ( + scope, + subject_hash, + created_at + ) VALUES (?, ?, ?) + """, + (scope, subject_hash, now_value), + ) + db.commit() + + return None + + +def rate_limited(scope: str): + retry_after = check_rate_limit(scope) + if retry_after is None: + return None + return ( + jsonify( + { + "error": "Too many requests. Please wait and try again.", + "code": "rate_limited", + "retry_after": retry_after, + } + ), + 429, + ) + + +def user_payload(row, settings_row=None) -> dict: + status = row["status"] + restricted_until = parse_iso(row["restricted_until"]) + if status == "restricted" and restricted_until and restricted_until <= utc_now(): + status = "active" + return { + "id": row["id"], + "username": row["username"], + "role": row["role"], + "status": status, + "restricted_until": row["restricted_until"], + } + + +def current_user() -> Optional[dict]: + user_id = session.get("user_id") + if not user_id: + return None + + with get_db() as db: + row = db.execute( + """ + SELECT + id, + username, + role, + status, + restricted_until + FROM users + WHERE id = ? + """, + (user_id,), + ).fetchone() + + if not row: + session.clear() + return None + + user = user_payload(row) + + if user["status"] == "restricted": + expires = parse_iso(user["restricted_until"]) + if expires and expires <= utc_now(): + db_write( + """ + UPDATE users + SET + status = 'active', + restricted_until = NULL, + restriction_reason = NULL, + is_active = 1 + WHERE id = ? + """, + (user["id"],), + ) + user["status"] = "active" + user["restricted_until"] = None + + if user["status"] in {"disabled", "blacklisted"}: + session.clear() + return None + + return user + + +def login_user(row) -> dict: + session.clear() + session.permanent = True + session["user_id"] = row["id"] + session["csrf_token"] = secrets.token_urlsafe(32) + return { + "id": row["id"], + "username": row["username"], + "role": row["role"], + "status": row["status"], + "restricted_until": row.get("restricted_until") if isinstance(row, dict) else row["restricted_until"], + } + + +def require_user(): + user = current_user() + if not user: + return None, (jsonify({"error": "Login required.", "code": "login_required"}), 401) + return user, None + + +def require_writable_user(): + user, error = require_user() + if error: + return None, error + if user["status"] == "restricted": + return None, ( + jsonify( + { + "error": "Your account is temporarily restricted.", + "code": "restricted", + "restricted_until": user["restricted_until"], + } + ), + 403, + ) + return user, None + + +def require_moderator(): + user, error = require_writable_user() + if error: + return None, error + if user["role"] not in {"moderator", "admin"}: + return None, (jsonify({"error": "Moderator access required.", "code": "forbidden"}), 403) + return user, None + + +def require_admin(): + user, error = require_writable_user() + if error: + return None, error + if user["role"] != "admin": + return None, (jsonify({"error": "Administrator access required.", "code": "forbidden"}), 403) + return user, None + + +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 register_account(data: dict): + access_key = data.get("access_key", "") + if not private_access_key_valid(access_key): + return None, (jsonify({"error": "Invalid private access key."}), 403) + + username = normalize_username(data.get("username")) + password = data.get("password", "") + + username_error = validate_username(username) + password_error = validate_password(password, username) + + if username_error: + return None, (jsonify({"error": username_error}), 400) + if password_error: + return None, (jsonify({"error": password_error}), 400) + + try: + cursor = db_write( + """ + INSERT INTO users ( + username, + password_hash, + role, + status, + is_admin, + is_active, + created_at + ) VALUES (?, ?, 'user', 'active', 0, 1, ?) + """, + (username, generate_password_hash(password), now_iso()), + ) + except Exception as error: + if "UNIQUE" in str(error).upper(): + return None, (jsonify({"error": "Unable to create account with those details."}), 409) + raise + + user = { + "id": cursor.lastrowid, + "username": username, + "role": "user", + "status": "active", + "restricted_until": None, + } + + db_write( + """ + INSERT OR IGNORE INTO user_profiles ( + user_id, + display_name, + badge_text, + bio, + bio_mode, + website, + location, + status_message, + banner_attachment_id, + updated_at + ) VALUES (?, ?, '', '', 'txt', '', '', '', NULL, ?) + """, + (user["id"], username, now_iso()), + ) + + db_write( + """ + INSERT OR IGNORE INTO user_settings ( + user_id, + accent_color, + name_color, + custom_css, + theme_mode, + profile_background_color, + profile_banner_color, + profile_visibility, + last_active_visibility, + presence_mode, + dm_layout_mode, + show_presence_badge, + updated_at + ) VALUES (?, '#f7dce8', NULL, '', 'dark', NULL, NULL, 'public', 'everyone', 'online', 'cards', 1, ?) + """, + (user["id"], now_iso()), + ) + + authenticated = login_user(user) + audit("account_registered", user["id"], "user", str(user["id"])) + return authenticated, None + + +def authenticate_account(data: dict): + access_key = data.get("access_key", "") + if not private_access_key_valid(access_key): + return None, (jsonify({"error": "Invalid private access key."}), 403) + + username = normalize_username(data.get("username")) + password = data.get("password", "") + + with get_db() as db: + row = db.execute( + """ + SELECT + id, + username, + password_hash, + role, + status, + restricted_until + FROM users + WHERE username = ? + """, + (username,), + ).fetchone() + + if not row or not isinstance(password, str) or not check_password_hash(row["password_hash"], password): + return None, (jsonify({"error": "Invalid username or password."}), 401) + + if row["status"] in {"disabled", "blacklisted"}: + return None, (jsonify({"error": "Invalid username or password."}), 401) + + db_write("UPDATE users SET last_login_at = ? WHERE id = ?", (now_iso(), row["id"])) + authenticated = login_user(row) + audit("account_logged_in", row["id"], "user", str(row["id"])) + return authenticated, None diff --git a/calls.py b/calls.py new file mode 100644 index 0000000..ff04d03 --- /dev/null +++ b/calls.py @@ -0,0 +1,464 @@ +import json +import time + +from db import db_write, get_db +from config import utc_now, now_iso, GROUP_CALL_PARTICIPANT_STALE_SECONDS + + +def create_call_session(dm_id: int, started_by_user_id: int) -> int: + cursor = db_write( + """ + INSERT INTO call_sessions ( + direct_conversation_id, + started_by_user_id, + status, + created_at, + ended_at + ) VALUES (?, ?, 'active', ?, NULL) + """, + (dm_id, started_by_user_id, now_iso()), + ) + return cursor.lastrowid + + +def append_call_signal(call_id: int, from_user_id: int, signal_type: str, payload: dict) -> int: + cursor = db_write( + """ + INSERT INTO call_signals ( + call_session_id, + from_user_id, + signal_type, + payload_json, + created_at + ) VALUES (?, ?, ?, ?, ?) + """, + ( + call_id, + from_user_id, + signal_type, + json.dumps(payload or {}), + now_iso(), + ), + ) + return cursor.lastrowid + + +def list_call_signals(call_id: int, after_id: int = 0) -> list[dict]: + with get_db() as db: + rows = db.execute( + """ + SELECT + cs.id, + cs.call_session_id, + cs.from_user_id, + u.username, + cs.signal_type, + cs.payload_json, + cs.created_at + FROM call_signals AS cs + JOIN users AS u + ON u.id = cs.from_user_id + WHERE cs.call_session_id = ? + AND cs.id > ? + ORDER BY cs.id ASC + LIMIT 200 + """, + (call_id, after_id), + ).fetchall() + + items = [] + for row in rows: + try: + payload = json.loads(row["payload_json"] or "{}") + except json.JSONDecodeError: + payload = {} + items.append( + { + "id": row["id"], + "call_session_id": row["call_session_id"], + "from_user_id": row["from_user_id"], + "username": row["username"], + "signal_type": row["signal_type"], + "payload": payload, + "created_at": row["created_at"], + } + ) + return items + + +def end_call(call_id: int) -> None: + db_write( + """ + UPDATE call_sessions + SET + status = 'ended', + ended_at = ? + WHERE id = ? + """, + (now_iso(), call_id), + ) + + +def get_call_with_dm(call_id: int): + with get_db() as db: + row = db.execute( + """ + SELECT + cs.id, + cs.direct_conversation_id, + cs.started_by_user_id, + cs.status, + cs.created_at, + cs.ended_at + FROM call_sessions AS cs + WHERE cs.id = ? + """, + (call_id,), + ).fetchone() + return row + + +def call_payload(call_id: int): + row = get_call_with_dm(call_id) + if not row: + return None + return { + "id": row["id"], + "direct_conversation_id": row["direct_conversation_id"], + "started_by_user_id": row["started_by_user_id"], + "status": row["status"], + "created_at": row["created_at"], + "ended_at": row["ended_at"], + } + + +def build_room_helper_call_id(room) -> str: + return f"room-{room['slug']}" + + +def build_channel_helper_call_id(channel) -> str: + return f"channel-{channel['team_slug']}--{channel['slug']}" + + +def get_or_create_group_call_session( + scope_kind: str, + scope_id: int, + scope_slug: str, + helper_call_id: str, + created_by_user_id: int, +): + with get_db() as db: + existing = db.execute( + """ + SELECT + id, + scope_kind, + scope_id, + scope_slug, + helper_call_id, + created_by_user_id, + status, + created_at, + ended_at + FROM group_call_sessions + WHERE helper_call_id = ? + LIMIT 1 + """, + (helper_call_id,), + ).fetchone() + + if existing and existing["status"] == "active": + return existing + + with get_db() as db: + db.execute("BEGIN IMMEDIATE") + existing = db.execute( + """ + SELECT + id, + scope_kind, + scope_id, + scope_slug, + helper_call_id, + created_by_user_id, + status, + created_at, + ended_at + FROM group_call_sessions + WHERE helper_call_id = ? + LIMIT 1 + """, + (helper_call_id,), + ).fetchone() + + if existing and existing["status"] == "active": + db.commit() + return existing + + if existing: + db.execute( + """ + UPDATE group_call_sessions + SET + status = 'active', + ended_at = NULL, + created_at = ? + WHERE id = ? + """, + (now_iso(), existing["id"]), + ) + db.commit() + refreshed = db.execute( + """ + SELECT + id, + scope_kind, + scope_id, + scope_slug, + helper_call_id, + created_by_user_id, + status, + created_at, + ended_at + FROM group_call_sessions + WHERE id = ? + """, + (existing["id"],), + ).fetchone() + return refreshed + + cursor = db.execute( + """ + INSERT INTO group_call_sessions ( + scope_kind, + scope_id, + scope_slug, + helper_call_id, + created_by_user_id, + status, + created_at, + ended_at + ) VALUES (?, ?, ?, ?, ?, 'active', ?, NULL) + """, + ( + scope_kind, + scope_id, + scope_slug, + helper_call_id, + created_by_user_id, + now_iso(), + ), + ) + session_id = cursor.lastrowid + db.commit() + created = db.execute( + """ + SELECT + id, + scope_kind, + scope_id, + scope_slug, + helper_call_id, + created_by_user_id, + status, + created_at, + ended_at + FROM group_call_sessions + WHERE id = ? + """, + (session_id,), + ).fetchone() + return created + + +def group_call_session_payload(row): + if not row: + return None + return { + "id": row["id"], + "scope_kind": row["scope_kind"], + "scope_id": row["scope_id"], + "scope_slug": row["scope_slug"], + "helper_call_id": row["helper_call_id"], + "created_by_user_id": row["created_by_user_id"], + "status": row["status"], + "created_at": row["created_at"], + "ended_at": row["ended_at"], + } + + +def get_group_call_session_by_id(session_id: int): + with get_db() as db: + return db.execute( + """ + SELECT + id, + scope_kind, + scope_id, + scope_slug, + helper_call_id, + created_by_user_id, + status, + created_at, + ended_at + FROM group_call_sessions + WHERE id = ? + LIMIT 1 + """, + (session_id,), + ).fetchone() + + +def cleanup_stale_group_call_participants(session_id: int): + cutoff = utc_now().timestamp() - GROUP_CALL_PARTICIPANT_STALE_SECONDS + db_write( + """ + UPDATE group_call_participants + SET + left_at = COALESCE(left_at, ?) + WHERE group_call_session_id = ? + AND left_at IS NULL + AND ( + updated_at IS NULL + OR strftime('%s', updated_at) < ? + ) + """, + (now_iso(), session_id, int(cutoff)), + ) + + +def upsert_group_call_participant(session_id: int, user_id: int): + db_write( + """ + INSERT INTO group_call_participants ( + group_call_session_id, + user_id, + joined_at, + updated_at, + left_at + ) VALUES (?, ?, ?, ?, NULL) + ON CONFLICT(group_call_session_id, user_id) + DO UPDATE SET + updated_at = excluded.updated_at, + left_at = NULL + """, + (session_id, user_id, now_iso(), now_iso()), + ) + + +def heartbeat_group_call_participant(session_id: int, user_id: int): + db_write( + """ + UPDATE group_call_participants + SET + updated_at = ?, + left_at = NULL + WHERE group_call_session_id = ? + AND user_id = ? + """, + (now_iso(), session_id, user_id), + ) + + +def mark_group_call_participant_left(session_id: int, user_id: int): + db_write( + """ + UPDATE group_call_participants + SET + updated_at = ?, + left_at = ? + WHERE group_call_session_id = ? + AND user_id = ? + AND left_at IS NULL + """, + (now_iso(), now_iso(), session_id, user_id), + ) + + +def list_group_call_participants(session_id: int) -> list[dict]: + cleanup_stale_group_call_participants(session_id) + + with get_db() as db: + rows = db.execute( + """ + SELECT + gcp.group_call_session_id, + gcp.user_id, + gcp.joined_at, + gcp.updated_at, + gcp.left_at, + u.username, + u.role, + up.display_name, + us.name_color + FROM group_call_participants AS gcp + JOIN users AS u + ON u.id = gcp.user_id + LEFT JOIN user_profiles AS up + ON up.user_id = u.id + LEFT JOIN user_settings AS us + ON us.user_id = u.id + WHERE gcp.group_call_session_id = ? + AND gcp.left_at IS NULL + ORDER BY gcp.joined_at ASC, u.username COLLATE NOCASE + """, + (session_id,), + ).fetchall() + + return [ + { + "user_id": row["user_id"], + "username": row["username"], + "display_name": row["display_name"] or row["username"], + "role": row["role"], + "name_color": row["name_color"], + "joined_at": row["joined_at"], + "updated_at": row["updated_at"], + } + for row in rows + ] + + +def active_group_call_participant_count(session_id: int) -> int: + cleanup_stale_group_call_participants(session_id) + + with get_db() as db: + row = db.execute( + """ + SELECT COUNT(*) AS count + FROM group_call_participants + WHERE group_call_session_id = ? + AND left_at IS NULL + """, + (session_id,), + ).fetchone() + return int(row["count"]) if row else 0 + + +def maybe_end_group_call_if_empty(session_id: int): + if active_group_call_participant_count(session_id) > 0: + return + + db_write( + """ + UPDATE group_call_sessions + SET + status = 'ended', + ended_at = ? + WHERE id = ? + AND status != 'ended' + """, + (now_iso(), session_id), + ) + + db_write( + """ + UPDATE group_call_invites + SET + status = 'dismissed', + responded_at = ? + WHERE group_call_session_id = ? + AND status = 'pending' + """, + (now_iso(), session_id), + ) diff --git a/config.py b/config.py new file mode 100644 index 0000000..1baa485 --- /dev/null +++ b/config.py @@ -0,0 +1,231 @@ +import os +import secrets +from datetime import timedelta, timezone, datetime +from pathlib import Path +from typing import Optional + +APP_ROOT = Path(__file__).resolve().parent + +DB_PATH = os.environ.get("CHAT_DB_PATH", str(APP_ROOT / "chat.db")) +UPLOAD_DIR = Path(os.environ.get("CHAT_UPLOAD_DIR", str(APP_ROOT / "uploads"))) + +SECRET_KEY_PATH = os.environ.get( + "CHAT_SECRET_KEY_PATH", + str(Path(DB_PATH).resolve().parent / "flask_secret.key"), +) +CLIENT_HASH_KEY_PATH = os.environ.get( + "CHAT_CLIENT_HASH_KEY_PATH", + str(Path(DB_PATH).resolve().parent / "client_hash.key"), +) + +MAX_ATTACHMENT_BYTES = 100 * 1024 * 1024 +MAX_REQUEST_BYTES = max(110 * 1024 * 1024, MAX_ATTACHMENT_BYTES + 10 * 1024 * 1024) + +MAX_MESSAGE_LENGTH = 1000 +MAX_USERNAME_LENGTH = 30 +MIN_USERNAME_LENGTH = 3 +MIN_PASSWORD_LENGTH = 7 +MAX_PASSWORD_LENGTH = 256 +MAX_HISTORY_MESSAGES = 100 +MAX_INCREMENTAL_MESSAGES = 100 +MAX_FEED_MESSAGES = 50 +MAX_THREAD_REPLIES = 100 +MAX_ATTACHMENT_NAME_LENGTH = 150 +ATTACHMENT_ORPHAN_TTL_HOURS = 24 +ATTACHMENT_CLEANUP_INTERVAL_SECONDS = 60 * 60 +INVITE_TTL_HOURS = 72 +TEAM_INVITE_TTL_HOURS = 72 +TYPING_TTL_SECONDS = 7 +PRESENCE_TTL_SECONDS = 40 +MAX_PROFILE_BIO_LENGTH = 5000 +MAX_DISPLAY_NAME_LENGTH = 80 +MAX_LOCATION_LENGTH = 120 +MAX_WEBSITE_LENGTH = 300 +MAX_CUSTOM_CSS_LENGTH = 20000 +MAX_PROFILE_BADGE_LENGTH = 48 +MAX_OWNED_TEAMS_PER_USER = 3 +MAX_MESSAGE_EDIT_WINDOW_SECONDS = 15 * 60 +MAX_SEARCH_QUERY_LENGTH = 120 +MAX_SEARCH_RESULTS = 100 +READ_RECEIPT_BATCH_LIMIT = 200 +GROUP_CALL_PARTICIPANT_STALE_SECONDS = 45 + +PRIVATE_ACCESS_KEY = os.environ.get("PRIVATE_ACCESS_KEY", "").strip() + +CALLS_BASE = os.environ.get("CALLS_BASE", "https://calls.example.com").rstrip("/") +CALL_HELPER_SHARED_SECRET = os.environ.get("CALL_HELPER_SHARED_SECRET", "").strip() +CALL_TOKEN_TTL_SECONDS = int(os.environ.get("CALL_TOKEN_TTL_SECONDS", "60")) + +ALLOWED_ATTACHMENT_EXTENSIONS = { + "txt", + "pdf", + "png", + "jpg", + "jpeg", + "gif", + "webp", + "mp3", + "wav", + "ogg", + "m4a", + "aac", + "flac", + "mp4", + "webm", + "mov", + "m4v", + "ogv", +} + +ALLOWED_ATTACHMENT_MIME_TYPES = { + "text/plain", + "application/pdf", + "image/png", + "image/jpeg", + "image/gif", + "image/webp", + "audio/mpeg", + "audio/mp3", + "audio/wav", + "audio/x-wav", + "audio/ogg", + "audio/mp4", + "audio/aac", + "audio/flac", + "video/mp4", + "video/webm", + "video/quicktime", + "video/ogg", +} + +ROLES = {"user", "moderator", "admin"} +STATUSES = {"active", "disabled", "restricted", "blacklisted"} +ROOM_VISIBILITIES = {"public", "private"} + +RATE_LIMITS = { + "login": (8, 10 * 60), + "register": (5, 60 * 60), + "send": (30, 60), + "admin": (60, 60), + "attachment": (20, 60), + "typing": (60, 60), + "reaction": (120, 60), + "profile": (30, 60), + "settings": (30, 60), +} + +PLACEHOLDER_SECRETS = { + "", + "change-this-secret-key", + "replace-this-with-a-long-random-secret", + "replace-me", + "secret", + "changeme", +} + +RUNTIME_STATE = { + "last_attachment_cleanup": 0.0, +} + + +def utc_now() -> datetime: + return datetime.now(timezone.utc) + + +def now_iso() -> str: + return utc_now().isoformat() + + +def parse_iso(value: Optional[str]) -> Optional[datetime]: + if not value: + return None + try: + parsed = datetime.fromisoformat(value) + except ValueError: + return None + if not parsed.tzinfo: + return parsed.replace(tzinfo=timezone.utc) + return parsed + + +def load_or_create_key(path_value: str, env_value: str, min_length: int = 32) -> str: + configured = os.environ.get(env_value, "").strip() + if configured and configured.lower() not in PLACEHOLDER_SECRETS and len(configured) >= min_length: + return configured + + path = Path(path_value) + path.parent.mkdir(parents=True, exist_ok=True) + + if path.exists(): + existing = path.read_text(encoding="utf-8").strip() + if len(existing) >= min_length: + return existing + + generated = secrets.token_urlsafe(64) + try: + path.write_text(generated, encoding="utf-8") + os.chmod(path, 0o600) + except OSError: + pass + return generated + + +SECRET_KEY = load_or_create_key(SECRET_KEY_PATH, "FLASK_SECRET_KEY") +CLIENT_HASH_KEY = load_or_create_key(CLIENT_HASH_KEY_PATH, "CHAT_CLIENT_HASH_KEY") + +TRUSTED_PROXY_HOPS = int(os.environ.get("TRUSTED_PROXY_HOPS", "0")) +SESSION_COOKIE_SECURE = (os.environ.get("SESSION_COOKIE_SECURE", "1") == "1") +PERMANENT_SESSION_LIFETIME = timedelta(days=14) + + +def get_asset_version() -> str: + try: + js_dir = APP_ROOT / "static" / "js" + values = [int((APP_ROOT / "static" / "style.css").stat().st_mtime)] + values.extend(int(path.stat().st_mtime) for path in js_dir.glob("*.js")) + return str(max(values)) + except OSError: + return "1" + + +def flask_config() -> dict: + return { + "MAX_CONTENT_LENGTH": MAX_REQUEST_BYTES, + "SESSION_COOKIE_NAME": "chat_session", + "SESSION_COOKIE_HTTPONLY": True, + "SESSION_COOKIE_SAMESITE": "Lax", + "SESSION_COOKIE_SECURE": SESSION_COOKIE_SECURE, + "PERMANENT_SESSION_LIFETIME": PERMANENT_SESSION_LIFETIME, + } + + +CONSTANTS = { + "MAX_MESSAGE_LENGTH": MAX_MESSAGE_LENGTH, + "MAX_HISTORY_MESSAGES": MAX_HISTORY_MESSAGES, + "MAX_INCREMENTAL_MESSAGES": MAX_INCREMENTAL_MESSAGES, + "MAX_FEED_MESSAGES": MAX_FEED_MESSAGES, + "MAX_THREAD_REPLIES": MAX_THREAD_REPLIES, + "MAX_ATTACHMENT_BYTES": MAX_ATTACHMENT_BYTES, + "MAX_ATTACHMENT_NAME_LENGTH": MAX_ATTACHMENT_NAME_LENGTH, + "ALLOWED_ATTACHMENT_EXTENSIONS": ALLOWED_ATTACHMENT_EXTENSIONS, + "ALLOWED_ATTACHMENT_MIME_TYPES": ALLOWED_ATTACHMENT_MIME_TYPES, + "INVITE_TTL_HOURS": INVITE_TTL_HOURS, + "TEAM_INVITE_TTL_HOURS": TEAM_INVITE_TTL_HOURS, + "TYPING_TTL_SECONDS": TYPING_TTL_SECONDS, + "PRESENCE_TTL_SECONDS": PRESENCE_TTL_SECONDS, + "MAX_SEARCH_QUERY_LENGTH": MAX_SEARCH_QUERY_LENGTH, + "MAX_SEARCH_RESULTS": MAX_SEARCH_RESULTS, + "READ_RECEIPT_BATCH_LIMIT": READ_RECEIPT_BATCH_LIMIT, + "MAX_MESSAGE_EDIT_WINDOW_SECONDS": MAX_MESSAGE_EDIT_WINDOW_SECONDS, + "MAX_DISPLAY_NAME_LENGTH": MAX_DISPLAY_NAME_LENGTH, + "MAX_PROFILE_BIO_LENGTH": MAX_PROFILE_BIO_LENGTH, + "MAX_LOCATION_LENGTH": MAX_LOCATION_LENGTH, + "MAX_WEBSITE_LENGTH": MAX_WEBSITE_LENGTH, + "MAX_CUSTOM_CSS_LENGTH": MAX_CUSTOM_CSS_LENGTH, + "MAX_PROFILE_BADGE_LENGTH": MAX_PROFILE_BADGE_LENGTH, + "MAX_OWNED_TEAMS_PER_USER": MAX_OWNED_TEAMS_PER_USER, + "ROOM_VISIBILITIES": ROOM_VISIBILITIES, + "ROLES": ROLES, + "STATUSES": STATUSES, + "GROUP_CALL_PARTICIPANT_STALE_SECONDS": GROUP_CALL_PARTICIPANT_STALE_SECONDS, +} diff --git a/db.py b/db.py new file mode 100644 index 0000000..fcaea56 --- /dev/null +++ b/db.py @@ -0,0 +1,948 @@ +import os +import sqlite3 +import time +from typing import Optional + +from werkzeug.security import generate_password_hash + +from config import ( + DB_PATH, + UPLOAD_DIR, + ROLES, + STATUSES, + now_iso, +) + + +def get_db() -> sqlite3.Connection: + db_dir = os.path.dirname(DB_PATH) + if db_dir: + os.makedirs(db_dir, exist_ok=True) + + conn = sqlite3.connect(DB_PATH, timeout=8, isolation_level=None, check_same_thread=False) + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA foreign_keys = ON") + conn.execute("PRAGMA journal_mode = WAL") + conn.execute("PRAGMA busy_timeout = 8000") + conn.execute("PRAGMA synchronous = NORMAL") + return conn + + +def db_write(query: str, params: tuple = ()) -> sqlite3.Cursor: + last_error = None + for attempt in range(5): + try: + with get_db() as db: + db.execute("BEGIN IMMEDIATE") + cursor = db.execute(query, params) + db.commit() + return cursor + except sqlite3.OperationalError as error: + last_error = error + if "locked" not in str(error).lower() and "busy" not in str(error).lower(): + raise + if attempt == 4: + raise + time.sleep(0.05 * (2 ** attempt)) + raise last_error + + +def column_exists(db: sqlite3.Connection, table: str, column: str) -> bool: + rows = db.execute(f"PRAGMA table_info({table})").fetchall() + return any(row["name"] == column for row in rows) + + +def init_db() -> None: + UPLOAD_DIR.mkdir(parents=True, exist_ok=True) + + with get_db() as db: + db.execute( + """ + CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT COLLATE NOCASE UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + is_admin INTEGER NOT NULL DEFAULT 0, + is_active INTEGER NOT NULL DEFAULT 1, + role TEXT NOT NULL DEFAULT 'user', + status TEXT NOT NULL DEFAULT 'active', + restricted_until TEXT, + restriction_reason TEXT, + created_at TEXT NOT NULL, + last_login_at TEXT + ) + """ + ) + + db.execute( + """ + CREATE TABLE IF NOT EXISTS rooms ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + slug TEXT COLLATE NOCASE UNIQUE NOT NULL, + name TEXT NOT NULL, + description TEXT, + visibility TEXT NOT NULL DEFAULT 'public', + created_by_user_id INTEGER, + created_at TEXT NOT NULL, + is_active INTEGER NOT NULL DEFAULT 1, + FOREIGN KEY (created_by_user_id) REFERENCES users(id) + ) + """ + ) + + db.execute( + """ + CREATE TABLE IF NOT EXISTS room_members ( + room_id INTEGER NOT NULL, + user_id INTEGER NOT NULL, + membership_role TEXT NOT NULL DEFAULT 'member', + added_at TEXT NOT NULL, + PRIMARY KEY (room_id, user_id), + FOREIGN KEY (room_id) REFERENCES rooms(id) ON DELETE CASCADE, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ) + """ + ) + + db.execute( + """ + CREATE TABLE IF NOT EXISTS attachments ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + uploader_user_id INTEGER NOT NULL, + stored_name TEXT UNIQUE NOT NULL, + original_name TEXT NOT NULL, + mime_type TEXT NOT NULL, + size_bytes INTEGER NOT NULL, + created_at TEXT NOT NULL, + FOREIGN KEY (uploader_user_id) REFERENCES users(id) ON DELETE CASCADE + ) + """ + ) + + db.execute( + """ + CREATE TABLE IF NOT EXISTS teams ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + slug TEXT COLLATE NOCASE UNIQUE NOT NULL, + name TEXT NOT NULL, + description TEXT, + created_by_user_id INTEGER, + created_at TEXT NOT NULL, + is_active INTEGER NOT NULL DEFAULT 1, + FOREIGN KEY (created_by_user_id) REFERENCES users(id) ON DELETE SET NULL + ) + """ + ) + + db.execute( + """ + CREATE TABLE IF NOT EXISTS team_members ( + team_id INTEGER NOT NULL, + user_id INTEGER NOT NULL, + membership_role TEXT NOT NULL DEFAULT 'member', + added_at TEXT NOT NULL, + PRIMARY KEY (team_id, user_id), + FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ) + """ + ) + + db.execute( + """ + CREATE TABLE IF NOT EXISTS team_invites ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + team_id INTEGER NOT NULL, + token_hash TEXT UNIQUE NOT NULL, + created_by_user_id INTEGER NOT NULL, + membership_role TEXT NOT NULL DEFAULT 'member', + created_at TEXT NOT NULL, + expires_at TEXT NOT NULL, + used_at TEXT, + used_by_user_id INTEGER, + FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE, + FOREIGN KEY (created_by_user_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY (used_by_user_id) REFERENCES users(id) ON DELETE SET NULL + ) + """ + ) + + db.execute( + """ + CREATE TABLE IF NOT EXISTS channels ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + team_id INTEGER NOT NULL, + legacy_room_id INTEGER UNIQUE, + slug TEXT COLLATE NOCASE NOT NULL, + name TEXT NOT NULL, + description TEXT, + created_by_user_id INTEGER, + created_at TEXT NOT NULL, + is_active INTEGER NOT NULL DEFAULT 1, + UNIQUE(team_id, slug), + FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE, + FOREIGN KEY (legacy_room_id) REFERENCES rooms(id) ON DELETE SET NULL, + FOREIGN KEY (created_by_user_id) REFERENCES users(id) ON DELETE SET NULL + ) + """ + ) + + db.execute( + """ + CREATE TABLE IF NOT EXISTS direct_conversations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + member_pair_key TEXT UNIQUE NOT NULL, + created_at TEXT NOT NULL + ) + """ + ) + + db.execute( + """ + CREATE TABLE IF NOT EXISTS direct_conversation_members ( + direct_conversation_id INTEGER NOT NULL, + user_id INTEGER NOT NULL, + added_at TEXT NOT NULL, + PRIMARY KEY (direct_conversation_id, user_id), + FOREIGN KEY (direct_conversation_id) REFERENCES direct_conversations(id) ON DELETE CASCADE, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ) + """ + ) + + db.execute( + """ + CREATE TABLE IF NOT EXISTS messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + room_id INTEGER, + channel_id INTEGER, + direct_conversation_id INTEGER, + user_id INTEGER NOT NULL, + username TEXT NOT NULL, + text TEXT NOT NULL, + attachment_id INTEGER, + parent_message_id INTEGER, + created_at TEXT NOT NULL, + FOREIGN KEY (room_id) REFERENCES rooms(id) ON DELETE CASCADE, + FOREIGN KEY (channel_id) REFERENCES channels(id) ON DELETE CASCADE, + FOREIGN KEY (direct_conversation_id) REFERENCES direct_conversations(id) ON DELETE CASCADE, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY (attachment_id) REFERENCES attachments(id) ON DELETE SET NULL, + FOREIGN KEY (parent_message_id) REFERENCES messages(id) ON DELETE CASCADE + ) + """ + ) + + db.execute( + """ + CREATE TABLE IF NOT EXISTS room_invites ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + room_id INTEGER NOT NULL, + token_hash TEXT UNIQUE NOT NULL, + created_by_user_id INTEGER NOT NULL, + created_at TEXT NOT NULL, + expires_at TEXT NOT NULL, + used_at TEXT, + used_by_user_id INTEGER, + FOREIGN KEY (room_id) REFERENCES rooms(id) ON DELETE CASCADE, + FOREIGN KEY (created_by_user_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY (used_by_user_id) REFERENCES users(id) ON DELETE SET NULL + ) + """ + ) + + db.execute( + """ + CREATE TABLE IF NOT EXISTS typing_events ( + room_id INTEGER NOT NULL, + user_id INTEGER NOT NULL, + updated_at REAL NOT NULL, + PRIMARY KEY (room_id, user_id), + FOREIGN KEY (room_id) REFERENCES rooms(id) ON DELETE CASCADE, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ) + """ + ) + + db.execute( + """ + CREATE TABLE IF NOT EXISTS channel_typing_events ( + channel_id INTEGER NOT NULL, + user_id INTEGER NOT NULL, + updated_at REAL NOT NULL, + PRIMARY KEY (channel_id, user_id), + FOREIGN KEY (channel_id) REFERENCES channels(id) ON DELETE CASCADE, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ) + """ + ) + + db.execute( + """ + CREATE TABLE IF NOT EXISTS dm_typing_events ( + direct_conversation_id INTEGER NOT NULL, + user_id INTEGER NOT NULL, + updated_at REAL NOT NULL, + PRIMARY KEY (direct_conversation_id, user_id), + FOREIGN KEY (direct_conversation_id) REFERENCES direct_conversations(id) ON DELETE CASCADE, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ) + """ + ) + + db.execute( + """ + CREATE TABLE IF NOT EXISTS blacklist_entries ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + kind TEXT NOT NULL, + value_hash TEXT NOT NULL, + reason TEXT, + created_at TEXT NOT NULL, + expires_at TEXT, + created_by_user_id INTEGER, + UNIQUE(kind, value_hash), + FOREIGN KEY (created_by_user_id) REFERENCES users(id) + ) + """ + ) + + db.execute( + """ + CREATE TABLE IF NOT EXISTS rate_limit_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + scope TEXT NOT NULL, + subject_hash TEXT NOT NULL, + created_at REAL NOT NULL + ) + """ + ) + + db.execute( + """ + CREATE TABLE IF NOT EXISTS audit_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + actor_user_id INTEGER, + action TEXT NOT NULL, + target_type TEXT, + target_id TEXT, + details TEXT, + client_hash TEXT, + created_at TEXT NOT NULL, + FOREIGN KEY (actor_user_id) REFERENCES users(id) ON DELETE SET NULL + ) + """ + ) + + db.execute( + """ + CREATE TABLE IF NOT EXISTS pages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + team_id INTEGER NOT NULL, + channel_id INTEGER, + slug TEXT COLLATE NOCASE NOT NULL, + title TEXT NOT NULL, + body TEXT NOT NULL DEFAULT '', + visibility TEXT NOT NULL DEFAULT 'team', + page_type TEXT NOT NULL DEFAULT 'document', + created_by_user_id INTEGER NOT NULL, + updated_by_user_id INTEGER NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + is_active INTEGER NOT NULL DEFAULT 1, + UNIQUE(team_id, slug), + FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE, + FOREIGN KEY (channel_id) REFERENCES channels(id) ON DELETE SET NULL, + FOREIGN KEY (created_by_user_id) REFERENCES users(id) ON DELETE RESTRICT, + FOREIGN KEY (updated_by_user_id) REFERENCES users(id) ON DELETE RESTRICT + ) + """ + ) + + db.execute( + """ + CREATE TABLE IF NOT EXISTS user_profiles ( + user_id INTEGER PRIMARY KEY, + display_name TEXT, + badge_text TEXT, + bio TEXT NOT NULL DEFAULT '', + bio_mode TEXT NOT NULL DEFAULT 'txt', + website TEXT, + location TEXT, + status_message TEXT NOT NULL DEFAULT '', + banner_attachment_id INTEGER, + updated_at TEXT NOT NULL, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY (banner_attachment_id) REFERENCES attachments(id) ON DELETE SET NULL + ) + """ + ) + + db.execute( + """ + CREATE TABLE IF NOT EXISTS user_settings ( + user_id INTEGER PRIMARY KEY, + accent_color TEXT, + name_color TEXT, + custom_css TEXT NOT NULL DEFAULT '', + theme_mode TEXT NOT NULL DEFAULT 'dark', + profile_background_color TEXT, + profile_banner_color TEXT, + profile_visibility TEXT NOT NULL DEFAULT 'public', + last_active_visibility TEXT NOT NULL DEFAULT 'everyone', + presence_mode TEXT NOT NULL DEFAULT 'online', + dm_layout_mode TEXT NOT NULL DEFAULT 'cards', + show_presence_badge INTEGER NOT NULL DEFAULT 1, + updated_at TEXT NOT NULL, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ) + """ + ) + + db.execute( + """ + CREATE TABLE IF NOT EXISTS message_reactions ( + message_id INTEGER NOT NULL, + user_id INTEGER NOT NULL, + emoji TEXT NOT NULL, + created_at TEXT NOT NULL, + PRIMARY KEY (message_id, user_id, emoji), + FOREIGN KEY (message_id) REFERENCES messages(id) ON DELETE CASCADE, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ) + """ + ) + + db.execute( + """ + CREATE TABLE IF NOT EXISTS message_edits ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + message_id INTEGER NOT NULL, + edited_by_user_id INTEGER NOT NULL, + previous_text TEXT NOT NULL, + new_text TEXT NOT NULL, + previous_attachments_json TEXT NOT NULL DEFAULT '[]', + new_attachments_json TEXT NOT NULL DEFAULT '[]', + created_at TEXT NOT NULL, + FOREIGN KEY (message_id) REFERENCES messages(id) ON DELETE CASCADE, + FOREIGN KEY (edited_by_user_id) REFERENCES users(id) ON DELETE CASCADE + ) + """ + ) + + db.execute( + """ + CREATE TABLE IF NOT EXISTS message_receipts ( + message_id INTEGER NOT NULL, + user_id INTEGER NOT NULL, + seen_at TEXT NOT NULL, + PRIMARY KEY (message_id, user_id), + FOREIGN KEY (message_id) REFERENCES messages(id) ON DELETE CASCADE, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ) + """ + ) + + db.execute( + """ + CREATE TABLE IF NOT EXISTS message_attachments ( + message_id INTEGER NOT NULL, + attachment_id INTEGER NOT NULL, + position INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (message_id, attachment_id), + FOREIGN KEY (message_id) REFERENCES messages(id) ON DELETE CASCADE, + FOREIGN KEY (attachment_id) REFERENCES attachments(id) ON DELETE CASCADE + ) + """ + ) + + db.execute( + """ + CREATE TABLE IF NOT EXISTS presence_events ( + user_id INTEGER PRIMARY KEY, + updated_at REAL NOT NULL, + view_name TEXT, + room_id INTEGER, + channel_id INTEGER, + direct_conversation_id INTEGER, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ) + """ + ) + + db.execute( + """ + CREATE TABLE IF NOT EXISTS call_sessions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + direct_conversation_id INTEGER NOT NULL, + started_by_user_id INTEGER NOT NULL, + status TEXT NOT NULL DEFAULT 'active', + created_at TEXT NOT NULL, + ended_at TEXT, + FOREIGN KEY (direct_conversation_id) REFERENCES direct_conversations(id) ON DELETE CASCADE, + FOREIGN KEY (started_by_user_id) REFERENCES users(id) ON DELETE CASCADE + ) + """ + ) + + db.execute( + """ + CREATE TABLE IF NOT EXISTS call_signals ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + call_session_id INTEGER NOT NULL, + from_user_id INTEGER NOT NULL, + signal_type TEXT NOT NULL, + payload_json TEXT NOT NULL DEFAULT '{}', + created_at TEXT NOT NULL, + FOREIGN KEY (call_session_id) REFERENCES call_sessions(id) ON DELETE CASCADE, + FOREIGN KEY (from_user_id) REFERENCES users(id) ON DELETE CASCADE + ) + """ + ) + + db.execute( + """ + CREATE TABLE IF NOT EXISTS group_call_sessions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + scope_kind TEXT NOT NULL, + scope_id INTEGER NOT NULL, + scope_slug TEXT NOT NULL, + helper_call_id TEXT NOT NULL UNIQUE, + created_by_user_id INTEGER NOT NULL, + status TEXT NOT NULL DEFAULT 'active', + created_at TEXT NOT NULL, + ended_at TEXT, + FOREIGN KEY (created_by_user_id) REFERENCES users(id) ON DELETE CASCADE + ) + """ + ) + + db.execute( + """ + CREATE TABLE IF NOT EXISTS group_call_invites ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + group_call_session_id INTEGER NOT NULL, + inviter_user_id INTEGER NOT NULL, + invitee_user_id INTEGER NOT NULL, + helper_token TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', + created_at TEXT NOT NULL, + expires_at TEXT NOT NULL, + responded_at TEXT, + FOREIGN KEY (group_call_session_id) REFERENCES group_call_sessions(id) ON DELETE CASCADE, + FOREIGN KEY (inviter_user_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY (invitee_user_id) REFERENCES users(id) ON DELETE CASCADE + ) + """ + ) + + db.execute( + """ + CREATE TABLE IF NOT EXISTS group_call_participants ( + group_call_session_id INTEGER NOT NULL, + user_id INTEGER NOT NULL, + joined_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + left_at TEXT, + PRIMARY KEY (group_call_session_id, user_id), + FOREIGN KEY (group_call_session_id) REFERENCES group_call_sessions(id) ON DELETE CASCADE, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ) + """ + ) + + if not column_exists(db, "messages", "room_id"): + db.execute("ALTER TABLE messages ADD COLUMN room_id INTEGER") + if not column_exists(db, "messages", "attachment_id"): + db.execute("ALTER TABLE messages ADD COLUMN attachment_id INTEGER") + if not column_exists(db, "messages", "parent_message_id"): + db.execute("ALTER TABLE messages ADD COLUMN parent_message_id INTEGER") + if not column_exists(db, "messages", "channel_id"): + db.execute("ALTER TABLE messages ADD COLUMN channel_id INTEGER") + if not column_exists(db, "messages", "direct_conversation_id"): + db.execute("ALTER TABLE messages ADD COLUMN direct_conversation_id INTEGER") + + if not column_exists(db, "user_profiles", "badge_text"): + db.execute("ALTER TABLE user_profiles ADD COLUMN badge_text TEXT") + if not column_exists(db, "user_profiles", "status_message"): + db.execute("ALTER TABLE user_profiles ADD COLUMN status_message TEXT NOT NULL DEFAULT ''") + if not column_exists(db, "user_profiles", "banner_attachment_id"): + db.execute("ALTER TABLE user_profiles ADD COLUMN banner_attachment_id INTEGER") + + if not column_exists(db, "user_settings", "name_color"): + db.execute("ALTER TABLE user_settings ADD COLUMN name_color TEXT") + if not column_exists(db, "user_settings", "profile_background_color"): + db.execute("ALTER TABLE user_settings ADD COLUMN profile_background_color TEXT") + if not column_exists(db, "user_settings", "profile_banner_color"): + db.execute("ALTER TABLE user_settings ADD COLUMN profile_banner_color TEXT") + if not column_exists(db, "user_settings", "profile_visibility"): + db.execute("ALTER TABLE user_settings ADD COLUMN profile_visibility TEXT NOT NULL DEFAULT 'public'") + if not column_exists(db, "user_settings", "last_active_visibility"): + db.execute("ALTER TABLE user_settings ADD COLUMN last_active_visibility TEXT NOT NULL DEFAULT 'everyone'") + if not column_exists(db, "user_settings", "presence_mode"): + db.execute("ALTER TABLE user_settings ADD COLUMN presence_mode TEXT NOT NULL DEFAULT 'online'") + if not column_exists(db, "user_settings", "dm_layout_mode"): + db.execute("ALTER TABLE user_settings ADD COLUMN dm_layout_mode TEXT NOT NULL DEFAULT 'cards'") + if not column_exists(db, "user_settings", "show_presence_badge"): + db.execute("ALTER TABLE user_settings ADD COLUMN show_presence_badge INTEGER NOT NULL DEFAULT 1") + + if not column_exists(db, "presence_events", "direct_conversation_id"): + db.execute("ALTER TABLE presence_events ADD COLUMN direct_conversation_id INTEGER") + + db.execute("CREATE INDEX IF NOT EXISTS idx_messages_room_id_id ON messages(room_id, id)") + db.execute("CREATE INDEX IF NOT EXISTS idx_messages_channel_id_id ON messages(channel_id, id)") + db.execute("CREATE INDEX IF NOT EXISTS idx_messages_dm_id_id ON messages(direct_conversation_id, id)") + db.execute("CREATE INDEX IF NOT EXISTS idx_messages_parent_message_id_id ON messages(parent_message_id, id)") + db.execute("CREATE INDEX IF NOT EXISTS idx_messages_attachment_id ON messages(attachment_id)") + db.execute("CREATE INDEX IF NOT EXISTS idx_room_members_user ON room_members(user_id)") + db.execute("CREATE INDEX IF NOT EXISTS idx_team_members_user_id ON team_members(user_id)") + db.execute("CREATE INDEX IF NOT EXISTS idx_channels_team_id_slug ON channels(team_id, slug)") + db.execute("CREATE INDEX IF NOT EXISTS idx_channels_legacy_room_id ON channels(legacy_room_id)") + db.execute("CREATE INDEX IF NOT EXISTS idx_pages_team_updated_at ON pages(team_id, updated_at DESC)") + db.execute("CREATE INDEX IF NOT EXISTS idx_channel_typing_events_updated_at ON channel_typing_events(channel_id, updated_at)") + db.execute("CREATE INDEX IF NOT EXISTS idx_dm_typing_events_updated_at ON dm_typing_events(direct_conversation_id, updated_at)") + db.execute("CREATE INDEX IF NOT EXISTS idx_invites_token_hash ON room_invites(token_hash)") + db.execute("CREATE INDEX IF NOT EXISTS idx_team_invites_token_hash ON team_invites(token_hash)") + db.execute("CREATE INDEX IF NOT EXISTS idx_typing_room_updated ON typing_events(room_id, updated_at)") + db.execute("CREATE INDEX IF NOT EXISTS idx_blacklist_lookup ON blacklist_entries(kind, value_hash)") + db.execute("CREATE INDEX IF NOT EXISTS idx_rate_limit_lookup ON rate_limit_events(scope, subject_hash, created_at)") + db.execute("CREATE INDEX IF NOT EXISTS idx_message_reactions_message ON message_reactions(message_id, emoji)") + db.execute("CREATE INDEX IF NOT EXISTS idx_message_receipts_user_seen ON message_receipts(user_id, seen_at)") + db.execute("CREATE INDEX IF NOT EXISTS idx_message_attachments_message_position ON message_attachments(message_id, position)") + db.execute("CREATE INDEX IF NOT EXISTS idx_presence_events_updated_at ON presence_events(updated_at)") + db.execute("CREATE INDEX IF NOT EXISTS idx_presence_events_dm_id ON presence_events(direct_conversation_id, updated_at)") + db.execute("CREATE INDEX IF NOT EXISTS idx_direct_conversation_members_user ON direct_conversation_members(user_id, direct_conversation_id)") + db.execute("CREATE INDEX IF NOT EXISTS idx_call_signals_lookup ON call_signals(call_session_id, id)") + db.execute("CREATE INDEX IF NOT EXISTS idx_user_settings_name_color ON user_settings(user_id, name_color)") + db.execute("CREATE INDEX IF NOT EXISTS idx_group_call_sessions_scope ON group_call_sessions(scope_kind, scope_id, status)") + db.execute("CREATE INDEX IF NOT EXISTS idx_group_call_invites_invitee_status ON group_call_invites(invitee_user_id, status, expires_at)") + db.execute("CREATE INDEX IF NOT EXISTS idx_group_call_participants_session_active ON group_call_participants(group_call_session_id, left_at)") + db.execute("CREATE INDEX IF NOT EXISTS idx_group_call_participants_user_active ON group_call_participants(user_id, left_at)") + db.execute("CREATE INDEX IF NOT EXISTS idx_group_call_participants_session_updated ON group_call_participants(group_call_session_id, updated_at)") + + general = db.execute("SELECT id FROM rooms WHERE slug = 'general'").fetchone() + if not general: + cursor = db.execute( + """ + INSERT INTO rooms ( + slug, + name, + description, + visibility, + created_at, + is_active + ) VALUES (?, ?, ?, ?, ?, 1) + """, + ("general", "general", "Default public chat room", "public", now_iso()), + ) + general_id = cursor.lastrowid + else: + general_id = general["id"] + + db.execute( + "UPDATE messages SET room_id = ? WHERE room_id IS NULL AND direct_conversation_id IS NULL", + (general_id,), + ) + + db.execute( + """ + UPDATE users + SET role = CASE + WHEN is_admin = 1 THEN 'admin' + ELSE 'user' + END + WHERE role IS NULL + OR role = '' + """ + ) + + db.execute( + """ + UPDATE users + SET status = CASE + WHEN is_active = 1 THEN 'active' + ELSE 'disabled' + END + WHERE status IS NULL + OR status = '' + """ + ) + + general_team = db.execute("SELECT id FROM teams WHERE slug = 'general'").fetchone() + if general_team: + general_team_id = general_team["id"] + else: + cursor = db.execute( + """ + INSERT INTO teams ( + slug, + name, + description, + created_by_user_id, + created_at, + is_active + ) VALUES (?, ?, ?, NULL, ?, 1) + """, + ("general", "General", "Default team for existing chat rooms.", now_iso()), + ) + general_team_id = cursor.lastrowid + + legacy_rooms = db.execute( + """ + SELECT + id, + slug, + name, + description, + created_by_user_id, + created_at + FROM rooms + WHERE is_active = 1 + """ + ).fetchall() + + for room in legacy_rooms: + existing_channel = db.execute( + "SELECT id FROM channels WHERE legacy_room_id = ?", + (room["id"],), + ).fetchone() + if existing_channel: + continue + 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"], + room["slug"], + room["name"], + room["description"], + room["created_by_user_id"], + room["created_at"], + ), + ) + + db.execute( + """ + UPDATE messages + SET channel_id = ( + SELECT c.id + FROM channels AS c + WHERE c.legacy_room_id = messages.room_id + LIMIT 1 + ) + WHERE channel_id IS NULL + AND room_id IS NOT NULL + AND direct_conversation_id IS NULL + """ + ) + + admin_users = db.execute( + """ + SELECT id + FROM users + WHERE role = 'admin' + AND status = 'active' + """ + ).fetchall() + + for admin_user in admin_users: + db.execute( + """ + INSERT OR IGNORE INTO team_members ( + team_id, + user_id, + membership_role, + added_at + ) VALUES (?, ?, 'admin', ?) + """, + (general_team_id, admin_user["id"], now_iso()), + ) + + db.execute( + """ + INSERT OR IGNORE INTO user_profiles ( + user_id, + display_name, + badge_text, + bio, + bio_mode, + website, + location, + status_message, + banner_attachment_id, + updated_at + ) + SELECT + id, + username, + '', + '', + 'txt', + '', + '', + '', + NULL, + ? + FROM users + """, + (now_iso(),), + ) + + db.execute( + """ + INSERT OR IGNORE INTO user_settings ( + user_id, + accent_color, + name_color, + custom_css, + theme_mode, + profile_background_color, + profile_banner_color, + profile_visibility, + last_active_visibility, + presence_mode, + dm_layout_mode, + show_presence_badge, + updated_at + ) + SELECT + id, + '#f7dce8', + NULL, + '', + 'dark', + NULL, + NULL, + 'public', + 'everyone', + 'online', + 'cards', + 1, + ? + FROM users + """, + (now_iso(),), + ) + + existing_message_attachment_counts = db.execute( + "SELECT COUNT(*) AS count FROM message_attachments" + ).fetchone()["count"] + + if existing_message_attachment_counts == 0: + rows = db.execute( + """ + SELECT id, attachment_id + FROM messages + WHERE attachment_id IS NOT NULL + ORDER BY id ASC + """ + ).fetchall() + for row in rows: + db.execute( + """ + INSERT OR IGNORE INTO message_attachments ( + message_id, + attachment_id, + position + ) VALUES (?, ?, 0) + """, + (row["id"], row["attachment_id"]), + ) + + db.commit() + + +def bootstrap_admin() -> None: + username = os.environ.get("ADMIN_USERNAME", "").strip().lower() + password = os.environ.get("ADMIN_PASSWORD", "") + + if not username or not password: + return + + with get_db() as db: + existing = db.execute( + "SELECT id FROM users WHERE username = ?", + (username,), + ).fetchone() + + if existing: + db.execute( + """ + UPDATE users + SET + role = 'admin', + status = 'active', + is_admin = 1, + is_active = 1 + WHERE id = ? + """, + (existing["id"],), + ) + user_id = existing["id"] + else: + cursor = db.execute( + """ + INSERT INTO users ( + username, + password_hash, + role, + status, + is_admin, + is_active, + created_at + ) VALUES (?, ?, 'admin', 'active', 1, 1, ?) + """, + (username, generate_password_hash(password), now_iso()), + ) + user_id = cursor.lastrowid + + db.execute( + """ + INSERT OR IGNORE INTO user_profiles ( + user_id, + display_name, + badge_text, + bio, + bio_mode, + website, + location, + status_message, + banner_attachment_id, + updated_at + ) VALUES (?, ?, '', '', 'txt', '', '', '', NULL, ?) + """, + (user_id, username, now_iso()), + ) + + db.execute( + """ + INSERT OR IGNORE INTO user_settings ( + user_id, + accent_color, + name_color, + custom_css, + theme_mode, + profile_background_color, + profile_banner_color, + profile_visibility, + last_active_visibility, + presence_mode, + dm_layout_mode, + show_presence_badge, + updated_at + ) VALUES (?, '#f7dce8', NULL, '', 'dark', NULL, NULL, 'public', 'everyone', 'online', 'cards', 1, ?) + """, + (user_id, now_iso()), + ) + + db.commit() diff --git a/features.py b/features.py deleted file mode 100644 index 35999e4..0000000 --- a/features.py +++ /dev/null @@ -1,1537 +0,0 @@ -# features.py -import json -import re -import time -from datetime import timedelta -from pathlib import Path -from typing import Optional - -from flask import abort, jsonify, send_from_directory -import markdown - -from sanitizer import sanitize_html_fragment - -IMAGE_MIME_PREFIX = "image/" -AUDIO_MIME_PREFIX = "audio/" -VIDEO_MIME_PREFIX = "video/" - -IMAGE_EXTENSIONS = {"png", "jpg", "jpeg", "gif", "webp"} -AUDIO_EXTENSIONS = {"mp3", "wav", "ogg", "m4a", "aac", "flac"} -VIDEO_EXTENSIONS = {"mp4", "webm", "mov", "m4v", "ogv"} - -DEFAULT_ROLE_NAME_COLORS = { - "admin": "#ff7b7b", - "moderator": "#8ab4ff", - "user": "#f5f5f7", -} - -PROFILE_BIO_MODES = {"txt", "md", "html"} -THEME_MODES = {"dark", "light"} -PAGE_VISIBILITIES = {"team", "public"} -PAGE_TYPES = {"project", "document", "update", "resource"} -TEAM_MEMBER_ROLES = {"owner", "admin", "member"} -PROFILE_VISIBILITIES = {"public", "members", "private"} -LAST_ACTIVE_VISIBILITIES = {"everyone", "members", "nobody"} -PRESENCE_MODES = {"online", "silent", "focus", "dnd"} -DM_LAYOUT_MODES = {"cards", "compact"} -REACTION_EMOJI_RE = re.compile(r"^.{1,16}$") -HEX_COLOR_RE = re.compile(r"^#[0-9a-fA-F]{6}$") -PAGE_LINK_RE = re.compile(r"\]\((?:/teams/[^/]+/pages/)?([a-z0-9-]{1,48})\)", re.IGNORECASE) - - -def valid_hex_color(value: object) -> bool: - return bool(isinstance(value, str) and HEX_COLOR_RE.fullmatch(value.strip())) - - -def normalized_hex_color(value: object) -> Optional[str]: - if not valid_hex_color(value): - return None - return str(value).strip().lower() - - -def render_markdown_to_safe_html(text: str) -> str: - rendered = markdown.markdown( - text or "", - extensions=[ - "extra", - "sane_lists", - "nl2br", - "tables", - "fenced_code", - ], - output_format="html5", - ) - return sanitize_html_fragment(rendered) - - -def safe_profile_bio_html(html_module, bio: str, bio_mode: str) -> str: - mode = bio_mode if bio_mode in PROFILE_BIO_MODES else "txt" - if mode == "html": - return sanitize_html_fragment(bio or "") - if mode == "md": - return render_markdown_to_safe_html(bio or "") - return "

" + html_module.escape(bio or "").replace("\n", "
") + "

" - - -def extract_page_reference_slugs(markdown_text: str) -> list[str]: - text = str(markdown_text or "") - matches = PAGE_LINK_RE.findall(text) - seen = set() - slugs = [] - for slug in matches: - normalized = slug.lower().strip() - if not normalized or normalized in seen: - continue - seen.add(normalized) - slugs.append(normalized) - return slugs - - -def detect_website_brand(website: str) -> dict: - value = str(website or "").strip().lower() - if not value: - return {"host": "", "brand": "web", "label": "website"} - - host = value - host = host.replace("https://", "").replace("http://", "") - host = host.split("/", 1)[0] - host = host.removeprefix("www.") - - mappings = [ - ("github.com", "github", "GitHub"), - ("youtube.com", "youtube", "YouTube"), - ("youtu.be", "youtube", "YouTube"), - ("spotify.com", "spotify", "Spotify"), - ("soundcloud.com", "soundcloud", "SoundCloud"), - ("twitter.com", "x", "X"), - ("x.com", "x", "X"), - ] - - for suffix, brand, label in mappings: - if host.endswith(suffix): - return {"host": host, "brand": brand, "label": label} - - return {"host": host, "brand": "web", "label": "website"} - - -def sqlite_table_exists(get_db, table_name: str) -> bool: - with get_db() as db: - row = db.execute( - """ - SELECT 1 - FROM sqlite_master - WHERE type = 'table' - AND name = ? - """, - (table_name,), - ).fetchone() - return bool(row) - - -def sqlite_column_exists(get_db, table_name: str, column_name: str) -> bool: - with get_db() as db: - rows = db.execute(f"PRAGMA table_info({table_name})").fetchall() - return any(row["name"] == column_name for row in rows) - - -def attachment_kind_from_name_and_mime( - original_name: Optional[str], - mime_type: Optional[str], -) -> str: - mime_type = str(mime_type or "").lower() - name = str(original_name or "") - ext = name.rsplit(".", 1)[-1].lower() if "." in name else "" - if mime_type.startswith(IMAGE_MIME_PREFIX) or ext in IMAGE_EXTENSIONS: - return "image" - if mime_type.startswith(AUDIO_MIME_PREFIX) or ext in AUDIO_EXTENSIONS: - return "audio" - if mime_type.startswith(VIDEO_MIME_PREFIX) or ext in VIDEO_EXTENSIONS: - return "video" - return "file" - - -def load_page_children(get_db, page_id: int) -> list[dict]: - if not sqlite_column_exists(get_db, "pages", "parent_page_id"): - return [] - - with get_db() as db: - rows = db.execute( - """ - SELECT - id, - slug, - title, - updated_at - FROM pages - WHERE parent_page_id = ? - AND is_active = 1 - ORDER BY title COLLATE NOCASE, id ASC - """, - (page_id,), - ).fetchall() - return [dict(row) for row in rows] - - -def load_page_backlinks(get_db, page_id: int, page_slug: str, team_id: int) -> list[dict]: - pattern_1 = f"%]({page_slug})%" - pattern_2 = f"%/pages/{page_slug})%" - with get_db() as db: - rows = db.execute( - """ - SELECT - id, - slug, - title, - updated_at - FROM pages - WHERE team_id = ? - AND is_active = 1 - AND id != ? - AND (body LIKE ? OR body LIKE ?) - ORDER BY updated_at DESC, id DESC - """, - (team_id, page_id, pattern_1, pattern_2), - ).fetchall() - return [dict(row) for row in rows] - - -def load_page_comments(get_db, page_id: int) -> list[dict]: - if not sqlite_table_exists(get_db, "page_comments"): - return [] - - with get_db() as db: - rows = 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.page_id = ? - AND pc.deleted_at IS NULL - ORDER BY pc.created_at ASC, pc.id ASC - """, - (page_id,), - ).fetchall() - return [ - { - "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": resolve_name_color(row["user_role"], row["user_name_color"]), - } - for row in rows - ] - - -def next_page_version_number(get_db, page_id: int) -> int: - if not sqlite_table_exists(get_db, "page_versions"): - return 1 - - with get_db() as db: - row = db.execute( - """ - SELECT COALESCE(MAX(version_number), 0) AS value - FROM page_versions - WHERE page_id = ? - """, - (page_id,), - ).fetchone() - return int(row["value"] or 0) + 1 - - -def snapshot_page_version( - get_db, - page_id: int, - title: str, - body: str, - visibility: str, - page_type: str, - channel_id: Optional[int], - parent_page_id: Optional[int], - created_by_user_id: int, - created_at: str, -) -> int: - if not sqlite_table_exists(get_db, "page_versions"): - return 0 - - version_number = next_page_version_number(get_db, page_id) - with get_db() as db: - db.execute("BEGIN IMMEDIATE") - cursor = db.execute( - """ - INSERT INTO page_versions ( - page_id, - version_number, - title, - body, - visibility, - page_type, - channel_id, - parent_page_id, - created_by_user_id, - created_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - """, - ( - page_id, - version_number, - title, - body, - visibility, - page_type, - channel_id, - parent_page_id, - created_by_user_id, - created_at, - ), - ) - db.commit() - return cursor.lastrowid - - -def load_page_versions(get_db, page_id: int) -> list[dict]: - if not sqlite_table_exists(get_db, "page_versions"): - return [] - - with get_db() as db: - rows = db.execute( - """ - SELECT - pv.id, - pv.page_id, - pv.version_number, - pv.title, - pv.visibility, - pv.page_type, - pv.channel_id, - pv.parent_page_id, - pv.created_by_user_id, - pv.created_at, - u.username AS created_by_username - FROM page_versions AS pv - JOIN users AS u - ON u.id = pv.created_by_user_id - WHERE pv.page_id = ? - ORDER BY pv.version_number DESC - """, - (page_id,), - ).fetchall() - return [dict(row) for row in rows] - - -def load_page_version_detail(get_db, version_id: int): - if not sqlite_table_exists(get_db, "page_versions"): - return None - - with get_db() as db: - row = db.execute( - """ - SELECT - pv.id, - pv.page_id, - pv.version_number, - pv.title, - pv.body, - pv.visibility, - pv.page_type, - pv.channel_id, - pv.parent_page_id, - pv.created_by_user_id, - pv.created_at, - u.username AS created_by_username - FROM page_versions AS pv - JOIN users AS u - ON u.id = pv.created_by_user_id - WHERE pv.id = ? - """, - (version_id,), - ).fetchone() - return row - - -def user_can_see_last_active(settings_row, viewer: Optional[dict], target_user_id: int, is_team_member_func=None) -> bool: - visibility = (settings_row["last_active_visibility"] if settings_row and "last_active_visibility" in settings_row.keys() else "everyone") or "everyone" - if visibility == "nobody": - return False - if visibility == "everyone": - return True - if visibility == "members": - if not viewer: - return False - if viewer["id"] == target_user_id: - return True - if viewer["role"] in {"moderator", "admin"}: - return True - if is_team_member_func: - return True - return False - return False - - -def page_payload(app_module, page, user: Optional[dict]) -> dict: - can_read = bool( - page["visibility"] == "public" - or ( - user - and ( - user["role"] in {"moderator", "admin"} - or app_module["is_team_member"](page["team_id"], user["id"]) - ) - ) - ) - rendered_html = render_markdown_to_safe_html(page["body"] or "") - parent_page_id = page["parent_page_id"] if "parent_page_id" in page.keys() else None - parent_page_slug = page["parent_page_slug"] if "parent_page_slug" in page.keys() else None - parent_page_title = page["parent_page_title"] if "parent_page_title" in page.keys() else None - return { - "id": page["id"], - "team_id": page["team_id"], - "team_slug": page["team_slug"], - "team_name": page["team_name"], - "channel_id": page["channel_id"], - "channel_slug": page["channel_slug"], - "channel_name": page["channel_name"], - "parent_page_id": parent_page_id, - "parent_page_slug": parent_page_slug, - "parent_page_title": parent_page_title, - "slug": page["slug"], - "title": page["title"], - "body": page["body"], - "body_html": rendered_html, - "visibility": page["visibility"], - "page_type": page["page_type"], - "created_by_user_id": page["created_by_user_id"], - "updated_by_user_id": page["updated_by_user_id"], - "created_at": page["created_at"], - "updated_at": page["updated_at"], - "can_read": can_read, - "can_edit": bool( - user - and ( - user["role"] in {"moderator", "admin"} - or app_module["is_team_admin"](page["team_id"], user) - ) - ), - "children": load_page_children(app_module["get_db"], page["id"]), - "backlinks": load_page_backlinks( - app_module["get_db"], - page["id"], - page["slug"], - page["team_id"], - ), - "references": extract_page_reference_slugs(page["body"] or ""), - "comments": load_page_comments(app_module["get_db"], page["id"]), - } - - -def attachment_payload_from_row(row) -> Optional[dict]: - if not row or not row["id"]: - return None - kind = attachment_kind_from_name_and_mime( - row["original_name"], - row["mime_type"], - ) - return { - "id": row["id"], - "original_name": row["original_name"], - "mime_type": row["mime_type"], - "size_bytes": row["size_bytes"], - "kind": kind, - "url": f"/api/attachments/{row['id']}/download", - } - - -def attachment_payload_legacy_from_message_select_row(row) -> Optional[dict]: - if not row["attachment_id"]: - return None - kind = attachment_kind_from_name_and_mime( - row["attachment_original_name"], - row["attachment_mime_type"], - ) - return { - "id": row["attachment_id"], - "original_name": row["attachment_original_name"], - "mime_type": row["attachment_mime_type"], - "size_bytes": row["attachment_size_bytes"], - "kind": kind, - "url": f"/api/attachments/{row['attachment_id']}/download", - } - - -def attachment_list_for_message(get_db, message_id: int) -> list[dict]: - with get_db() as db: - rows = db.execute( - """ - SELECT - a.id, - a.original_name, - a.mime_type, - a.size_bytes - FROM message_attachments AS ma - JOIN attachments AS a - ON a.id = ma.attachment_id - WHERE ma.message_id = ? - ORDER BY ma.position ASC, a.id ASC - """, - (message_id,), - ).fetchall() - return [attachment_payload_from_row(row) for row in rows] - - -def cleanup_orphaned_attachments( - get_db, - upload_dir: Path, - utc_now, - orphan_ttl_hours: int, - app_logger, -) -> None: - cutoff = (utc_now() - timedelta(hours=orphan_ttl_hours)).isoformat() - with get_db() as db: - orphaned = db.execute( - """ - SELECT - a.id, - a.stored_name - FROM attachments AS a - WHERE a.created_at < ? - AND NOT EXISTS ( - SELECT 1 - FROM messages AS m - WHERE m.attachment_id = a.id - ) - AND NOT EXISTS ( - SELECT 1 - FROM message_attachments AS ma - WHERE ma.attachment_id = a.id - ) - """, - (cutoff,), - ).fetchall() - for attachment in orphaned: - db.execute( - """ - DELETE FROM attachments - WHERE id = ? - """, - (attachment["id"],), - ) - db.commit() - - for attachment in orphaned: - try: - (upload_dir / attachment["stored_name"]).unlink(missing_ok=True) - except OSError: - app_logger.warning( - "Could not remove orphaned attachment file: %s", - attachment["stored_name"], - ) - - -def maybe_cleanup_orphaned_attachments( - state: dict, - cleanup_interval_seconds: int, - cleanup_func, - app_logger, -) -> None: - now = time.time() - last = state.get("last_attachment_cleanup", 0.0) - if now - last < cleanup_interval_seconds: - return - state["last_attachment_cleanup"] = now - try: - cleanup_func() - except Exception: - app_logger.exception("Could not clean orphaned attachments.") - - -def resolve_owned_attachment_ids( - get_db, - attachment_ids: object, - user_id: int, -) -> tuple[Optional[list[int]], Optional[tuple]]: - if attachment_ids is None: - return [], None - if not isinstance(attachment_ids, list): - return None, (jsonify({"error": "attachment_ids must be an array."}), 400) - - parsed = [] - seen = set() - for item in attachment_ids[:10]: - try: - attachment_id = int(item) - except (TypeError, ValueError): - return None, (jsonify({"error": "Invalid attachment."}), 400) - if attachment_id in seen: - continue - seen.add(attachment_id) - with get_db() as db: - row = db.execute( - """ - SELECT id, uploader_user_id - FROM attachments - WHERE id = ? - """, - (attachment_id,), - ).fetchone() - if not row: - return None, (jsonify({"error": "Attachment not found."}), 404) - if row["uploader_user_id"] != user_id: - return None, ( - jsonify({"error": "You can only attach files you uploaded."}), - 403, - ) - parsed.append(attachment_id) - return parsed, None - - -def resolve_owned_attachment( - get_db, - attachment_id: object, - user_id: int, -) -> tuple[Optional[int], Optional[tuple]]: - if attachment_id is None: - return None, None - try: - parsed_id = int(attachment_id) - except (TypeError, ValueError): - return None, (jsonify({"error": "Invalid attachment."}), 400) - - with get_db() as db: - attachment = db.execute( - """ - SELECT - id, - uploader_user_id - FROM attachments - WHERE id = ? - """, - (parsed_id,), - ).fetchone() - if not attachment: - return None, (jsonify({"error": "Attachment not found."}), 404) - if attachment["uploader_user_id"] != user_id: - return None, ( - jsonify({"error": "You can only attach files you uploaded."}), - 403, - ) - return parsed_id, None - - -def can_download_attachment( - get_db, - attachment_id: int, - user: Optional[dict], - can_access_room, - can_access_channel, - get_channel_by_id, -) -> bool: - if not user: - return False - with get_db() as db: - room_rows = db.execute( - """ - SELECT DISTINCT - r.id, - r.slug, - r.name, - r.description, - r.visibility, - r.created_by_user_id, - r.is_active - FROM message_attachments AS ma - JOIN messages AS m - ON m.id = ma.message_id - JOIN rooms AS r - ON r.id = m.room_id - WHERE ma.attachment_id = ? - AND r.is_active = 1 - """, - (attachment_id,), - ).fetchall() - channel_rows = db.execute( - """ - SELECT DISTINCT - m.channel_id - FROM message_attachments AS ma - JOIN messages AS m - ON m.id = ma.message_id - WHERE ma.attachment_id = ? - AND m.channel_id IS NOT NULL - """, - (attachment_id,), - ).fetchall() - dm_rows = db.execute( - """ - SELECT DISTINCT dc.id - FROM message_attachments AS ma - JOIN messages AS m - ON m.id = ma.message_id - JOIN direct_conversations AS dc - ON dc.id = m.direct_conversation_id - JOIN direct_conversation_members AS dcm - ON dcm.direct_conversation_id = dc.id - WHERE ma.attachment_id = ? - AND dcm.user_id = ? - """, - (attachment_id, user["id"]), - ).fetchall() - - if dm_rows: - return True - if any(can_access_room(room, user) for room in room_rows): - return True - for item in channel_rows: - channel = get_channel_by_id(item["channel_id"]) - if channel and can_access_channel(channel, user): - return True - return False - - -def send_attachment_file( - get_db, - upload_dir: Path, - attachment_id: int, - user: Optional[dict], - can_access_room, - can_access_channel, - get_channel_by_id, -): - with get_db() as db: - attachment = db.execute( - """ - SELECT - id, - stored_name, - original_name, - mime_type - FROM attachments - WHERE id = ? - """, - (attachment_id,), - ).fetchone() - if not attachment: - abort(404) - - if not can_download_attachment( - get_db, - attachment_id, - user, - can_access_room, - can_access_channel, - get_channel_by_id, - ): - abort(403) - - return send_from_directory( - upload_dir, - attachment["stored_name"], - as_attachment=True, - download_name=attachment["original_name"], - mimetype=attachment["mime_type"], - ) - - -def role_default_name_color(role: Optional[str]) -> str: - return DEFAULT_ROLE_NAME_COLORS.get(str(role or "user"), DEFAULT_ROLE_NAME_COLORS["user"]) - - -def resolve_name_color(user_role: Optional[str], explicit_color: Optional[str]) -> str: - return normalized_hex_color(explicit_color) or role_default_name_color(user_role) - - -def reaction_summary_for_message(get_db, message_id: int, user: Optional[dict]) -> list[dict]: - with get_db() as db: - rows = db.execute( - """ - SELECT - emoji, - COUNT(*) AS count, - MAX(CASE WHEN user_id = ? THEN 1 ELSE 0 END) AS reacted - FROM message_reactions - WHERE message_id = ? - GROUP BY emoji - ORDER BY count DESC, emoji ASC - """, - (user["id"] if user else -1, message_id), - ).fetchall() - return [ - { - "emoji": row["emoji"], - "count": row["count"], - "reacted": bool(row["reacted"]), - } - for row in rows - ] - - -def richer_message_payload( - get_db, - row, - user: Optional[dict], - current_user_id: Optional[int], -) -> dict: - with get_db() as db: - edited = db.execute( - """ - SELECT created_at - FROM message_edits - WHERE message_id = ? - ORDER BY id DESC - LIMIT 1 - """, - (row["id"],), - ).fetchone() - - receipt_count = db.execute( - """ - SELECT COUNT(*) AS count - FROM message_receipts - WHERE message_id = ? - """, - (row["id"],), - ).fetchone()["count"] - - seen_by_user = False - is_bookmarked = False - is_pinned = False - pin_row = None - - bookmark_table_exists = bool( - db.execute( - """ - SELECT 1 - FROM sqlite_master - WHERE type = 'table' - AND name = 'message_bookmarks' - """ - ).fetchone() - ) - pin_table_exists = bool( - db.execute( - """ - SELECT 1 - FROM sqlite_master - WHERE type = 'table' - AND name = 'message_pins' - """ - ).fetchone() - ) - - if current_user_id: - seen_by_user = bool( - db.execute( - """ - SELECT 1 - FROM message_receipts - WHERE message_id = ? - AND user_id = ? - """, - (row["id"], current_user_id), - ).fetchone() - ) - if bookmark_table_exists: - is_bookmarked = bool( - db.execute( - """ - SELECT 1 - FROM message_bookmarks - WHERE message_id = ? - AND user_id = ? - """, - (row["id"], current_user_id), - ).fetchone() - ) - - if pin_table_exists: - pin_row = db.execute( - """ - SELECT pinned_at, pinned_by_user_id - FROM message_pins - WHERE message_id = ? - """, - (row["id"],), - ).fetchone() - is_pinned = bool(pin_row) - - primary_attachment = attachment_payload_legacy_from_message_select_row(row) - attachments = attachment_list_for_message(get_db, row["id"]) - if primary_attachment and not any(item["id"] == primary_attachment["id"] for item in attachments): - attachments.insert(0, primary_attachment) - - user_role = row["user_role"] if "user_role" in row.keys() else "user" - user_name_color = row["user_name_color"] if "user_name_color" in row.keys() else None - resolved_color = resolve_name_color(user_role, user_name_color) - - return { - "id": row["id"], - "room_id": row["room_id"], - "channel_id": row["channel_id"], - "direct_conversation_id": row["direct_conversation_id"] if "direct_conversation_id" in row.keys() else None, - "user_id": row["user_id"], - "username": row["username"], - "user_role": user_role, - "user_name_color": resolved_color, - "text": row["text"], - "created_at": row["created_at"], - "updated_at": edited["created_at"] if edited else None, - "is_edited": bool(edited), - "parent_message_id": row["parent_message_id"], - "thread_reply_count": row["thread_reply_count"], - "attachment": primary_attachment, - "attachments": attachments, - "read_receipt_count": receipt_count, - "seen_by_current_user": seen_by_user, - "reactions": reaction_summary_for_message(get_db, row["id"], user), - "is_dm": bool(("direct_conversation_id" in row.keys()) and row["direct_conversation_id"]), - "is_pinned": is_pinned, - "pinned_at": pin_row["pinned_at"] if pin_row else None, - "pinned_by_user_id": pin_row["pinned_by_user_id"] if pin_row else None, - "is_bookmarked": is_bookmarked, - } - - -def feed_message_payload( - get_db, - row, - user: Optional[dict], -) -> dict: - payload = richer_message_payload( - get_db, - row, - user, - user["id"] if user else None, - ) - payload.update( - { - "room_slug": row["room_slug"] if "room_slug" in row.keys() else None, - "room_name": row["room_name"] if "room_name" in row.keys() else None, - "room_visibility": row["room_visibility"] if "room_visibility" in row.keys() else None, - "team_slug": row["team_slug"] if "team_slug" in row.keys() else None, - "team_name": row["team_name"] if "team_name" in row.keys() else None, - "channel_slug": row["channel_slug"] if "channel_slug" in row.keys() else None, - "channel_name": row["channel_name"] if "channel_name" in row.keys() else None, - "dm_id": row["direct_conversation_id"] if "direct_conversation_id" in row.keys() else None, - "is_mention": bool( - user and f"@{user['username'].lower()}" in payload["text"].lower() - ), - } - ) - return payload - - -def profile_payload( - html_module, - user_payload_func, - user_row, - profile_row, - settings_row, - viewer: Optional[dict], -) -> dict: - is_self = bool(viewer and viewer["id"] == user_row["id"]) - status_payload = user_payload_func(user_row, settings_row) - bio = profile_row["bio"] or "" - bio_mode = profile_row["bio_mode"] or "txt" - explicit_name_color = settings_row["name_color"] if settings_row else None - resolved = resolve_name_color(user_row["role"], explicit_name_color) - - profile_visibility = settings_row["profile_visibility"] if settings_row and "profile_visibility" in settings_row.keys() else "public" - if profile_visibility not in PROFILE_VISIBILITIES: - profile_visibility = "public" - - last_active_visibility = settings_row["last_active_visibility"] if settings_row and "last_active_visibility" in settings_row.keys() else "everyone" - if last_active_visibility not in LAST_ACTIVE_VISIBILITIES: - last_active_visibility = "everyone" - - presence_mode = settings_row["presence_mode"] if settings_row and "presence_mode" in settings_row.keys() else "online" - if presence_mode not in PRESENCE_MODES: - presence_mode = "online" - - dm_layout_mode = settings_row["dm_layout_mode"] if settings_row and "dm_layout_mode" in settings_row.keys() else "cards" - if dm_layout_mode not in DM_LAYOUT_MODES: - dm_layout_mode = "cards" - - show_presence_badge = bool( - settings_row["show_presence_badge"] if settings_row and "show_presence_badge" in settings_row.keys() else 1 - ) - - last_active_at = user_row["last_login_at"] if "last_login_at" in user_row.keys() else None - - can_show_last_active = False - if last_active_visibility == "everyone": - can_show_last_active = True - elif last_active_visibility == "members": - can_show_last_active = bool(viewer and (viewer["id"] == user_row["id"] or viewer["role"] in {"moderator", "admin"})) - elif last_active_visibility == "nobody": - can_show_last_active = bool(viewer and viewer["id"] == user_row["id"]) - - website = profile_row["website"] or "" - website_meta = detect_website_brand(website) if website else {"host": "", "brand": "web", "label": "website"} - - return { - "id": user_row["id"], - "username": user_row["username"], - "role": user_row["role"], - "status": status_payload["status"], - "presence_mode": presence_mode, - "created_at": user_row["created_at"], - "last_active_at": last_active_at if can_show_last_active else None, - "display_name": profile_row["display_name"] if profile_row["display_name"] else user_row["username"], - "badge_text": (profile_row["badge_text"] or "").strip() if "badge_text" in profile_row.keys() else "", - "bio": bio, - "bio_mode": bio_mode, - "bio_html": safe_profile_bio_html(html_module, bio, bio_mode), - "website": website, - "website_meta": website_meta, - "location": profile_row["location"] or "", - "status_message": profile_row["status_message"] if "status_message" in profile_row.keys() else "", - "banner_attachment_id": profile_row["banner_attachment_id"] if "banner_attachment_id" in profile_row.keys() else None, - "banner_image_url": f"/api/attachments/{profile_row['banner_attachment_id']}/download" if ("banner_attachment_id" in profile_row.keys() and profile_row["banner_attachment_id"]) else "", - "updated_at": profile_row["updated_at"], - "is_self": is_self, - "name_color": explicit_name_color, - "resolved_name_color": resolved, - "profile_background_color": settings_row["profile_background_color"] if settings_row and "profile_background_color" in settings_row.keys() else None, - "profile_banner_color": settings_row["profile_banner_color"] if settings_row and "profile_banner_color" in settings_row.keys() else None, - "profile_visibility": profile_visibility, - "last_active_visibility": last_active_visibility, - "show_presence_badge": show_presence_badge, - "dm_layout_mode": dm_layout_mode, - } - - -def list_dms_for_user(get_db, user_id: int) -> list[dict]: - with get_db() as db: - rows = db.execute( - """ - SELECT - dc.id, - dc.created_at, - other.id AS other_user_id, - other.username AS other_username, - other.role AS other_role, - us.name_color AS other_name_color, - up.display_name AS other_display_name, - up.status_message AS other_status_message, - us.presence_mode AS other_presence_mode, - ( - SELECT m.text - FROM messages AS m - WHERE m.direct_conversation_id = dc.id - ORDER BY m.id DESC - LIMIT 1 - ) AS last_message_text, - ( - SELECT m.created_at - FROM messages AS m - WHERE m.direct_conversation_id = dc.id - ORDER BY m.id DESC - LIMIT 1 - ) AS last_message_created_at, - ( - SELECT m.id - FROM messages AS m - WHERE m.direct_conversation_id = dc.id - ORDER BY m.id DESC - LIMIT 1 - ) AS last_message_id - FROM direct_conversations AS dc - JOIN direct_conversation_members AS self_member - ON self_member.direct_conversation_id = dc.id - JOIN direct_conversation_members AS other_member - ON other_member.direct_conversation_id = dc.id - AND other_member.user_id != self_member.user_id - JOIN users AS other - ON other.id = other_member.user_id - LEFT JOIN user_profiles AS up - ON up.user_id = other.id - LEFT JOIN user_settings AS us - ON us.user_id = other.id - WHERE self_member.user_id = ? - ORDER BY COALESCE(last_message_id, 0) DESC, dc.id DESC - """, - (user_id,), - ).fetchall() - - items = [] - for row in rows: - items.append( - { - "id": row["id"], - "created_at": row["created_at"], - "other_user": { - "id": row["other_user_id"], - "username": row["other_username"], - "display_name": row["other_display_name"] or row["other_username"], - "role": row["other_role"], - "name_color": row["other_name_color"], - "resolved_name_color": resolve_name_color(row["other_role"], row["other_name_color"]), - "status_message": row["other_status_message"] or "", - "presence_mode": row["other_presence_mode"] or "online", - }, - "last_message_text": row["last_message_text"], - "last_message_created_at": row["last_message_created_at"], - "last_message_id": row["last_message_id"], - "unread_count": 0, - } - ) - return items - - -def find_or_create_dm(get_db, db_write, now_iso, user_a_id: int, user_b_id: int) -> int: - low_id, high_id = sorted([int(user_a_id), int(user_b_id)]) - with get_db() as db: - existing = db.execute( - """ - SELECT dc.id - FROM direct_conversations AS dc - JOIN direct_conversation_members AS m1 - ON m1.direct_conversation_id = dc.id AND m1.user_id = ? - JOIN direct_conversation_members AS m2 - ON m2.direct_conversation_id = dc.id AND m2.user_id = ? - WHERE dc.member_pair_key = ? - LIMIT 1 - """, - (low_id, high_id, f"{low_id}:{high_id}"), - ).fetchone() - if existing: - return existing["id"] - - with get_db() as db: - db.execute("BEGIN IMMEDIATE") - existing = db.execute( - """ - SELECT id - FROM direct_conversations - WHERE member_pair_key = ? - LIMIT 1 - """, - (f"{low_id}:{high_id}",), - ).fetchone() - if existing: - db.commit() - return existing["id"] - - cursor = db.execute( - """ - INSERT INTO direct_conversations ( - member_pair_key, - created_at - ) VALUES (?, ?) - """, - (f"{low_id}:{high_id}", now_iso()), - ) - dm_id = cursor.lastrowid - db.execute( - """ - INSERT INTO direct_conversation_members ( - direct_conversation_id, - user_id, - added_at - ) VALUES (?, ?, ?) - """, - (dm_id, low_id, now_iso()), - ) - db.execute( - """ - INSERT INTO direct_conversation_members ( - direct_conversation_id, - user_id, - added_at - ) VALUES (?, ?, ?) - """, - (dm_id, high_id, now_iso()), - ) - db.commit() - return dm_id - - -def get_dm_for_user(get_db, dm_id: int, user_id: int): - with get_db() as db: - row = db.execute( - """ - SELECT - dc.id, - dc.created_at - FROM direct_conversations AS dc - JOIN direct_conversation_members AS dcm - ON dcm.direct_conversation_id = dc.id - WHERE dc.id = ? - AND dcm.user_id = ? - """, - (dm_id, user_id), - ).fetchone() - return row - - -def get_dm_other_user(get_db, dm_id: int, user_id: int): - with get_db() as db: - row = db.execute( - """ - SELECT - u.id, - u.username, - u.role, - up.display_name, - up.status_message, - us.name_color, - us.presence_mode - FROM direct_conversation_members AS dcm - JOIN users AS u - ON u.id = dcm.user_id - LEFT JOIN user_profiles AS up - ON up.user_id = u.id - LEFT JOIN user_settings AS us - ON us.user_id = u.id - WHERE dcm.direct_conversation_id = ? - AND dcm.user_id != ? - LIMIT 1 - """, - (dm_id, user_id), - ).fetchone() - return row - - -def validate_dm_access(get_db, dm_id: int, user: Optional[dict]) -> bool: - if not user: - return False - return bool(get_dm_for_user(get_db, dm_id, user["id"])) - - -def dm_payload(get_db, dm_id: int, user_id: int) -> dict: - dm = get_dm_for_user(get_db, dm_id, user_id) - other = get_dm_other_user(get_db, dm_id, user_id) - return { - "id": dm["id"], - "created_at": dm["created_at"], - "other_user": { - "id": other["id"], - "username": other["username"], - "display_name": other["display_name"] or other["username"], - "role": other["role"], - "name_color": other["name_color"], - "resolved_name_color": resolve_name_color(other["role"], other["name_color"]), - "status_message": other["status_message"] or "", - "presence_mode": other["presence_mode"] or "online", - }, - } - - -def update_presence( - db_write, - user_id: int, - view_name: Optional[str] = None, - room_id: Optional[int] = None, - channel_id: Optional[int] = None, - direct_conversation_id: Optional[int] = None, -) -> None: - db_write( - """ - INSERT INTO presence_events ( - user_id, - updated_at, - view_name, - room_id, - channel_id, - direct_conversation_id - ) VALUES (?, ?, ?, ?, ?, ?) - ON CONFLICT(user_id) - DO UPDATE SET - updated_at = excluded.updated_at, - view_name = excluded.view_name, - room_id = excluded.room_id, - channel_id = excluded.channel_id, - direct_conversation_id = excluded.direct_conversation_id - """, - ( - user_id, - time.time(), - view_name, - room_id, - channel_id, - direct_conversation_id, - ), - ) - - -def room_presence_count(get_db, ttl_seconds: int, room_id: int) -> int: - cutoff = time.time() - ttl_seconds - with get_db() as db: - row = db.execute( - """ - SELECT COUNT(*) AS count - FROM presence_events - WHERE room_id = ? - AND updated_at >= ? - """, - (room_id, cutoff), - ).fetchone() - return row["count"] if row else 0 - - -def channel_presence_count(get_db, ttl_seconds: int, channel_id: int) -> int: - cutoff = time.time() - ttl_seconds - with get_db() as db: - row = db.execute( - """ - SELECT COUNT(*) AS count - FROM presence_events - WHERE channel_id = ? - AND updated_at >= ? - """, - (channel_id, cutoff), - ).fetchone() - return row["count"] if row else 0 - - -def dm_presence_count(get_db, ttl_seconds: int, dm_id: int) -> int: - cutoff = time.time() - ttl_seconds - with get_db() as db: - row = db.execute( - """ - SELECT COUNT(*) AS count - FROM presence_events - WHERE direct_conversation_id = ? - AND updated_at >= ? - """, - (dm_id, cutoff), - ).fetchone() - return row["count"] if row else 0 - - -def create_call_session(db_write, now_iso, dm_id: int, started_by_user_id: int) -> int: - cursor = db_write( - """ - INSERT INTO call_sessions ( - direct_conversation_id, - started_by_user_id, - status, - created_at, - ended_at - ) VALUES (?, ?, 'active', ?, NULL) - """, - (dm_id, started_by_user_id, now_iso()), - ) - return cursor.lastrowid - - -def append_call_signal( - db_write, - now_iso, - call_id: int, - from_user_id: int, - signal_type: str, - payload: dict, -) -> int: - cursor = db_write( - """ - INSERT INTO call_signals ( - call_session_id, - from_user_id, - signal_type, - payload_json, - created_at - ) VALUES (?, ?, ?, ?, ?) - """, - ( - call_id, - from_user_id, - signal_type, - json.dumps(payload or {}), - now_iso(), - ), - ) - return cursor.lastrowid - - -def list_call_signals(get_db, call_id: int, after_id: int = 0) -> list[dict]: - with get_db() as db: - rows = db.execute( - """ - SELECT - cs.id, - cs.call_session_id, - cs.from_user_id, - u.username, - cs.signal_type, - cs.payload_json, - cs.created_at - FROM call_signals AS cs - JOIN users AS u - ON u.id = cs.from_user_id - WHERE cs.call_session_id = ? - AND cs.id > ? - ORDER BY cs.id ASC - LIMIT 200 - """, - (call_id, after_id), - ).fetchall() - - items = [] - for row in rows: - try: - payload = json.loads(row["payload_json"] or "{}") - except json.JSONDecodeError: - payload = {} - items.append( - { - "id": row["id"], - "call_session_id": row["call_session_id"], - "from_user_id": row["from_user_id"], - "username": row["username"], - "signal_type": row["signal_type"], - "payload": payload, - "created_at": row["created_at"], - } - ) - return items - - -def end_call(db_write, now_iso, call_id: int) -> None: - db_write( - """ - UPDATE call_sessions - SET - status = 'ended', - ended_at = ? - WHERE id = ? - """, - (now_iso(), call_id), - ) - - -def get_call_with_dm(get_db, call_id: int): - with get_db() as db: - row = db.execute( - """ - SELECT - cs.id, - cs.direct_conversation_id, - cs.started_by_user_id, - cs.status, - cs.created_at, - cs.ended_at - FROM call_sessions AS cs - WHERE cs.id = ? - """, - (call_id,), - ).fetchone() - return row - - -def call_payload(get_db, call_id: int) -> Optional[dict]: - row = get_call_with_dm(get_db, call_id) - if not row: - return None - return { - "id": row["id"], - "direct_conversation_id": row["direct_conversation_id"], - "started_by_user_id": row["started_by_user_id"], - "status": row["status"], - "created_at": row["created_at"], - "ended_at": row["ended_at"], - } - - -def dispatch_scheduled_messages(get_db, now_iso_func, app_logger) -> None: - if not sqlite_table_exists(get_db, "scheduled_messages"): - return - - now_value = now_iso_func() - with get_db() as db: - rows = db.execute( - """ - SELECT - id, - room_id, - channel_id, - direct_conversation_id, - user_id, - username, - text, - attachment_ids_json - FROM scheduled_messages - WHERE sent_at IS NULL - AND cancelled_at IS NULL - AND deliver_at <= ? - ORDER BY deliver_at ASC, id ASC - LIMIT 100 - """, - (now_value,), - ).fetchall() - - if not rows: - return - - db.execute("BEGIN IMMEDIATE") - for row in rows: - try: - attachment_ids = json.loads(row["attachment_ids_json"] or "[]") - except json.JSONDecodeError: - attachment_ids = [] - - attachment_id = attachment_ids[0] if attachment_ids else None - - cursor = db.execute( - """ - INSERT INTO messages ( - room_id, - channel_id, - direct_conversation_id, - user_id, - username, - text, - attachment_id, - parent_message_id, - created_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, NULL, ?) - """, - ( - row["room_id"], - row["channel_id"], - row["direct_conversation_id"], - row["user_id"], - row["username"], - row["text"], - attachment_id, - now_value, - ), - ) - message_id = cursor.lastrowid - - for position, extra_attachment_id in enumerate(attachment_ids): - db.execute( - """ - INSERT OR IGNORE INTO message_attachments ( - message_id, - attachment_id, - position - ) VALUES (?, ?, ?) - """, - (message_id, extra_attachment_id, position), - ) - - db.execute( - """ - UPDATE scheduled_messages - SET sent_at = ? - WHERE id = ? - """, - (now_value, row["id"]), - ) - db.commit() \ No newline at end of file diff --git a/models.py b/models.py new file mode 100644 index 0000000..ab093d1 --- /dev/null +++ b/models.py @@ -0,0 +1,642 @@ +import json +import re +import time +from typing import Optional + +from config import utc_now +from db import get_db +from auth import user_payload + +ROOM_SLUG_RE = re.compile(r"^[a-z0-9](?:[a-z0-9-]{0,46}[a-z0-9])?$") + + +MESSAGE_SELECT = """ + SELECT + m.id, + m.room_id, + m.channel_id, + m.direct_conversation_id, + m.user_id, + m.username, + m.text, + m.created_at, + m.attachment_id, + m.parent_message_id, + ( + SELECT COUNT(*) + FROM messages AS thread_reply + WHERE thread_reply.parent_message_id = m.id + ) AS thread_reply_count, + a.original_name AS attachment_original_name, + 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 + FROM messages AS m + JOIN users AS u + ON u.id = m.user_id + LEFT JOIN user_settings AS us + ON us.user_id = u.id + LEFT JOIN attachments AS a + ON a.id = m.attachment_id +""" + + +def team_page_select() -> str: + has_parent_page = False + try: + with get_db() as db: + rows = db.execute("PRAGMA table_info(pages)").fetchall() + has_parent_page = any(row["name"] == "parent_page_id" for row in rows) + except Exception: + has_parent_page = False + + if has_parent_page: + return """ + SELECT + p.id, + p.team_id, + p.channel_id, + p.parent_page_id, + parent.slug AS parent_page_slug, + parent.title AS parent_page_title, + p.slug, + p.title, + p.body, + p.visibility, + p.page_type, + p.created_by_user_id, + p.updated_by_user_id, + p.created_at, + p.updated_at, + p.is_active, + t.slug AS team_slug, + t.name AS team_name, + c.slug AS channel_slug, + c.name AS channel_name + FROM pages AS p + JOIN teams AS t + ON t.id = p.team_id + LEFT JOIN channels AS c + ON c.id = p.channel_id + LEFT JOIN pages AS parent + ON parent.id = p.parent_page_id + """ + + return """ + SELECT + p.id, + p.team_id, + p.channel_id, + NULL AS parent_page_id, + NULL AS parent_page_slug, + NULL AS parent_page_title, + p.slug, + p.title, + p.body, + p.visibility, + p.page_type, + p.created_by_user_id, + p.updated_by_user_id, + p.created_at, + p.updated_at, + p.is_active, + t.slug AS team_slug, + t.name AS team_name, + c.slug AS channel_slug, + c.name AS channel_name + FROM pages AS p + JOIN teams AS t + ON t.id = p.team_id + LEFT JOIN channels AS c + ON c.id = p.channel_id + """ + + +def team_slug_from_name(name: str) -> str: + return re.sub(r"[^a-z0-9-]+", "-", name.lower()).strip("-")[:48].strip("-") + + +def page_slug_from_title(title: str) -> str: + return re.sub(r"[^a-z0-9-]+", "-", title.lower()).strip("-")[:48].strip("-") + + +def room_by_id(room_id: int): + with get_db() as db: + return db.execute( + """ + SELECT + id, + slug, + name, + description, + visibility, + created_by_user_id, + is_active + FROM rooms + WHERE id = ? + AND is_active = 1 + """, + (room_id,), + ).fetchone() + + +def get_room_by_slug(slug: str): + with get_db() as db: + return db.execute( + """ + SELECT + id, + slug, + name, + description, + visibility, + created_by_user_id, + is_active + FROM rooms + WHERE slug = ? + AND is_active = 1 + """, + (slug,), + ).fetchone() + + +def room_member(room_id: int, user_id: int) -> bool: + with get_db() as db: + row = db.execute( + """ + SELECT 1 + FROM room_members + WHERE room_id = ? + AND user_id = ? + """, + (room_id, user_id), + ).fetchone() + return bool(row) + + +def room_owner(room_id: int, user_id: int) -> bool: + with get_db() as db: + row = db.execute( + """ + SELECT 1 + FROM room_members + WHERE room_id = ? + AND user_id = ? + AND membership_role = 'owner' + """, + (room_id, user_id), + ).fetchone() + return bool(row) + + +def can_access_room(room, user: Optional[dict]) -> bool: + if room["visibility"] == "public": + return True + if not user: + return False + if user["role"] in {"moderator", "admin"}: + return True + return room_member(room["id"], user["id"]) + + +def can_write_room(room, user: Optional[dict]) -> bool: + if not user or user["status"] != "active": + return False + return can_access_room(room, user) + + +def can_manage_room(room, user: Optional[dict]) -> bool: + if not user: + return False + if user["role"] == "admin": + return True + return room_owner(room["id"], user["id"]) + + +def room_payload(room, user: Optional[dict]) -> dict: + return { + "id": room["id"], + "slug": room["slug"], + "name": room["name"], + "description": room["description"], + "visibility": room["visibility"], + "can_read": can_access_room(room, user), + "can_write": can_write_room(room, user), + "can_manage": can_manage_room(room, user), + } + + +def get_team_by_slug(slug: str): + with get_db() as db: + return db.execute( + """ + SELECT + id, + slug, + name, + description, + created_by_user_id, + created_at, + is_active + FROM teams + WHERE slug = ? + AND is_active = 1 + """, + (slug,), + ).fetchone() + + +def get_channel_by_id(channel_id: int): + with get_db() as db: + return db.execute( + """ + SELECT + c.id, + c.team_id, + c.legacy_room_id, + c.slug, + c.name, + c.description, + c.created_by_user_id, + c.created_at, + c.is_active, + t.slug AS team_slug, + t.name AS team_name, + t.description AS team_description, + t.is_active AS team_is_active + FROM channels AS c + JOIN teams AS t + ON t.id = c.team_id + WHERE c.id = ? + AND c.is_active = 1 + AND t.is_active = 1 + """, + (channel_id,), + ).fetchone() + + +def get_channel_by_slug(team_slug: str, channel_slug: str): + with get_db() as db: + return db.execute( + """ + SELECT + c.id, + c.team_id, + c.legacy_room_id, + c.slug, + c.name, + c.description, + c.created_by_user_id, + c.created_at, + c.is_active, + t.slug AS team_slug, + t.name AS team_name, + t.description AS team_description, + t.is_active AS team_is_active + FROM channels AS c + JOIN teams AS t + ON t.id = c.team_id + WHERE t.slug = ? + AND c.slug = ? + AND t.is_active = 1 + AND c.is_active = 1 + """, + (team_slug, channel_slug), + ).fetchone() + + +def get_channel_by_legacy_room_id(room_id: int): + with get_db() as db: + return db.execute( + """ + SELECT + c.id, + c.team_id, + c.legacy_room_id, + c.slug, + c.name, + c.description, + c.created_by_user_id, + c.created_at, + c.is_active, + t.slug AS team_slug, + t.name AS team_name, + t.description AS team_description, + t.is_active AS team_is_active + FROM channels AS c + JOIN teams AS t + ON t.id = c.team_id + WHERE c.legacy_room_id = ? + AND c.is_active = 1 + AND t.is_active = 1 + """, + (room_id,), + ).fetchone() + + +def team_member_role(team_id: int, user_id: int) -> Optional[str]: + with get_db() as db: + row = db.execute( + """ + SELECT membership_role + FROM team_members + WHERE team_id = ? + AND user_id = ? + """, + (team_id, user_id), + ).fetchone() + return row["membership_role"] if row else None + + +def is_team_member(team_id: int, user_id: int) -> bool: + return team_member_role(team_id, user_id) is not None + + +def is_team_admin(team_id: int, user: Optional[dict]) -> bool: + if not user: + return False + if user["role"] == "admin": + return True + return team_member_role(team_id, user["id"]) in {"owner", "admin"} + + +def can_edit_team(team_id: int, user: Optional[dict]) -> bool: + return is_team_admin(team_id, user) + + +def can_access_team(team, user: Optional[dict]) -> bool: + if not team["is_active"]: + return False + if user and user["role"] in {"moderator", "admin"}: + return True + return bool(user and is_team_member(team["id"], user["id"])) + + +def legacy_room_for_channel(channel): + if not channel["legacy_room_id"]: + return None + return room_by_id(channel["legacy_room_id"]) + + +def can_access_channel(channel, user: Optional[dict]) -> bool: + if user and user["role"] in {"moderator", "admin"}: + return True + legacy_room = legacy_room_for_channel(channel) + if legacy_room: + return can_access_room(legacy_room, user) + return bool(user and is_team_member(channel["team_id"], user["id"])) + + +def can_write_channel(channel, user: Optional[dict]) -> bool: + return bool(user and user["status"] == "active" and can_access_channel(channel, user)) + + +def team_payload(team, user: Optional[dict]) -> dict: + membership_role = team_member_role(team["id"], user["id"]) if user else None + return { + "id": team["id"], + "slug": team["slug"], + "name": team["name"], + "description": team["description"], + "membership_role": membership_role, + "can_read": can_access_team(team, user), + "can_manage": is_team_admin(team["id"], user), + } + + +def channel_payload(channel, user: Optional[dict]) -> dict: + return { + "id": channel["id"], + "team_id": channel["team_id"], + "team_slug": channel["team_slug"], + "team_name": channel["team_name"], + "legacy_room_id": channel["legacy_room_id"], + "slug": channel["slug"], + "name": channel["name"], + "description": channel["description"], + "can_read": can_access_channel(channel, user), + "can_write": can_write_channel(channel, user), + "can_manage": is_team_admin(channel["team_id"], user), + } + + +def get_dm_for_user(dm_id: int, user_id: int): + with get_db() as db: + row = db.execute( + """ + SELECT + dc.id, + dc.created_at + FROM direct_conversations AS dc + JOIN direct_conversation_members AS dcm + ON dcm.direct_conversation_id = dc.id + WHERE dc.id = ? + AND dcm.user_id = ? + """, + (dm_id, user_id), + ).fetchone() + return row + + +def get_dm_other_user(dm_id: int, user_id: int): + with get_db() as db: + row = db.execute( + """ + SELECT + u.id, + u.username, + u.role, + up.display_name, + up.status_message, + us.name_color, + us.presence_mode + FROM direct_conversation_members AS dcm + JOIN users AS u + ON u.id = dcm.user_id + LEFT JOIN user_profiles AS up + ON up.user_id = u.id + LEFT JOIN user_settings AS us + ON us.user_id = u.id + WHERE dcm.direct_conversation_id = ? + AND dcm.user_id != ? + LIMIT 1 + """, + (dm_id, user_id), + ).fetchone() + return row + + +def validate_dm_access(dm_id: int, user: Optional[dict]) -> bool: + if not user: + return False + return bool(get_dm_for_user(dm_id, user["id"])) + + +def dm_payload(dm_id: int, user_id: int, resolve_name_color) -> dict: + dm = get_dm_for_user(dm_id, user_id) + other = get_dm_other_user(dm_id, user_id) + return { + "id": dm["id"], + "created_at": dm["created_at"], + "other_user": { + "id": other["id"], + "username": other["username"], + "display_name": other["display_name"] or other["username"], + "role": other["role"], + "name_color": other["name_color"], + "resolved_name_color": resolve_name_color(other["role"], other["name_color"]), + "status_message": other["status_message"] or "", + "presence_mode": other["presence_mode"] or "online", + }, + } + + +def find_or_create_dm(user_a_id: int, user_b_id: int, now_iso_func) -> int: + low_id, high_id = sorted([int(user_a_id), int(user_b_id)]) + with get_db() as db: + existing = db.execute( + """ + SELECT dc.id + FROM direct_conversations AS dc + JOIN direct_conversation_members AS m1 + ON m1.direct_conversation_id = dc.id AND m1.user_id = ? + JOIN direct_conversation_members AS m2 + ON m2.direct_conversation_id = dc.id AND m2.user_id = ? + WHERE dc.member_pair_key = ? + LIMIT 1 + """, + (low_id, high_id, f"{low_id}:{high_id}"), + ).fetchone() + if existing: + return existing["id"] + + with get_db() as db: + db.execute("BEGIN IMMEDIATE") + existing = db.execute( + """ + SELECT id + FROM direct_conversations + WHERE member_pair_key = ? + LIMIT 1 + """, + (f"{low_id}:{high_id}",), + ).fetchone() + if existing: + db.commit() + return existing["id"] + + cursor = db.execute( + """ + INSERT INTO direct_conversations ( + member_pair_key, + created_at + ) VALUES (?, ?) + """, + (f"{low_id}:{high_id}", now_iso_func()), + ) + dm_id = cursor.lastrowid + db.execute( + """ + INSERT INTO direct_conversation_members ( + direct_conversation_id, + user_id, + added_at + ) VALUES (?, ?, ?) + """, + (dm_id, low_id, now_iso_func()), + ) + db.execute( + """ + INSERT INTO direct_conversation_members ( + direct_conversation_id, + user_id, + added_at + ) VALUES (?, ?, ?) + """, + (dm_id, high_id, now_iso_func()), + ) + db.commit() + return dm_id + + +def update_presence( + db_write_func, + user_id: int, + view_name: Optional[str] = None, + room_id: Optional[int] = None, + channel_id: Optional[int] = None, + direct_conversation_id: Optional[int] = None, +) -> None: + db_write_func( + """ + INSERT INTO presence_events ( + user_id, + updated_at, + view_name, + room_id, + channel_id, + direct_conversation_id + ) VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT(user_id) + DO UPDATE SET + updated_at = excluded.updated_at, + view_name = excluded.view_name, + room_id = excluded.room_id, + channel_id = excluded.channel_id, + direct_conversation_id = excluded.direct_conversation_id + """, + ( + user_id, + time.time(), + view_name, + room_id, + channel_id, + direct_conversation_id, + ), + ) + + +def room_presence_count(ttl_seconds: int, room_id: int) -> int: + cutoff = time.time() - ttl_seconds + with get_db() as db: + row = db.execute( + """ + SELECT COUNT(*) AS count + FROM presence_events + WHERE room_id = ? + AND updated_at >= ? + """, + (room_id, cutoff), + ).fetchone() + return row["count"] if row else 0 + + +def channel_presence_count(ttl_seconds: int, channel_id: int) -> int: + cutoff = time.time() - ttl_seconds + with get_db() as db: + row = db.execute( + """ + SELECT COUNT(*) AS count + FROM presence_events + WHERE channel_id = ? + AND updated_at >= ? + """, + (channel_id, cutoff), + ).fetchone() + return row["count"] if row else 0 + + +def dm_presence_count(ttl_seconds: int, dm_id: int) -> int: + cutoff = time.time() - ttl_seconds + with get_db() as db: + row = db.execute( + """ + SELECT COUNT(*) AS count + FROM presence_events + WHERE direct_conversation_id = ? + AND updated_at >= ? + """, + (dm_id, cutoff), + ).fetchone() + return row["count"] if row else 0 diff --git a/profiles.py b/profiles.py new file mode 100644 index 0000000..0b1bc45 --- /dev/null +++ b/profiles.py @@ -0,0 +1,658 @@ +import html +import json +import re +import time +from typing import Optional + +import markdown + +from sanitizer import sanitize_html_fragment +from db import get_db + +DEFAULT_ROLE_NAME_COLORS = { + "admin": "#ff7b7b", + "moderator": "#8ab4ff", + "user": "#f5f5f7", +} + +PROFILE_BIO_MODES = {"txt", "md", "html"} +THEME_MODES = {"dark", "light"} +PAGE_VISIBILITIES = {"team", "public"} +PAGE_TYPES = {"project", "document", "update", "resource"} +TEAM_MEMBER_ROLES = {"owner", "admin", "member"} +PROFILE_VISIBILITIES = {"public", "members", "private"} +LAST_ACTIVE_VISIBILITIES = {"everyone", "members", "nobody"} +PRESENCE_MODES = {"online", "silent", "focus", "dnd"} +DM_LAYOUT_MODES = {"cards", "compact"} +REACTION_EMOJI_RE = re.compile(r"^.{1,16}$") +HEX_COLOR_RE = re.compile(r"^#[0-9a-fA-F]{6}$") +PAGE_LINK_RE = re.compile(r"\]\((?:/teams/[^/]+/pages/)?([a-z0-9-]{1,48})\)", re.IGNORECASE) + + +def valid_hex_color(value: object) -> bool: + return bool(isinstance(value, str) and HEX_COLOR_RE.fullmatch(value.strip())) + + +def normalized_hex_color(value: object) -> Optional[str]: + if not valid_hex_color(value): + return None + return str(value).strip().lower() + + +def role_default_name_color(role: Optional[str]) -> str: + return DEFAULT_ROLE_NAME_COLORS.get(str(role or "user"), DEFAULT_ROLE_NAME_COLORS["user"]) + + +def resolve_name_color(user_role: Optional[str], explicit_color: Optional[str]) -> str: + return normalized_hex_color(explicit_color) or role_default_name_color(user_role) + + +def render_markdown_to_safe_html(text: str) -> str: + rendered = markdown.markdown( + text or "", + extensions=[ + "extra", + "sane_lists", + "nl2br", + "tables", + "fenced_code", + ], + output_format="html5", + ) + return sanitize_html_fragment(rendered) + + +def safe_profile_bio_html(bio: str, bio_mode: str) -> str: + mode = bio_mode if bio_mode in PROFILE_BIO_MODES else "txt" + if mode == "html": + return sanitize_html_fragment(bio or "") + if mode == "md": + return render_markdown_to_safe_html(bio or "") + return "

" + html.escape(bio or "").replace("\n", "
") + "

" + + +def extract_page_reference_slugs(markdown_text: str) -> list[str]: + text = str(markdown_text or "") + matches = PAGE_LINK_RE.findall(text) + seen = set() + slugs = [] + for slug in matches: + normalized = slug.lower().strip() + if not normalized or normalized in seen: + continue + seen.add(normalized) + slugs.append(normalized) + return slugs + + +def detect_website_brand(website: str) -> dict: + value = str(website or "").strip().lower() + if not value: + return {"host": "", "brand": "web", "label": "website"} + + host = value + host = host.replace("https://", "").replace("http://", "") + host = host.split("/", 1)[0] + host = host.removeprefix("www.") + + mappings = [ + ("github.com", "github", "GitHub"), + ("youtube.com", "youtube", "YouTube"), + ("youtu.be", "youtube", "YouTube"), + ("spotify.com", "spotify", "Spotify"), + ("soundcloud.com", "soundcloud", "SoundCloud"), + ("twitter.com", "x", "X"), + ("x.com", "x", "X"), + ] + + for suffix, brand, label in mappings: + if host.endswith(suffix): + return {"host": host, "brand": brand, "label": label} + + return {"host": host, "brand": "web", "label": "website"} + + +def sqlite_table_exists(table_name: str) -> bool: + with get_db() as db: + row = db.execute( + """ + SELECT 1 + FROM sqlite_master + WHERE type = 'table' + AND name = ? + """, + (table_name,), + ).fetchone() + return bool(row) + + +def sqlite_column_exists(table_name: str, column_name: str) -> bool: + with get_db() as db: + rows = db.execute(f"PRAGMA table_info({table_name})").fetchall() + return any(row["name"] == column_name for row in rows) + + +def load_page_children(page_id: int) -> list[dict]: + if not sqlite_column_exists("pages", "parent_page_id"): + return [] + + with get_db() as db: + rows = db.execute( + """ + SELECT + id, + slug, + title, + updated_at + FROM pages + WHERE parent_page_id = ? + AND is_active = 1 + ORDER BY title COLLATE NOCASE, id ASC + """, + (page_id,), + ).fetchall() + return [dict(row) for row in rows] + + +def load_page_backlinks(page_id: int, page_slug: str, team_id: int) -> list[dict]: + pattern_1 = f"%]({page_slug})%" + pattern_2 = f"%/pages/{page_slug})%" + with get_db() as db: + rows = db.execute( + """ + SELECT + id, + slug, + title, + updated_at + FROM pages + WHERE team_id = ? + AND is_active = 1 + AND id != ? + AND (body LIKE ? OR body LIKE ?) + ORDER BY updated_at DESC, id DESC + """, + (team_id, page_id, pattern_1, pattern_2), + ).fetchall() + return [dict(row) for row in rows] + + +def load_page_comments(page_id: int) -> list[dict]: + if not sqlite_table_exists("page_comments"): + return [] + + with get_db() as db: + rows = 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.page_id = ? + AND pc.deleted_at IS NULL + ORDER BY pc.created_at ASC, pc.id ASC + """, + (page_id,), + ).fetchall() + return [ + { + "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": resolve_name_color(row["user_role"], row["user_name_color"]), + } + for row in rows + ] + + +def reaction_summary_for_message(message_id: int, user: Optional[dict]) -> list[dict]: + with get_db() as db: + rows = db.execute( + """ + SELECT + emoji, + COUNT(*) AS count, + MAX(CASE WHEN user_id = ? THEN 1 ELSE 0 END) AS reacted + FROM message_reactions + WHERE message_id = ? + GROUP BY emoji + ORDER BY count DESC, emoji ASC + """, + (user["id"] if user else -1, message_id), + ).fetchall() + return [ + { + "emoji": row["emoji"], + "count": row["count"], + "reacted": bool(row["reacted"]), + } + for row in rows + ] + + +def build_profile_privacy_context(viewer: Optional[dict], target_user_id: int) -> dict: + is_self = bool(viewer and viewer["id"] == target_user_id) + is_staff = bool(viewer and viewer["role"] in {"moderator", "admin"}) + is_authenticated = bool(viewer) + return { + "is_self": is_self, + "is_staff": is_staff, + "is_authenticated": is_authenticated, + } + + +def can_view_profile_record(settings_row, viewer: Optional[dict], target_user_id: int) -> bool: + visibility = "public" + if settings_row and "profile_visibility" in settings_row.keys(): + visibility = (settings_row["profile_visibility"] or "public").lower() + if visibility not in PROFILE_VISIBILITIES: + visibility = "public" + + ctx = build_profile_privacy_context(viewer, target_user_id) + + if visibility == "public": + return True + if visibility == "members": + return ctx["is_authenticated"] or ctx["is_staff"] or ctx["is_self"] + if visibility == "private": + return ctx["is_self"] or ctx["is_staff"] + return True + + +def can_view_last_active(settings_row, viewer: Optional[dict], target_user_id: int) -> bool: + visibility = "everyone" + if settings_row and "last_active_visibility" in settings_row.keys(): + visibility = (settings_row["last_active_visibility"] or "everyone").lower() + if visibility not in LAST_ACTIVE_VISIBILITIES: + visibility = "everyone" + + ctx = build_profile_privacy_context(viewer, target_user_id) + + if visibility == "everyone": + return True + if visibility == "members": + return ctx["is_authenticated"] or ctx["is_staff"] or ctx["is_self"] + if visibility == "nobody": + return ctx["is_self"] or ctx["is_staff"] + return False + + +def profile_presence_badge_visible(settings_row, viewer: Optional[dict], target_user_id: int) -> bool: + ctx = build_profile_privacy_context(viewer, target_user_id) + if ctx["is_self"] or ctx["is_staff"]: + return True + if settings_row and "show_presence_badge" in settings_row.keys(): + return bool(settings_row["show_presence_badge"]) + return True + + +def profile_payload(user_payload_func, user_row, profile_row, settings_row, viewer: Optional[dict]) -> dict: + is_self = bool(viewer and viewer["id"] == user_row["id"]) + status_payload = user_payload_func(user_row, settings_row) + bio = profile_row["bio"] or "" + bio_mode = profile_row["bio_mode"] or "txt" + explicit_name_color = settings_row["name_color"] if settings_row else None + resolved = resolve_name_color(user_row["role"], explicit_name_color) + + profile_visibility = settings_row["profile_visibility"] if settings_row and "profile_visibility" in settings_row.keys() else "public" + if profile_visibility not in PROFILE_VISIBILITIES: + profile_visibility = "public" + + last_active_visibility = settings_row["last_active_visibility"] if settings_row and "last_active_visibility" in settings_row.keys() else "everyone" + if last_active_visibility not in LAST_ACTIVE_VISIBILITIES: + last_active_visibility = "everyone" + + presence_mode = settings_row["presence_mode"] if settings_row and "presence_mode" in settings_row.keys() else "online" + if presence_mode not in PRESENCE_MODES: + presence_mode = "online" + + dm_layout_mode = settings_row["dm_layout_mode"] if settings_row and "dm_layout_mode" in settings_row.keys() else "cards" + if dm_layout_mode not in DM_LAYOUT_MODES: + dm_layout_mode = "cards" + + show_presence_badge = profile_presence_badge_visible(settings_row, viewer, user_row["id"]) + + last_active_at = user_row["last_login_at"] if "last_login_at" in user_row.keys() else None + if not can_view_last_active(settings_row, viewer, user_row["id"]): + last_active_at = None + + website = profile_row["website"] or "" + website_meta = detect_website_brand(website) if website else {"host": "", "brand": "web", "label": "website"} + + return { + "id": user_row["id"], + "username": user_row["username"], + "role": user_row["role"], + "status": status_payload["status"], + "presence_mode": presence_mode, + "created_at": user_row["created_at"], + "last_active_at": last_active_at, + "display_name": profile_row["display_name"] if profile_row["display_name"] else user_row["username"], + "badge_text": (profile_row["badge_text"] or "").strip() if "badge_text" in profile_row.keys() else "", + "bio": bio, + "bio_mode": bio_mode, + "bio_html": safe_profile_bio_html(bio, bio_mode), + "website": website, + "website_meta": website_meta, + "location": profile_row["location"] or "", + "status_message": profile_row["status_message"] if "status_message" in profile_row.keys() else "", + "banner_attachment_id": profile_row["banner_attachment_id"] if "banner_attachment_id" in profile_row.keys() else None, + "banner_image_url": f"/api/attachments/{profile_row['banner_attachment_id']}/download" if ("banner_attachment_id" in profile_row.keys() and profile_row["banner_attachment_id"]) else "", + "updated_at": profile_row["updated_at"], + "is_self": is_self, + "name_color": explicit_name_color, + "resolved_name_color": resolved, + "profile_background_color": settings_row["profile_background_color"] if settings_row and "profile_background_color" in settings_row.keys() else None, + "profile_banner_color": settings_row["profile_banner_color"] if settings_row and "profile_banner_color" in settings_row.keys() else None, + "profile_visibility": profile_visibility, + "last_active_visibility": last_active_visibility, + "show_presence_badge": show_presence_badge, + "dm_layout_mode": dm_layout_mode, + } + + +def page_payload(page, user: Optional[dict], is_team_member_func, is_team_admin_func) -> dict: + can_read = bool( + page["visibility"] == "public" + or ( + user + and ( + user["role"] in {"moderator", "admin"} + or is_team_member_func(page["team_id"], user["id"]) + ) + ) + ) + rendered_html = render_markdown_to_safe_html(page["body"] or "") + parent_page_id = page["parent_page_id"] if "parent_page_id" in page.keys() else None + parent_page_slug = page["parent_page_slug"] if "parent_page_slug" in page.keys() else None + parent_page_title = page["parent_page_title"] if "parent_page_title" in page.keys() else None + return { + "id": page["id"], + "team_id": page["team_id"], + "team_slug": page["team_slug"], + "team_name": page["team_name"], + "channel_id": page["channel_id"], + "channel_slug": page["channel_slug"], + "channel_name": page["channel_name"], + "parent_page_id": parent_page_id, + "parent_page_slug": parent_page_slug, + "parent_page_title": parent_page_title, + "slug": page["slug"], + "title": page["title"], + "body": page["body"], + "body_html": rendered_html, + "visibility": page["visibility"], + "page_type": page["page_type"], + "created_by_user_id": page["created_by_user_id"], + "updated_by_user_id": page["updated_by_user_id"], + "created_at": page["created_at"], + "updated_at": page["updated_at"], + "can_read": can_read, + "can_edit": bool( + user + and ( + user["role"] in {"moderator", "admin"} + or is_team_admin_func(page["team_id"], user) + ) + ), + "children": load_page_children(page["id"]), + "backlinks": load_page_backlinks( + page["id"], + page["slug"], + page["team_id"], + ), + "references": extract_page_reference_slugs(page["body"] or ""), + "comments": load_page_comments(page["id"]), + } + + +def list_dms_for_user(user_id: int) -> list[dict]: + with get_db() as db: + rows = db.execute( + """ + SELECT + dc.id, + dc.created_at, + other.id AS other_user_id, + other.username AS other_username, + other.role AS other_role, + us.name_color AS other_name_color, + up.display_name AS other_display_name, + up.status_message AS other_status_message, + us.presence_mode AS other_presence_mode, + ( + SELECT m.text + FROM messages AS m + WHERE m.direct_conversation_id = dc.id + ORDER BY m.id DESC + LIMIT 1 + ) AS last_message_text, + ( + SELECT m.created_at + FROM messages AS m + WHERE m.direct_conversation_id = dc.id + ORDER BY m.id DESC + LIMIT 1 + ) AS last_message_created_at, + ( + SELECT m.id + FROM messages AS m + WHERE m.direct_conversation_id = dc.id + ORDER BY m.id DESC + LIMIT 1 + ) AS last_message_id, + ( + SELECT COUNT(*) + FROM messages AS m2 + WHERE m2.direct_conversation_id = dc.id + AND m2.user_id != ? + AND NOT EXISTS ( + SELECT 1 + FROM message_receipts AS mr + WHERE mr.message_id = m2.id + AND mr.user_id = ? + ) + ) AS unread_count + FROM direct_conversations AS dc + JOIN direct_conversation_members AS self_member + ON self_member.direct_conversation_id = dc.id + JOIN direct_conversation_members AS other_member + ON other_member.direct_conversation_id = dc.id + AND other_member.user_id != self_member.user_id + JOIN users AS other + ON other.id = other_member.user_id + LEFT JOIN user_profiles AS up + ON up.user_id = other.id + LEFT JOIN user_settings AS us + ON us.user_id = other.id + WHERE self_member.user_id = ? + ORDER BY COALESCE(last_message_id, 0) DESC, dc.id DESC + """, + (user_id, user_id, user_id), + ).fetchall() + + items = [] + for row in rows: + items.append( + { + "id": row["id"], + "created_at": row["created_at"], + "other_user": { + "id": row["other_user_id"], + "username": row["other_username"], + "display_name": row["other_display_name"] or row["other_username"], + "role": row["other_role"], + "name_color": row["other_name_color"], + "resolved_name_color": resolve_name_color(row["other_role"], row["other_name_color"]), + "status_message": row["other_status_message"] or "", + "presence_mode": row["other_presence_mode"] or "online", + }, + "last_message_text": row["last_message_text"], + "last_message_created_at": row["last_message_created_at"], + "last_message_id": row["last_message_id"], + "unread_count": int(row["unread_count"] or 0), + } + ) + return items + + +def richer_message_payload(row, user: Optional[dict], attachment_payload_legacy_from_message_select_row, attachment_list_for_message) -> dict: + with get_db() as db: + edited = db.execute( + """ + SELECT created_at + FROM message_edits + WHERE message_id = ? + ORDER BY id DESC + LIMIT 1 + """, + (row["id"],), + ).fetchone() + + receipt_count = db.execute( + """ + SELECT COUNT(*) AS count + FROM message_receipts + WHERE message_id = ? + """, + (row["id"],), + ).fetchone()["count"] + + seen_by_user = False + is_bookmarked = False + is_pinned = False + pin_row = None + + bookmark_table_exists = bool( + db.execute( + """ + SELECT 1 + FROM sqlite_master + WHERE type = 'table' + AND name = 'message_bookmarks' + """ + ).fetchone() + ) + pin_table_exists = bool( + db.execute( + """ + SELECT 1 + FROM sqlite_master + WHERE type = 'table' + AND name = 'message_pins' + """ + ).fetchone() + ) + + if user: + seen_by_user = bool( + db.execute( + """ + SELECT 1 + FROM message_receipts + WHERE message_id = ? + AND user_id = ? + """, + (row["id"], user["id"]), + ).fetchone() + ) + if bookmark_table_exists: + is_bookmarked = bool( + db.execute( + """ + SELECT 1 + FROM message_bookmarks + WHERE message_id = ? + AND user_id = ? + """, + (row["id"], user["id"]), + ).fetchone() + ) + + if pin_table_exists: + pin_row = db.execute( + """ + SELECT pinned_at, pinned_by_user_id + FROM message_pins + WHERE message_id = ? + """, + (row["id"],), + ).fetchone() + is_pinned = bool(pin_row) + + primary_attachment = attachment_payload_legacy_from_message_select_row(row) + attachments = attachment_list_for_message(row["id"]) + if primary_attachment and not any(item["id"] == primary_attachment["id"] for item in attachments): + attachments.insert(0, primary_attachment) + + user_role = row["user_role"] if "user_role" in row.keys() else "user" + user_name_color = row["user_name_color"] if "user_name_color" in row.keys() else None + resolved_color = resolve_name_color(user_role, user_name_color) + + return { + "id": row["id"], + "room_id": row["room_id"], + "channel_id": row["channel_id"], + "direct_conversation_id": row["direct_conversation_id"] if "direct_conversation_id" in row.keys() else None, + "user_id": row["user_id"], + "username": row["username"], + "user_role": user_role, + "user_name_color": resolved_color, + "text": row["text"], + "created_at": row["created_at"], + "updated_at": edited["created_at"] if edited else None, + "is_edited": bool(edited), + "parent_message_id": row["parent_message_id"], + "thread_reply_count": row["thread_reply_count"], + "attachment": primary_attachment, + "attachments": attachments, + "read_receipt_count": receipt_count, + "seen_by_current_user": seen_by_user, + "reactions": reaction_summary_for_message(row["id"], user), + "is_dm": bool(("direct_conversation_id" in row.keys()) and row["direct_conversation_id"]), + "is_pinned": is_pinned, + "pinned_at": pin_row["pinned_at"] if pin_row else None, + "pinned_by_user_id": pin_row["pinned_by_user_id"] if pin_row else None, + "is_bookmarked": is_bookmarked, + } + + +def feed_message_payload(row, user: Optional[dict], attachment_payload_legacy_from_message_select_row, attachment_list_for_message) -> dict: + payload = richer_message_payload( + row, + user, + attachment_payload_legacy_from_message_select_row, + attachment_list_for_message, + ) + payload.update( + { + "room_slug": row["room_slug"] if "room_slug" in row.keys() else None, + "room_name": row["room_name"] if "room_name" in row.keys() else None, + "room_visibility": row["room_visibility"] if "room_visibility" in row.keys() else None, + "team_slug": row["team_slug"] if "team_slug" in row.keys() else None, + "team_name": row["team_name"] if "team_name" in row.keys() else None, + "channel_slug": row["channel_slug"] if "channel_slug" in row.keys() else None, + "channel_name": row["channel_name"] if "channel_name" in row.keys() else None, + "dm_id": row["direct_conversation_id"] if "direct_conversation_id" in row.keys() else None, + "is_mention": bool( + user and f"@{user['username'].lower()}" in payload["text"].lower() + ), + } + ) + return payload diff --git a/routes.py b/routes.py index 0c1b0f4..bc7a596 100644 --- a/routes.py +++ b/routes.py @@ -1,4 +1,3 @@ -# routes.py import json import re import sqlite3 @@ -6,7 +5,76 @@ import time from flask import jsonify, request -from features import ( +from config import ( + CALLS_BASE, + CALL_HELPER_SHARED_SECRET, + CALL_TOKEN_TTL_SECONDS, + CONSTANTS, + PRIVATE_ACCESS_KEY, + UPLOAD_DIR, + now_iso, + parse_iso, + utc_now, +) +from db import get_db, db_write +from auth import ( + safe_json, + csrf_valid, + rate_limited, + current_user, + require_user, + require_writable_user, + require_moderator, + require_admin, + normalize_username, + audit, + hash_identifier, + sign_call_helper_token, + user_payload, +) +from models import ( + ROOM_SLUG_RE, + MESSAGE_SELECT, + team_page_select, + team_slug_from_name, + page_slug_from_title, + room_by_id, + get_room_by_slug, + can_access_room, + can_write_room, + can_manage_room, + room_payload, + get_team_by_slug, + get_channel_by_id, + get_channel_by_slug, + get_channel_by_legacy_room_id, + team_member_role, + is_team_member, + is_team_admin, + can_edit_team, + can_access_team, + can_access_channel, + can_write_channel, + team_payload, + channel_payload, + get_dm_for_user, + get_dm_other_user, + validate_dm_access, + dm_payload, + find_or_create_dm, + update_presence, + room_presence_count, + channel_presence_count, + dm_presence_count, +) +from attachments import ( + attachment_list_for_message, + attachment_payload_legacy_from_message_select_row, + resolve_owned_attachment, + resolve_owned_attachment_ids, + send_attachment_file, +) +from profiles import ( DM_LAYOUT_MODES, LAST_ACTIVE_VISIBILITIES, PAGE_TYPES, @@ -17,518 +85,169 @@ from features import ( REACTION_EMOJI_RE, TEAM_MEMBER_ROLES, THEME_MODES, - append_call_signal, - call_payload, - channel_presence_count, - dm_payload, - dm_presence_count, - end_call, - feed_message_payload, - find_or_create_dm, - get_call_with_dm, - get_dm_for_user, - list_call_signals, - list_dms_for_user, normalized_hex_color, + valid_hex_color, + resolve_name_color, page_payload, profile_payload, reaction_summary_for_message, - resolve_owned_attachment, - resolve_owned_attachment_ids, + list_dms_for_user, richer_message_payload, - room_presence_count, - send_attachment_file, - update_presence, - validate_dm_access, - valid_hex_color, + feed_message_payload, + can_view_profile_record, +) +from calls import ( + append_call_signal, + call_payload, + create_call_session, + end_call, + get_call_with_dm, + list_call_signals, + build_room_helper_call_id, + build_channel_helper_call_id, + get_or_create_group_call_session, + group_call_session_payload, + get_group_call_session_by_id, + upsert_group_call_participant, + heartbeat_group_call_participant, + mark_group_call_participant_left, + list_group_call_participants, + maybe_end_group_call_if_empty, ) -ROOM_SLUG_RE = re.compile(r"^[a-z0-9](?:[a-z0-9-]{0,46}[a-z0-9])?$") +MAX_MESSAGE_LENGTH = CONSTANTS["MAX_MESSAGE_LENGTH"] +MAX_HISTORY_MESSAGES = CONSTANTS["MAX_HISTORY_MESSAGES"] +MAX_INCREMENTAL_MESSAGES = CONSTANTS["MAX_INCREMENTAL_MESSAGES"] +MAX_FEED_MESSAGES = CONSTANTS["MAX_FEED_MESSAGES"] +MAX_THREAD_REPLIES = CONSTANTS["MAX_THREAD_REPLIES"] +MAX_ATTACHMENT_BYTES = CONSTANTS["MAX_ATTACHMENT_BYTES"] +MAX_ATTACHMENT_NAME_LENGTH = CONSTANTS["MAX_ATTACHMENT_NAME_LENGTH"] +ALLOWED_ATTACHMENT_EXTENSIONS = CONSTANTS["ALLOWED_ATTACHMENT_EXTENSIONS"] +ALLOWED_ATTACHMENT_MIME_TYPES = CONSTANTS["ALLOWED_ATTACHMENT_MIME_TYPES"] +INVITE_TTL_HOURS = CONSTANTS["INVITE_TTL_HOURS"] +TEAM_INVITE_TTL_HOURS = CONSTANTS["TEAM_INVITE_TTL_HOURS"] +TYPING_TTL_SECONDS = CONSTANTS["TYPING_TTL_SECONDS"] +PRESENCE_TTL_SECONDS = CONSTANTS["PRESENCE_TTL_SECONDS"] +MAX_SEARCH_QUERY_LENGTH = CONSTANTS["MAX_SEARCH_QUERY_LENGTH"] +MAX_SEARCH_RESULTS = CONSTANTS["MAX_SEARCH_RESULTS"] +READ_RECEIPT_BATCH_LIMIT = CONSTANTS["READ_RECEIPT_BATCH_LIMIT"] +MAX_MESSAGE_EDIT_WINDOW_SECONDS = CONSTANTS["MAX_MESSAGE_EDIT_WINDOW_SECONDS"] +MAX_DISPLAY_NAME_LENGTH = CONSTANTS["MAX_DISPLAY_NAME_LENGTH"] +MAX_PROFILE_BIO_LENGTH = CONSTANTS["MAX_PROFILE_BIO_LENGTH"] +MAX_LOCATION_LENGTH = CONSTANTS["MAX_LOCATION_LENGTH"] +MAX_WEBSITE_LENGTH = CONSTANTS["MAX_WEBSITE_LENGTH"] +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"] +GROUP_CALL_PARTICIPANT_STALE_SECONDS = CONSTANTS["GROUP_CALL_PARTICIPANT_STALE_SECONDS"] +ROOM_VISIBILITIES = CONSTANTS["ROOM_VISIBILITIES"] +ROLES = CONSTANTS["ROLES"] +STATUSES = CONSTANTS["STATUSES"] -def register_routes(app, ctx): - get_db = ctx["get_db"] - db_write = ctx["db_write"] - safe_json = ctx["safe_json"] - csrf_valid = ctx["csrf_valid"] - rate_limited = ctx["rate_limited"] - current_user = ctx["current_user"] - require_user = ctx["require_user"] - require_writable_user = ctx["require_writable_user"] - require_moderator = ctx["require_moderator"] - require_admin = ctx["require_admin"] - now_iso = ctx["now_iso"] - utc_now = ctx["utc_now"] - parse_iso = ctx["parse_iso"] - normalize_username = ctx["normalize_username"] - audit = ctx["audit"] - hash_identifier = ctx["hash_identifier"] - sign_call_helper_token = ctx["sign_call_helper_token"] - CALLS_BASE = ctx["CALLS_BASE"] - CALL_TOKEN_TTL_SECONDS = ctx["CALL_TOKEN_TTL_SECONDS"] - CALL_HELPER_SHARED_SECRET = ctx["CALL_HELPER_SHARED_SECRET"] - room_by_id = ctx["room_by_id"] - get_room_by_slug = ctx["get_room_by_slug"] - can_access_room = ctx["can_access_room"] - can_write_room = ctx["can_write_room"] - can_manage_room = ctx["can_manage_room"] - room_payload = ctx["room_payload"] - team_slug_from_name = ctx["team_slug_from_name"] - page_slug_from_title = ctx["page_slug_from_title"] - get_team_by_slug = ctx["get_team_by_slug"] - get_channel_by_id = ctx["get_channel_by_id"] - get_channel_by_slug = ctx["get_channel_by_slug"] - get_channel_by_legacy_room_id = ctx["get_channel_by_legacy_room_id"] - team_member_role = ctx["team_member_role"] - is_team_member = ctx["is_team_member"] - is_team_admin = ctx["is_team_admin"] - can_edit_team = ctx["can_edit_team"] - can_access_team = ctx["can_access_team"] - can_access_channel = ctx["can_access_channel"] - can_write_channel = ctx["can_write_channel"] - team_payload = ctx["team_payload"] - channel_payload = ctx["channel_payload"] - user_payload = ctx["user_payload"] - get_dm_other_user = ctx["get_dm_other_user"] - MESSAGE_SELECT = ctx["MESSAGE_SELECT"] - team_page_select = ctx["team_page_select"] - constants = ctx["constants"] +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 - MAX_MESSAGE_LENGTH = constants["MAX_MESSAGE_LENGTH"] - MAX_HISTORY_MESSAGES = constants["MAX_HISTORY_MESSAGES"] - MAX_INCREMENTAL_MESSAGES = constants["MAX_INCREMENTAL_MESSAGES"] - MAX_FEED_MESSAGES = constants["MAX_FEED_MESSAGES"] - MAX_THREAD_REPLIES = constants["MAX_THREAD_REPLIES"] - TYPING_TTL_SECONDS = constants["TYPING_TTL_SECONDS"] - PRESENCE_TTL_SECONDS = constants["PRESENCE_TTL_SECONDS"] - INVITE_TTL_HOURS = constants["INVITE_TTL_HOURS"] - TEAM_INVITE_TTL_HOURS = constants["TEAM_INVITE_TTL_HOURS"] - MAX_SEARCH_QUERY_LENGTH = constants["MAX_SEARCH_QUERY_LENGTH"] - MAX_SEARCH_RESULTS = constants["MAX_SEARCH_RESULTS"] - READ_RECEIPT_BATCH_LIMIT = constants["READ_RECEIPT_BATCH_LIMIT"] - MAX_MESSAGE_EDIT_WINDOW_SECONDS = constants["MAX_MESSAGE_EDIT_WINDOW_SECONDS"] - MAX_DISPLAY_NAME_LENGTH = constants["MAX_DISPLAY_NAME_LENGTH"] - MAX_PROFILE_BIO_LENGTH = constants["MAX_PROFILE_BIO_LENGTH"] - MAX_WEBSITE_LENGTH = constants["MAX_WEBSITE_LENGTH"] - MAX_LOCATION_LENGTH = constants["MAX_LOCATION_LENGTH"] - 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"] - GROUP_CALL_PARTICIPANT_STALE_SECONDS = constants["GROUP_CALL_PARTICIPANT_STALE_SECONDS"] - ROOM_VISIBILITIES = constants["ROOM_VISIBILITIES"] - ROLES = constants["ROLES"] - STATUSES = constants["STATUSES"] - PRIVATE_ACCESS_KEY = ctx["PRIVATE_ACCESS_KEY"] +def build_message_payload(row, user): + return richer_message_payload( + row, + user, + attachment_payload_legacy_from_message_select_row, + attachment_list_for_message, + ) - 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_feed_payload(row, user): + return feed_message_payload( + row, + user, + attachment_payload_legacy_from_message_select_row, + attachment_list_for_message, + ) - def build_message_payload(row, user): - return richer_message_payload( - get_db, - row, - user, - user["id"] if user else None, - ) - def build_local_stub_share_url(kind: str, object_id: int) -> str: - return f"/shared/{kind}/{object_id}" +def build_local_stub_share_url(kind: str, object_id: int) -> str: + return f"/shared/{kind}/{object_id}" - def build_room_helper_call_id(room) -> str: - return f"room-{room['slug']}" - - def build_channel_helper_call_id(channel) -> str: - return f"channel-{channel['team_slug']}--{channel['slug']}" - - def get_or_create_group_call_session( - scope_kind: str, - scope_id: int, - scope_slug: str, - helper_call_id: str, - created_by_user_id: int, - ): - with get_db() as db: - existing = db.execute( - """ - SELECT - id, - scope_kind, - scope_id, - scope_slug, - helper_call_id, - created_by_user_id, - status, - created_at, - ended_at - FROM group_call_sessions - WHERE helper_call_id = ? - LIMIT 1 - """, - (helper_call_id,), - ).fetchone() - - if existing and existing["status"] == "active": - return existing - - with get_db() as db: - db.execute("BEGIN IMMEDIATE") - existing = db.execute( - """ - SELECT - id, - scope_kind, - scope_id, - scope_slug, - helper_call_id, - created_by_user_id, - status, - created_at, - ended_at - FROM group_call_sessions - WHERE helper_call_id = ? - LIMIT 1 - """, - (helper_call_id,), - ).fetchone() - - if existing and existing["status"] == "active": - db.commit() - return existing - - if existing: - db.execute( - """ - UPDATE group_call_sessions - SET - status = 'active', - ended_at = NULL, - created_at = ? - WHERE id = ? - """, - (now_iso(), existing["id"]), - ) - db.commit() - refreshed = db.execute( - """ - SELECT - id, - scope_kind, - scope_id, - scope_slug, - helper_call_id, - created_by_user_id, - status, - created_at, - ended_at - FROM group_call_sessions - WHERE id = ? - """, - (existing["id"],), - ).fetchone() - return refreshed - - cursor = db.execute( - """ - INSERT INTO group_call_sessions ( - scope_kind, - scope_id, - scope_slug, - helper_call_id, - created_by_user_id, - status, - created_at, - ended_at - ) VALUES (?, ?, ?, ?, ?, 'active', ?, NULL) - """, - ( - scope_kind, - scope_id, - scope_slug, - helper_call_id, - created_by_user_id, - now_iso(), - ), - ) - session_id = cursor.lastrowid - db.commit() - created = db.execute( - """ - SELECT - id, - scope_kind, - scope_id, - scope_slug, - helper_call_id, - created_by_user_id, - status, - created_at, - ended_at - FROM group_call_sessions - WHERE id = ? - """, - (session_id,), - ).fetchone() - return created - - def group_call_session_payload(row): - if not row: - return None - return { - "id": row["id"], - "scope_kind": row["scope_kind"], - "scope_id": row["scope_id"], - "scope_slug": row["scope_slug"], - "helper_call_id": row["helper_call_id"], - "created_by_user_id": row["created_by_user_id"], - "status": row["status"], - "created_at": row["created_at"], - "ended_at": row["ended_at"], - } - - def get_group_call_session_by_id(session_id: int): - with get_db() as db: - return db.execute( - """ - SELECT - id, - scope_kind, - scope_id, - scope_slug, - helper_call_id, - created_by_user_id, - status, - created_at, - ended_at - FROM group_call_sessions - WHERE id = ? - LIMIT 1 - """, - (session_id,), - ).fetchone() - - def can_access_group_call_session(session_row, user: dict) -> bool: - if not session_row or not user: - return False - - if session_row["scope_kind"] == "room": - room = room_by_id(session_row["scope_id"]) - return bool(room and can_access_room(room, user)) - - if session_row["scope_kind"] == "channel": - channel = get_channel_by_id(session_row["scope_id"]) - return bool(channel and can_access_channel(channel, user)) +def can_access_group_call_session(session_row, user: dict) -> bool: + if not session_row or not user: return False - def can_manage_group_call_session(session_row, user: dict) -> bool: - if not session_row or not user: - return False + if session_row["scope_kind"] == "room": + room = room_by_id(session_row["scope_id"]) + return bool(room and can_access_room(room, user)) - if user["role"] in {"moderator", "admin"}: - return True + if session_row["scope_kind"] == "channel": + channel = get_channel_by_id(session_row["scope_id"]) + return bool(channel and can_access_channel(channel, user)) - if int(session_row["created_by_user_id"]) == int(user["id"]): - return True + return False - if session_row["scope_kind"] == "room": - room = room_by_id(session_row["scope_id"]) - return bool(room and can_manage_room(room, user)) - - if session_row["scope_kind"] == "channel": - channel = get_channel_by_id(session_row["scope_id"]) - return bool(channel and can_edit_team(channel["team_id"], user)) +def can_manage_group_call_session(session_row, user: dict) -> bool: + if not session_row or not user: return False - def cleanup_stale_group_call_participants(session_id: int): - cutoff = utc_now().timestamp() - GROUP_CALL_PARTICIPANT_STALE_SECONDS - db_write( - """ - UPDATE group_call_participants - SET - left_at = COALESCE(left_at, ?) - WHERE group_call_session_id = ? - AND left_at IS NULL - AND ( - updated_at IS NULL - OR strftime('%s', updated_at) < ? - ) - """, - (now_iso(), session_id, int(cutoff)), - ) + if user["role"] in {"moderator", "admin"}: + return True - def upsert_group_call_participant(session_id: int, user_id: int): - db_write( - """ - INSERT INTO group_call_participants ( - group_call_session_id, - user_id, - joined_at, - updated_at, - left_at - ) VALUES (?, ?, ?, ?, NULL) - ON CONFLICT(group_call_session_id, user_id) - DO UPDATE SET - updated_at = excluded.updated_at, - left_at = NULL - """, - (session_id, user_id, now_iso(), now_iso()), - ) + if int(session_row["created_by_user_id"]) == int(user["id"]): + return True - def heartbeat_group_call_participant(session_id: int, user_id: int): - db_write( - """ - UPDATE group_call_participants - SET - updated_at = ?, - left_at = NULL - WHERE group_call_session_id = ? - AND user_id = ? - """, - (now_iso(), session_id, user_id), - ) + if session_row["scope_kind"] == "room": + room = room_by_id(session_row["scope_id"]) + return bool(room and can_manage_room(room, user)) - def mark_group_call_participant_left(session_id: int, user_id: int): - db_write( - """ - UPDATE group_call_participants - SET - updated_at = ?, - left_at = ? - WHERE group_call_session_id = ? - AND user_id = ? - AND left_at IS NULL - """, - (now_iso(), now_iso(), session_id, user_id), - ) + if session_row["scope_kind"] == "channel": + channel = get_channel_by_id(session_row["scope_id"]) + return bool(channel and can_edit_team(channel["team_id"], user)) - def list_group_call_participants(session_id: int) -> list[dict]: - cleanup_stale_group_call_participants(session_id) + return False - with get_db() as db: - rows = db.execute( - """ - SELECT - gcp.group_call_session_id, - gcp.user_id, - gcp.joined_at, - gcp.updated_at, - gcp.left_at, - u.username, - u.role, - up.display_name, - us.name_color - FROM group_call_participants AS gcp - JOIN users AS u - ON u.id = gcp.user_id - LEFT JOIN user_profiles AS up - ON up.user_id = u.id - LEFT JOIN user_settings AS us - ON us.user_id = u.id - WHERE gcp.group_call_session_id = ? - AND gcp.left_at IS NULL - ORDER BY gcp.joined_at ASC, u.username COLLATE NOCASE - """, - (session_id,), - ).fetchall() - return [ - { - "user_id": row["user_id"], - "username": row["username"], - "display_name": row["display_name"] or row["username"], - "role": row["role"], - "name_color": row["name_color"], - "joined_at": row["joined_at"], - "updated_at": row["updated_at"], +def group_call_scope_payload(session_row, user): + payload = { + "scope_kind": session_row["scope_kind"], + "scope_id": session_row["scope_id"], + "scope_slug": session_row["scope_slug"], + } + + if session_row["scope_kind"] == "room": + room = room_by_id(session_row["scope_id"]) + if room: + payload["room"] = room_payload(room, user) + payload["scope_label"] = room["name"] + elif session_row["scope_kind"] == "channel": + channel = get_channel_by_id(session_row["scope_id"]) + if channel: + payload["channel"] = channel_payload(channel, user) + payload["team"] = { + "slug": channel["team_slug"], + "name": channel["team_name"], } - for row in rows - ] + payload["scope_label"] = f"# {channel['name']}" - def active_group_call_participant_count(session_id: int) -> int: - cleanup_stale_group_call_participants(session_id) + return payload - with get_db() as db: - row = db.execute( - """ - SELECT COUNT(*) AS count - FROM group_call_participants - WHERE group_call_session_id = ? - AND left_at IS NULL - """, - (session_id,), - ).fetchone() - return int(row["count"]) if row else 0 - def maybe_end_group_call_if_empty(session_id: int): - if active_group_call_participant_count(session_id) > 0: - return +def group_call_permissions_payload(session_row, user): + can_manage = can_manage_group_call_session(session_row, user) + can_access = can_access_group_call_session(session_row, user) + return { + "can_manage": can_manage, + "can_end": can_manage, + "can_invite": can_access, + } - db_write( - """ - UPDATE group_call_sessions - SET - status = 'ended', - ended_at = ? - WHERE id = ? - AND status != 'ended' - """, - (now_iso(), session_id), - ) - - db_write( - """ - UPDATE group_call_invites - SET - status = 'dismissed', - responded_at = ? - WHERE group_call_session_id = ? - AND status = 'pending' - """, - (now_iso(), session_id), - ) - - def group_call_scope_payload(session_row, user): - payload = { - "scope_kind": session_row["scope_kind"], - "scope_id": session_row["scope_id"], - "scope_slug": session_row["scope_slug"], - } - - if session_row["scope_kind"] == "room": - room = room_by_id(session_row["scope_id"]) - if room: - payload["room"] = room_payload(room, user) - payload["scope_label"] = room["name"] - elif session_row["scope_kind"] == "channel": - channel = get_channel_by_id(session_row["scope_id"]) - if channel: - payload["channel"] = channel_payload(channel, user) - payload["team"] = { - "slug": channel["team_slug"], - "name": channel["team_name"], - } - payload["scope_label"] = f"# {channel['name']}" - - return payload - - def group_call_permissions_payload(session_row, user): - can_manage = can_manage_group_call_session(session_row, user) - can_access = can_access_group_call_session(session_row, user) - return { - "can_manage": can_manage, - "can_end": can_manage, - "can_invite": can_access, - } +def register_routes(app): @app.route("/api/attachments", methods=["POST"]) def upload_attachment(): if not csrf_valid(): @@ -542,42 +261,42 @@ def register_routes(app, ctx): if limited: return limited - ctx["maybe_cleanup_orphaned_attachments"]() + app.maybe_cleanup_orphaned_attachments() uploaded = request.files.get("attachment") if not uploaded or not uploaded.filename: return jsonify({"error": "Choose a file to upload."}), 400 - original_name = ctx["secure_filename"](uploaded.filename)[: constants["MAX_ATTACHMENT_NAME_LENGTH"]] + original_name = app.secure_filename(uploaded.filename)[:MAX_ATTACHMENT_NAME_LENGTH] if not original_name or "." not in original_name: return jsonify({"error": "Attachment must have an allowed file extension."}), 400 extension = original_name.rsplit(".", 1)[1].lower() - if extension not in constants["ALLOWED_ATTACHMENT_EXTENSIONS"]: + if extension not in ALLOWED_ATTACHMENT_EXTENSIONS: return jsonify({"error": "This file type is not allowed."}), 400 declared_mime = (uploaded.mimetype or "").lower() - guessed_mime = ctx["mimetypes"].guess_type(original_name)[0] or "" + guessed_mime = app.mimetypes.guess_type(original_name)[0] or "" - if declared_mime and declared_mime not in constants["ALLOWED_ATTACHMENT_MIME_TYPES"]: + if declared_mime and declared_mime not in 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"]: + if guessed_mime and guessed_mime not in ALLOWED_ATTACHMENT_MIME_TYPES: return jsonify({"error": "This file extension is not allowed."}), 400 - content = uploaded.stream.read(constants["MAX_ATTACHMENT_BYTES"] + 1) + content = uploaded.stream.read(MAX_ATTACHMENT_BYTES + 1) if not content: return jsonify({"error": "Attachment cannot be empty."}), 400 - if len(content) > constants["MAX_ATTACHMENT_BYTES"]: + if len(content) > 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 + stored_name = f"{app.secrets.token_urlsafe(24)}.{extension}" + destination = UPLOAD_DIR / stored_name try: destination.write_bytes(content) - ctx["os"].chmod(destination, 0o600) + app.os.chmod(destination, 0o600) except OSError: app.logger.exception("Could not save attachment.") return jsonify({"error": "Could not save attachment."}), 500 @@ -623,8 +342,7 @@ def register_routes(app, ctx): return error return send_attachment_file( - get_db, - ctx["UPLOAD_DIR"], + UPLOAD_DIR, attachment_id, user, can_access_room, @@ -727,7 +445,6 @@ def register_routes(app, ctx): return jsonify({"error": "Message is too long."}), 400 attachment_ids, attachment_error = resolve_owned_attachment_ids( - get_db, data.get("attachment_ids", []), user["id"], ) @@ -736,7 +453,6 @@ def register_routes(app, ctx): if not attachment_ids and data.get("attachment_id") is not None: single_attachment_id, single_attachment_error = resolve_owned_attachment( - get_db, data.get("attachment_id"), user["id"], ) @@ -927,7 +643,6 @@ def register_routes(app, ctx): return jsonify({"error": "Message is too long."}), 400 attachment_ids, attachment_error = resolve_owned_attachment_ids( - get_db, data.get("attachment_ids", []), user["id"], ) @@ -936,7 +651,6 @@ def register_routes(app, ctx): if not attachment_ids and data.get("attachment_id") is not None: single_attachment_id, single_attachment_error = resolve_owned_attachment( - get_db, data.get("attachment_id"), user["id"], ) @@ -1022,7 +736,6 @@ def register_routes(app, ctx): new_text = new_text.strip() attachment_ids, attachment_error = resolve_owned_attachment_ids( - get_db, data.get("attachment_ids", []), user["id"], ) @@ -1052,7 +765,7 @@ def register_routes(app, ctx): if age > MAX_MESSAGE_EDIT_WINDOW_SECONDS: return jsonify({"error": "Edit window has expired."}), 403 - old_attachments = ctx["attachment_list_for_message"](message_id) + old_attachments = attachment_list_for_message(message_id) with get_db() as db: db.execute("BEGIN IMMEDIATE") @@ -1190,7 +903,7 @@ def register_routes(app, ctx): except ValueError: return jsonify({"error": "dm_id is required for DM history."}), 400 - if not validate_dm_access(get_db, dm_id, user): + if not validate_dm_access(dm_id, user): return jsonify({"error": "DM not found."}), 404 with get_db() as db: @@ -1233,7 +946,7 @@ def register_routes(app, ctx): return jsonify({"error": "Thread root message not found."}), 404 if root["direct_conversation_id"]: - if not validate_dm_access(get_db, root["direct_conversation_id"], user): + if not validate_dm_access(root["direct_conversation_id"], user): if not user: return jsonify({"error": "Login required.", "code": "login_required"}), 401 return jsonify({"error": "You do not have access to this thread.", "code": "forbidden"}), 403 @@ -1266,7 +979,7 @@ def register_routes(app, ctx): } if root["direct_conversation_id"]: - payload["dm"] = dm_payload(get_db, root["direct_conversation_id"], user["id"]) + payload["dm"] = dm_payload(root["direct_conversation_id"], user["id"], resolve_name_color) else: channel = ( get_channel_by_id(root["channel_id"]) @@ -1310,7 +1023,7 @@ def register_routes(app, ctx): return jsonify({"error": "Thread root message not found."}), 404 if root["direct_conversation_id"]: - if not validate_dm_access(get_db, root["direct_conversation_id"], user): + if not validate_dm_access(root["direct_conversation_id"], user): return jsonify({"error": "You cannot reply in this thread.", "code": "forbidden"}), 403 else: channel = None @@ -1335,7 +1048,6 @@ def register_routes(app, ctx): return jsonify({"error": "Reply is too long."}), 400 attachment_ids, attachment_error = resolve_owned_attachment_ids( - get_db, data.get("attachment_ids", []), user["id"], ) @@ -1344,7 +1056,6 @@ def register_routes(app, ctx): if not attachment_ids and data.get("attachment_id") is not None: single_attachment_id, single_attachment_error = resolve_owned_attachment( - get_db, data.get("attachment_id"), user["id"], ) @@ -1551,7 +1262,7 @@ def register_routes(app, ctx): 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] + entries = [build_feed_payload(row, user) for row in rows] latest_id = entries[-1]["id"] if entries else after_id return jsonify( { @@ -1577,7 +1288,7 @@ def register_routes(app, ctx): 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] + entries = [build_feed_payload(row, user) for row in rows] latest_id = entries[-1]["id"] if entries else 0 return jsonify({"entries": entries, "latest_id": latest_id, "has_more": False}) @@ -1649,7 +1360,7 @@ def register_routes(app, ctx): for row in rows: allowed = False if row["direct_conversation_id"]: - allowed = bool(user and validate_dm_access(get_db, row["direct_conversation_id"], user)) + allowed = bool(user and validate_dm_access(row["direct_conversation_id"], user)) elif row["channel_id"]: channel = get_channel_by_id(row["channel_id"]) allowed = bool(channel and can_access_channel(channel, user)) @@ -1658,7 +1369,7 @@ def register_routes(app, ctx): allowed = bool(room and can_access_room(room, user)) if allowed: - visible.append(feed_message_payload(get_db, row, user)) + visible.append(build_feed_payload(row, user)) return jsonify({"query": query, "results": visible}) @@ -1709,7 +1420,7 @@ def register_routes(app, ctx): allowed = False if row["direct_conversation_id"]: - allowed = validate_dm_access(get_db, row["direct_conversation_id"], user) + allowed = validate_dm_access(row["direct_conversation_id"], user) elif row["channel_id"]: channel = get_channel_by_id(row["channel_id"]) allowed = bool(channel and can_access_channel(channel, user)) @@ -1759,7 +1470,7 @@ def register_routes(app, ctx): allowed = False if message["direct_conversation_id"]: - allowed = validate_dm_access(get_db, message["direct_conversation_id"], user) + allowed = validate_dm_access(message["direct_conversation_id"], user) elif message["channel_id"]: channel = get_channel_by_id(message["channel_id"]) allowed = bool(channel and can_access_channel(channel, user)) @@ -1823,7 +1534,7 @@ def register_routes(app, ctx): allowed = False if row["direct_conversation_id"]: - allowed = bool(user and validate_dm_access(get_db, row["direct_conversation_id"], user)) + allowed = bool(user and validate_dm_access(row["direct_conversation_id"], user)) elif row["channel_id"]: channel = get_channel_by_id(row["channel_id"]) allowed = bool(channel and can_access_channel(channel, user)) @@ -1918,7 +1629,7 @@ def register_routes(app, ctx): allowed = False if row["direct_conversation_id"]: - allowed = validate_dm_access(get_db, row["direct_conversation_id"], user) + allowed = validate_dm_access(row["direct_conversation_id"], user) elif row["channel_id"]: channel = get_channel_by_id(row["channel_id"]) allowed = bool(channel and can_access_channel(channel, user)) @@ -1945,7 +1656,7 @@ def register_routes(app, ctx): return jsonify( { "message_id": message_id, - "reactions": reaction_summary_for_message(get_db, message_id, user), + "reactions": reaction_summary_for_message(message_id, user), } ), 201 @@ -1976,7 +1687,7 @@ def register_routes(app, ctx): return jsonify( { "message_id": message_id, - "reactions": reaction_summary_for_message(get_db, message_id, user), + "reactions": reaction_summary_for_message(message_id, user), } ) @@ -2106,7 +1817,7 @@ def register_routes(app, ctx): return jsonify( { "room_id": room["id"], - "online_count": room_presence_count(get_db, PRESENCE_TTL_SECONDS, room["id"]), + "online_count": room_presence_count(PRESENCE_TTL_SECONDS, room["id"]), } ) @@ -2119,19 +1830,19 @@ def register_routes(app, ctx): return jsonify( { "channel_id": channel["id"], - "online_count": channel_presence_count(get_db, PRESENCE_TTL_SECONDS, channel["id"]), + "online_count": channel_presence_count(PRESENCE_TTL_SECONDS, channel["id"]), } ) @app.route("/api/dms//presence") def api_dm_presence(dm_id: int): user = current_user() - if not validate_dm_access(get_db, dm_id, user): + if not validate_dm_access(dm_id, user): return jsonify({"error": "DM not found."}), 404 return jsonify( { "dm_id": dm_id, - "online_count": dm_presence_count(get_db, PRESENCE_TTL_SECONDS, dm_id), + "online_count": dm_presence_count(PRESENCE_TTL_SECONDS, dm_id), } ) @@ -2305,7 +2016,7 @@ def register_routes(app, ctx): if limited: return limited - if not validate_dm_access(get_db, dm_id, user): + if not validate_dm_access(dm_id, user): return jsonify({"error": "DM not found."}), 404 try: @@ -2343,7 +2054,7 @@ def register_routes(app, ctx): @app.route("/api/dms//typing") def api_get_dm_typing(dm_id: int): user = current_user() - if not validate_dm_access(get_db, dm_id, user): + if not validate_dm_access(dm_id, user): return jsonify({"error": "DM not found."}), 404 cutoff = time.time() - TYPING_TTL_SECONDS @@ -2737,8 +2448,8 @@ def register_routes(app, ctx): 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() + token = app.secrets.token_urlsafe(32) + expires_at = (utc_now() + app.timedelta(hours=INVITE_TTL_HOURS)).isoformat() db_write( """ @@ -3702,8 +3413,8 @@ def register_routes(app, ctx): if membership_role not in {"member", "admin"}: return jsonify({"error": "Invite role must be member or admin."}), 400 - token = ctx["secrets"].token_urlsafe(32) - expires_at = (utc_now() + ctx["timedelta"](hours=TEAM_INVITE_TTL_HOURS)).isoformat() + token = app.secrets.token_urlsafe(32) + expires_at = (utc_now() + app.timedelta(hours=TEAM_INVITE_TTL_HOURS)).isoformat() db_write( """ @@ -3849,7 +3560,12 @@ def register_routes(app, ctx): 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(page, user, is_team_member, is_team_admin) for page in rows], + } + ) @app.route("/api/teams//pages", methods=["POST"]) def api_create_page(): @@ -3948,7 +3664,7 @@ def register_routes(app, ctx): 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(page, user, is_team_member, is_team_admin)}), 201 @app.route("/api/teams//pages/") def api_get_page(team_slug: str, page_slug: str): @@ -3979,7 +3695,7 @@ def register_routes(app, ctx): 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)}) + return jsonify({"page": page_payload(page, user, is_team_member, is_team_admin)}) @app.route("/api/teams//pages/", methods=["PATCH"]) def api_update_page(team_slug: str, page_slug: str): @@ -4059,7 +3775,7 @@ def register_routes(app, ctx): 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)}) + return jsonify({"page": page_payload(updated, user, is_team_member, is_team_admin)}) @app.route("/api/teams//pages/", methods=["DELETE"]) def api_delete_page(team_slug: str, page_slug: str): @@ -4299,7 +4015,7 @@ def register_routes(app, ctx): (user["id"],), ).fetchone() - return jsonify({"profile": profile_payload(html_module, user_payload, user_row, profile_row, settings_row, user)}) + return jsonify({"profile": profile_payload(user_payload, user_row, profile_row, settings_row, user)}) @app.route("/api/profile", methods=["PATCH"]) def api_update_my_profile(): @@ -4449,11 +4165,12 @@ def register_routes(app, ctx): ).fetchone() audit("profile_updated", user["id"], "user", str(user["id"])) - return jsonify({"profile": profile_payload(html_module, user_payload, user_row, profile_row, settings_row, user)}) + return jsonify({"profile": profile_payload(user_payload, user_row, profile_row, settings_row, user)}) @app.route("/api/profiles/") def api_get_public_profile(username: str): normalized = normalize_username(username) + viewer = current_user() with get_db() as db: user_row = db.execute( @@ -4511,7 +4228,12 @@ def register_routes(app, ctx): (user_row["id"],), ).fetchone() - return jsonify({"profile": profile_payload(html_module, user_payload, user_row, profile_row, settings_row, current_user())}) + if not can_view_profile_record(settings_row, viewer, user_row["id"]): + if not viewer: + return jsonify({"error": "Login required.", "code": "login_required"}), 401 + return jsonify({"error": "You do not have access to this profile.", "code": "forbidden"}), 403 + + return jsonify({"profile": profile_payload(user_payload, user_row, profile_row, settings_row, viewer)}) @app.route("/api/settings") def api_get_settings(): @@ -4724,7 +4446,7 @@ def register_routes(app, ctx): user, error = require_user() if error: return error - return jsonify({"dms": list_dms_for_user(get_db, user["id"])}) + return jsonify({"dms": list_dms_for_user(user["id"])}) @app.route("/api/dms", methods=["POST"]) def api_create_or_open_dm(): @@ -4775,9 +4497,9 @@ def register_routes(app, ctx): if target["status"] in {"disabled", "blacklisted"}: return jsonify({"error": "User not available."}), 400 - dm_id = find_or_create_dm(get_db, db_write, now_iso, user["id"], target["id"]) + dm_id = find_or_create_dm(user["id"], target["id"], now_iso) audit("dm_opened", user["id"], "dm", str(dm_id), str(target["id"])) - return jsonify({"dm": dm_payload(get_db, dm_id, user["id"])}), 201 + return jsonify({"dm": dm_payload(dm_id, user["id"], resolve_name_color)}), 201 @app.route("/api/dms//messages") def api_get_dm_messages(dm_id: int): @@ -4785,7 +4507,7 @@ def register_routes(app, ctx): if error: return error - if not validate_dm_access(get_db, dm_id, user): + if not validate_dm_access(dm_id, user): return jsonify({"error": "DM not found."}), 404 after_raw = request.args.get("after_id", "").strip() @@ -4830,7 +4552,7 @@ def register_routes(app, ctx): return jsonify( { - "dm": dm_payload(get_db, dm_id, user["id"]), + "dm": dm_payload(dm_id, user["id"], resolve_name_color), "messages": messages, "latest_id": latest_id, "has_more": len(messages) == MAX_INCREMENTAL_MESSAGES, @@ -4850,7 +4572,7 @@ def register_routes(app, ctx): if error: return error - if not validate_dm_access(get_db, dm_id, user): + if not validate_dm_access(dm_id, user): return jsonify({"error": "DM not found."}), 404 try: @@ -4867,7 +4589,6 @@ def register_routes(app, ctx): return jsonify({"error": "Message is too long."}), 400 attachment_ids, attachment_error = resolve_owned_attachment_ids( - get_db, data.get("attachment_ids", []), user["id"], ) @@ -4876,7 +4597,6 @@ def register_routes(app, ctx): if not attachment_ids and data.get("attachment_id") is not None: single_attachment_id, single_attachment_error = resolve_owned_attachment( - get_db, data.get("attachment_id"), user["id"], ) @@ -5002,7 +4722,7 @@ def register_routes(app, ctx): "exp": int(time.time()) + CALL_TOKEN_TTL_SECONDS, } - token = sign_call_helper_token(payload) + token = sign_call_helper_token(payload, CALL_HELPER_SHARED_SECRET) audit( "group_call_token_issued", @@ -5115,8 +4835,8 @@ def register_routes(app, ctx): "exp": int(time.time()) + CALL_TOKEN_TTL_SECONDS, } - helper_token = sign_call_helper_token(payload) - expires_at = (utc_now() + ctx["timedelta"](minutes=10)).isoformat() + helper_token = sign_call_helper_token(payload, CALL_HELPER_SHARED_SECRET) + expires_at = (utc_now() + app.timedelta(minutes=10)).isoformat() cursor = db_write( """ @@ -5453,13 +5173,21 @@ def register_routes(app, ctx): @app.route("/api/group-calls//leave", methods=["POST"]) def api_leave_group_call_session(session_id: int): - if not csrf_valid(): - return jsonify({"error": "Security token expired.", "code": "csrf"}), 403 - + """ + keep csrf behavior for normal requests, but allow same-origin unload-style cleanup + where the frontend keepalive/beacon path may not be able to send the custom header. + """ user, error = require_user() if error: return error + if not csrf_valid(): + origin = request.headers.get("Origin", "") + sec_fetch_site = request.headers.get("Sec-Fetch-Site", "") + same_origin_hint = (not origin) or origin.rstrip("/") == request.host_url.rstrip("/") or sec_fetch_site == "same-origin" + if not same_origin_hint: + return jsonify({"error": "Security token expired.", "code": "csrf"}), 403 + session_row = get_group_call_session_by_id(session_id) if not session_row: return jsonify({"error": "Group call session not found."}), 404 @@ -5586,7 +5314,7 @@ def register_routes(app, ctx): except (TypeError, ValueError): return jsonify({"error": "Invalid dm_id."}), 400 - if not validate_dm_access(get_db, dm_id, user): + if not validate_dm_access(dm_id, user): return jsonify({"error": "DM not found."}), 404 peer = get_dm_other_user(dm_id, user["id"]) @@ -5619,7 +5347,7 @@ def register_routes(app, ctx): "exp": int(time.time()) + CALL_TOKEN_TTL_SECONDS, } - token = sign_call_helper_token(payload) + token = sign_call_helper_token(payload, CALL_HELPER_SHARED_SECRET) audit( "call_helper_token_issued", @@ -5646,12 +5374,12 @@ def register_routes(app, ctx): if error: return error - if not validate_dm_access(get_db, dm_id, user): + if not validate_dm_access(dm_id, user): return jsonify({"error": "DM not found."}), 404 - call_id = create_call_session(db_write, now_iso, dm_id, user["id"]) + call_id = create_call_session(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 + return jsonify({"call": call_payload(call_id)}), 201 @app.route("/api/calls/") def api_get_call(call_id: int): @@ -5659,14 +5387,14 @@ def register_routes(app, ctx): if error: return error - call = get_call_with_dm(get_db, call_id) + call = get_call_with_dm(call_id) if not call: return jsonify({"error": "Call not found."}), 404 - if not validate_dm_access(get_db, call["direct_conversation_id"], user): + if not validate_dm_access(call["direct_conversation_id"], user): return jsonify({"error": "Call not found."}), 404 - return jsonify({"call": call_payload(get_db, call_id)}) + return jsonify({"call": call_payload(call_id)}) @app.route("/api/calls//signal", methods=["POST"]) def api_post_call_signal(call_id: int): @@ -5677,11 +5405,11 @@ def register_routes(app, ctx): if error: return error - call = get_call_with_dm(get_db, call_id) + call = get_call_with_dm(call_id) if not call: return jsonify({"error": "Call not found."}), 404 - if not validate_dm_access(get_db, call["direct_conversation_id"], user): + if not validate_dm_access(call["direct_conversation_id"], user): return jsonify({"error": "Call not found."}), 404 if call["status"] != "active": @@ -5700,7 +5428,7 @@ def register_routes(app, ctx): if not isinstance(payload, dict): return jsonify({"error": "payload must be an object."}), 400 - signal_id = append_call_signal(db_write, now_iso, call_id, user["id"], signal_type, payload) + signal_id = append_call_signal(call_id, user["id"], signal_type, payload) return jsonify({"status": "ok", "signal_id": signal_id}), 201 @app.route("/api/calls//signals") @@ -5709,11 +5437,11 @@ def register_routes(app, ctx): if error: return error - call = get_call_with_dm(get_db, call_id) + call = get_call_with_dm(call_id) if not call: return jsonify({"error": "Call not found."}), 404 - if not validate_dm_access(get_db, call["direct_conversation_id"], user): + if not validate_dm_access(call["direct_conversation_id"], user): return jsonify({"error": "Call not found."}), 404 after_raw = str(request.args.get("after_id", "")).strip() @@ -5724,7 +5452,7 @@ def register_routes(app, ctx): except ValueError: return jsonify({"error": "after_id must be a non-negative integer."}), 400 - signals = list_call_signals(get_db, call_id, after_id) + signals = list_call_signals(call_id, after_id) latest_id = signals[-1]["id"] if signals else after_id return jsonify({"signals": signals, "latest_id": latest_id}) @@ -5737,13 +5465,13 @@ def register_routes(app, ctx): if error: return error - call = get_call_with_dm(get_db, call_id) + call = get_call_with_dm(call_id) if not call: return jsonify({"error": "Call not found."}), 404 - if not validate_dm_access(get_db, call["direct_conversation_id"], user): + if not validate_dm_access(call["direct_conversation_id"], user): return jsonify({"error": "Call not found."}), 404 - end_call(db_write, now_iso, call_id) + end_call(call_id) audit("call_ended", user["id"], "call", str(call_id)) - return jsonify({"status": "ended", "call": call_payload(get_db, call_id)}) \ No newline at end of file + return jsonify({"status": "ended", "call": call_payload(call_id)})