from __future__ import annotations import json import secrets from dataclasses import dataclass from datetime import datetime, timezone from typing import Any, Literal from .models.settings import Setting SettingType = Literal["string", "bool", "int"] @dataclass(frozen=True) class SettingDef: key: str type: SettingType default: Any label: str description: str group: str # Ints only. Enforced server-side in validate_updates and passed to the UI so the # number input carries them too. These exist because several of the security # values have ranges where a typo is not merely wrong but dangerous — a proxy hop # count of 50 would trust anything a caller sent, and a sign-in limit of 0 would # lock every account out permanently. minimum: int | None = None maximum: int | None = None # The source of truth for every user-facing setting. Add a row here and it appears # in the admin Settings UI with a working default — no migration, no env var. REGISTRY: list[SettingDef] = [ SettingDef( "site_name", "string", "ThoughtSync", "Site name", "Shown in the header and the browser tab.", "General" ), SettingDef( "allow_registration", "bool", True, "Allow new registrations", "When off, only existing users can sign in. Closes itself once the first " "account exists — turn it back on only while you're adding someone.", "Access", ), SettingDef( "session_ttl_days", "int", 30, "Session length (days)", "How long a signed-in session stays valid before another login is required.", "Access", ), SettingDef( "trash_retention_days", "int", 30, "Trash retention (days)", "How long a note stays in Trash before it's permanently deleted, freeing its " "attachments from disk. Set to 0 to keep trashed notes until they're deleted by hand.", "Notes", ), SettingDef( "max_attachment_mb", "int", 25, "Max attachment size (MB)", "Largest single file that can be attached to a note. Capped by the server body limit.", "Attachments", ), SettingDef( "enable_url_unfurl", "bool", True, "Link previews", "Let the server fetch a page's title/description/image to preview pasted links. " "The server contacts the linked site; private/internal addresses are always blocked.", "Links", ), # --- Security ----------------------------------------------------------------- # # Read on paths too hot for a database round trip (the credential throttle checks # them BEFORE opening a connection, which is the point of checking a throttle # before doing expensive work), so they are cached — see `live()` below. SettingDef( "trusted_proxy_hops", "int", 1, "Trusted proxy hops", "How many proxies sit in front of this server. 1 for a single reverse proxy " "terminating HTTPS; 2 if a CDN like Cloudflare sits in front of that; 0 if " "the app is exposed directly. This decides which entry of X-Forwarded-For is " "believed — set it TOO HIGH and a visitor can forge their own address and " "slip the sign-in limits below.", "Security", minimum=0, maximum=10, ), SettingDef( "signin_limit_per_account", "int", 10, "Failed sign-ins per account", "How many failures one account tolerates within the window before it stops " "answering. Comfortably above mistyping a password, far below anything that " "makes guessing worth attempting.", "Security", minimum=1, maximum=1000, ), SettingDef( "signin_limit_per_address", "int", 50, "Failed sign-ins per address", "The same, counted per visitor address instead of per account — it bounds one " "source trying many accounts. Wider, because one address is legitimately many " "people: a household, an office, a phone on carrier NAT.", "Security", minimum=1, maximum=10000, ), SettingDef( "signin_window_minutes", "int", 15, "Sign-in window (minutes)", "The trailing period both sign-in limits are counted over.", "Security", minimum=1, maximum=1440, ), SettingDef( "register_limit_per_address", "int", 5, "Sign-ups per address", "How many accounts one address may create within its window. Counted per " "attempt rather than per failure — each one is a row either way.", "Security", minimum=1, maximum=1000, ), SettingDef( "register_window_minutes", "int", 60, "Sign-up window (minutes)", "The trailing period the sign-up limit is counted over.", "Security", minimum=1, maximum=10080, ), ] _BY_KEY: dict[str, SettingDef] = {d.key: d for d in REGISTRY} # Internal, non-UI reserved key: the persisted cookie-signing secret. Stored in the # same table but never listed in the registry, so it never shows in the Settings UI. SECRET_KEY_SETTING = "secret_key" def _coerce_bool(raw: Any) -> bool: if isinstance(raw, bool): return raw if isinstance(raw, str): return raw.strip().lower() in ("1", "true", "yes", "on") return bool(raw) def _coerce(defn: SettingDef, raw: Any) -> Any: if defn.type == "bool": return _coerce_bool(raw) if defn.type == "int": try: return int(raw) except (ValueError, TypeError): return defn.default return str(raw) async def _load_raw(db, key: str) -> Any: row = await db.get(Setting, key) if row is None: return None try: return json.loads(row.value) except (ValueError, TypeError): return None async def _upsert(db, key: str, value: Any) -> None: row = await db.get(Setting, key) payload = json.dumps(value) if row is None: db.add(Setting(key=key, value=payload)) else: row.value = payload row.updated_at = datetime.now(timezone.utc) async def get_setting(db, key: str) -> Any: defn = _BY_KEY.get(key) if defn is None: raise KeyError(key) raw = await _load_raw(db, key) return defn.default if raw is None else _coerce(defn, raw) async def get_public_config(db) -> dict: """Non-sensitive settings every client reads — the login/register screen before sign-in, and the app itself afterwards. Nothing here is owner-scoped.""" return { "site_name": await get_setting(db, "site_name"), "allow_registration": await get_setting(db, "allow_registration"), "enable_url_unfurl": await get_setting(db, "enable_url_unfurl"), # Server policy, not user data: clients need it to say how long a note has # left in Trash. A native client also reads it BEFORE linking, which is why # it belongs on the unauthenticated config rather than behind login. "trash_retention_days": await get_setting(db, "trash_retention_days"), } async def get_admin_settings(db) -> list[dict]: """Every registry setting with its current value + metadata, for the admin UI.""" result: list[dict] = [] for d in REGISTRY: result.append( { "key": d.key, "type": d.type, "value": await get_setting(db, d.key), "default": d.default, "label": d.label, "description": d.description, "group": d.group, "minimum": d.minimum, "maximum": d.maximum, } ) return result # Settings the app must be able to read WITHOUT awaiting a database. # # The credential throttle consults these before opening a connection — deliberately, # because a refused attempt is supposed to cost nothing, and the proxy hop count is # needed to know who is even asking. A per-request query would undo both. # # Seeded from the registry defaults so the app works before (and without) a database — # unit tests construct it with no Postgres at all — then refreshed from the DB at boot # and again whenever an admin saves. Same live-update contract `session_ttl_days` # already has in settings_api.py. _LIVE_KEYS = ( "trusted_proxy_hops", "signin_limit_per_account", "signin_limit_per_address", "signin_window_minutes", "register_limit_per_address", "register_window_minutes", ) _live: dict[str, Any] = {k: _BY_KEY[k].default for k in _LIVE_KEYS} def live(key: str) -> Any: """The cached value of a hot setting. Synchronous, never touches the database.""" return _live[key] async def refresh_live(db) -> None: """Re-read the hot settings into the cache. Called at boot and after every save.""" for key in _LIVE_KEYS: _live[key] = await get_setting(db, key) def reset_live() -> None: """Back to registry defaults. For tests — nothing in the app calls this.""" for key in _LIVE_KEYS: _live[key] = _BY_KEY[key].default def validate_updates(updates: dict) -> tuple[dict, str | None]: """Coerce/validate a {key: value} dict against the registry. Returns (clean_values, error_message). An unknown key or a bad int is rejected.""" clean: dict = {} for key, val in updates.items(): defn = _BY_KEY.get(key) if defn is None: return {}, f"unknown setting: {key}" if defn.type == "int": try: n = int(val) except (ValueError, TypeError): return {}, f"{defn.label} must be a whole number" # Rejected rather than clamped: silently accepting a number and storing a # different one is how somebody ends up believing a protection is set to # something it is not. if defn.minimum is not None and n < defn.minimum: return {}, f"{defn.label} must be at least {defn.minimum}" if defn.maximum is not None and n > defn.maximum: return {}, f"{defn.label} must be at most {defn.maximum}" clean[key] = n elif defn.type == "bool": clean[key] = _coerce_bool(val) else: clean[key] = str(val) return clean, None async def set_settings(db, updates: dict) -> None: for key, value in updates.items(): await _upsert(db, key, value) async def load_or_create_secret_key(db) -> str: """Return the persisted cookie-signing secret, generating + storing one on first run. Keeps sessions valid across restarts with no env var or volume required.""" row = await db.get(Setting, SECRET_KEY_SETTING) if row is not None: try: val = json.loads(row.value) if isinstance(val, str) and val: return val except (ValueError, TypeError): pass key = secrets.token_urlsafe(48) await _upsert(db, SECRET_KEY_SETTING, key) await db.commit() return key