feat!: remove the shared demo account

The demo account — one shared user that whitelisted emails entered from
the login page, reset on a schedule — goes end to end (owner decision,
E34): demo.py and the reset job, users.is_demo and demo_whitelist
(migration 0098 deletes the demo user, its audit rows, the reset job's
override and notices, then drops the column and table; the downgrade
restores the empty schema), the /auth/demo entry, the admin whitelist and
reset, the Demo tab, the banner, the login page's "Try the demo", and
every is_demo branch in routes, userscope, scheduler and the UI.

require_human only ever refused the demo account, so its 87 uses become
current_user (owner-approved); require_write_scope keeps its own check.
The login page's "or" divider now shows only beside the Google button:
without the demo entry under it, an instance without Google would have
said "or" to nothing.

A contract test (removedFeatures) fails on the word anywhere in the
backend, frontend, e2e or scripts — seen red on a planted hit. Size
ceilings tightened for the modules that shrank; admin.py is back under
the limit.
This commit is contained in:
2026-10-05 00:19:38 +02:00
parent cca3315836
commit 6d8b7be6d8
109 changed files with 487 additions and 1400 deletions
+40 -45
View File
@@ -1,9 +1,8 @@
"""Download center HTTP API — the per-user surface over the worker/cache/quota layers.
All routes are behind `require_human` (the shared demo account can't spend server disk / needs a
real identity). Admin routes add `admin_user`. Live job state is polled by the frontend (the
worker is a separate process); this module never downloads — it enqueues, manages job state,
serves finished files (range-aware, with the user's custom display name), and shares them.
All routes are behind `current_user`; admin routes add `admin_user`. Live job state is polled by the
frontend (the worker is a separate process); this module never downloads — it enqueues, manages job
state, serves finished files (range-aware, with the user's custom display name), and shares them.
"""
import os
@@ -24,7 +23,7 @@ from sqlalchemy.orm import Session
from app.mailtext import lang_for_user
from app.collation import collated
from app import audit, sysconfig
from app.auth import admin_user, require_human
from app.auth import admin_user, current_user
from app.config import settings
from app.db import get_db, release_read_connection
from app.downloads import credentials as dlcreds
@@ -212,7 +211,7 @@ def _serialize_profile(p: DownloadProfile) -> dict:
@router.get("/profiles")
def list_profiles(user: User = Depends(require_human), db: Session = Depends(get_db)) -> list[dict]:
def list_profiles(user: User = Depends(current_user), db: Session = Depends(get_db)) -> list[dict]:
rows = (
db.execute(
select(DownloadProfile)
@@ -234,7 +233,7 @@ class CreateProfileIn(BaseModel):
@router.post("/profiles")
def create_profile(
body: CreateProfileIn, user: User = Depends(require_human), db: Session = Depends(get_db)
body: CreateProfileIn, user: User = Depends(current_user), db: Session = Depends(get_db)
) -> dict:
name = body.name.strip()
spec = body.spec
@@ -262,7 +261,7 @@ class UpdateProfileIn(BaseModel):
def update_profile(
profile_id: int,
body: UpdateProfileIn,
user: User = Depends(require_human),
user: User = Depends(current_user),
db: Session = Depends(get_db),
) -> dict:
p = db.get(DownloadProfile, profile_id)
@@ -284,7 +283,7 @@ def update_profile(
@router.delete("/profiles/{profile_id}")
def delete_profile(
profile_id: int, user: User = Depends(require_human), db: Session = Depends(get_db)
profile_id: int, user: User = Depends(current_user), db: Session = Depends(get_db)
) -> dict:
p = db.get(DownloadProfile, profile_id)
if p is None or p.is_builtin or p.user_id != user.id:
@@ -300,7 +299,7 @@ def delete_profile(
@router.get("/credentials")
def list_credentials(user: User = Depends(require_human), db: Session = Depends(get_db)) -> dict:
def list_credentials(user: User = Depends(current_user), db: Session = Depends(get_db)) -> dict:
return {"hosts": dlcreds.status_list(db, user.id)}
@@ -312,7 +311,7 @@ class SetCredentialIn(BaseModel):
def set_credential(
host_key: str,
body: SetCredentialIn,
user: User = Depends(require_human),
user: User = Depends(current_user),
db: Session = Depends(get_db),
) -> dict:
"""Store (encrypted) this user's cookies.txt for one supported host. Write-only: the value is
@@ -337,7 +336,7 @@ def set_credential(
@router.delete("/credentials/{host_key}")
def delete_credential(
host_key: str, user: User = Depends(require_human), db: Session = Depends(get_db)
host_key: str, user: User = Depends(current_user), db: Session = Depends(get_db)
) -> dict:
if not dlcreds.is_supported_host(host_key):
raise not_found("host")
@@ -388,7 +387,7 @@ class EnqueueDownloadIn(BaseModel):
@router.post("")
def enqueue_download(
body: EnqueueDownloadIn, user: User = Depends(require_human), db: Session = Depends(get_db)
body: EnqueueDownloadIn, user: User = Depends(current_user), db: Session = Depends(get_db)
) -> dict:
source_kind, source_ref = resolve_source(body.source)
# SEC-12: a non-YouTube source is a raw URL the worker will fetch server-side, so refuse the
@@ -413,7 +412,7 @@ def enqueue_download(
# The upload's filename and display name travel as HEADERS, not as multipart parts. That is not a
# style choice: a DECLARED body parameter (`file: UploadFile = File(...)`) is read and spooled by
# FastAPI *before the first dependency runs*, so `require_human` could not gate it and an anonymous
# FastAPI *before the first dependency runs*, so the auth dependency could not gate it and an anonymous
# caller could make the process write the whole per-file allowance to temp, per request (P1; SEC-20
# bounded the number but could not stop the writing). Reading the body ourselves, from a dependency
# declared AFTER the auth one, is the only shape that closes it — the same one
@@ -454,13 +453,13 @@ def _header_text(request: Request, name: str) -> str:
async def spool_upload(
request: Request,
user: User = Depends(require_human),
user: User = Depends(current_user),
db: Session = Depends(get_db),
) -> AsyncIterator[SpooledUpload]:
"""Stream the request body to a staging temp file, enforcing the per-file cap as it arrives.
`user` is taken but unused: it is the whole point. Dependencies resolve in declaration order, so
naming `require_human` here makes the auth check a precondition of reading the body at all — a
naming `current_user` here makes the auth check a precondition of reading the body at all — a
stranger is refused before a byte moves. The `yield` shape owns the temp file's life: ingest
MOVES it into place on success (the unlink is then a no-op), and on any other exit — cap hit,
empty, quota, probe or move error, a client that vanished mid-upload — it is removed."""
@@ -532,7 +531,7 @@ class EnqueueEditIn(BaseModel):
@router.post("/edit")
def enqueue_edit(
body: EnqueueEditIn, user: User = Depends(require_human), db: Session = Depends(get_db)
body: EnqueueEditIn, user: User = Depends(current_user), db: Session = Depends(get_db)
) -> dict:
"""Queue a phase-2 editor derivative (trim/crop) of a download this user can access.
@@ -560,9 +559,7 @@ def _quota_detail(e: quota.QuotaExceeded) -> dict:
@router.get("")
def list_downloads(
user: User = Depends(require_human), db: Session = Depends(get_db)
) -> list[dict]:
def list_downloads(user: User = Depends(current_user), db: Session = Depends(get_db)) -> list[dict]:
jobs = (
db.execute(
select(DownloadJob)
@@ -578,7 +575,7 @@ def list_downloads(
@router.get("/usage")
def my_usage(user: User = Depends(require_human), db: Session = Depends(get_db)) -> dict:
def my_usage(user: User = Depends(current_user), db: Session = Depends(get_db)) -> dict:
"""The caller's download quota, plus the per-file upload cap.
The cap rides here rather than in `quota.usage` because it is not a quota value — it is a
@@ -600,7 +597,7 @@ _INDEX_RANK = {"done": 4, "running": 3, "paused": 2, "queued": 1}
@router.get("/index")
def my_download_index(user: User = Depends(require_human), db: Session = Depends(get_db)) -> dict:
def my_download_index(user: User = Depends(current_user), db: Session = Depends(get_db)) -> dict:
"""source_ref -> best active status, for the feed's per-card "downloaded / queued" badge.
Small + cheap so the feed can poll it without loading the full job list."""
rows = db.execute(
@@ -617,9 +614,7 @@ def my_download_index(user: User = Depends(require_human), db: Session = Depends
@router.get("/shared")
def shared_with_me(
user: User = Depends(require_human), db: Session = Depends(get_db)
) -> list[dict]:
def shared_with_me(user: User = Depends(current_user), db: Session = Depends(get_db)) -> list[dict]:
jobs = (
db.execute(
select(DownloadJob)
@@ -679,7 +674,7 @@ class UpdateDownloadMetaIn(BaseModel):
def update_download_meta(
job_id: int,
body: UpdateDownloadMetaIn,
user: User = Depends(require_human),
user: User = Depends(current_user),
db: Session = Depends(get_db),
) -> dict:
"""Update a download's display metadata: title, channel name + link, and extra reference URLs.
@@ -702,7 +697,7 @@ def update_download_meta(
@router.post("/{job_id}/pause")
def pause_download(
job_id: int, user: User = Depends(require_human), db: Session = Depends(get_db)
job_id: int, user: User = Depends(current_user), db: Session = Depends(get_db)
) -> dict:
job = _own_job_for_update(db, user, job_id)
service.pause(db, job)
@@ -711,7 +706,7 @@ def pause_download(
@router.post("/{job_id}/resume")
def resume_download(
job_id: int, user: User = Depends(require_human), db: Session = Depends(get_db)
job_id: int, user: User = Depends(current_user), db: Session = Depends(get_db)
) -> dict:
job = _own_job_for_update(db, user, job_id)
service.resume(db, job)
@@ -720,7 +715,7 @@ def resume_download(
@router.post("/{job_id}/redownload")
def redownload_download(
job_id: int, user: User = Depends(require_human), db: Session = Depends(get_db)
job_id: int, user: User = Depends(current_user), db: Session = Depends(get_db)
) -> dict:
"""Force a fresh re-download of a finished (or failed) job, rebuilding the file + Plex tree.
@@ -747,7 +742,7 @@ def redownload_download(
@router.post("/{job_id}/cancel")
def cancel_download(
job_id: int, user: User = Depends(require_human), db: Session = Depends(get_db)
job_id: int, user: User = Depends(current_user), db: Session = Depends(get_db)
) -> dict:
job = _own_job_for_update(db, user, job_id)
service.cancel(db, job)
@@ -756,7 +751,7 @@ def cancel_download(
@router.delete("/{job_id}")
def delete_download(
job_id: int, user: User = Depends(require_human), db: Session = Depends(get_db)
job_id: int, user: User = Depends(current_user), db: Session = Depends(get_db)
) -> dict:
job = _own_job_for_update(db, user, job_id)
service.delete(db, job)
@@ -786,7 +781,7 @@ def _accessible_job(db: Session, user: User, job_id: int) -> DownloadJob:
def download_file(
job_id: int,
request: Request,
user: User = Depends(require_human),
user: User = Depends(current_user),
db: Session = Depends(get_db),
):
job = _accessible_job(db, user, job_id)
@@ -825,7 +820,7 @@ def download_file(
@router.get("/{job_id}/storyboard")
def storyboard_meta(
job_id: int, user: User = Depends(require_human), db: Session = Depends(get_db)
job_id: int, user: User = Depends(current_user), db: Session = Depends(get_db)
) -> dict:
"""Filmstrip geometry for the editor's scrub track, generating the sprite on first use.
@@ -850,7 +845,7 @@ def storyboard_meta(
@router.get("/{job_id}/poster.jpg")
def poster_image(job_id: int, user: User = Depends(require_human), db: Session = Depends(get_db)):
def poster_image(job_id: int, user: User = Depends(current_user), db: Session = Depends(get_db)):
"""The locally-generated poster frame for a download whose source had no thumbnail."""
job = _accessible_job(db, user, job_id)
asset = db.get(MediaAsset, job.asset_id) if job.asset_id else None
@@ -864,7 +859,7 @@ def poster_image(job_id: int, user: User = Depends(require_human), db: Session =
@router.get("/{job_id}/storyboard.jpg")
def storyboard_image(
job_id: int, user: User = Depends(require_human), db: Session = Depends(get_db)
job_id: int, user: User = Depends(current_user), db: Session = Depends(get_db)
):
job = _accessible_job(db, user, job_id)
asset = db.get(MediaAsset, job.asset_id) if job.asset_id else None
@@ -887,7 +882,7 @@ class ShareDownloadIn(BaseModel):
def share_download(
job_id: int,
body: ShareDownloadIn,
user: User = Depends(require_human),
user: User = Depends(current_user),
db: Session = Depends(get_db),
) -> dict:
job = owned_or_404(db, DownloadJob, job_id, user, what="download")
@@ -899,7 +894,7 @@ def share_download(
recipient = db.execute(
select(User).where(func.lower(User.email) == target)
).scalar_one_or_none()
# Uniform "no user" for a missing OR non-messageable (demo/suspended/deactivated) target, so
# Uniform "no user" for a missing OR non-messageable (suspended/deactivated) target, so
# sharing can't be used to probe which addresses exist or to reach a suspended account.
if not is_messageable_user(recipient):
raise not_found("recipient")
@@ -937,7 +932,7 @@ def share_download(
def unshare_download(
job_id: int,
email: str,
user: User = Depends(require_human),
user: User = Depends(current_user),
db: Session = Depends(get_db),
) -> dict:
owned_or_404(
@@ -961,7 +956,7 @@ def unshare_download(
@router.delete("/shared/{job_id}")
def remove_shared_with_me(
job_id: int, user: User = Depends(require_human), db: Session = Depends(get_db)
job_id: int, user: User = Depends(current_user), db: Session = Depends(get_db)
) -> dict:
"""Remove a download that was shared WITH this user from their 'Shared with me' list. Deletes
only the recipient's share grant — the owner's job and the physical file are untouched (this is
@@ -982,10 +977,10 @@ def remove_shared_with_me(
@router.get("/recipients")
def share_recipients(
user: User = Depends(require_human), db: Session = Depends(get_db)
user: User = Depends(current_user), db: Session = Depends(get_db)
) -> list[dict]:
"""Registered users this user can share a download with (excludes self + any non-messageable
account — demo, suspended, or deactivated — so the picker never leaks those addresses)."""
account — suspended or deactivated — so the picker never leaks those addresses)."""
rows = (
db.execute(
select(User)
@@ -1021,7 +1016,7 @@ def _link_expiry(days: int | None) -> datetime | None:
@router.get("/{job_id}/links")
def list_links(
job_id: int, user: User = Depends(require_human), db: Session = Depends(get_db)
job_id: int, user: User = Depends(current_user), db: Session = Depends(get_db)
) -> list[dict]:
owned_or_404(db, DownloadJob, job_id, user, what="download")
rows = (
@@ -1051,7 +1046,7 @@ class CreateLinkIn(BaseModel):
def create_link(
job_id: int,
body: CreateLinkIn,
user: User = Depends(require_human),
user: User = Depends(current_user),
db: Session = Depends(get_db),
) -> dict:
job = owned_or_404(db, DownloadJob, job_id, user, what="download")
@@ -1082,7 +1077,7 @@ class UpdateLinkIn(BaseModel):
def update_link(
link_id: int,
body: UpdateLinkIn,
user: User = Depends(require_human),
user: User = Depends(current_user),
db: Session = Depends(get_db),
) -> dict:
link = _own_link(db, user, link_id)
@@ -1103,7 +1098,7 @@ def update_link(
@router.delete("/links/{link_id}")
def revoke_link(
link_id: int, user: User = Depends(require_human), db: Session = Depends(get_db)
link_id: int, user: User = Depends(current_user), db: Session = Depends(get_db)
) -> dict:
link = _own_link(db, user, link_id)
db.delete(link)