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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user