diff --git a/backend/alembic/versions/0021_system_config.py b/backend/alembic/versions/0021_system_config.py new file mode 100644 index 0000000..5981675 --- /dev/null +++ b/backend/alembic/versions/0021_system_config.py @@ -0,0 +1,38 @@ +"""generic admin-editable system_config key/value store + +Revision ID: 0021_system_config +Revises: 0020_rename_quota_actions +Create Date: 2026-06-19 + +Adds the system_config table: admin overrides for operational config that otherwise comes +from env/config defaults (see app.sysconfig for the registry of known keys). Secret values +are stored Fernet-encrypted (encrypted flag). Purely additive; absent rows = use defaults. +""" +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "0021_system_config" +down_revision: Union[str, None] = "0020_rename_quota_actions" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "system_config", + sa.Column("key", sa.String(length=64), primary_key=True), + sa.Column("value", sa.Text(), nullable=True), + sa.Column("encrypted", sa.Boolean(), nullable=False, server_default="false"), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.func.now(), + ), + ) + + +def downgrade() -> None: + op.drop_table("system_config") diff --git a/backend/app/email.py b/backend/app/email.py index 74fcf75..27119ca 100644 --- a/backend/app/email.py +++ b/backend/app/email.py @@ -10,13 +10,29 @@ import ssl from email.message import EmailMessage from email.utils import formatdate +from app import sysconfig from app.config import settings +from app.db import SessionLocal log = logging.getLogger("subfeed.email") +def _smtp() -> dict: + """Effective SMTP config (admin DB override over env defaults). Opens its own short + session because email is often sent from a BackgroundTask, outside a request's db.""" + with SessionLocal() as db: + return { + "host": sysconfig.get_str(db, "smtp_host"), + "port": sysconfig.get_int(db, "smtp_port"), + "user": sysconfig.get_str(db, "smtp_user"), + "password": sysconfig.get_str(db, "smtp_password"), + "from": sysconfig.get_str(db, "smtp_from"), + } + + def email_enabled() -> bool: - return bool(settings.smtp_host and settings.smtp_user and settings.smtp_password) + c = _smtp() + return bool(c["host"] and c["user"] and c["password"]) def _admin_contact() -> str | None: @@ -27,12 +43,13 @@ def _send(to: list[str], subject: str, body: str, reply_to: str | None = None) - recipients = [e for e in to if e] if not recipients: return False - if not email_enabled(): + c = _smtp() + if not (c["host"] and c["user"] and c["password"]): log.info("SMTP not configured; skipping email %r to %s", subject, recipients) return False msg = EmailMessage() msg["Subject"] = subject - msg["From"] = settings.smtp_from or settings.smtp_user + msg["From"] = c["from"] or c["user"] msg["To"] = ", ".join(recipients) # A real Date and a Reply-To (so it's a conversation, not a no-reply blast) both # nudge spam filters the right way; reputation still does most of the work. @@ -41,9 +58,9 @@ def _send(to: list[str], subject: str, body: str, reply_to: str | None = None) - msg["Reply-To"] = reply_to msg.set_content(body) try: - with smtplib.SMTP(settings.smtp_host, settings.smtp_port, timeout=20) as s: + with smtplib.SMTP(c["host"], c["port"], timeout=20) as s: s.starttls(context=ssl.create_default_context()) - s.login(settings.smtp_user, settings.smtp_password) + s.login(c["user"], c["password"]) s.send_message(msg) log.info("Sent email %r to %s", subject, recipients) return True @@ -73,3 +90,12 @@ def send_admin_new_request(admins: list[str], requester: str) -> bool: "(Reply to this email to reach the requester directly.)\n" ) return _send(admins, f"Siftlode access request from {requester}", body, reply_to=requester) + + +def send_test(to: str) -> bool: + body = ( + "This is a test email from Siftlode.\n\n" + "If you're reading this, your SMTP settings work.\n\n" + "— Siftlode" + ) + return _send([to], "Siftlode SMTP test", body) diff --git a/backend/app/main.py b/backend/app/main.py index d134beb..77c2a5e 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -29,6 +29,7 @@ from app.config import settings from app.routes import ( admin, channels, + config as config_routes, feed, health, me, @@ -82,6 +83,7 @@ app.include_router(notifications.router) app.include_router(channels.router) app.include_router(playlists.router) app.include_router(admin.router) +app.include_router(config_routes.router) app.include_router(scheduler_routes.router) app.include_router(quota.router) app.include_router(version.router) diff --git a/backend/app/models.py b/backend/app/models.py index f6185a8..0952533 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -358,6 +358,24 @@ class SchedulerSetting(Base): interval_minutes: Mapped[int] = mapped_column(Integer) +class SystemConfig(Base): + """Admin-set overrides for operational config that otherwise comes from env/config + defaults. Generic key/value store (one row per key); absent = use the env/config + default. DB-backed so it's editable from the admin Configuration page at runtime without + a redeploy. Secret values (e.g. SMTP password) are stored Fernet-encrypted (`encrypted` + flag) using TOKEN_ENCRYPTION_KEY; non-secrets are plaintext. The set of known keys + their + types/groups/defaults lives in app.sysconfig (the single source of truth).""" + + __tablename__ = "system_config" + + key: Mapped[str] = mapped_column(String(64), primary_key=True) + value: Mapped[str | None] = mapped_column(Text) + encrypted: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false") + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=func.now(), onupdate=func.now() + ) + + class Playlist(Base): """A per-user named, ordered collection of videos from the shared catalog. diff --git a/backend/app/routes/config.py b/backend/app/routes/config.py new file mode 100644 index 0000000..3d05345 --- /dev/null +++ b/backend/app/routes/config.py @@ -0,0 +1,68 @@ +"""Admin Configuration page API: read/edit the DB-overridable operational settings defined in +app.sysconfig. Secret values (e.g. SMTP password) are write-only — never returned.""" +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session + +from app import email as email_mod +from app import sysconfig +from app.db import get_db +from app.models import User +from app.routes.admin import admin_user + +router = APIRouter(prefix="/api/admin/config", tags=["admin"]) + + +@router.get("") +def get_config(_: User = Depends(admin_user), db: Session = Depends(get_db)) -> dict: + return sysconfig.describe(db) + + +@router.patch("/{key}") +def set_config( + key: str, + payload: dict, + _: User = Depends(admin_user), + db: Session = Depends(get_db), +) -> dict: + s = sysconfig.spec(key) + if s is None: + raise HTTPException(status_code=404, detail="Unknown config key") + if "value" not in payload: + raise HTTPException(status_code=400, detail="Missing 'value'") + if s.secret and not sysconfig.secrets_manageable(): + raise HTTPException( + status_code=400, + detail="Set TOKEN_ENCRYPTION_KEY to store secrets in the database.", + ) + try: + sysconfig.set_value(db, key, payload["value"]) + except (TypeError, ValueError) as exc: + raise HTTPException(status_code=400, detail=str(exc) or "Invalid value") + return sysconfig.describe(db) + + +@router.delete("/{key}") +def reset_config( + key: str, + _: User = Depends(admin_user), + db: Session = Depends(get_db), +) -> dict: + if sysconfig.spec(key) is None: + raise HTTPException(status_code=404, detail="Unknown config key") + sysconfig.reset(db, key) + return sysconfig.describe(db) + + +@router.post("/test-email") +def send_test_email( + user: User = Depends(admin_user), + db: Session = Depends(get_db), +) -> dict: + """Send a test email to the requesting admin using the current (DB or env) SMTP config — + lets the admin verify the settings actually work.""" + if not email_mod.email_enabled(): + raise HTTPException(status_code=400, detail="SMTP is not configured.") + sent = email_mod.send_test(user.email) + if not sent: + raise HTTPException(status_code=422, detail="SMTP send failed — check the settings.") + return {"sent": True, "to": user.email} diff --git a/backend/app/sysconfig.py b/backend/app/sysconfig.py new file mode 100644 index 0000000..eabba05 --- /dev/null +++ b/backend/app/sysconfig.py @@ -0,0 +1,157 @@ +"""Admin-editable system configuration: a DB-override layer over the env/config defaults. + +The registry (SPECS) is the single source of truth for which config keys can be overridden +from the admin Configuration page, plus each key's type, admin-UI group, env/config default, +bounds, and whether it's a secret. The resolver returns the DB override if one is set, else +``settings.`` (same DB-override-or-default pattern as app.state). Secrets are +stored Fernet-encrypted at rest (requires TOKEN_ENCRYPTION_KEY; without it, secrets can't be +stored in the DB and stay env-only). +""" +from dataclasses import dataclass + +from sqlalchemy.orm import Session + +from app import security +from app.config import settings +from app.models import SystemConfig + + +@dataclass(frozen=True) +class ConfigSpec: + key: str + type: str # "int" | "str" | "bool" + group: str # admin-UI grouping (logically related keys stay together) + default_attr: str # attribute on `settings` holding the env/config default + secret: bool = False + min: int | None = None + max: int | None = None + + +# Registry of DB-overridable keys. Add a key here + wire its read through get_*() to move it +# off env into the admin UI; the admin page renders it automatically from this list. +SPECS: tuple[ConfigSpec, ...] = ( + # --- Email / SMTP (one logical group; the password is the only secret) --- + ConfigSpec("smtp_host", "str", "email", "smtp_host"), + ConfigSpec("smtp_port", "int", "email", "smtp_port", min=1, max=65535), + ConfigSpec("smtp_user", "str", "email", "smtp_user"), + ConfigSpec("smtp_from", "str", "email", "smtp_from"), + ConfigSpec("smtp_password", "str", "email", "smtp_password", secret=True), +) + +_BY_KEY: dict[str, ConfigSpec] = {s.key: s for s in SPECS} + + +def spec(key: str) -> ConfigSpec | None: + return _BY_KEY.get(key) + + +def secrets_manageable() -> bool: + """Secret values can only be stored when Fernet is configured (TOKEN_ENCRYPTION_KEY).""" + return bool(settings.token_encryption_key) + + +def _default(s: ConfigSpec): + return getattr(settings, s.default_attr) + + +def _coerce(s: ConfigSpec, raw: str): + if s.type == "int": + return int(raw) + if s.type == "bool": + return raw == "true" + return raw + + +def _raw_override(db: Session, key: str) -> str | None: + """Stored (decrypted) override string for `key`, or None if no override is set.""" + row = db.get(SystemConfig, key) + if row is None or row.value is None: + return None + return security.decrypt(row.value) if row.encrypted else row.value + + +def get(db: Session, key: str): + """Effective value: the DB override (typed) if set, else the env/config default.""" + s = _BY_KEY[key] + raw = _raw_override(db, key) + if raw is None or raw == "": + return _default(s) + try: + return _coerce(s, raw) + except (TypeError, ValueError): + return _default(s) + + +def get_str(db: Session, key: str) -> str: + v = get(db, key) + return "" if v is None else str(v) + + +def get_int(db: Session, key: str) -> int: + return int(get(db, key)) + + +def get_bool(db: Session, key: str) -> bool: + return bool(get(db, key)) + + +def set_value(db: Session, key: str, value) -> None: + """Validate, coerce, and persist a DB override for `key`. Secrets are encrypted.""" + s = _BY_KEY[key] + if s.type == "int": + v = int(value) + if s.min is not None and v < s.min: + raise ValueError(f"{key} must be >= {s.min}") + if s.max is not None and v > s.max: + raise ValueError(f"{key} must be <= {s.max}") + raw = str(v) + elif s.type == "bool": + raw = "true" if value else "false" + else: + raw = "" if value is None else str(value) + + stored, encrypted = raw, False + if s.secret: + if not secrets_manageable(): + raise RuntimeError("TOKEN_ENCRYPTION_KEY is not configured") + stored, encrypted = security.encrypt(raw) or "", True + + row = db.get(SystemConfig, key) + if row is None: + row = SystemConfig(key=key) + db.add(row) + row.value = stored + row.encrypted = encrypted + db.commit() + + +def reset(db: Session, key: str) -> None: + """Remove the DB override for `key` (fall back to the env/config default).""" + row = db.get(SystemConfig, key) + if row is not None: + db.delete(row) + db.commit() + + +def describe(db: Session) -> dict: + """Admin-UI metadata + current values, grouped. Secret values are never echoed — + only whether an override is set and whether an env default exists.""" + groups: dict[str, list[dict]] = {} + for s in SPECS: + is_set = db.get(SystemConfig, s.key) is not None + item: dict = { + "key": s.key, + "type": s.type, + "group": s.group, + "secret": s.secret, + "min": s.min, + "max": s.max, + "is_set": is_set, # an override row exists + } + if s.secret: + item["default_is_set"] = bool(_default(s)) + else: + item["value"] = get(db, s.key) + item["default"] = _default(s) + groups.setdefault(s.group, []).append(item) + return {"groups": groups, "secrets_manageable": secrets_manageable()}