diff --git a/backend/alembic/versions/0098_drop_demo_account.py b/backend/alembic/versions/0098_drop_demo_account.py new file mode 100644 index 00000000..7cbcd8df --- /dev/null +++ b/backend/alembic/versions/0098_drop_demo_account.py @@ -0,0 +1,55 @@ +"""demo: drop the shared demo account (the owner's decision, 2026-10-04) + +The demo account — one shared user that whitelisted emails entered from the login page, reset on a +schedule — goes with its code. Prod held the demo user and 10 whitelist rows. + +The upgrade deletes the demo user first (its playlists, states and subscriptions cascade; audit and +quota rows keep their history with the actor nulled, as for any deleted account), then the traces +that only the demo code could render: its audit rows (whitelist edits, manual resets, the scheduled +reset's job runs), the reset job's interval override and its completion notices. Nothing is +migrated anywhere; the nightly backup holds the rows. + +The downgrade restores the schema as it stood (0014), empty: the demo user is created lazily on its +first sign-in, so an empty `demo_whitelist` is the whole of its state. +""" + +import sqlalchemy as sa +from alembic import op + +revision: str = "0098_drop_demo_account" +down_revision: str | None = "0097_drop_messages" +branch_labels: str | None = None +depends_on: str | None = None + + +def upgrade() -> None: + op.execute("DELETE FROM users WHERE is_demo") + op.execute( + "DELETE FROM audit_log WHERE action IN ('demo_add', 'demo_remove', 'demo_reset')" + " OR (action = 'job_run' AND target_id = 'demo_reset')" + ) + op.execute("DELETE FROM scheduler_settings WHERE job_id = 'demo_reset'") + op.execute("DELETE FROM notifications WHERE data->>'job_id' = 'demo_reset'") + op.drop_index("ix_demo_whitelist_email", table_name="demo_whitelist") + op.drop_table("demo_whitelist") + op.drop_index("ix_users_is_demo", table_name="users") + op.drop_column("users", "is_demo") + + +def downgrade() -> None: + op.add_column( + "users", + sa.Column("is_demo", sa.Boolean(), nullable=False, server_default=sa.false()), + ) + op.create_index("ix_users_is_demo", "users", ["is_demo"]) + op.create_table( + "demo_whitelist", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("email", sa.String(length=320), nullable=False), + sa.Column("note", sa.Text(), nullable=True), + sa.Column("added_by", sa.String(length=320), nullable=True), + sa.Column( + "created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False + ), + ) + op.create_index("ix_demo_whitelist_email", "demo_whitelist", ["email"], unique=True) diff --git a/backend/app/README.md b/backend/app/README.md index f200d455..b8cd114c 100644 --- a/backend/app/README.md +++ b/backend/app/README.md @@ -1,7 +1,7 @@ # Backend conventions The rules below are already followed across `backend/app` — they lived only in a few module -docstrings (`demo.py`, `userscope.py`, `routes/_common.py`) until this file collected them (E3 S3, +docstrings (`userscope.py`, `routes/_common.py`) until this file collected them (E3 S3, B-core-20). When you add code, match these so the next reader doesn't have to guess "which pattern do I copy". diff --git a/backend/app/audit.py b/backend/app/audit.py index 6dfba3d1..1419ceeb 100644 --- a/backend/app/audit.py +++ b/backend/app/audit.py @@ -39,9 +39,6 @@ class AuditAction: # E9.5 S6: the admin chose to tell someone their address is whitelisted. Adding the address is # silent, so this is the act that actually reaches a person — and the one worth a record. INVITE_SEND = "invite_send" - DEMO_ADD = "demo_add" - DEMO_REMOVE = "demo_remove" - DEMO_RESET = "demo_reset" DISCOVERY_PURGE = "discovery_purge" # Config CONFIG_SET = "config_set" diff --git a/backend/app/auth.py b/backend/app/auth.py index 2460042e..cf5a79cd 100644 --- a/backend/app/auth.py +++ b/backend/app/auth.py @@ -24,8 +24,6 @@ def operator_contact() -> str | None: return next(iter(sorted(settings.admin_email_set)), None) -DEMO_GOOGLE_SUB = "__demo__" -DEMO_EMAIL = "demo@siftlode.local" MAX_SESSION_ACCOUNTS = 6 # OAuth scope vocabulary — the atoms + the request bundles, kept together as core. has_*_scope check @@ -129,7 +127,7 @@ def can_activate(db: Session, user: User) -> bool: Lives in core so the admin surface and the /api/me badge count answer from ONE definition; a button that appears and a number that counts must never disagree about who is stuck. """ - if user.is_demo or user.is_suspended or user.is_active: + if user.is_suspended or user.is_active: return False return is_allowed(db, user.email) @@ -147,7 +145,6 @@ def count_pending_activations(db: Session) -> int: rows = ( db.execute( select(User).where( - User.is_demo.is_(False), User.is_suspended.is_(False), User.is_active.is_(False), ) @@ -188,24 +185,6 @@ def find_user_by_email(db: Session, email: str) -> User | None: return candidates[0] if len(candidates) == 1 else None -def get_or_create_demo_user(db: Session) -> User: - """The one shared demo user, created on first use. No OAuth token / YouTube scope, so - has_read_scope/has_write_scope are False and every YouTube-touching path is closed to it.""" - user = db.query(User).filter(User.is_demo.is_(True)).one_or_none() - if user is None: - user = User( - google_sub=DEMO_GOOGLE_SUB, - email=DEMO_EMAIL, - display_name="Demo", - role="user", - is_demo=True, - preferences={"language": "en"}, - ) - db.add(user) - db.commit() - return user - - def revoke_google_token(token: str | None) -> None: """Best-effort revocation of a Google OAuth grant at Google's endpoint. Revoking the refresh token tears down the whole grant, so a later sign-in starts from a clean consent (no scopes @@ -402,25 +381,10 @@ def optional_current_user(request: Request, db: Session = Depends(get_db)) -> Us return None -def require_human(user: User = Depends(current_user)) -> User: - """Reject the shared demo account from actions that need a real Google/YouTube identity - or spend the shared quota. Most YouTube paths are already closed to it implicitly (it has - no token, so has_read_scope/has_write_scope are False); this makes the boundary explicit - where there's no scope check to lean on.""" - if user.is_demo: - # Structured, because `req` leaves 403 to the caller and `notifyActionError` only shows a - # STRUCTURED detail — a plain string here reached the demo user as their caller's generic - # "couldn't do that", with no hint that the demo account is the reason (round 6). Affordances - # this gate sits behind are not all UI-gated on `is_demo` (the comfort shelf is not). - raise HTTPException(status_code=403, detail={"code": "demo"}) - return user - - -def require_write_scope(user: User = Depends(require_human)) -> User: +def require_write_scope(user: User = Depends(current_user)) -> User: """Dependency: require the YouTube write scope (subscribe/unsubscribe, playlist export/edit). Single source of truth for the write gate — replaces the inline `if not has_write_scope(user)` - checks that had drifted into three different 403 messages across channels/playlists. Layers on - `require_human` because writing to YouTube needs a real, non-demo identity.""" + checks that had drifted into three different 403 messages across channels/playlists.""" if not has_write_scope(user): raise refused(403, "youtube_editing_off") return user diff --git a/backend/app/bodylimit.py b/backend/app/bodylimit.py index 5b4dc3a5..ddd9660e 100644 --- a/backend/app/bodylimit.py +++ b/backend/app/bodylimit.py @@ -61,7 +61,7 @@ DEFAULT_MAX_BODY_BYTES = 1024 * 1024 # Both listed routes now satisfy that. The screenshot route always did (`capped_body` streams it, # and the owner check resolves first). The upload route did NOT until P1: it declared # `file: UploadFile = File(...)`, so an anonymous caller could make the process write the whole -# per-file allowance to temp before `require_human` refused them — this middleware bounded that +# per-file allowance to temp before the auth dependency refused them — this middleware bounded that # number but could not stop the writing. It now reads its own body from a dependency declared after # the auth one, so its in-handler cap measures bytes as they arrive, like everything else here. # diff --git a/backend/app/config.py b/backend/app/config.py index 02ce10d1..3dbd21e8 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -146,8 +146,6 @@ 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 diff --git a/backend/app/demo.py b/backend/app/demo.py deleted file mode 100644 index 3cb09cd5..00000000 --- a/backend/app/demo.py +++ /dev/null @@ -1,82 +0,0 @@ -"""Demo-account service: reset the shared demo sandbox to a clean, seeded baseline. - -Shared by the admin's manual reset endpoint (routes/admin.py) and the scheduled demo-reset job -(scheduler.py). Lives in the service layer, not routes, so the scheduler can call it without an -`app.routes -> app.scheduler` import cycle (the reason its import used to be lazy). -""" - -from sqlalchemy import delete, func, select -from sqlalchemy.orm import Session - -from app.models import Playlist, PlaylistItem, Subscription, User, Video, VideoState - - -def _seed_demo_playlists(db: Session, demo: User) -> int: - """Seed a few sample playlists from the shared catalog so a freshly-reset demo isn't - empty — gives visitors something to play with. Best-effort: skips silently if the - catalog is too small.""" - recent = ( - db.execute( - select(Video.id) - .where(Video.is_short.is_(False), Video.live_status == "none") - .order_by(Video.published_at.desc().nullslast()) - .limit(60) - ) - .scalars() - .all() - ) - if not recent: - return 0 - samples = [ - ("Fresh picks", recent[0:10]), - ("Quick watch", recent[10:18]), - ("Saved for later", recent[18:26]), - ] - created = 0 - for pos, (name, vids) in enumerate(samples): - if not vids: - continue - pl = Playlist(user_id=demo.id, name=name, position=pos) - db.add(pl) - db.flush() - for ipos, vid in enumerate(vids): - db.add(PlaylistItem(playlist_id=pl.id, video_id=vid, position=ipos)) - created += 1 - return created - - -def _seed_demo_subscriptions(db: Session, demo: User, n: int = 14) -> int: - """Seed the demo with a handful of catalog channels (the ones with the most videos) so its - Channel manager isn't empty — gives visitors something to explore and matches the landing-page - screenshot. Idempotent: clears the demo's subscriptions first, then re-adds the top N.""" - db.execute(delete(Subscription).where(Subscription.user_id == demo.id)) - top = ( - db.execute( - select(Video.channel_id) - .where(Video.channel_id.is_not(None)) - .group_by(Video.channel_id) - .order_by(func.count().desc()) - .limit(n) - ) - .scalars() - .all() - ) - for channel_id in top: - db.add(Subscription(user_id=demo.id, channel_id=channel_id)) - return len(top) - - -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 + channel subscriptions. Returns - the seeded playlist 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)) - demo.preferences = {"language": "en"} - db.commit() - seeded = _seed_demo_playlists(db, demo) - _seed_demo_subscriptions(db, demo) - db.commit() - return seeded diff --git a/backend/app/diagnostics.py b/backend/app/diagnostics.py index 38e5adb4..e88facdd 100644 --- a/backend/app/diagnostics.py +++ b/backend/app/diagnostics.py @@ -10,7 +10,6 @@ Three levels, named in the UI Alap / Részletes / Teljes: A raised level always lapses after `ELEVATION_HOURS`: the expiry is computed HERE at consent time, never taken from the client, and an expired level reads as INFO without anything having to clear it. -The shared demo account cannot raise its level — its log would mix every visitor's session. Core layer: imports `models` and `sysconfig`. """ diff --git a/backend/app/models.py b/backend/app/models.py index 5d5d22fd..a13dc481 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -106,7 +106,7 @@ class User(Base, TimestampMixin, CanonicalEmailMixin): # half of the truth lives, for the UI to show when the two differ. NULL until the next Google # sign-in for accounts linked before this column existed. google_email: Mapped[str | None] = mapped_column(String(320)) - # argon2id hash for email+password login; NULL for Google-only / demo accounts. + # argon2id hash for email+password login; NULL for Google-only accounts. password_hash: Mapped[str | None] = mapped_column(String(255)) # When SEC-10 dropped a preset password on Google adoption (see the single caller of # auth.revoke_preset_password_if_unverified; admin approval no longer revokes — E9.5 S1). Without @@ -123,7 +123,7 @@ class User(Base, TimestampMixin, CanonicalEmailMixin): is_active: Mapped[bool] = mapped_column(Boolean, default=True, server_default="true") # Admin-imposed access block, distinct from is_active (approval lifecycle). A suspended # account can't sign in by any method and its live sessions are rejected; the user is told - # why and pointed at the operator's contact. The demo account is never suspendable. + # why and pointed at the operator's contact. is_suspended: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false") # Server-side session revocation (SA4): a monotonic counter bumped on password reset, password # change, or "log out everywhere". A signed session cookie records the epoch it was minted under; @@ -132,7 +132,7 @@ class User(Base, TimestampMixin, CanonicalEmailMixin): session_epoch: Mapped[int] = mapped_column(Integer, default=0, server_default="0") # When this account last signed in (Google or password), stamped by the one sign-in path, # routes/auth._establish_session. NULL = not since the column existed (0085; no backfill is - # possible). The shared demo account is never stamped — its "last login" would mean nobody. + # possible). last_login_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) # The diagnostics level this user consented to (E22 S5) and when it lapses back to the base # level. Both NULL = base. Written only by `PUT /api/me/diagnostics`, which computes the expiry @@ -146,12 +146,6 @@ class User(Base, TimestampMixin, CanonicalEmailMixin): # The user's own YouTube channel id, resolved lazily (channels.list?mine=true) and # cached. Used to exclude their own channel from playlist discovery. NULL = unresolved. yt_channel_id: Mapped[str | None] = mapped_column(String(32)) - # The single shared "demo" account: signed into without Google OAuth (via a whitelisted - # email on the login page), has no OAuth token / YouTube scope, and its per-user state is - # shared by everyone who enters this way. Exactly one such row is expected. - is_demo: Mapped[bool] = mapped_column( - Boolean, default=False, server_default="false", index=True - ) # Free-form UI preferences (theme, color scheme, font scale, default filters…). preferences: Mapped[dict | None] = mapped_column(JSON) @@ -224,19 +218,6 @@ class Invite(Base, CanonicalEmailMixin): lang: Mapped[str | None] = mapped_column(String(8)) -class DemoWhitelist(Base, TimestampMixin): - """Emails that may enter the shared demo account from the login page (no Google OAuth). - A hidden, admin-curated list of "keys to the same door" — every entry logs into the one - shared demo user. Distinct from Invite (which gates real Google sign-in).""" - - __tablename__ = "demo_whitelist" - - id: Mapped[int] = mapped_column(primary_key=True) - email: Mapped[str] = mapped_column(String(320), unique=True, index=True) - note: Mapped[str | None] = mapped_column(Text) - added_by: Mapped[str | None] = mapped_column(String(320)) # admin email - - class Channel(Base, TimestampMixin): """A YouTube channel. Shared across all users (one channel's videos are the same for everyone), so its expensive metadata is fetched and stored only once.""" diff --git a/backend/app/routes/admin.py b/backend/app/routes/admin.py index a24c0326..76187fbd 100644 --- a/backend/app/routes/admin.py +++ b/backend/app/routes/admin.py @@ -1,5 +1,4 @@ -"""Admin-only onboarding: review access requests (Invites), approve/deny, manual add. -Also the demo-account admin surface: the demo email whitelist + a manual state reset.""" +"""Admin-only onboarding: review access requests (Invites), approve/deny, manual add.""" from collections.abc import Iterable from datetime import datetime, timezone @@ -19,15 +18,12 @@ from app.auth import ( count_admins, env_allowed_canonicals, find_user_by_email, - get_or_create_demo_user, is_allowed, operator_contact, purge_user, ) from app.db import get_db -from app.demo import reset_demo_state from app.models import ( - DemoWhitelist, Invite, User, ) @@ -131,7 +127,7 @@ def _welcome_mail_parts(db: Session, email: str) -> tuple[str | None, bool, int] """ hours = int(APPROVAL_VERIFY_TTL.total_seconds() // 3600) user = find_user_by_email(db, email) - if user is None or user.is_demo: + if user is None: return None, False, hours if user.email_verified or not email_mod.email_enabled(db): return None, True, hours @@ -400,7 +396,6 @@ def _serialize_user(u: User) -> dict: "is_active": u.is_active, "is_suspended": u.is_suspended, "email_verified": u.email_verified, - "is_demo": u.is_demo, "has_password": u.password_hash is not None, # Distinguishes "this account never had a password" (Google-only) from "SEC-10 cleared it" — # the same distinction _send_reset_if_eligible needs, and the one an admin has to see before @@ -412,7 +407,7 @@ def _serialize_user(u: User) -> dict: # confusing sign-in. "google_email": u.google_email if u.google_email != u.email else None, "created_at": iso(u.created_at), - # NULL = no sign-in since the column existed (0085), or the shared demo account. + # NULL = no sign-in since the column existed (0085). "last_login_at": iso(u.last_login_at), } @@ -435,14 +430,12 @@ def set_user_role( admin: User = Depends(admin_user), db: Session = Depends(get_db), ) -> dict: - """Promote/demote a user. Guards: can't change your own role, can't touch the demo - account, and can't remove the last remaining admin. Emails the user when it actually changes.""" + """Promote/demote a user. Guards: can't change your own role and can't remove the + last remaining admin. Emails the user when it actually changes.""" role = body.role if role not in ("user", "admin"): raise refused(400, "invalid_role") target = get_or_404(db, User, user_id, what="user") - if target.is_demo: - raise refused(400, "demo_role") if target.id == admin.id: raise refused(400, "own_role") if ( @@ -490,14 +483,11 @@ def set_user_suspended( db: Session = Depends(get_db), ) -> dict: """Suspend or un-suspend an account. A suspended user can't sign in by any method and any - live session is dropped on its next request. Guards: can't suspend the demo account, can't - suspend yourself, and can't suspend the last active admin (that would lock admin out). On + live session is dropped on its next request. Guards: can't suspend yourself, and can't suspend the last active admin (that would lock admin out). On un-suspend the user is emailed they can return (suspend is announced reactively, on a blocked sign-in).""" suspended = body.suspended target = get_or_404(db, User, user_id, what="user") - if target.is_demo: - raise refused(400, "demo_suspend") if target.id == admin.id: raise refused(400, "own_suspend") if ( @@ -538,10 +528,8 @@ def admin_delete_user( ) -> dict: """Admin-triggered account deletion — the same full erasure a user can perform on themselves (cascade delete of all personal data + access-request cleanup + Google-grant revoke). Guards: - not the demo account, not yourself (use Settings → Account), and not the last admin.""" + not yourself (use Settings → Account), and not the last admin.""" target = get_or_404(db, User, user_id, what="user") - if target.is_demo: - raise refused(400, "demo_delete") if target.id == admin.id: raise refused(400, "own_delete") if ( @@ -563,9 +551,6 @@ def admin_delete_user( return {"deleted": user_id} -# --- Demo account: email whitelist + state reset ------------------------------------- - - # --- Stuck sign-ups: the admin lifeline (E9.5 S5, roadmap UX-25) --------------------------------- @@ -587,7 +572,6 @@ def _pending_access_rows(db: Session) -> list[User]: db.execute( select(User) .where( - User.is_demo.is_(False), User.is_suspended.is_(False), or_(User.is_active.is_(False), User.email_verified.is_(False)), ) @@ -653,13 +637,8 @@ def create_set_password_link( Deliberately NOT gated on the account's state. It is useful for a pre-empted address, for a revoked password, for a Google-only account that wants one, and for a plain forgotten password on an instance with no mail — narrowing it to one of those would only make the admin's real case the - one it refuses. The demo account is the single exception: it is shared, so handing anyone a way to - change its password is handing them everyone else's session.""" + one it refuses.""" target = get_or_404(db, User, user_id, what="user") - if target.is_demo: - raise HTTPException( - status_code=400, detail={"code": "setpw", "reason": "demo", "email": target.email} - ) url, expires_at = issue_set_password_link(db, target) # The URL itself is NOT recorded: the audit log is read by humans and kept long, and a live # credential-setting link is the last thing that belongs in it. That the link was issued, by whom, @@ -692,8 +671,6 @@ def activate_user( already do exactly this by re-entering the address in the whitelist box (`add_invite` → `_activate_user_by_email`). What was missing was any way to know they had to.""" target = get_or_404(db, User, user_id, what="user") - if target.is_demo: - raise refused(400, "demo_activate") if target.is_active: return _serialize_user(target) # idempotent: a double click is not an error if not is_allowed(db, target.email): @@ -731,7 +708,7 @@ def resend_user_verification( Not rate-limited: an admin is not a threat model to enumerate against, and the deliberate silence of the public endpoint is exactly what this exists to escape.""" target = get_or_404(db, User, user_id, what="user") - if target.is_demo or target.email_verified: + if target.email_verified: raise HTTPException( status_code=409, detail={"code": "resend", "reason": "not_applicable", "email": target.email}, @@ -757,93 +734,6 @@ def resend_user_verification( return {"ok": True, "email": target.email} -def _serialize_demo(row: DemoWhitelist) -> dict: - return { - "id": row.id, - "email": row.email, - "note": row.note, - "added_by": row.added_by, - "created_at": iso(row.created_at), - } - - -@router.get("/demo/whitelist") -def list_demo_whitelist(_: User = Depends(admin_user), db: Session = Depends(get_db)) -> list[dict]: - rows = ( - db.execute(select(DemoWhitelist).order_by(DemoWhitelist.created_at.desc())).scalars().all() - ) - return [_serialize_demo(r) for r in rows] - - -class AddDemoWhitelistIn(BaseModel): - email: str = "" - note: str | None = None - - -@router.post("/demo/whitelist") -def add_demo_whitelist( - body: AddDemoWhitelistIn, - admin: User = Depends(admin_user), - db: Session = Depends(get_db), -) -> dict: - """Add an email that may enter the shared demo account from the login page (no Google - sign-in). Idempotent — re-adding an existing email just returns it.""" - email = body.email.strip().lower() - if not valid_email(email): - raise refused(400, "invalid_email") - row = db.execute(select(DemoWhitelist).where(DemoWhitelist.email == email)).scalar_one_or_none() - if row is None: - row = DemoWhitelist( - email=email, - note=(body.note or "").strip() or None, - added_by=admin.email, - ) - db.add(row) - audit.record( - db, admin.id, AuditAction.DEMO_ADD, target_type="demo", target_id=email, summary=email - ) - db.commit() - return _serialize_demo(row) - - -@router.delete("/demo/whitelist/{entry_id}") -def remove_demo_whitelist( - entry_id: int, - admin: User = Depends(admin_user), - db: Session = Depends(get_db), -) -> dict: - row = db.get(DemoWhitelist, entry_id) - if row is not None: - audit.record( - db, - admin.id, - AuditAction.DEMO_REMOVE, - target_type="demo", - target_id=row.email, - summary=row.email, - ) - db.delete(row) - db.commit() - return {"deleted": entry_id} - - -@router.post("/demo/reset") -def reset_demo(admin: 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) - seeded = reset_demo_state(db, demo) - audit.record( - db, - admin.id, - AuditAction.DEMO_RESET, - summary=f"{seeded} sample playlists re-seeded", - after={"playlists_seeded": seeded}, # the count as data: named in the reader's language - ) - db.commit() - return {"ok": True, "reset": True, "playlists_seeded": seeded} - - @router.post("/purge-discovery") def purge_discovery(admin: User = Depends(admin_user), db: Session = Depends(get_db)) -> dict: """Reclaim all un-kept discovery content NOW (ignoring the grace period): live-search result diff --git a/backend/app/routes/auth.py b/backend/app/routes/auth.py index d3396c40..3fbdefd8 100644 --- a/backend/app/routes/auth.py +++ b/backend/app/routes/auth.py @@ -31,7 +31,6 @@ from app.auth import ( env_admin_canonicals, env_allowed_canonicals, find_user_by_email, - get_or_create_demo_user, google_enabled, is_allowed, log, @@ -40,7 +39,7 @@ from app.auth import ( ) from app.config import settings from app.db import SessionLocal, get_db -from app.models import AuthToken, DemoWhitelist, Invite, OAuthToken, User, display_text +from app.models import AuthToken, Invite, OAuthToken, User, display_text from app.ratelimit import RateLimiter, client_key from app.security import encrypt, hash_password, hash_token, verify_password from app.utils import canonical_email, valid_email @@ -92,8 +91,6 @@ def _notify_suspended(background: BackgroundTasks, user: User) -> None: ) -_demo_limiter = RateLimiter(max_events=5, window_seconds=60) - router = APIRouter(prefix="/auth", tags=["auth"]) _oauth = OAuth() @@ -161,18 +158,6 @@ def notify_admins_of_request( background.add_task(email_mod.send_admin_new_request, recipients, lang, requester) -def is_demo_allowed(db: Session, email: str) -> bool: - """Whether this email may enter the shared demo account (no Google sign-in).""" - if not email: - return False - return ( - db.execute( - select(DemoWhitelist).where(DemoWhitelist.email == email.lower()) - ).scalar_one_or_none() - is not None - ) - - class InviteRequest(NamedTuple): """What an access request settled on. @@ -588,7 +573,7 @@ def _complete_link( if link_uid != request.session.get("user_id") and link_uid not in accounts: return RedirectResponse(url="/?link=error") user = db.get(User, link_uid) - if user is None or user.is_demo: + if user is None: return RedirectResponse(url="/?link=error") other = db.query(User).filter(User.google_sub == sub, User.id != user.id).one_or_none() if other is not None: @@ -634,36 +619,12 @@ async def logout(request: Request): return JSONResponse({"ok": True, "switched": False}) -@router.post("/demo") -def demo_login(body: EmailIn, request: Request, db: Session = Depends(get_db)) -> dict: - """Hidden demo entry: a whitelisted email logs straight into the shared demo account, - no Google OAuth. The login page fires this quietly (debounced) as the user types/pastes, - so there is no visible 'demo login' button. Rate-limited per IP; when blocked it answers - exactly like a non-match (authenticated=false) so the field can't be hammered as an - enumeration oracle. On a match it sets the session and the client reloads into the app.""" - if not _demo_limiter.allow(client_key(request)): - return {"ok": True, "authenticated": False} - email = (body.email or "").strip().lower() - if valid_email(email) and is_demo_allowed(db, email): - demo = get_or_create_demo_user(db) - # Isolate the demo: drop any real-account wallet on this tab first, so the demo session - # can't switch to / act as a previously signed-in account via the multi-account header. - request.session.clear() - request.session["user_id"] = demo.id - _remember_epoch(request, demo) - # The demo key IS an address, so it is not in the line — see the denied branch above. - log.info("signed in", extra=logs.event("auth.login.ok", method="demo", uid=demo.id)) - return {"ok": True, "authenticated": True} - return {"ok": True, "authenticated": False} - - def _establish_session(request: Request, user: User) -> None: """Sign `user` in for this browser session — the ONE sign-in path, shared by the Google callback and password login, so both join the same account switcher and both stamp `last_login_at` (the caller commits). Multi-session: every account that authenticated in this browser is remembered so the user can switch between them; only accounts that actually proved themselves HERE land in that - wallet (it is the proof they may be switched to). The demo entry deliberately does not come - through here: it clears the wallet instead, and its shared account has no meaningful last login.""" + wallet (it is the proof they may be switched to).""" accounts = [a for a in (request.session.get("account_ids") or []) if a != user.id] accounts.append(user.id) request.session["account_ids"] = accounts[-MAX_SESSION_ACCOUNTS:] @@ -847,7 +808,7 @@ def _register_account(email: str, password: str, lang: str | None = None) -> Non # primitive: password-reset accepts this account, proves the mailbox, rewrites the hash # and bumps the session epoch. The residual is logged as SEC-16. user = existing - if not user.email_verified and not user.is_demo and email_mod.email_enabled(): + if not user.email_verified and email_mod.email_enabled(): send_verification_link(db, user) return # Without working SMTP we can't deliver a verification link, so email ownership can't gate @@ -981,7 +942,7 @@ def _resend_verify_if_eligible(email: str) -> None: return with SessionLocal() as db: user = find_user_by_email(db, email) - if user is not None and not user.email_verified and not user.is_demo: + if user is not None and not user.email_verified: send_verification_link(db, user) @@ -1027,11 +988,11 @@ def password_login( email = (body.email or "").strip().lower() password = body.password or "" user = find_user_by_email(db, email) - # Always run the KDF (against a decoy hash for an unknown / passwordless / demo account) so the + # Always run the KDF (against a decoy hash for an unknown / passwordless account) so the # response time is the same whether or not the email exists — no timing-based enumeration. - candidate_hash = user.password_hash if (user and not user.is_demo) else None + candidate_hash = user.password_hash if user else None password_ok = verify_password(password, candidate_hash or _DECOY_PASSWORD_HASH) - if user is None or user.is_demo or not password_ok: + if user is None or not password_ok: raise _login_refused(401, "invalid") if user.is_suspended: _notify_suspended(background, user) @@ -1070,7 +1031,7 @@ def password_reset_request( def _send_reset_if_eligible(email: str) -> None: """Background worker for password_reset_request: issue a reset token + email the link, but ONLY - for a real (non-demo) password account. Runs after the response with its own DB session, so the + for a password account. Runs after the response with its own DB session, so the request's timing never reveals whether the account exists. Token rides the URL FRAGMENT (#) so it can't leak into proxy/access logs or a Referer (SB3). @@ -1081,7 +1042,7 @@ def _send_reset_if_eligible(email: str) -> None: proves identity through the session rather than by mail.""" with SessionLocal() as db: user = find_user_by_email(db, email) - if user is None or user.is_demo: + if user is None: return if user.password_hash or user.password_revoked_at: raw = _issue_token(db, user, "reset", RESET_TTL) @@ -1208,9 +1169,7 @@ def logout_others( ) -> dict: """SA4: sign every OTHER session for this account out — bump the epoch so all existing cookies are rejected, then re-stamp THIS cookie so the caller stays signed in. For "I left myself logged - in somewhere" / a suspected session compromise. The demo account (shared) is excluded.""" - if user.is_demo: - raise HTTPException(status_code=403, detail={"code": "demo"}) + in somewhere" / a suspected session compromise.""" # Commit the bump BEFORE re-stamping this cookie, so a failed commit can't strand the current # session at a newer epoch than the DB (see set_password). bump_session_epoch(user) @@ -1229,8 +1188,6 @@ async def link_google( """Start linking a Google account to the signed-in (e.g. password) account. Requests only the identity scopes — YouTube access is granted separately via /auth/upgrade. The callback sees `oauth_link_uid` and attaches the identity instead of creating a new account.""" - if user.is_demo: - return RedirectResponse(url="/") client = google_oauth(db) if client is None: return RedirectResponse(url="/?link=error") @@ -1256,8 +1213,6 @@ def set_password( """Set or change the signed-in account's password. Setting a first password (Google-only account) needs no current password — the session already proves identity. Changing an existing one requires the current password, so a hijacked session can't silently rotate it.""" - if user.is_demo: - raise HTTPException(status_code=403, detail={"code": "demo"}) new_password = body.password or "" if len(new_password) < PASSWORD_MIN_LEN: raise refused(400, "weak_password", min=PASSWORD_MIN_LEN) @@ -1288,10 +1243,6 @@ async def upgrade( (unsubscribe). The shared callback stores whatever Google returns, so `can_read` / `can_write` flip on once granted. Anything other than an explicit `write` defaults to the least-privileged read scope.""" - # This is the browser-facing redirect entry point, so the shared demo account is bounced - # straight home (no YouTube link is ever attached to it) rather than shown a raw 403. - if user.is_demo: - return RedirectResponse(url="/") client = google_oauth(db) if client is None: return RedirectResponse(url="/") diff --git a/backend/app/routes/channels.py b/backend/app/routes/channels.py index 31147bf5..8f9923f5 100644 --- a/backend/app/routes/channels.py +++ b/backend/app/routes/channels.py @@ -12,7 +12,7 @@ from sqlalchemy.orm import Session from app.collation import collated, sorted_words from app.mailtext import lang_for_user from app import logs, quota -from app.auth import admin_user, current_user, require_human, require_write_scope +from app.auth import admin_user, current_user, require_write_scope from app.db import get_db from app.models import ( LIVE_OR_UPCOMING, @@ -163,8 +163,8 @@ def discover_channels( can judge a channel BEFORE subscribing — but we do NOT pull its videos. Most-present channels come first; subscribing is a separate action.""" # Resolve & cache the user's own channel id so it can be excluded — you don't subscribe - # to yourself. Lazy + cached; needs OAuth, so demo / token-less users just skip it. - if user.yt_channel_id is None and user.token is not None and not user.is_demo: + # to yourself. Lazy + cached; needs OAuth, so token-less users just skip it. + if user.yt_channel_id is None and user.token is not None: try: with ( quota.attribute(user.id, quota.QuotaAction.CHANNELS_DISCOVER), @@ -192,8 +192,7 @@ def discover_channels( # (fresh subscriber counts/thumbnails carry through without a re-query). Only the title can # change, which affects the tie-break order → re-sort in Python instead of re-hitting the DB. need = [ch.id for ch, _, _ in rows if ch.details_synced_at is None][:DISCOVERY_ENRICH_CAP] - # Skip the quota-spending enrich for the demo account (it just sees the stub metadata). - if need and not user.is_demo: + if need: try: with ( quota.attribute(user.id, quota.QuotaAction.CHANNELS_DISCOVER), @@ -311,12 +310,12 @@ def channel_detail( Lazily fills the About metadata (view count, join date, banner, links) the first time it's needed (a stub) and refreshes it once it is stale (`DETAILS_MAX_AGE`), which is how a channel nobody subscribes to (an explored one, outside the subscription import) gets a banner that still resolves. The enrich uses the API key (1 unit, - cached after), so it's skipped for the quota-less demo account.""" + cached after).""" channel = get_or_404(db, Channel, channel_id, what="channel") # No `published_at is None` sentinel: a terminated channel never gets one, so it re-fetched on # every view; rows enriched before that field existed are long past DETAILS_MAX_AGE anyway. stale = details_stale(channel, datetime.now(timezone.utc)) - if stale and not user.is_demo: + if stale: try: with ( quota.attribute(user.id, quota.QuotaAction.CHANNELS_DISCOVER), @@ -389,7 +388,7 @@ def channel_detail( def explore_channel( channel_id: str, page_token: str | None = None, - user: User = Depends(require_human), + user: User = Depends(current_user), db: Session = Depends(get_db), ) -> dict: """Open an un-subscribed channel for browsing. Records the exploration (a per-user, @@ -397,7 +396,7 @@ def explore_channel( drives the later cleanup), flags the channel ephemeral unless someone organically follows it, then ingests one page of its uploads (newest-first) marked via_explore + enriched. Returns the next page token so the page can "load more". Guarded against the shared - backfill quota reserve; blocked for the demo account (it spends shared quota).""" + backfill quota reserve.""" channel = get_or_404(db, Channel, channel_id, what="channel") if _is_blocked(db, user, channel_id): raise refused(403, "channel_blocked") @@ -493,10 +492,7 @@ def update_channel( sub.priority = body.priority if body.hidden is not None: sub.hidden = body.hidden - # The demo account is SHARED, so it must not persist deep_requested (a quota-relevant flag) onto - # the shared subscription — skip the write entirely for demo, which also keeps deep_turned_on - # False so the immediate backfill below can never fire for it. - if body.deep_requested is not None and not user.is_demo: + if body.deep_requested is not None: # Opt this channel into (or out of) full-history backfill. The deep scheduler # picks the flag up on its next run; turning it off won't undo already-fetched # videos, it just stops further deep paging if nobody else still wants it. @@ -507,7 +503,7 @@ def update_channel( # Opting a channel into full history should give an immediate feed: if we haven't even # fetched its recent uploads yet, do that one channel now (cheap) instead of waiting for - # the scheduler. (deep_turned_on is only ever True for a non-demo user — see the guard above.) + # the scheduler. if deep_turned_on and channel is not None and channel.recent_synced_at is None: with quota.attribute(user.id, quota.QuotaAction.VIDEOS_BACKFILL_RECENT): run_recent_backfill(db, [channel], max_channels=1) diff --git a/backend/app/routes/debug_sessions.py b/backend/app/routes/debug_sessions.py index 76345ea1..c1dd97c3 100644 --- a/backend/app/routes/debug_sessions.py +++ b/backend/app/routes/debug_sessions.py @@ -2,8 +2,7 @@ The session itself — what tags a request, its cap, its level — is `app/debugsession.py`. These routes are the user's handle on their OWN sessions only: another user's id 404s (`owned_or_404`), including -for an admin, who reads everyone's lines in the diagnostics console instead. The shared demo account -cannot record: its log would interleave every visitor under one account. +for an admin, who reads everyone's lines in the diagnostics console instead. Screenshots arrive as a raw image body (`image/png`, `image/jpeg`, `image/webp`), not a multipart form — one image per request, capped while it arrives, and kept only if its bytes really are one of @@ -21,7 +20,7 @@ from sqlalchemy.orm import Session from app import audit, debugsession, logs, logsink from app.audit import AuditAction -from app.auth import require_human +from app.auth import current_user from app.db import get_db, release_read_connection from app.issues import bundle, gitea from app.ratelimit import RateLimiter @@ -51,7 +50,7 @@ _new_sessions = RateLimiter(max_events=20, window_seconds=3600) @router.post("") def start_session( body: StartIn, - user: User = Depends(require_human), + user: User = Depends(current_user), db: Session = Depends(get_db), ) -> dict: """Start recording, or join the recording already running (another tab started it).""" @@ -79,7 +78,7 @@ def start_session( @router.post("/{sid}/stop") def stop_session( - sid: str, user: User = Depends(require_human), db: Session = Depends(get_db) + sid: str, user: User = Depends(current_user), db: Session = Depends(get_db) ) -> dict: session = owned_or_404(db, DebugSession, sid, user, what="debug_session") if session.ended_at is None: @@ -93,7 +92,7 @@ def stop_session( @router.get("") -def list_sessions(user: User = Depends(require_human), db: Session = Depends(get_db)) -> dict: +def list_sessions(user: User = Depends(current_user), db: Session = Depends(get_db)) -> dict: sessions = db.scalars( select(DebugSession) .where(DebugSession.user_id == user.id) @@ -105,14 +104,14 @@ def list_sessions(user: User = Depends(require_human), db: Session = Depends(get @router.get("/{sid}") def get_session( - sid: str, user: User = Depends(require_human), db: Session = Depends(get_db) + sid: str, user: User = Depends(current_user), db: Session = Depends(get_db) ) -> dict: return _view(db, owned_or_404(db, DebugSession, sid, user, what="debug_session")) @router.get("/{sid}/events") def session_events( - sid: str, user: User = Depends(require_human), db: Session = Depends(get_db) + sid: str, user: User = Depends(current_user), db: Session = Depends(get_db) ) -> dict: """The rows an issue from this session would carry — browser and server, in time order.""" session = owned_or_404(db, DebugSession, sid, user, what="debug_session") @@ -141,7 +140,7 @@ class DraftIn(BaseModel): def save_draft( sid: str, body: DraftIn, - user: User = Depends(require_human), + user: User = Depends(current_user), db: Session = Depends(get_db), ) -> dict: session = owned_or_404(db, DebugSession, sid, user, what="debug_session") @@ -162,7 +161,7 @@ def save_draft( @router.get("/{sid}/export") def export_session( - sid: str, user: User = Depends(require_human), db: Session = Depends(get_db) + sid: str, user: User = Depends(current_user), db: Session = Depends(get_db) ) -> Response: """The issue as a zip — the text, the two logs and the screenshots — for an instance without an issue tracker, or for sending it by hand. Built from the SAVED draft: what the composer shows is @@ -208,9 +207,7 @@ def _issues_rate_limited() -> HTTPException: @router.post("/{sid}/issue") -def send_issue( - sid: str, user: User = Depends(require_human), db: Session = Depends(get_db) -) -> dict: +def send_issue(sid: str, user: User = Depends(current_user), db: Session = Depends(get_db)) -> dict: """Send the session's issue to the tracker — from the SAVED draft, as the export is built — or finish a send that stopped half-way (the issue exists; only what is missing is sent).""" session = owned_or_404(db, DebugSession, sid, user, what="debug_session") @@ -302,7 +299,7 @@ class PreviewIn(BaseModel): def preview_issue( sid: str, body: PreviewIn, - user: User = Depends(require_human), + user: User = Depends(current_user), db: Session = Depends(get_db), ) -> dict: """The body as the tracker will render it. Gitea sanitises its own output; the composer still @@ -323,14 +320,14 @@ def _shot_view(shot: DebugScreenshot) -> dict: @router.get("/{sid}/screenshots") def list_screenshots( - sid: str, user: User = Depends(require_human), db: Session = Depends(get_db) + sid: str, user: User = Depends(current_user), db: Session = Depends(get_db) ) -> dict: session = owned_or_404(db, DebugSession, sid, user, what="debug_session") return {"items": [_shot_view(s) for s in debugsession.screenshots(db, session)]} def _upload_target( - sid: str, user: User = Depends(require_human), db: Session = Depends(get_db) + sid: str, user: User = Depends(current_user), db: Session = Depends(get_db) ) -> DebugSession: return owned_or_404(db, DebugSession, sid, user, what="debug_session") @@ -393,7 +390,7 @@ def _screenshot_of(db: Session, session: DebugSession, shot_id: int) -> DebugScr @router.get("/{sid}/screenshots/{shot_id}") def get_screenshot( - sid: str, shot_id: int, user: User = Depends(require_human), db: Session = Depends(get_db) + sid: str, shot_id: int, user: User = Depends(current_user), db: Session = Depends(get_db) ) -> Response: shot = _session_screenshot(db, sid, shot_id, user) # `nosniff`: the type was checked against the bytes at upload, and the browser must not guess. @@ -406,7 +403,7 @@ def get_screenshot( @router.delete("/{sid}/screenshots/{shot_id}") def delete_screenshot( - sid: str, shot_id: int, user: User = Depends(require_human), db: Session = Depends(get_db) + sid: str, shot_id: int, user: User = Depends(current_user), db: Session = Depends(get_db) ) -> dict: session = owned_or_404(db, DebugSession, sid, user, what="debug_session") shot = _screenshot_of(db, session, shot_id) diff --git a/backend/app/routes/discover.py b/backend/app/routes/discover.py index 0d61f99e..09a98572 100644 --- a/backend/app/routes/discover.py +++ b/backend/app/routes/discover.py @@ -17,7 +17,7 @@ from sqlalchemy import select from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session -from app.auth import current_user, require_human +from app.auth import current_user from app.db import get_db from app.discover import hero as hero_mod from app.discover import pins, rails @@ -127,15 +127,14 @@ def list_pins( @router.post("/pins") def add_pin( body: PinIn, - user: User = Depends(require_human), + user: User = Depends(current_user), db: Session = Depends(get_db), ) -> dict: """Pin something to the comfort shelf. - `require_human`, like every other personal-state write: the shared demo account must not be able - to curate a communal shelf. Re-pinning something already on the shelf is a no-op that returns the - existing pin rather than a 409 — the gesture is "make sure this is on my shelf", and the unique - constraint is the mechanism, not the message.""" + Re-pinning something already on the shelf is a no-op that returns the existing pin rather than + a 409 — the gesture is "make sure this is on my shelf", and the unique constraint is the + mechanism, not the message.""" resolved = pins.resolve_ref(db, user, body.kind, body.ref) if resolved is None: raise not_found("item") @@ -178,7 +177,7 @@ def add_pin( @router.delete("/pins/{pin_id}") def remove_pin( pin_id: int, - user: User = Depends(require_human), + user: User = Depends(current_user), db: Session = Depends(get_db), ) -> dict: pin = owned_or_404(db, ComfortPin, pin_id, user, what="pin") @@ -190,7 +189,7 @@ def remove_pin( @router.post("/pins/reorder") def reorder_pins( body: ReorderIn, - user: User = Depends(require_human), + user: User = Depends(current_user), db: Session = Depends(get_db), ) -> dict: """Rewrite the shelf order from the list of pin ids the client shows. diff --git a/backend/app/routes/downloads.py b/backend/app/routes/downloads.py index d9a11554..681464b1 100644 --- a/backend/app/routes/downloads.py +++ b/backend/app/routes/downloads.py @@ -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) diff --git a/backend/app/routes/feed.py b/backend/app/routes/feed.py index 46c62601..360c32eb 100644 --- a/backend/app/routes/feed.py +++ b/backend/app/routes/feed.py @@ -419,11 +419,7 @@ def get_video_detail( "duration_seconds": v.duration_seconds, } - # The DB path above is free; the off-catalog YouTube lookup below spends a quota unit, so the - # demo account can't reach it — it just sees "unknown" for a video we don't already store. - if user.is_demo: - raise not_found("video") - + # The DB path above is free; the off-catalog YouTube lookup below spends a quota unit. try: with ( quota.attribute(user.id, quota.QuotaAction.VIDEOS_LOOKUP), diff --git a/backend/app/routes/me.py b/backend/app/routes/me.py index 52cd8f3f..61d4fa6b 100644 --- a/backend/app/routes/me.py +++ b/backend/app/routes/me.py @@ -1,6 +1,6 @@ from typing import Any -from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Request +from fastapi import APIRouter, BackgroundTasks, Depends, Request from pydantic import BaseModel, RootModel from sqlalchemy import func, select from sqlalchemy.orm import Session @@ -131,7 +131,6 @@ def get_me( "display_name": user.display_name, "avatar_url": user.avatar_url, "role": user.role, - "is_demo": user.is_demo, "has_google": user.google_sub is not None, # The address Google answers with, when it DIFFERS from the account's own. Same string for # most people, and then this is None so no surface has to decide whether to show it — the @@ -141,7 +140,7 @@ def get_me( ), "has_password": user.password_hash is not None, # The account's latest sign-in on any device — shown in Settings → Account, so a sign-in the - # user does not recognise is something they can see (null: none since 0.88, or the demo). + # user does not recognise is something they can see (null: none since 0.88). "last_login_at": iso(user.last_login_at), # Instance-wide: whether Google sign-in / YouTube connect is available at all (the UI hides # YouTube-access affordances and the onboarding nudge when it isn't configured). @@ -184,15 +183,8 @@ def delete_my_account( """GDPR self-service erasure: permanently delete the signed-in account and ALL its personal data — OAuth tokens, subscriptions, tags, watch/save/hide states, playlists, notifications — which all cascade on the users row. Quota-audit events are kept but anonymised (FK SET NULL). - The shared demo account can't be deleted, and the last remaining admin can't delete itself - (that would lock everyone out of the admin surfaces).""" - # Structured, like the other three demo refusals: `req` leaves 403 to the caller and - # `notifyActionError` discards a plain-string detail in favour of the caller's translated - # fallback, so a hand-written English sentence here reaches a Hungarian UI as a generic - # "couldn't do that" — or, on this affordance, as nothing at all. `reason` distinguishes it - # from the other sites' bare `{code: "demo"}` so the UI can say what specifically is refused. - if user.is_demo: - raise HTTPException(status_code=403, detail={"code": "demo", "reason": "delete_account"}) + The last remaining admin can't delete itself (that would lock everyone out of the admin + surfaces).""" if user.role == "admin" and count_admins(db) <= 1: raise refused(400, "only_admin_delete") user_id = user.id @@ -242,10 +234,7 @@ def update_diagnostics( ) -> dict: """Raise the diagnostics level (the user consented in Settings → Diagnosztika) or return to the base. The expiry is the server's: a raised level always lapses after - `diagnostics.ELEVATION_HOURS`, whatever the client asks. The shared demo account cannot raise - it — its log would interleave every visitor's session under one account.""" - if user.is_demo and body.level != diagnostics.BASE: - raise HTTPException(status_code=403, detail={"code": "demo", "reason": "diagnostics"}) + `diagnostics.ELEVATION_HOURS`, whatever the client asks.""" diagnostics.set_level(user, body.level) db.commit() # The rest of THIS request, and the process's elevation registry, follow the new level now. diff --git a/backend/app/routes/notifications.py b/backend/app/routes/notifications.py index 33fb61d4..52d3a24e 100644 --- a/backend/app/routes/notifications.py +++ b/backend/app/routes/notifications.py @@ -1,9 +1,5 @@ """Per-user notification inbox (phase 1): the durable, server-backed center that coexists -with the client-side transient bell. Read-only listing plus read/dismiss/clear mutations. - -All endpoints use `current_user` (not `require_human`): notifications and their read/dismiss -state are personal UI state with no quota or YouTube involvement, so the shared demo account -manages its own inbox normally.""" +with the client-side transient bell. Read-only listing plus read/dismiss/clear mutations.""" from fastapi import APIRouter, Depends, Query from sqlalchemy import func, select, update diff --git a/backend/app/routes/plex/__init__.py b/backend/app/routes/plex/__init__.py index ce713a10..604029b4 100644 --- a/backend/app/routes/plex/__init__.py +++ b/backend/app/routes/plex/__init__.py @@ -1,7 +1,7 @@ """Plex route package — assembles the admin/browse/playback sub-routers. -Read endpoints (libraries/browse/show/image) are available to ANY authenticated user (demo + -pure-Siftlode included) — this is an access layer to the Plex library WITHOUT requiring a plex.tv +Read endpoints (libraries/browse/show/image) are available to ANY authenticated user +(pure-Siftlode included) — this is an access layer to the Plex library WITHOUT requiring a plex.tv account, and playback is a local file. Admin endpoints test the connection and run the catalog sync. Split out of the former single routes/plex.py (R12 S1). `router` keeps the same shape the app diff --git a/backend/app/routes/public.py b/backend/app/routes/public.py index fa2a2107..90bd0a4d 100644 --- a/backend/app/routes/public.py +++ b/backend/app/routes/public.py @@ -1,6 +1,6 @@ """Public, login-free surface for shared download links (the `/watch/{token}` player page). -These routes are deliberately OUTSIDE the auth/`require_human` gate — the unguessable token IS the +These routes are deliberately OUTSIDE the auth gate — the unguessable token IS the credential (a capability URL). They only ever expose one file's stream + minimal metadata, never the app or any account data. Password-protected links exchange the password once at `/unlock` for a short-lived signed grant that the media URL carries (a `