feat(demo): scheduled demo-account reset (5c)

A security audit of every mutating endpoint found no isolation gaps — all routes
scope by current_user, admin routes are gated, and the demo account is sandboxed
in its own user_id space (can't reach others' data, escalate, or hit admin
routes). The remaining demo concern is communal pollution of its own shared state,
so add an automatic reset: a new 'demo_reset' scheduler job (admin-tunable
interval, default 12h) reuses the manual reset logic, and no-ops without ever
creating a demo account if none exists. Verified: a triggered run wiped the demo's
video states and re-seeded its sample playlists.
This commit is contained in:
npeter83 2026-06-19 15:25:13 +02:00
parent 7ca28189bb
commit 571f337fdf
6 changed files with 39 additions and 12 deletions

View file

@ -94,6 +94,8 @@ class Settings(BaseSettings):
autotag_interval_minutes: int = 30
subscriptions_resync_minutes: int = 360
playlist_sync_minutes: int = 360
# How often the shared demo account is auto-reset to a clean baseline (communal sandbox).
demo_reset_minutes: int = 720
# --- Maintenance / validation job ---
# Runs once a day by default (admin-tunable via the Scheduler dashboard). Grace periods
# are in days because that's the granularity that matters; the rolling re-validation

View file

@ -270,14 +270,10 @@ def _seed_demo_playlists(db: Session, demo: User) -> int:
return created
@router.post("/demo/reset")
def reset_demo(
_: User = Depends(admin_user), db: Session = Depends(get_db)
) -> dict:
"""Wipe the shared demo account's per-user state (watch/save/hide states, playlists,
preferences) back to a clean baseline and re-seed a few sample playlists. There's no
automatic reset this is the admin's manual 'clean up the sandbox' button."""
demo = get_or_create_demo_user(db)
def reset_demo_state(db: Session, demo: User) -> int:
"""Wipe the demo account's per-user state (watch/save/hide states, playlists, preferences)
back to a clean baseline and re-seed a few sample playlists. Returns the seeded count.
Shared by the admin's manual reset button and the scheduled demo reset (see scheduler)."""
db.execute(delete(VideoState).where(VideoState.user_id == demo.id))
# Playlist items cascade on playlist delete (ondelete="CASCADE").
db.execute(delete(Playlist).where(Playlist.user_id == demo.id))
@ -285,4 +281,14 @@ def reset_demo(
db.commit()
seeded = _seed_demo_playlists(db, demo)
db.commit()
return {"reset": True, "playlists_seeded": seeded}
return seeded
@router.post("/demo/reset")
def reset_demo(
_: User = Depends(admin_user), db: Session = Depends(get_db)
) -> dict:
"""Manually clean up the shared demo sandbox back to a baseline. A scheduled job does the
same automatically (admin-tunable interval); this is the admin's on-demand button."""
demo = get_or_create_demo_user(db)
return {"reset": True, "playlists_seeded": reset_demo_state(db, demo)}

View file

@ -56,6 +56,7 @@ JOB_INTERVALS: dict[str, int] = {
"subscriptions": settings.subscriptions_resync_minutes,
"playlist_sync": settings.playlist_sync_minutes,
"maintenance": settings.maintenance_interval_minutes,
"demo_reset": settings.demo_reset_minutes,
}
# Sane bounds for an admin-set interval (minutes).
@ -253,6 +254,20 @@ def _maintenance_job() -> None:
_job("maintenance", work)
def _demo_reset_job() -> None:
# Auto-clean the shared demo sandbox. No-op (and never creates one) if there's no demo
# account. Lazy import avoids a routes<->scheduler import cycle.
def work(db):
from app.models import User
demo = db.query(User).filter(User.is_demo.is_(True)).one_or_none()
if demo is None:
return {"skipped": "no demo account"}
from app.routes.admin import reset_demo_state
return {"playlists_seeded": reset_demo_state(db, demo)}
_job("demo_reset", work)
# job_id -> wrapper. The single source of truth for which jobs exist and how to run one,
# shared by start_scheduler (recurring registration) and trigger_job (manual "run now").
JOB_FUNCS: dict[str, Callable[[], None]] = {
@ -264,6 +279,7 @@ JOB_FUNCS: dict[str, Callable[[], None]] = {
"subscriptions": _subscriptions_job,
"playlist_sync": _playlist_sync_job,
"maintenance": _maintenance_job,
"demo_reset": _demo_reset_job,
}

View file

@ -76,7 +76,8 @@
"shorts": "Shorts-Klassifizierung",
"subscriptions": "Abo-Resync",
"playlist_sync": "YouTube-Wiedergabelisten-Sync",
"maintenance": "Wartung + Validierung"
"maintenance": "Wartung + Validierung",
"demo_reset": "Demo-Konto-Reset"
},
"queue": {
"recentPending": "Zu synchronisierende Kanäle",

View file

@ -76,7 +76,8 @@
"shorts": "Shorts classification",
"subscriptions": "Subscription resync",
"playlist_sync": "YouTube playlist sync",
"maintenance": "Maintenance + validation"
"maintenance": "Maintenance + validation",
"demo_reset": "Demo account reset"
},
"queue": {
"recentPending": "Channels to sync",

View file

@ -76,7 +76,8 @@
"shorts": "Shorts-besorolás",
"subscriptions": "Feliratkozások újraszinkronja",
"playlist_sync": "YouTube lejátszási listák szinkronja",
"maintenance": "Karbantartás + ellenőrzés"
"maintenance": "Karbantartás + ellenőrzés",
"demo_reset": "Demo fiók visszaállítása"
},
"queue": {
"recentPending": "Szinkronra váró csatorna",