The audit log, the server log and the admin quota view were three pages (the quota one a Stats tab),
each with its own filters, and the background runs had no list at all. Now one page ("Napló" /
"Activity") reads all four, each still its own table on the server: a shared bar (text, a period of
1 h to 90 days defaulting to 7, an account) and each tab's own filters, the whole view in the URL.
The old page ids open their tab.
Server side: the audit log and the new run list (`/api/admin/runs`) filter and page like the server
log, through one keyset helper (`routes/_keyset`) the log now shares; an audit row keeps the request
it was made in (migration 0111), so its log lines are a pivot away, as a run's are (`lrun`). The
quota breakdown takes since/until and one account. The Scheduler rows get a history button to their
job's runs.
Also fixed on the way: re-saving the value in force on the Configuration page staged its override
without committing it (the commit sat inside the audit branch), so the save was lost.
150 lines
5.4 KiB
Python
150 lines
5.4 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.config_page_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)},
|
|
)
|
|
# The override is committed even when the trail skips it: re-saving the value in force still
|
|
# pins it as an override, and an uncommitted write would be dropped with the session.
|
|
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.config_page_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
|