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