feat(config): DB-backed system_config infrastructure + SMTP group
Add a generic admin-editable config layer (epic 4a): system_config KV table (migration 0021), a sysconfig registry that is the single source of truth for DB-overridable keys (type/group/default/bounds/secret) + a DB-override-or-env resolver, and admin endpoints (GET/PATCH/DELETE /api/admin/config + a test-email probe). Secrets are Fernet-encrypted at rest (TOKEN_ENCRYPTION_KEY); without it they stay env-only. First group wired end-to-end: Email/SMTP — email.py now reads host/port/user/from/password via the resolver (own session, since email is sent from background tasks).
This commit is contained in:
parent
46de58082c
commit
8e4754b0b0
6 changed files with 314 additions and 5 deletions
38
backend/alembic/versions/0021_system_config.py
Normal file
38
backend/alembic/versions/0021_system_config.py
Normal file
|
|
@ -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")
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
68
backend/app/routes/config.py
Normal file
68
backend/app/routes/config.py
Normal file
|
|
@ -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}
|
||||
157
backend/app/sysconfig.py
Normal file
157
backend/app/sysconfig.py
Normal file
|
|
@ -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.<default_attr>`` (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()}
|
||||
Loading…
Add table
Add a link
Reference in a new issue