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", "
") + "
" + html.escape(bio or "").replace("\n", "
") + "