Files
inkwell/src/thoughtsync/settings.py
T
bvandeusen 09b5f874b6
CI & Build / Build now, or wait for Android? (push) Successful in 2s
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Failing after 9s
CI & Build / integration (push) Failing after 12s
CI & Build / Build & push image (push) Successful in 32s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m17s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m14s
Desktop (Tauri) / Update manifest (push) Successful in 5s
Security values move into the Settings UI
Operator: *"proxy hops defaults to 1 and should be in the settings UI not in the
envs, we need the security values to be in the UI."* Overrules the call I made
yesterday, and rule 25 is on your side — I argued deployment-topology, but the
operator has to be able to SEE what protects them, and reading a container's
environment is not seeing.

Six new settings in a **Security** group: trusted proxy hops (default 1), the
per-account and per-address sign-in limits with their shared window, and the
sign-up limit with its own. `THOUGHTSYNC_TRUSTED_PROXY_HOPS` is gone; the rate
limits are no longer hardcoded constants.

**The hard part was keeping the throttle cheap.** It consults these BEFORE opening
a database connection — deliberately, because a refused attempt is meant to cost
nothing, and the hop count is needed to know who is even asking. A query per
attempt would undo both. So there is a small cache seeded from the registry
defaults (the app works with no database at all, which is what the DB-free unit
lane relies on), loaded at boot, and refreshed on every settings save — the same
live-update contract `session_ttl_days` already had.

`SlidingWindow` now takes its limit and window as SUPPLIERS rather than values, so
a saved number applies to the next attempt instead of the next deploy.

**Bounds are rejected, not clamped.** A hop count of 99 would trust anything a
caller sent; a sign-in limit of 0 would lock every account out permanently. Both
now fail validation with a message naming the range, and the number input carries
min/max so the browser objects first. Silently storing a different number than the
one typed is how somebody ends up believing a protection is set to something it is
not.

`MAX_BUCKETS` stays a constant on purpose: it protects the limiter from itself
rather than the app from a caller, and there is no operator judgment to apply.

Two integration tests, because the whole point is the round trip: a dangerous
value refused, a legitimate one reaching the cache the throttle reads and
persisting; and every Security row reaching the admin payload with bounds and a
description that explains itself.
2026-08-23 15:24:15 -04:00

331 lines
11 KiB
Python

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