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
@@ -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)
+1 -1
View File
@@ -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".
-3
View File
@@ -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"
+3 -39
View File
@@ -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
+1 -1
View File
@@ -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.
#
-2
View File
@@ -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
-82
View File
@@ -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
-1
View File
@@ -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`.
"""
+3 -22
View File
@@ -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."""
+9 -119
View File
@@ -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
+11 -60
View File
@@ -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="/")
+10 -14
View File
@@ -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)
+15 -18
View File
@@ -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)
+7 -8
View File
@@ -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.
+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)
+1 -5
View File
@@ -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),
+5 -16
View File
@@ -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.
+1 -5
View File
@@ -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
+2 -2
View File
@@ -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
+1 -1
View File
@@ -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 `<video src>` can't send a header).
+7 -8
View File
@@ -4,15 +4,14 @@ from sqlalchemy import func, select, update
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from app.auth import require_human
from app.auth import current_user
from app.db import get_db
from app.models import SavedView, User
from app.routes._common import owned_or_404
from app.refusal import refused
# Saved "smart views": per-user named snapshots of a module's filters (feed or plex, keyed by
# `source`). Gated behind require_human so the shared demo account can't pollute a communal list
# (mirrors how other personal-state writes are scoped).
# `source`).
router = APIRouter(prefix="/api/saved-views", tags=["saved-views"])
# The modules that own saved views. A view is scoped to exactly one; the default is "feed" so a caller
@@ -37,7 +36,7 @@ def _serialize(v: SavedView) -> dict:
@router.get("")
def list_views(
source: str = "feed",
user: User = Depends(require_human),
user: User = Depends(current_user),
db: Session = Depends(get_db),
) -> list[dict]:
source = _norm_source(source)
@@ -81,7 +80,7 @@ class CreateViewIn(BaseModel):
@router.post("")
def create_view(
body: CreateViewIn,
user: User = Depends(require_human),
user: User = Depends(current_user),
db: Session = Depends(get_db),
) -> dict:
name = body.name.strip()
@@ -124,7 +123,7 @@ class UpdateViewIn(BaseModel):
def update_view(
view_id: int,
body: UpdateViewIn,
user: User = Depends(require_human),
user: User = Depends(current_user),
db: Session = Depends(get_db),
) -> dict:
view = owned_or_404(db, SavedView, view_id, user, what="view")
@@ -163,7 +162,7 @@ def update_view(
@router.delete("/{view_id}")
def delete_view(
view_id: int,
user: User = Depends(require_human),
user: User = Depends(current_user),
db: Session = Depends(get_db),
) -> dict:
view = owned_or_404(db, SavedView, view_id, user, what="view")
@@ -179,7 +178,7 @@ class ReorderViewsIn(BaseModel):
@router.post("/reorder")
def reorder_views(
body: ReorderViewsIn,
user: User = Depends(require_human),
user: User = Depends(current_user),
db: Session = Depends(get_db),
) -> dict:
ids = body.ids
+5 -5
View File
@@ -5,8 +5,8 @@ with the normal feed cards + in-app player and gain per-user state (watch / save
source is admin-selectable (``search_source``): **scrape** (InnerTube, the website's own
endpoint, zero API quota — the default) or **api** (search.list, **100 quota units per page**).
Either way it's gated behind a per-user daily cap, the API source additionally behind a budget
pre-check, and only real users (never the shared demo account) may call it. The result ids are
enriched through the cheap shared videos.list path (1 unit / 50) regardless of source.
pre-check. The result ids are enriched through the cheap shared videos.list path (1 unit / 50)
regardless of source.
Ingested results are flagged (videos.via_search / channels.from_search) so the Library can hide
this search-discovered "noise" by default. Shorts and currently-live/upcoming videos are never
@@ -23,7 +23,7 @@ from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.orm import Session, aliased
from app import logs, quota, sysconfig
from app.auth import require_human
from app.auth import current_user
from app.db import get_db
from app.models import (
LIVE_OR_UPCOMING,
@@ -251,7 +251,7 @@ def search_youtube(
q: str | None = None,
cursor: str | None = None,
limit: int = 20,
user: User = Depends(require_human),
user: User = Depends(current_user),
db: Session = Depends(get_db),
) -> dict:
"""Live YouTube search results materialised into the catalog. `limit` is how many results to
@@ -402,7 +402,7 @@ class ClearSearchIn(BaseModel):
@router.post("/clear")
def clear_search(
body: ClearSearchIn,
user: User = Depends(require_human),
user: User = Depends(current_user),
db: Session = Depends(get_db),
) -> dict:
"""Discard the given live-search results "as if never added": drop this user's search-finds
+3 -3
View File
@@ -4,7 +4,7 @@ from sqlalchemy.orm import Session
from app import audit, quota, state
from app.audit import AuditAction
from app.auth import admin_user, current_user, has_read_scope, require_human
from app.auth import admin_user, current_user, has_read_scope
from app.db import get_db
from app.models import Channel, Subscription, User, Video
from app.routes._common import youtube_refusal
@@ -26,7 +26,7 @@ _sync_limiter = RateLimiter(max_events=3, window_seconds=600)
@router.post("/subscriptions")
def sync_subscriptions(user: User = Depends(require_human), db: Session = Depends(get_db)) -> dict:
def sync_subscriptions(user: User = Depends(current_user), db: Session = Depends(get_db)) -> dict:
if not has_read_scope(user):
# No YouTube read grant yet — the onboarding wizard hasn't been completed.
raise refused(403, "youtube_read_scope")
@@ -138,7 +138,7 @@ def my_status(user: User = Depends(current_user), db: Session = Depends(get_db))
@router.post("/deep-all")
def deep_all(
user: User = Depends(require_human),
user: User = Depends(current_user),
db: Session = Depends(get_db),
on: bool = True,
) -> dict:
+2 -17
View File
@@ -13,9 +13,8 @@ from app import audit, debugsession, logs, logsink, progress, quota
from app.audit import AuditAction
from app.config import settings
from app.db import SessionLocal
from app.demo import reset_demo_state
from app.downloads.gc import run_download_gc
from app.models import SchedulerSetting, User
from app.models import SchedulerSetting
from app.plex.sync import sync as run_plex_sync
from app.plex.tmdb import run_enrich as run_tmdb_enrich
from app.plex.tmdb import run_people_enrich
@@ -63,7 +62,6 @@ JOB_INTERVALS: dict[str, int] = {
"subscriptions": settings.subscriptions_resync_minutes,
"playlist_sync": settings.playlist_sync_minutes,
"maintenance": settings.maintenance_interval_minutes,
"demo_reset": settings.demo_reset_minutes,
"explore_cleanup": settings.explore_cleanup_minutes,
"download_gc": settings.download_gc_minutes,
"plex_sync": settings.plex_sync_interval_min,
@@ -139,8 +137,7 @@ def _notify_done(
def _run_changed(result) -> bool:
"""Did an automatic job run actually do something worth an audit row? (No-op polls don't.)
A "change" = a truthy NUMERIC count; status-string dicts like {"skipped": "disabled"} (Plex
off — the default) or {"skipped": "no demo account"} are no-ops, not changes, so they don't
flood the trail every interval."""
off — the default) are no-ops, not changes, so they don't flood the trail every interval."""
if result is None:
return False
if isinstance(result, dict):
@@ -370,17 +367,6 @@ def _maintenance_job() -> None:
_job("maintenance", work)
def _demo_reset_job() -> None:
# Auto-clean the shared demo sandbox. No-op (and never creates one) if there's no demo account.
def work(db):
demo = db.query(User).filter(User.is_demo.is_(True)).one_or_none()
if demo is None:
return {"skipped": "no demo account"}
return {"playlists_seeded": reset_demo_state(db, demo)}
_job("demo_reset", work)
def _explore_cleanup_job() -> None:
# Reclaim un-kept ephemeral discovery content (explored channels + live-search results) after
# their grace periods. 0 quota (pure DB), so no quota attribution.
@@ -466,7 +452,6 @@ JOB_FUNCS: dict[str, Callable[[], None]] = {
"subscriptions": _subscriptions_job,
"playlist_sync": _playlist_sync_job,
"maintenance": _maintenance_job,
"demo_reset": _demo_reset_job,
"explore_cleanup": _explore_cleanup_job,
"download_gc": _download_gc_job,
"plex_sync": _plex_sync_job,
+8 -9
View File
@@ -1,23 +1,22 @@
"""Shared "who is a real, active, non-suspended human" predicate.
"""Shared "who is an active, non-suspended account" predicate.
Used wherever a user must be filtered out of a directory / recipient list or rejected as a target:
the messaging directory AND the download share-by-email / recipient-picker endpoints. Kept in one
place so the two forms (SQL clauses for a query, Python check for a loaded row) can't drift apart,
and so a route module doesn't have to import another route module just to reuse the rule.
Used wherever a user must be filtered out of a recipient list or rejected as a target: the download
share-by-email and recipient-picker endpoints. Kept in one place so the two forms (SQL clauses for a
query, Python check for a loaded row) can't drift apart, and so a route module doesn't have to import
another route module just to reuse the rule.
"""
from app.models import User
def messageable_clauses() -> list:
"""SQL clauses for "a real, active, non-suspended human" — splat into a directory/recipient query."""
"""SQL clauses for "an active, non-suspended account" — splat into a recipient query."""
return [
User.is_demo.is_(False),
User.is_active.is_(True),
User.is_suspended.is_(False),
]
def is_messageable_user(u: User | None) -> bool:
"""Python mirror of `messageable_clauses()` for an already-loaded row (WS auth, send recipient)."""
return bool(u and not u.is_demo and u.is_active and not u.is_suspended)
"""Python mirror of `messageable_clauses()` for an already-loaded row (a share recipient)."""
return bool(u and u.is_active and not u.is_suspended)
+1 -1
View File
@@ -13,7 +13,7 @@ def iso(dt: date | None) -> str | None:
# Single source of truth for "looks like an email" — used by every endpoint that accepts an
# address (auth register/reset/demo, admin invites + demo whitelist, the setup wizard). Keep
# address (auth register/reset, admin invites, the setup wizard). Keep
# the strictness in one place so routes don't drift between this and a looser "@"-in check.
_EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
-6
View File
@@ -22,12 +22,6 @@ def test_clearing_the_audit_log_keeps_the_count_as_data(as_user, db, make_user):
assert _after(db, "audit_clear") == {"cleared": 2}
def test_a_demo_reset_keeps_the_count_as_data(as_user, db, make_user):
admin = make_user(email="boss@example.com", role="admin")
seeded = as_user(admin).post("/api/admin/demo/reset").json()["playlists_seeded"]
assert _after(db, "demo_reset") == {"playlists_seeded": seeded}
def test_a_discovery_purge_keeps_its_counts_as_data(as_user, db, make_user):
admin = make_user(email="boss@example.com", role="admin")
body = as_user(admin).post("/api/admin/purge-discovery").json()
+2 -2
View File
@@ -120,7 +120,7 @@ def test_a_raised_ceiling_is_still_a_ceiling():
"""The difference between this and an exemption, and the reason the list is a mapping: a route
that needs more bytes does not thereby get unlimited bytes. Left uncounted, the upload route
would let an ANONYMOUS caller stream without bound — FastAPI parses a declared body before the
first dependency, so `require_human` has not run yet when the bytes land."""
first dependency, so the auth dependency has not run yet when the bytes land."""
over = b"x" * (BIG[("POST", "/api/things/{tid}/blob")] + 1)
r = _client(routes=BIG).post("/api/things/abc/blob", content=over)
assert r.status_code == 413
@@ -514,7 +514,7 @@ def test_no_route_with_a_raised_ceiling_declares_a_body_parameter(method, templa
A raised ceiling is only honest when the route's own cap applies to the bytes AS THEY ARRIVE.
FastAPI reads and spools a DECLARED body (`= File(...)`, `= Form(...)`, a Pydantic model)
BEFORE the first dependency resolves — so on such a route this middleware's ceiling is the only
thing between an anonymous caller and the whole allowance, `require_human` never gets a say, and
thing between an anonymous caller and the whole allowance, the auth dependency never gets a say, and
the route's in-handler cap measures bytes that have already landed. That is exactly what P1
closed on the upload route, which sat in `ROUTE_MAX_BODY_BYTES` with a 500 MiB ceiling and a
`file: UploadFile = File(...)` body for one release.
+4 -14
View File
@@ -1,5 +1,5 @@
"""R14 backend coverage: the channel-detail Normal/Shorts/Live counts (moved off the manager table),
the update_channel -> immediate-backfill wiring (incl. the demo guard), and the interactive
the update_channel -> immediate-backfill wiring, and the interactive
"get full history" guard that keeps it from racing the scheduler's cursor or draining the budget."""
from datetime import datetime, timezone
@@ -89,7 +89,7 @@ def test_channel_detail_hidden_defaults_false_when_not_subscribed(db, monkeypatc
assert resp.json()["hidden"] is False
def _setup_channel_and_sub(db, *, email, is_demo=False):
def _setup_channel_and_sub(db, *, email):
ch = Channel(
id="UC_wire",
title="Wire",
@@ -98,7 +98,7 @@ def _setup_channel_and_sub(db, *, email, is_demo=False):
uploads_playlist_id="UU_wire",
)
db.add(ch)
user = User(email=email, is_demo=is_demo)
user = User(email=email)
db.add(user)
db.flush()
db.add(Subscription(user_id=user.id, channel_id=ch.id, deep_requested=False))
@@ -117,7 +117,7 @@ def _patch_deep_requested(db, monkeypatch, override_dep, user):
return resp, calls
def test_deep_request_schedules_immediate_backfill_for_non_demo(db, monkeypatch, override_dep):
def test_deep_request_schedules_immediate_backfill(db, monkeypatch, override_dep):
ch, me = _setup_channel_and_sub(db, email="wire@example.com")
resp, calls = _patch_deep_requested(db, monkeypatch, override_dep, me)
assert resp.status_code == 200, resp.text
@@ -126,16 +126,6 @@ def test_deep_request_schedules_immediate_backfill_for_non_demo(db, monkeypatch,
assert calls == [(ch.id, me.id)]
def test_deep_request_does_not_schedule_immediate_backfill_for_demo(db, monkeypatch, override_dep):
# The SHARED demo account must not be able to kick immediate full backfills (budget drain).
_ch, demo = _setup_channel_and_sub(db, email="demo@example.com", is_demo=True)
resp, calls = _patch_deep_requested(db, monkeypatch, override_dep, demo)
assert resp.status_code == 200, resp.text
assert calls == [], "demo must not schedule an immediate backfill"
# …and deep_requested is not persisted on the shared demo subscription either.
assert resp.json()["deep_requested"] is False
class TestImmediateBackfillGuard:
"""backfill_channel_now coordinates with the paced scheduler through _backfill_in_progress (per-
channel mutual exclusion, both directions) + _immediate_running (a cap on interactive runs so a
+1 -6
View File
@@ -2,7 +2,7 @@
Four parts:
* the session's life: one open per user, a second Start joins it, a Stop ends it, the cap ends it
on its own, the demo account cannot record, nobody reaches another user's session (DB lane);
on its own, nobody reaches another user's session (DB lane);
* the tag: a request naming the caller's own open session is logged at its level with every line
tagged, while a header naming anything else is ignored (a real sign-in through `_session_user`);
* the browser's batches: a session counts as consent to its level, and only its own events keep
@@ -71,11 +71,6 @@ def test_only_the_two_raised_levels_can_be_recorded(db, make_user, as_user):
assert client.post("/api/debug-sessions", json={"level": bogus}).status_code == 422
def test_the_demo_account_cannot_record(db, make_user, as_user):
r = as_user(make_user(is_demo=True)).post("/api/debug-sessions", json={"level": "DEBUG"})
assert r.status_code == 403 and r.json()["detail"]["code"] == "demo"
def test_stop_ends_it_and_me_stops_reporting_it(db, client, make_user):
_signed_in(client, make_user)
sid = _start(client)["id"]
-92
View File
@@ -1,92 +0,0 @@
"""The shared demo account's refusal says WHY, in the reader's language (deep-review round 6).
`require_human` answered `403 "Not available in the demo account."` — a plain string. `req` leaves
403 to the caller, and round 5 taught `notifyActionError` to discard a plain-string detail in favour
of the caller's already-translated fallback, so from that commit on the demo user got a generic
"couldn't do that" and no hint that the demo account was the reason. Not every affordance behind
this gate is UI-gated on `is_demo` either — the comfort shelf is not, so the pin button is live.
A structured `{code: "demo"}` is what `localizeDetail` can turn into `errors.demo` in both locales.
Pinned at every site: the shared dependency, the two auth routes that hand-roll the same check,
the account-delete route in `routes/me.py` — which round 6 missed while its docstring claimed
all the sites were covered, so the next reader had no reason to look (round 7). An asymmetric fix is
the smell this repo bans, and a docstring asserting symmetry it does not have is worse than none.
The list is derived from the source, not typed out: `test_every_is_demo_refusal_is_on_the_list`
below fails when a fifth `is_demo` 403 appears anywhere in `app/`, so this file cannot fall behind
the code the way round 6's did. (E22 S5 added the fifth: raising the diagnostics level.) Needs the DB.
"""
import re
from pathlib import Path
from app.models import User
def test_every_demo_refusal_carries_the_same_structured_reason(db, make_user, as_user):
demo = make_user(email="demo@example.com", is_demo=True)
client = as_user(demo)
for method, url, body in [
# `require_human` itself, through an affordance the UI does NOT gate on is_demo.
("post", "/api/discover/pins", {"kind": "video", "ref": "v1"}),
# The three routes that repeat the check inline rather than depending on the gate.
("post", "/auth/logout-others", None),
("post", "/auth/set-password", {"password": "a long enough password"}),
("delete", "/api/me/account", None),
# E22 S5: the shared account's log would interleave every visitor's session.
("put", "/api/me/diagnostics", {"level": "DEBUG"}),
]:
r = getattr(client, method)(url, **({"json": body} if body is not None else {}))
assert r.status_code == 403, (url, r.text)
detail = r.json()["detail"]
# Every one of them is a dict whose `code` the frontend can localize; the delete route adds
# a `reason` so its refusal can be worded specifically.
assert isinstance(detail, dict), (url, detail)
assert detail["code"] == "demo", (url, detail)
def test_every_is_demo_refusal_is_on_the_list():
"""A fifth site must not be able to appear unnoticed.
Round 6 converted three of four `is_demo` 403s and wrote a docstring saying it had converted
them all; the fourth kept answering a bare English string for another round because nothing
counted them. So count them here: every `is_demo`-guarded 403 in `app/` has to raise a
structured detail, and the number of them has to match what this file exercises."""
app_dir = Path(__file__).resolve().parents[1] / "app"
sites = []
for path in sorted(app_dir.rglob("*.py")):
lines = path.read_text(encoding="utf-8").splitlines()
for i, line in enumerate(lines):
# `if user.is_demo:` / `if u.is_demo and …:` — the hand-rolled gate, not the ORM
# filters (`User.is_demo.is_(False)`) and not the column definition.
if not re.search(r"^\s*if\s+\w+\.is_demo\b[^:]*:", line):
continue
block = "\n".join(lines[i : i + 6])
if "403" not in block:
continue
sites.append(f"{path.relative_to(app_dir)}:{i + 1}")
assert 'detail={"code": "demo"' in block, (
f"{path.relative_to(app_dir)}:{i + 1} refuses the demo account with an "
f"unstructured detail — the frontend cannot localize it:\n{block}"
)
# auth.py's shared gate + routes/auth.py's two + routes/me.py's delete and diagnostics level. A
# new one is welcome, but it has to be added to the loop above in the same commit.
assert len(sites) == 5, sites
def test_a_real_account_is_not_refused_by_the_gate(db, make_user, as_user):
"""The other half: the structured detail must not come from having broken the gate open the
other way. A non-demo account gets past `require_human` and fails (or succeeds) on its own
merits — anything but a 403 demo refusal."""
client = as_user(make_user(email="real@example.com"))
r = client.post("/auth/logout-others")
assert r.status_code == 200, r.text
assert not isinstance(r.json().get("detail"), dict)
def test_the_gate_reads_the_flag_and_not_the_email(db):
"""`is_demo` is a column, not a convention about the address — a real account that happens to
be called demo@ must not be refused."""
from app.auth import require_human
assert require_human(User(email="demo@example.com", is_demo=False)) is not None
+2 -9
View File
@@ -1,8 +1,8 @@
"""E22 S5 — a user's consented diagnostics level, and the browser log it lets in.
Three parts:
* the level itself: the server computes its expiry, a lapsed one reads as the base, the demo
account cannot raise it (DB lane, through the real routes);
* the level itself: the server computes its expiry, a lapsed one reads as the base (DB lane,
through the real routes);
* `POST /api/diagnostics/events`: refused at the base level, and nothing in a batch is trusted —
the account comes from the session, everything is redacted again, events under the consented
level are dropped, sizes and pace are capped (DB lane);
@@ -93,13 +93,6 @@ def test_only_the_three_levels_can_be_chosen(db, make_user, as_user):
assert client.put("/api/me/diagnostics", json={"level": bogus}).status_code == 422
def test_the_demo_account_can_return_to_the_base_but_never_raise_it(db, make_user, as_user):
client = as_user(make_user(is_demo=True))
r = client.put("/api/me/diagnostics", json={"level": "TRACE"})
assert r.status_code == 403 and r.json()["detail"]["code"] == "demo"
assert client.put("/api/me/diagnostics", json={"level": "INFO"}).status_code == 200
def test_preferences_must_be_a_json_object(db, make_user, as_user):
client = as_user(make_user())
assert client.put("/api/me/preferences", json=["not", "an", "object"]).status_code == 422
+2 -15
View File
@@ -2,14 +2,13 @@
The roadmap assumed stamping it in `_establish_session` would cover Google and password alike. It
would not have: only password login called it, and the Google callback carried its own copy of the
same four lines. So these pin BOTH doors through the real routes — plus the two that must not stamp
(a refused password, the shared demo account) and the admin list that shows it.
same four lines. So these pin BOTH doors through the real routes — plus the one that must not stamp
(a refused password) and the admin list that shows it.
"""
from datetime import datetime, timedelta, timezone
from app.config import settings
from app.models import DemoWhitelist
from app.routes import auth as auth_routes
_PW = "correct horse battery"
@@ -73,18 +72,6 @@ def test_a_google_sign_in_stamps_last_login(client, db, make_user, monkeypatch):
assert client.get("/api/me").json()["id"] == user.id
def test_the_demo_entry_does_not_stamp_the_shared_account(client, db):
db.add(DemoWhitelist(email="visitor@example.com"))
db.commit()
r = client.post("/auth/demo", json={"email": "visitor@example.com"})
assert r.json() == {"ok": True, "authenticated": True}
demo = auth_routes.get_or_create_demo_user(db)
db.refresh(demo)
assert demo.last_login_at is None
def test_the_admin_user_list_carries_last_login(as_user, db, make_user):
admin = make_user(email="boss@example.com", role="admin")
never = make_user(email="never@example.com")
+1 -1
View File
@@ -1,4 +1,4 @@
"""The sliding-window limiter behind /auth/register, password-login, password-reset and demo.
"""The sliding-window limiter behind /auth/register, password-login and password-reset.
Pure logic, no DB. `reset()` in particular is only ever called by a test fixture (conftest clears
the module-level limiters between tests), so without a test here a refactor could turn it into a
-2
View File
@@ -46,9 +46,7 @@ SERIALIZERS = {
"_collection_card",
"_playlist_card",
"_serialize",
"_serialize_demo",
"_serialize_job",
"_serialize_msg",
"_serialize_profile",
"_serialize_user",
"_summary",
+1 -1
View File
@@ -16,7 +16,7 @@ def _client(db, user, monkeypatch, override_dep):
state, "_configured_cache", True
) # the request gate 503s an unconfigured app
override_dep(get_db, db.info["request"])
override_dep(current_user, lambda: user) # require_human resolves through this
override_dep(current_user, lambda: user)
return TestClient(app)
+2 -2
View File
@@ -104,7 +104,7 @@ def test_search_youtube_route_returns_422_when_ingest_fails(db, monkeypatch, ove
from fastapi.testclient import TestClient
import app.state as state
from app.auth import require_human
from app.auth import current_user
from app.db import get_db
from app.main import app
from app.routes import search as search_mod
@@ -146,7 +146,7 @@ def test_search_youtube_route_returns_422_when_ingest_fails(db, monkeypatch, ove
monkeypatch.setattr(search_mod, "_ingest_candidates", _boom)
override_dep(get_db, db.info["request"])
override_dep(require_human, lambda: user)
override_dep(current_user, lambda: user)
resp = TestClient(app).get("/api/search/youtube?q=test+term")
assert resp.status_code == 422, resp.text
-12
View File
@@ -155,18 +155,6 @@ def test_only_an_admin_can_mint_one(db, as_user, make_user):
assert db.query(AuthToken).filter(AuthToken.kind == "setpw").count() == 0
def test_the_shared_demo_account_is_refused(db, as_user, make_user):
"""The demo account is shared, so handing anyone a way to change its password hands them
everyone else's session on it."""
admin = make_user(role="admin")
demo = make_user(is_demo=True)
db.commit()
r = as_user(admin).post(f"/api/admin/users/{demo.id}/set-password-link")
assert r.status_code == 400
assert r.json()["detail"]["reason"] == "demo"
def test_the_access_panel_reports_the_gates(db, as_user, make_user):
admin = make_user(role="admin")
target = make_user(email_verified=False, is_active=False)
+3 -4
View File
@@ -31,12 +31,11 @@ BIG_MODULES: dict[str, int] = {
"app/downloads/edit.py": 833,
"app/downloads/pipeline.py": 870,
"app/logs.py": 839,
"app/models.py": 1607,
"app/models.py": 1525,
"app/plex/stream.py": 1128,
"app/plex/subtitles.py": 1092,
"app/routes/admin.py": 864,
"app/routes/auth.py": 1353,
"app/routes/downloads.py": 1237,
"app/routes/auth.py": 1273,
"app/routes/downloads.py": 1232,
}
COMPLEX: dict[str, int] = {
+1 -3
View File
@@ -186,7 +186,7 @@ def test_an_anonymous_upload_is_refused_before_a_byte_of_it_is_read(client):
"""P1 — the defect E27 bounded but did not close.
While the route declared `file: UploadFile = File(...)`, FastAPI parsed and spooled the body
BEFORE the first dependency ran, so `require_human` could not gate it: an unauthenticated
BEFORE the first dependency ran, so the auth dependency could not gate it: an unauthenticated
caller could make the server write the whole per-file allowance (500 MiB by default) to temp,
per request. SEC-20's ceiling capped the number; it could not stop the writing.
@@ -532,7 +532,6 @@ def _display_write_sites() -> dict[tuple[str, str], set[str]]:
# file has transferred — so each of these has to be bounded, and each one here has been read.
#
# Bounded by `display_text` unless noted:
# auth.get_or_create_demo_user a literal.
# service.redownload CLEARS the field so the fresh metadata wins — `None` is the
# one value that needs no bound, and a clear is still a write.
# service.enqueue / enqueue_edit `display_name` is already bounded by its caller; the fallback
@@ -544,7 +543,6 @@ def _display_write_sites() -> dict[tuple[str, str], set[str]]:
# `not []` and enforcing nothing, which is how a drift guard dies quietly (round 3). This one fails
# when a site appears, when one disappears, and when one changes what it writes.
DISPLAY_WRITE_SITES = {
("auth.py", "get_or_create_demo_user"): {"'Demo'"},
("downloads/service.py", "redownload"): {"None"},
("downloads/service.py", "enqueue"): {
"display_name or (asset.title[:DISPLAY_TEXT_MAX] "
+6 -7
View File
@@ -1,16 +1,16 @@
"""is_messageable_user — the "real, active, non-suspended human" predicate reused by the messaging
directory and the download share endpoints. A row must be non-demo AND active AND not-suspended."""
"""is_messageable_user — the "active, non-suspended account" predicate reused by the download share
endpoints. A row must be active AND not-suspended."""
from types import SimpleNamespace
from app.userscope import is_messageable_user
def _user(is_demo=False, is_active=True, is_suspended=False):
return SimpleNamespace(is_demo=is_demo, is_active=is_active, is_suspended=is_suspended)
def _user(is_active=True, is_suspended=False):
return SimpleNamespace(is_active=is_active, is_suspended=is_suspended)
def test_a_real_active_human_is_messageable():
def test_an_active_account_is_messageable():
assert is_messageable_user(_user()) is True
@@ -18,7 +18,6 @@ def test_none_is_not_messageable():
assert is_messageable_user(None) is False
def test_demo_suspended_or_inactive_are_not_messageable():
assert is_messageable_user(_user(is_demo=True)) is False
def test_suspended_or_inactive_are_not_messageable():
assert is_messageable_user(_user(is_suspended=True)) is False
assert is_messageable_user(_user(is_active=False)) is False