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:
+9
-119
@@ -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
@@ -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="/")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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,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).
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user