M1.5 backend: admin role + DB-backed settings, DB-URL-only install
- users.is_admin; first registered user becomes admin; registration gated by the allow_registration setting (first account always allowed). is_admin in /api/auth/* responses; require_admin guard (live DB check). - settings table + code registry (site_name, allow_registration, session_ttl_days) with typed defaults — empty table = all defaults (rule 26). get/set/validate service; GET /api/config (public) + GET/PATCH /api/settings (admin), live session-TTL apply with no restart (rule 25). - Cookie-signing secret now persisted in the DB (before_serving load-or-create), so sessions survive restarts with no volume. Config: DATABASE_URL is the only required env; SECRET_KEY + DATA_DIR are optional break-glass items. - Migration 0003; DB-free tests for settings validation + admin guard. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
This commit is contained in:
@@ -0,0 +1,32 @@
|
|||||||
|
"""roles + settings
|
||||||
|
|
||||||
|
Revision ID: 0003
|
||||||
|
Revises: 0002
|
||||||
|
Create Date: 2026-07-19
|
||||||
|
|
||||||
|
Adds the admin role bit and the DB-backed settings table. No rows are seeded —
|
||||||
|
defaults live in the code registry (thoughtsync.settings), so an empty table
|
||||||
|
means every setting is at its default.
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision = "0003"
|
||||||
|
down_revision = "0002"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column("users", sa.Column("is_admin", sa.Boolean(), nullable=False, server_default=sa.false()))
|
||||||
|
op.create_table(
|
||||||
|
"settings",
|
||||||
|
sa.Column("key", sa.Text(), primary_key=True),
|
||||||
|
sa.Column("value", sa.Text(), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table("settings")
|
||||||
|
op.drop_column("users", "is_admin")
|
||||||
+33
-6
@@ -1,34 +1,61 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
import secrets
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
from quart import Quart, jsonify, send_from_directory
|
from quart import Quart, jsonify, send_from_directory
|
||||||
|
|
||||||
from . import __version__
|
from . import __version__
|
||||||
from .auth import bp as auth_bp
|
from .auth import bp as auth_bp
|
||||||
from .config import Config
|
from .config import Config
|
||||||
|
from .db import session_scope
|
||||||
from .notes import bp as notes_bp
|
from .notes import bp as notes_bp
|
||||||
|
from .settings import get_public_config, get_setting, load_or_create_secret_key
|
||||||
|
from .settings_api import bp as settings_bp
|
||||||
|
|
||||||
STATIC_DIR = os.path.join(os.path.dirname(__file__), "static")
|
STATIC_DIR = os.path.join(os.path.dirname(__file__), "static")
|
||||||
|
|
||||||
|
|
||||||
def create_app() -> Quart:
|
def create_app() -> Quart:
|
||||||
# static_folder=None: the SPA catch-all below owns static serving instead of
|
# static_folder=None: the SPA catch-all below owns static serving.
|
||||||
# Quart's default /static handler.
|
|
||||||
app = Quart(__name__, static_folder=None)
|
app = Quart(__name__, static_folder=None)
|
||||||
app.secret_key = Config.secret_key()
|
# Ephemeral/env secret so the app (and DB-free unit tests) construct without a
|
||||||
|
# database. before_serving swaps in the real, DB-persisted key before serving.
|
||||||
|
app.secret_key = Config.secret_key_env() or secrets.token_urlsafe(48)
|
||||||
app.config["APP_VERSION"] = os.environ.get("APP_VERSION", __version__)
|
app.config["APP_VERSION"] = os.environ.get("APP_VERSION", __version__)
|
||||||
|
app.config["SESSION_COOKIE_HTTPONLY"] = True
|
||||||
|
app.config["SESSION_COOKIE_SAMESITE"] = "Lax"
|
||||||
|
app.config["PERMANENT_SESSION_LIFETIME"] = timedelta(days=30)
|
||||||
|
|
||||||
app.register_blueprint(auth_bp)
|
app.register_blueprint(auth_bp)
|
||||||
app.register_blueprint(notes_bp)
|
app.register_blueprint(notes_bp)
|
||||||
|
app.register_blueprint(settings_bp)
|
||||||
|
|
||||||
|
@app.before_serving
|
||||||
|
async def _bootstrap() -> None:
|
||||||
|
# Load (or generate + persist) the real signing secret and the live session
|
||||||
|
# lifetime from the DB, before any request is served.
|
||||||
|
async with session_scope() as db:
|
||||||
|
app.secret_key = await load_or_create_secret_key(db)
|
||||||
|
try:
|
||||||
|
days = int(await get_setting(db, "session_ttl_days"))
|
||||||
|
app.config["PERMANENT_SESSION_LIFETIME"] = timedelta(days=days)
|
||||||
|
except (ValueError, TypeError, KeyError):
|
||||||
|
pass
|
||||||
|
|
||||||
@app.get("/api/health")
|
@app.get("/api/health")
|
||||||
async def health():
|
async def health():
|
||||||
return jsonify({"status": "ok", "version": app.config["APP_VERSION"]})
|
return jsonify({"status": "ok", "version": app.config["APP_VERSION"]})
|
||||||
|
|
||||||
# Serve the built Vue SPA (baked into static/ by the Docker build) with a
|
@app.get("/api/config")
|
||||||
# history-fallback to index.html. In dev the Vite server proxies /api here, so
|
async def public_config():
|
||||||
# a missing static/ dir is expected and simply 404s the frontend.
|
# Public: the login/register screen reads site name + whether signups are open.
|
||||||
|
async with session_scope() as db:
|
||||||
|
data = await get_public_config(db)
|
||||||
|
data["version"] = app.config["APP_VERSION"]
|
||||||
|
return jsonify(data)
|
||||||
|
|
||||||
@app.get("/", defaults={"path": ""})
|
@app.get("/", defaults={"path": ""})
|
||||||
@app.get("/<path:path>")
|
@app.get("/<path:path>")
|
||||||
async def spa(path: str):
|
async def spa(path: str):
|
||||||
|
|||||||
+51
-7
@@ -4,11 +4,12 @@ import functools
|
|||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from quart import Blueprint, g, jsonify, request, session
|
from quart import Blueprint, g, jsonify, request, session
|
||||||
from sqlalchemy import select
|
from sqlalchemy import func, select
|
||||||
|
|
||||||
from .db import session_scope
|
from .db import session_scope
|
||||||
from .models.user import User
|
from .models.user import User
|
||||||
from .security import hash_password, verify_password
|
from .security import hash_password, verify_password
|
||||||
|
from .settings import get_setting
|
||||||
|
|
||||||
bp = Blueprint("auth", __name__, url_prefix="/api/auth")
|
bp = Blueprint("auth", __name__, url_prefix="/api/auth")
|
||||||
|
|
||||||
@@ -22,22 +23,52 @@ def _serialize_user(user: User) -> dict:
|
|||||||
"email": user.email,
|
"email": user.email,
|
||||||
"display_name": user.display_name,
|
"display_name": user.display_name,
|
||||||
"email_verified": user.email_verified,
|
"email_verified": user.email_verified,
|
||||||
|
"is_admin": user.is_admin,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _session_user_id() -> uuid.UUID | None:
|
||||||
|
raw = session.get(SESSION_KEY)
|
||||||
|
if not raw:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return uuid.UUID(raw)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
session.pop(SESSION_KEY, None)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def login_required(fn):
|
def login_required(fn):
|
||||||
"""Guard: 401 unless a valid session is present. Sets g.user_id for the view."""
|
"""Guard: 401 unless a valid session is present. Sets g.user_id for the view."""
|
||||||
|
|
||||||
@functools.wraps(fn)
|
@functools.wraps(fn)
|
||||||
async def wrapper(*args, **kwargs):
|
async def wrapper(*args, **kwargs):
|
||||||
raw = session.get(SESSION_KEY)
|
uid = _session_user_id()
|
||||||
if not raw:
|
if uid is None:
|
||||||
return jsonify({"error": "authentication required"}), 401
|
return jsonify({"error": "authentication required"}), 401
|
||||||
try:
|
g.user_id = uid
|
||||||
g.user_id = uuid.UUID(raw)
|
return await fn(*args, **kwargs)
|
||||||
except (ValueError, TypeError):
|
|
||||||
|
return wrapper
|
||||||
|
|
||||||
|
|
||||||
|
def require_admin(fn):
|
||||||
|
"""Guard: 401 unauthenticated, 403 non-admin. Checks is_admin live from the DB
|
||||||
|
so a demoted admin loses access immediately."""
|
||||||
|
|
||||||
|
@functools.wraps(fn)
|
||||||
|
async def wrapper(*args, **kwargs):
|
||||||
|
uid = _session_user_id()
|
||||||
|
if uid is None:
|
||||||
|
return jsonify({"error": "authentication required"}), 401
|
||||||
|
async with session_scope() as db:
|
||||||
|
user = await db.get(User, uid)
|
||||||
|
if user is None:
|
||||||
session.pop(SESSION_KEY, None)
|
session.pop(SESSION_KEY, None)
|
||||||
return jsonify({"error": "authentication required"}), 401
|
return jsonify({"error": "authentication required"}), 401
|
||||||
|
if not user.is_admin:
|
||||||
|
return jsonify({"error": "admin access required"}), 403
|
||||||
|
g.user_id = uid
|
||||||
return await fn(*args, **kwargs)
|
return await fn(*args, **kwargs)
|
||||||
|
|
||||||
return wrapper
|
return wrapper
|
||||||
@@ -58,14 +89,26 @@ async def register():
|
|||||||
display_name = email.split("@", 1)[0]
|
display_name = email.split("@", 1)[0]
|
||||||
|
|
||||||
async with session_scope() as db:
|
async with session_scope() as db:
|
||||||
|
user_count = await db.scalar(select(func.count()).select_from(User)) or 0
|
||||||
|
is_first = user_count == 0
|
||||||
|
# The first account bootstraps the admin and is always allowed, even when
|
||||||
|
# registration is otherwise closed.
|
||||||
|
if not is_first and not await get_setting(db, "allow_registration"):
|
||||||
|
return jsonify({"error": "registration is closed"}), 403
|
||||||
existing = await db.scalar(select(User).where(User.email == email))
|
existing = await db.scalar(select(User).where(User.email == email))
|
||||||
if existing is not None:
|
if existing is not None:
|
||||||
return jsonify({"error": "an account with that email already exists"}), 409
|
return jsonify({"error": "an account with that email already exists"}), 409
|
||||||
user = User(email=email, password_hash=hash_password(password), display_name=display_name)
|
user = User(
|
||||||
|
email=email,
|
||||||
|
password_hash=hash_password(password),
|
||||||
|
display_name=display_name,
|
||||||
|
is_admin=is_first,
|
||||||
|
)
|
||||||
db.add(user)
|
db.add(user)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(user)
|
await db.refresh(user)
|
||||||
session[SESSION_KEY] = str(user.id)
|
session[SESSION_KEY] = str(user.id)
|
||||||
|
session.permanent = True
|
||||||
return jsonify(_serialize_user(user)), 201
|
return jsonify(_serialize_user(user)), 201
|
||||||
|
|
||||||
|
|
||||||
@@ -80,6 +123,7 @@ async def login():
|
|||||||
if user is None or not user.password_hash or not verify_password(password, user.password_hash):
|
if user is None or not user.password_hash or not verify_password(password, user.password_hash):
|
||||||
return jsonify({"error": "invalid email or password"}), 401
|
return jsonify({"error": "invalid email or password"}), 401
|
||||||
session[SESSION_KEY] = str(user.id)
|
session[SESSION_KEY] = str(user.id)
|
||||||
|
session.permanent = True
|
||||||
return jsonify(_serialize_user(user))
|
return jsonify(_serialize_user(user))
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+14
-29
@@ -1,52 +1,37 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import secrets
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
"""Runtime configuration.
|
"""Bootstrap configuration.
|
||||||
|
|
||||||
Values come from environment variables with working local-dev defaults, so the
|
For a basic install, ``THOUGHTSYNC_DATABASE_URL`` is the ONLY required env var —
|
||||||
app boots with zero configuration (family rule 26 — integrations default to
|
every other tunable lives in the DB-backed Settings UI (rule 25). The remaining
|
||||||
working, never coerce setup). As the product grows, anything an operator wants
|
env vars are optional "break-glass" / bootstrap items:
|
||||||
to tune moves into a DB-backed Settings UI (rule 25); env is bootstrap only.
|
|
||||||
|
- ``THOUGHTSYNC_SECRET_KEY`` — optional override for the cookie-signing secret.
|
||||||
|
If unset, a key is generated and persisted in the DB (see
|
||||||
|
``thoughtsync.settings.load_or_create_secret_key``), so sessions survive
|
||||||
|
restarts with no volume required.
|
||||||
|
- ``THOUGHTSYNC_DATA_DIR`` — optional, defaults to ``/var/thoughtsync``. Only
|
||||||
|
used for uploaded media (M2); irrelevant to a basic text-notes install.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# Where runtime-generated secrets + uploaded media live.
|
|
||||||
DATA_DIR = os.environ.get("THOUGHTSYNC_DATA_DIR", "/var/thoughtsync")
|
DATA_DIR = os.environ.get("THOUGHTSYNC_DATA_DIR", "/var/thoughtsync")
|
||||||
# Empty -> defaults to <DATA_DIR>/media (see media_root()).
|
|
||||||
MEDIA_ROOT = os.environ.get("THOUGHTSYNC_MEDIA_ROOT", "")
|
MEDIA_ROOT = os.environ.get("THOUGHTSYNC_MEDIA_ROOT", "")
|
||||||
|
|
||||||
# postgresql+asyncpg URL. Mirrors alembic.ini's local-dev default.
|
|
||||||
DATABASE_URL = os.environ.get(
|
DATABASE_URL = os.environ.get(
|
||||||
"THOUGHTSYNC_DATABASE_URL",
|
"THOUGHTSYNC_DATABASE_URL",
|
||||||
"postgresql+asyncpg://thoughtsync:thoughtsync@localhost:5432/thoughtsync",
|
"postgresql+asyncpg://thoughtsync:thoughtsync@localhost:5432/thoughtsync",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Auth session cookie: a capture app should rarely log you out, so keep it long.
|
|
||||||
AUTH_TTL_SECONDS = 60 * 60 * 24 * 30 # 30 days
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def media_root(cls) -> Path:
|
def media_root(cls) -> Path:
|
||||||
return Path(cls.MEDIA_ROOT or os.path.join(cls.DATA_DIR, "media"))
|
return Path(cls.MEDIA_ROOT or os.path.join(cls.DATA_DIR, "media"))
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def secret_key(cls) -> bytes:
|
def secret_key_env(cls) -> str | None:
|
||||||
"""Secret used to sign the auth session cookie.
|
"""Optional break-glass override for the cookie-signing secret."""
|
||||||
|
return os.environ.get("THOUGHTSYNC_SECRET_KEY") or None
|
||||||
Read from env if set; otherwise read/generate a persistent key file under
|
|
||||||
DATA_DIR so signed sessions survive restarts (no forced re-login on deploy).
|
|
||||||
"""
|
|
||||||
env = os.environ.get("THOUGHTSYNC_SECRET_KEY")
|
|
||||||
if env:
|
|
||||||
return env.encode()
|
|
||||||
data_dir = Path(cls.DATA_DIR)
|
|
||||||
data_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
key_file = data_dir / "secret_key"
|
|
||||||
if key_file.exists():
|
|
||||||
return key_file.read_bytes()
|
|
||||||
key = secrets.token_bytes(32)
|
|
||||||
key_file.write_bytes(key)
|
|
||||||
return key
|
|
||||||
|
|||||||
@@ -3,4 +3,4 @@
|
|||||||
Imported for side effects only (model registration on Base.metadata).
|
Imported for side effects only (model registration on Base.metadata).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from . import group, note, share, user # noqa: F401
|
from . import group, note, settings, share, user # noqa: F401
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import DateTime, Text, func
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from . import Base
|
||||||
|
|
||||||
|
|
||||||
|
class Setting(Base):
|
||||||
|
"""A single persisted key/value setting. Values are JSON-encoded text; the
|
||||||
|
typed defaults + metadata live in the code registry (thoughtsync.settings), so
|
||||||
|
an empty table means "all defaults" (rule 26)."""
|
||||||
|
|
||||||
|
__tablename__ = "settings"
|
||||||
|
|
||||||
|
key: Mapped[str] = mapped_column(Text(), primary_key=True)
|
||||||
|
value: Mapped[str] = mapped_column(Text(), nullable=False)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
|
||||||
|
)
|
||||||
@@ -17,6 +17,8 @@ class User(Base):
|
|||||||
# CITEXT so lookups/uniqueness are case-insensitive without lower() everywhere.
|
# CITEXT so lookups/uniqueness are case-insensitive without lower() everywhere.
|
||||||
email: Mapped[str] = mapped_column(CITEXT(), nullable=False, unique=True)
|
email: Mapped[str] = mapped_column(CITEXT(), nullable=False, unique=True)
|
||||||
email_verified: Mapped[bool] = mapped_column(Boolean(), nullable=False, server_default=func.false())
|
email_verified: Mapped[bool] = mapped_column(Boolean(), nullable=False, server_default=func.false())
|
||||||
|
# First registered user becomes admin (see auth.register); admin gates Settings.
|
||||||
|
is_admin: Mapped[bool] = mapped_column(Boolean(), nullable=False, server_default=func.false())
|
||||||
# Nullable: leaves room for external-identity-only accounts later (rule 26).
|
# Nullable: leaves room for external-identity-only accounts later (rule 26).
|
||||||
password_hash: Mapped[str | None] = mapped_column(Text(), nullable=True)
|
password_hash: Mapped[str | None] = mapped_column(Text(), nullable=True)
|
||||||
display_name: Mapped[str] = mapped_column(Text(), nullable=False)
|
display_name: Mapped[str] = mapped_column(Text(), nullable=False)
|
||||||
|
|||||||
@@ -0,0 +1,167 @@
|
|||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
# 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. The first account is always allowed.",
|
||||||
|
"Access",
|
||||||
|
),
|
||||||
|
SettingDef(
|
||||||
|
"session_ttl_days",
|
||||||
|
"int",
|
||||||
|
30,
|
||||||
|
"Session length (days)",
|
||||||
|
"How long a signed-in session stays valid before another login is required.",
|
||||||
|
"Access",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
_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 the unauthenticated login/register screen needs."""
|
||||||
|
return {
|
||||||
|
"site_name": await get_setting(db, "site_name"),
|
||||||
|
"allow_registration": await get_setting(db, "allow_registration"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
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:
|
||||||
|
clean[key] = int(val)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return {}, f"{defn.label} must be a whole number"
|
||||||
|
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
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
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, 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()
|
||||||
|
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})
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import pytest
|
||||||
|
|
||||||
|
from thoughtsync.app import create_app
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def app():
|
||||||
|
return create_app()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_settings_requires_auth(app):
|
||||||
|
# require_admin returns 401 before any DB access when unauthenticated.
|
||||||
|
client = app.test_client()
|
||||||
|
resp = await client.get("/api/settings")
|
||||||
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
async def test_settings_patch_requires_auth(app):
|
||||||
|
client = app.test_client()
|
||||||
|
resp = await client.patch("/api/settings", json={"site_name": "x"})
|
||||||
|
assert resp.status_code == 401
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
from thoughtsync.settings import REGISTRY, validate_updates
|
||||||
|
|
||||||
|
|
||||||
|
def test_registry_has_expected_keys():
|
||||||
|
keys = {d.key for d in REGISTRY}
|
||||||
|
assert {"site_name", "allow_registration", "session_ttl_days"} <= keys
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_rejects_unknown_key():
|
||||||
|
clean, error = validate_updates({"nope": 1})
|
||||||
|
assert error is not None
|
||||||
|
assert clean == {}
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_coerces_bool_and_int():
|
||||||
|
clean, error = validate_updates({"allow_registration": "true", "session_ttl_days": "45"})
|
||||||
|
assert error is None
|
||||||
|
assert clean["allow_registration"] is True
|
||||||
|
assert clean["session_ttl_days"] == 45
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_rejects_bad_int():
|
||||||
|
clean, error = validate_updates({"session_ttl_days": "not-a-number"})
|
||||||
|
assert error is not None
|
||||||
|
assert clean == {}
|
||||||
Reference in New Issue
Block a user