# app.py 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.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 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_REQUEST_BYTES = 6 * 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_BYTES = 100 * 1024 * 1024 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: app.wsgi_app = ProxyFix( app.wsgi_app, 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), ) 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: return """ SELECT p.id, p.team_id, p.channel_id, 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) -> 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 def get_asset_template_values(): return { "asset_version": get_asset_version(), "csp_nonce": getattr(g, "csp_nonce", ""), } @app.before_request def setup_request_security(): g.csp_nonce = 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 return render_template("blocked.html", asset_version=get_asset_version()), 403 @app.context_processor def inject_template_values(): return get_asset_template_values() @app.after_request def add_security_headers(response): response.headers["X-Content-Type-Options"] = "nosniff" response.headers["X-Frame-Options"] = "DENY" response.headers["Referrer-Policy"] = "same-origin" response.headers["Permissions-Policy"] = "camera=(self), microphone=(self), geolocation=()" response.headers["Cross-Origin-Opener-Policy"] = "same-origin" response.headers["Cross-Origin-Resource-Policy"] = "same-origin" response.headers["Content-Security-Policy"] = ( "default-src 'self'; " "base-uri 'self'; " "form-action 'self'; " "frame-ancestors 'none'; " "object-src 'none'; " "img-src 'self' data: blob:; " "media-src 'self' blob:; " "style-src 'self' 'unsafe-inline'; " "script-src 'self'; " "connect-src 'self';" ) if request.path.startswith("/static/"): response.headers["Cache-Control"] = "public, max-age=31536000, immutable" else: response.headers["Cache-Control"] = "no-store" return response @app.errorhandler(RequestEntityTooLarge) def request_too_large(_error): return jsonify({"error": "Request is too large."}), 413 @app.errorhandler(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, updated_at TEXT NOT NULL, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ) """ ) 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', 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_settings", "name_color"): db.execute("ALTER TABLE user_settings ADD COLUMN name_color TEXT") if not column_exists(db, "presence_events", "direct_conversation_id"): db.execute("ALTER TABLE presence_events 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") 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, updated_at ) SELECT id, username, '', '', 'txt', '', '', ? FROM users """, (now_iso(),), ) db.execute( """ INSERT OR IGNORE INTO user_settings ( user_id, accent_color, name_color, custom_css, theme_mode, updated_at ) SELECT id, '#f7dce8', NULL, '', 'dark', ? 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, updated_at ) VALUES (?, ?, '', '', 'txt', '', '', ?) """, (user_id, username, now_iso()), ) db.execute( """ INSERT OR IGNORE INTO user_settings ( user_id, accent_color, name_color, custom_css, theme_mode, updated_at ) VALUES (?, '#f7dce8', NULL, '', 'dark', ?) """, (user_id, now_iso()), ) db.commit() @app.route("/") def index(): return render_template("index.html") @app.route("/api/csrf") def api_csrf(): return jsonify({"csrf_token": csrf_token()}) @app.route("/api/me") def api_me(): user = current_user() return jsonify( { "authenticated": bool(user), "user": user, "csrf_token": csrf_token(), } ) @app.route("/api/register", methods=["POST"]) def register(): limited = rate_limited("register") if limited: return limited if not csrf_valid(): return jsonify({"error": "Security token expired.", "code": "csrf"}), 403 try: data = safe_json() 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, updated_at ) VALUES (?, ?, '', '', 'txt', '', '', ?) """, (user["id"], username, now_iso()), ) db_write( """ INSERT OR IGNORE INTO user_settings ( user_id, accent_color, name_color, custom_css, theme_mode, updated_at ) VALUES (?, '#f7dce8', NULL, '', 'dark', ?) """, (user["id"], now_iso()), ) authenticated = login_user(user) audit("account_registered", user["id"], "user", str(user["id"])) return jsonify( { "status": "registered", "user": authenticated, "csrf_token": csrf_token(), } ), 201 @app.route("/api/login", methods=["POST"]) def login(): limited = rate_limited("login") if limited: return limited if not csrf_valid(): return jsonify({"error": "Security token expired.", "code": "csrf"}), 403 try: data = safe_json() 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"])) return jsonify( { "status": "logged_in", "user": authenticated, "csrf_token": csrf_token(), } ) @app.route("/api/logout", methods=["POST"]) def logout(): if not csrf_valid(): return jsonify({"error": "Security token expired.", "code": "csrf"}), 403 session.clear() return jsonify({"status": "logged_out", "csrf_token": csrf_token()}) def _maybe_cleanup_orphaned_attachments(): maybe_cleanup_orphaned_attachments( _runtime_state, ATTACHMENT_CLEANUP_INTERVAL_SECONDS, lambda: __import__("features").cleanup_orphaned_attachments( get_db, UPLOAD_DIR, utc_now, ATTACHMENT_ORPHAN_TTL_HOURS, app.logger, ), app.logger, ) 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, }, }, ) init_db() bootstrap_admin() if __name__ == "__main__": app.run( host="0.0.0.0", port=int(os.environ.get("PORT", 5000)), debug=os.environ.get("FLASK_DEBUG", "0") == "1", )