The code cited its plan instead of saying why: `E28 review J4`, `(R6 S3b)`, `S1 justified…`,
`deep-review round 5`. That plan lives in the private ops repo, so for every reader of this one the
citation was noise, and a sentence whose subject was the plan step ("round 2 showed…") hid its
lesson behind an id. Each one is gone and the "why" kept: a bare citation is deleted, a sentence
carrying a lesson is reworded around it ("a later attempt showed…").
Measured (`scripts/audit_metrics.py`, product code): 652 lines in 270 files carried an epic code by
the definition the next commit holds (507 / 241 by the old, two-digit-only one) -> 0. Across all
code, tests, scripts and configs: ~1100 epic-code lines plus ~320 sprint/round ids.
Behaviour unchanged, checked mechanically: every changed .py file has the same AST once docstrings
are blanked; css/sh/toml/yml/json files are identical once comments are stripped; the only
non-comment changes in TS/JS are test titles, one description string in a contract table, and five
ESLint messages, whose text is all the gates snapshot diff holds. Not touched: Markdown (docs and
the changelog keep their history), Alembic migrations, TV episode notation (`S2 · E5`), DESIGN.md
ids (`C1`, `B4`, `S3h`), tracker ids (`SEC-16`, `DX-17`).
One stale claim fixed on the way: test_hls_reaper's docstring said the player checkpoints "every
10 s whether or not it is playing", which plex/stream.py calls wrong; it now says what the code does.
148 lines
5.2 KiB
Python
148 lines
5.2 KiB
Python
"""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."""
|
|
|
|
import re
|
|
from typing import Any
|
|
|
|
from fastapi import APIRouter, Depends
|
|
from pydantic import BaseModel
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app import audit
|
|
from app import email as email_mod
|
|
from app import logs
|
|
from app import mailtext
|
|
from app import sysconfig
|
|
from app.audit import AuditAction
|
|
from app.auth import admin_user
|
|
from app.db import get_db
|
|
from app.models import User
|
|
from app.refusal import refused
|
|
from app.issues import gitea
|
|
from app.routes._common import issue_refused, not_found
|
|
from app.refusal import setting_refused
|
|
|
|
router = APIRouter(prefix="/api/admin/config", tags=["admin"])
|
|
|
|
# Strip URL userinfo (user:pass@) so a non-secret setting that happens to embed credentials —
|
|
# e.g. an authenticated egress proxy http://user:pass@host:port — never lands plaintext in the
|
|
# audit trail. Non-secret values are shown live in the UI, but the log shouldn't retain creds.
|
|
_URL_USERINFO = re.compile(r"(://)[^/@\s]+@")
|
|
|
|
|
|
def _redact(v):
|
|
return _URL_USERINFO.sub(r"\1***@", v) if isinstance(v, str) else v
|
|
|
|
|
|
def _apply_runtime(db: Session, key: str) -> None:
|
|
"""Some knobs change how THIS process behaves, not just what a later read returns — they have to
|
|
take effect now, without a restart. One dispatch for both call sites (set and reset), so the next
|
|
such key can't be wired into one of them and forgotten in the other."""
|
|
if key == "log_level":
|
|
logs.set_level(sysconfig.get_str(db, "log_level"))
|
|
|
|
|
|
@router.get("")
|
|
def get_config(_: User = Depends(admin_user), db: Session = Depends(get_db)) -> dict:
|
|
return sysconfig.describe(db)
|
|
|
|
|
|
class ConfigSetIn(BaseModel):
|
|
# A config value is arbitrarily typed (str/int/bool/…), validated downstream by
|
|
# sysconfig.set_value. `value` defaults to a sentinel so a MISSING value stays a 400
|
|
# ("Missing 'value'") while an explicit `null` is a real, settable value.
|
|
value: Any = None
|
|
|
|
|
|
@router.patch("/{key}")
|
|
def set_config(
|
|
key: str,
|
|
body: ConfigSetIn,
|
|
admin: User = Depends(admin_user),
|
|
db: Session = Depends(get_db),
|
|
) -> dict:
|
|
s = sysconfig.spec(key)
|
|
if s is None:
|
|
raise not_found("config_key")
|
|
if "value" not in body.model_fields_set:
|
|
raise refused(400, "value_missing")
|
|
if s.secret and not sysconfig.secrets_manageable():
|
|
raise refused(400, "no_encryption_key")
|
|
old = None if s.secret else sysconfig.get(db, key)
|
|
try:
|
|
sysconfig.set_value(db, key, body.value)
|
|
except sysconfig.InvalidSetting as exc:
|
|
raise setting_refused(exc) from None
|
|
# Never log a secret's value — only that it was changed, by whom. Non-secret values are logged
|
|
# but with URL credentials redacted (a proxy/URL setting can embed user:pass). Skip the row when
|
|
# a non-secret value didn't actually change (a no-op re-save shouldn't add trail noise); secrets
|
|
# are write-only so we can't compare — always log.
|
|
new = None if s.secret else sysconfig.get(db, key)
|
|
if s.secret or new != old:
|
|
audit.record(
|
|
db,
|
|
admin.id,
|
|
AuditAction.CONFIG_SET,
|
|
target_type="config",
|
|
target_id=key,
|
|
summary=f"{key} = (secret updated)" if s.secret else f"{key} = {_redact(new)}",
|
|
before=None if s.secret else {"value": _redact(old)},
|
|
after=None if s.secret else {"value": _redact(new)},
|
|
)
|
|
db.commit()
|
|
_apply_runtime(db, key)
|
|
return sysconfig.describe(db)
|
|
|
|
|
|
@router.delete("/{key}")
|
|
def reset_config(
|
|
key: str,
|
|
admin: User = Depends(admin_user),
|
|
db: Session = Depends(get_db),
|
|
) -> dict:
|
|
s = sysconfig.spec(key)
|
|
if s is None:
|
|
raise not_found("config_key")
|
|
old = None if s.secret else sysconfig.get(db, key)
|
|
sysconfig.reset(db, key)
|
|
audit.record(
|
|
db,
|
|
admin.id,
|
|
AuditAction.CONFIG_RESET,
|
|
target_type="config",
|
|
target_id=key,
|
|
summary=f"{key} → default",
|
|
before=None if s.secret else {"value": _redact(old)},
|
|
)
|
|
db.commit()
|
|
_apply_runtime(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 refused(400, "smtp_off")
|
|
sent = email_mod.send_test(user.email, mailtext.lang_for_user(user))
|
|
if not sent:
|
|
raise refused(422, "smtp_failed")
|
|
return {"ok": True, "sent": True, "to": user.email}
|
|
|
|
|
|
@router.post("/test-issues")
|
|
def test_issue_tracker(
|
|
_: User = Depends(admin_user),
|
|
db: Session = Depends(get_db),
|
|
) -> dict:
|
|
"""Check the configured issue tracker: the repository answers with this token, takes
|
|
issues, and is PRIVATE — the one it must be, since an issue carries its reporter's log."""
|
|
try:
|
|
return {"ok": True, **gitea.check(db)}
|
|
except gitea.GiteaError as exc:
|
|
raise issue_refused(exc) from None
|