diff --git a/app/db.py b/app/db.py index b630a5d..81e50f5 100644 --- a/app/db.py +++ b/app/db.py @@ -71,6 +71,28 @@ def init_db() -> None: conn.close() +def prune_old_job_runs(max_age_days: int = 90) -> int: + """Delete job_runs rows older than max_age_days so the table doesn't grow + without bound (roughly 30 scheduled runs/day). Rows are tiny and useful for + history, so the retention is longer than the 30-day log-file rotation. + + manual_imports.job_run_id references job_runs, so null those references + first to respect the foreign key (the import record itself is kept). dedup + and genre candidates are review state, not run history, so they are left + alone here. Returns the number of job_runs rows deleted.""" + cutoff = time.time() - max_age_days * 86400 + with engine.begin() as conn: + conn.execute( + text( + "UPDATE manual_imports SET job_run_id = NULL " + "WHERE job_run_id IN (SELECT id FROM job_runs WHERE started_at < :c)" + ), + {"c": cutoff}, + ) + result = conn.execute(text("DELETE FROM job_runs WHERE started_at < :c"), {"c": cutoff}) + return result.rowcount or 0 + + def mark_interrupted_runs() -> None: """A job_run still marked 'running' at startup was killed mid-flight: the subprocess does not survive a container restart. Mark those so the UI and diff --git a/app/routers/auth.py b/app/routers/auth.py index 856753c..c94e96e 100644 --- a/app/routers/auth.py +++ b/app/routers/auth.py @@ -1,6 +1,6 @@ import time -from fastapi import APIRouter, Request +from fastapi import APIRouter, HTTPException, Request from fastapi.responses import RedirectResponse from sqlalchemy import select @@ -8,6 +8,7 @@ from app.db import SessionLocal from app.models import User from app.security.oidc import oauth from app.services import diagnostics +from app.settings import settings router = APIRouter(prefix="/auth", tags=["auth"]) @@ -33,6 +34,12 @@ async def auth_callback(request: Request): name = userinfo.get("name") now = time.time() + # Enforce the allowlist here, before creating any user row or session, so a + # disallowed identity never gets persisted or a cookie (require_auth also + # checks it on every request, but that's after the fact). + if settings.allowed_email and email != settings.allowed_email: + raise HTTPException(status_code=403, detail="This account is not authorized to use alembic.") + with SessionLocal() as db: user = db.execute(select(User).where(User.oidc_sub == sub)).scalar_one_or_none() if user is None: diff --git a/app/routers/credentials.py b/app/routers/credentials.py index e9e6849..d3a602e 100644 --- a/app/routers/credentials.py +++ b/app/routers/credentials.py @@ -30,6 +30,7 @@ async def credentials_index(request: Request, user: dict = Depends(require_auth) "scope_fields": credential_service.SCOPE_FIELDS, "scope_descriptions": credential_service.SCOPE_DESCRIPTIONS, "core_scopes": credential_service.CORE_SCOPES, + "secret_keys": credential_service.SECRET_KEYS, "configured": configured, "enabled": enabled, "key_present": crypto.key_present(), diff --git a/app/services/credential_service.py b/app/services/credential_service.py index e1d37fa..623b29d 100644 --- a/app/services/credential_service.py +++ b/app/services/credential_service.py @@ -44,6 +44,20 @@ SCOPE_DESCRIPTIONS = { CORE_SCOPES = {"spotify", "soulseek", "navidrome"} OPTIONAL_SCOPES = {"bandcamp", "azuracast", "qobuz", "telegram"} +# The genuinely-secret fields, masked in the UI. Everything else is an +# identifier, URL, or preference the user should be able to see while typing +# so they can verify it (e.g. the Navidrome base URL or their Soulseek +# username). Values are write-only either way -- never sent back to the browser. +SECRET_KEYS = { + "client_secret", + "password", + "admin_pass", + "cookies_txt", + "api_key", + "token", + "bot_token", +} + def _upsert(db: Session, scope: str, key: str, value: str) -> None: row = db.execute( diff --git a/app/services/scheduler_service.py b/app/services/scheduler_service.py index c91ed08..51c3ba3 100644 --- a/app/services/scheduler_service.py +++ b/app/services/scheduler_service.py @@ -85,7 +85,10 @@ async def _genre_run(triggered_by: str = "schedule"): async def _log_rotation(triggered_by: str = "schedule"): - """Replaces `find /var/log/sldl -name '*.log' -mtime +30 -delete`.""" + """Replaces `find /var/log/sldl -name '*.log' -mtime +30 -delete`, and also + prunes old job_runs rows so the database doesn't grow without bound.""" + from app.db import prune_old_job_runs + cutoff = time.time() - 30 * 86400 removed = 0 if settings.logs_dir.exists(): @@ -93,7 +96,8 @@ async def _log_rotation(triggered_by: str = "schedule"): if path.is_file() and path.stat().st_mtime < cutoff: path.unlink(missing_ok=True) removed += 1 - return removed + pruned_rows = prune_old_job_runs() + return {"logs_removed": removed, "job_run_rows_pruned": pruned_rows} MAINTENANCE_JOBS: dict[str, tuple[dict, callable]] = { diff --git a/app/templates/credentials/index.html b/app/templates/credentials/index.html index 39ed214..db09ae2 100644 --- a/app/templates/credentials/index.html +++ b/app/templates/credentials/index.html @@ -54,8 +54,10 @@ {% if field in ("cookies_txt",) %} - {% else %} + {% elif field in secret_keys %} + {% else %} + {% endif %} {% endfor %} diff --git a/app/templates/dashboard.html b/app/templates/dashboard.html index bd18df9..154ed85 100644 --- a/app/templates/dashboard.html +++ b/app/templates/dashboard.html @@ -39,12 +39,12 @@