Files
alembic/app/db.py
T
andrew 2641a1b874 P2 cleanups: pin deps, prune job-run rows, unmask non-secret creds, enforce allowlist at login
- R11: pin requirements.txt to the versions running in the image, so a rebuild
  can't pull a breaking upstream release. Documented how to upgrade.
- R12: log rotation now also prunes job_runs rows older than 90 days (nulling
  the manual_imports FK reference first), so the table doesn't grow without
  bound. dedup/genre candidates are review state and left alone.
- U5: credential form shows non-secret fields (URLs, usernames, prefs) as plain
  text so they can be verified while typing; only real secrets stay masked.
  Dashboard IP card reworded from gluetun-specific to generic "Outbound IP".
- S9: enforce ALLOWED_EMAIL at the OIDC callback, before any user row or session
  is created, instead of only on later requests.

Verified: pinned build resolves; prune deletes only old rows and keeps the
manual_imports record; credentials page renders text+password inputs; a
disallowed email gets 403 with no user row, an allowed one succeeds.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 15:44:31 -06:00

129 lines
4.6 KiB
Python

import sqlite3
import time
from pathlib import Path
from sqlalchemy import create_engine, event, text
from sqlalchemy.orm import sessionmaker
from app.settings import settings
SCHEMA_DIR = Path(__file__).resolve().parent.parent / "schema"
engine = create_engine(
f"sqlite:///{settings.app_db_path}",
connect_args={"check_same_thread": False},
)
@event.listens_for(engine, "connect")
def _set_sqlite_pragmas(dbapi_connection, _record):
cursor = dbapi_connection.cursor()
cursor.execute("PRAGMA journal_mode=WAL")
cursor.execute("PRAGMA foreign_keys=ON")
cursor.close()
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False)
def _raw_schema_version(conn: sqlite3.Connection) -> int:
try:
row = conn.execute("SELECT version FROM schema_version").fetchone()
return row[0] if row else 0
except sqlite3.OperationalError:
return 0 # schema_version table doesn't exist yet (fresh database)
def _set_schema_version(conn: sqlite3.Connection, version: int) -> None:
"""schema_version holds a single row (created by migration 0001). Set it
to the migration we just applied, so the version bump never depends on the
.sql file remembering to do it itself."""
cur = conn.execute("UPDATE schema_version SET version = ?", (version,))
if cur.rowcount == 0:
conn.execute("INSERT INTO schema_version (version) VALUES (?)", (version,))
def init_db() -> None:
"""Apply schema/*.sql migrations in order, tracked by schema_version.
Plain SQL files, not the `alembic` Python migration tool — see
docs/ARCHITECTURE.md for why.
Each file is applied with executescript (so triggers and semicolons inside
string literals survive, unlike a naive split on ';'), and this function
sets schema_version after each file rather than trusting the file to. That
makes re-running safe: a file whose version is already recorded is skipped,
so a forgotten in-file version bump can't cause an ALTER to run twice and
crash startup."""
settings.app_db_path.parent.mkdir(parents=True, exist_ok=True)
migrations = sorted(SCHEMA_DIR.glob("*.sql"))
conn = sqlite3.connect(settings.app_db_path)
try:
current = _raw_schema_version(conn)
for path in migrations:
version = int(path.stem.split("_", 1)[0])
if version <= current:
continue
conn.executescript(path.read_text())
_set_schema_version(conn, version)
conn.commit()
finally:
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
history don't show a run stuck 'running' forever. Runs once at startup,
before the scheduler can create any new runs."""
with engine.begin() as conn:
conn.execute(
text(
"UPDATE job_runs SET status = 'interrupted', "
"finished_at = COALESCE(finished_at, :now) WHERE status = 'running'"
),
{"now": time.time()},
)
def enable_beets_db_wal() -> None:
"""One-time WAL enable on the beets DB so alembic's read-only status
queries can run concurrently with beets writes."""
if not settings.beets_db_path.exists():
return
conn = sqlite3.connect(settings.beets_db_path)
try:
conn.execute("PRAGMA journal_mode=WAL")
finally:
conn.close()
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()