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)})