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.
49 lines
1.7 KiB
Python
49 lines
1.7 KiB
Python
from __future__ import annotations
|
|
|
|
from datetime import timedelta
|
|
|
|
from quart import Blueprint, current_app, jsonify, request
|
|
|
|
from .auth import require_admin
|
|
from .db import session_scope
|
|
from .settings import get_admin_settings, refresh_live, set_settings, validate_updates
|
|
|
|
bp = Blueprint("settings", __name__, url_prefix="/api/settings")
|
|
|
|
|
|
@bp.get("")
|
|
@require_admin
|
|
async def list_settings():
|
|
async with session_scope() as db:
|
|
return jsonify({"settings": await get_admin_settings(db)})
|
|
|
|
|
|
@bp.patch("")
|
|
@require_admin
|
|
async def update_settings():
|
|
raw = await request.get_json(silent=True)
|
|
data = raw if isinstance(raw, dict) else {}
|
|
# Accept either {settings: {...}} or a bare {key: value} object.
|
|
updates = data.get("settings") if isinstance(data.get("settings"), dict) else data
|
|
if not isinstance(updates, dict):
|
|
return jsonify({"error": "expected an object of settings"}), 400
|
|
|
|
clean, error = validate_updates(updates)
|
|
if error is not None:
|
|
return jsonify({"error": error}), 400
|
|
|
|
async with session_scope() as db:
|
|
await set_settings(db, clean)
|
|
await db.commit()
|
|
# Re-read the cached security values so a saved limit or hop count applies to
|
|
# the very next request. Unconditional: cheap, and a conditional here would be
|
|
# one more place that has to know which keys are hot.
|
|
await refresh_live(db)
|
|
result = await get_admin_settings(db)
|
|
|
|
# Apply the live-tunable knob without a restart (rule 25).
|
|
if "session_ttl_days" in clean:
|
|
current_app.config["PERMANENT_SESSION_LIFETIME"] = timedelta(days=int(clean["session_ttl_days"]))
|
|
|
|
return jsonify({"settings": result})
|