glo/config.py

232 lines
6.5 KiB
Python

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