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
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.
331 lines
11 KiB
Python
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
|