Deep-review round 3 over main..improvement/watch-background-audio. Account boot - The notification history and settings, and the hints toggle, are read when their modules load, which in a new tab (bookmark, typed URL) is before BootGate pins the account: they read as nobody's, and the tab's first notice replaced the account's stored history (up to 100 entries) with a one-item list. They now read again when the tab is pinned (onAccountPinned, run by setActiveAccount); a notice raised before the pin stays, after the history. - A tab pinned to an account still in this browser's wallet but whose session ended (suspended, deleted, or revoked by "log out everywhere") got "signed out" from /api/me while the default account was still signed in, and signing in again as anyone else left it there. A refused per-tab account now leaves the wallet, and the probe answers as the default, so the tab reloads onto it. Share page (/watch) - The password unlock is limited per viewer (link + client IP), under a per-link ceiling that counts only wrong passwords. Keyed on the link alone, ten wrong guesses every five minutes locked out every viewer, the ones with the password included. client_ip moves to app.ratelimit so the public routes can key on it. - Its failure messages and the load-failure screen are announced (role=alert, the password input described by its error). The sign-in, passphrase, password-change and setup form errors get role=alert too. Comments that still described removed machinery are corrected: App's collapse flags, SettingsPanel's Save bar, ConfigPanel, authed_format_sig. Tests: the account's history and hints across the pin; a revoked and a suspended pinned account; the per-viewer limit and the ceiling; the limiter's exhausted(); the share page's announced failures; a share link's feed filters persisted. Each mutation-checked RED for its own reason.
200 lines
8.8 KiB
Python
200 lines
8.8 KiB
Python
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Request
|
|
from pydantic import BaseModel
|
|
from sqlalchemy import func, select
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app.auth import (
|
|
count_admins,
|
|
count_pending_activations,
|
|
current_user,
|
|
drop_from_wallet,
|
|
google_enabled,
|
|
has_read_scope,
|
|
has_write_scope,
|
|
is_allowed,
|
|
optional_current_user,
|
|
purge_user,
|
|
)
|
|
from app import email as email_mod
|
|
from app import sysconfig
|
|
from app.db import get_db
|
|
from app.models import Invite, User
|
|
|
|
router = APIRouter(prefix="/api/me", tags=["me"])
|
|
|
|
|
|
@router.get("/accounts")
|
|
def my_accounts(
|
|
request: Request,
|
|
user: User = Depends(current_user),
|
|
db: Session = Depends(get_db),
|
|
) -> list[dict]:
|
|
"""The accounts that have authenticated in this browser session (switchable without a
|
|
new Google sign-in). The active one is flagged."""
|
|
ids = request.session.get("account_ids") or [user.id]
|
|
rows = {u.id: u for u in db.query(User).filter(User.id.in_(ids)).all()}
|
|
out = []
|
|
for uid in ids: # preserve recency order from the session
|
|
u = rows.get(uid)
|
|
if u is not None:
|
|
out.append(
|
|
{
|
|
"id": u.id,
|
|
"email": u.email,
|
|
"display_name": u.display_name,
|
|
"avatar_url": u.avatar_url,
|
|
"active": u.id == user.id,
|
|
}
|
|
)
|
|
return out
|
|
|
|
|
|
class SwitchAccountIn(BaseModel):
|
|
user_id: int
|
|
|
|
|
|
@router.post("/switch")
|
|
def switch_account(
|
|
body: SwitchAccountIn,
|
|
request: Request,
|
|
user: User = Depends(current_user),
|
|
db: Session = Depends(get_db),
|
|
) -> dict:
|
|
"""Switch the active account to another one already authenticated in this browser. No
|
|
Google round-trip — but only accounts present in the session list (proof they signed in
|
|
here) are allowed, and access is re-checked in case the invite was revoked since."""
|
|
target = body.user_id
|
|
if target not in (request.session.get("account_ids") or []):
|
|
raise HTTPException(status_code=403, detail="Not an account you've signed into here.")
|
|
u = db.get(User, target)
|
|
if u is None:
|
|
raise HTTPException(status_code=404, detail="That account no longer exists.")
|
|
if not is_allowed(db, u.email):
|
|
raise HTTPException(status_code=403, detail="That account no longer has access.")
|
|
if u.is_suspended:
|
|
# Don't make a suspended account the active one — the next request's current_user would
|
|
# see the suspension and clear the WHOLE wallet session, logging out every account here.
|
|
raise HTTPException(status_code=403, detail="That account is suspended.")
|
|
# Same reasoning for a REVOKED session (a password reset/change or "log out everywhere" bumped the
|
|
# account's epoch): making it the session default would trip current_user's epoch check on the next
|
|
# request and clear the whole wallet. Mirror the suspend guard — drop the stale account from the
|
|
# switch list and refuse, rather than logging everyone out. (B-core-9)
|
|
epochs = request.session.get("epochs") or {}
|
|
if int(epochs.get(str(target), 0)) != (u.session_epoch or 0):
|
|
drop_from_wallet(request.session, target)
|
|
raise HTTPException(
|
|
status_code=403, detail="That account was signed out elsewhere — sign in again."
|
|
)
|
|
request.session["user_id"] = target
|
|
return {"ok": True, "user_id": target}
|
|
|
|
|
|
@router.get("")
|
|
def get_me(
|
|
user: User | None = Depends(optional_current_user), db: Session = Depends(get_db)
|
|
) -> dict:
|
|
# The app's bootstrap probe: return 200 with `authenticated: False` when logged out (rather
|
|
# than 401) so the public landing never logs a failed /api/me to the browser console. Other
|
|
# protected endpoints still 401, so mid-session expiry is still caught by the global handler.
|
|
if user is None:
|
|
return {"authenticated": False}
|
|
pending_invites = 0
|
|
pending_activations = 0
|
|
# Admin-only, because it drives an operator warning: email is fail-soft, so a half-filled SMTP
|
|
# config costs verification links, password resets and access-request notices with nothing
|
|
# visible anywhere. Surfacing it here puts the warning on the screens where onboarding happens.
|
|
email_configured = True
|
|
if user.role == "admin":
|
|
pending_invites = (
|
|
db.scalar(select(func.count()).select_from(Invite).where(Invite.status == "pending"))
|
|
or 0
|
|
)
|
|
# Counted separately from pending_invites rather than folded into it: the two are different
|
|
# facts (a request awaiting a decision vs a decided address whose sign-up never got let in),
|
|
# and quietly widening one number would make an existing field mean something new.
|
|
pending_activations = count_pending_activations(db)
|
|
email_configured = email_mod.email_enabled(db) # this request's session; no second connect
|
|
return {
|
|
"authenticated": True,
|
|
"id": user.id,
|
|
"email": user.email,
|
|
"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
|
|
# comparison lives here, once, instead of in every consumer.
|
|
"google_email": (
|
|
user.google_email if user.google_email and user.google_email != user.email else None
|
|
),
|
|
"has_password": user.password_hash is not None,
|
|
# 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).
|
|
"google_enabled": google_enabled(db),
|
|
# Whether the optional Plex module is enabled (UI shows the Plex feed source when so).
|
|
"plex_enabled": sysconfig.get_bool(db, "plex_enabled"),
|
|
"can_read": has_read_scope(user),
|
|
"can_write": has_write_scope(user),
|
|
"pending_invites": pending_invites,
|
|
# Sign-ups an admin still has to let in (roadmap UX-25): approved BEFORE they registered, so
|
|
# nothing ever reached the Access-requests queue and the badge used to read zero.
|
|
"pending_activations": pending_activations,
|
|
"email_configured": email_configured,
|
|
"preferences": user.preferences or {},
|
|
}
|
|
|
|
|
|
@router.delete("/account")
|
|
def delete_my_account(
|
|
request: Request,
|
|
background: BackgroundTasks,
|
|
user: User = Depends(current_user),
|
|
db: Session = Depends(get_db),
|
|
) -> dict:
|
|
"""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"})
|
|
if user.role == "admin" and count_admins(db) <= 1:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail="You're the only admin — promote another admin before deleting your account.",
|
|
)
|
|
user_id = user.id
|
|
# Full erasure (cascades) + access-request cleanup + Google-grant revocation; shared with the
|
|
# admin delete path. Capture the id first — `user` is detached once purge_user deletes the row.
|
|
purge_user(db, user, background)
|
|
# Drop this account from the browser session; switch to another signed-in account if any.
|
|
remaining = [a for a in (request.session.get("account_ids") or []) if a != user_id]
|
|
if remaining:
|
|
request.session["account_ids"] = remaining
|
|
request.session["user_id"] = remaining[-1]
|
|
else:
|
|
request.session.clear()
|
|
return {"deleted": True}
|
|
|
|
|
|
@router.put("/preferences")
|
|
def update_preferences(
|
|
preferences: dict,
|
|
user: User = Depends(current_user),
|
|
db: Session = Depends(get_db),
|
|
) -> dict:
|
|
# Merge so partial updates don't wipe other keys.
|
|
merged = dict(user.preferences or {})
|
|
merged.update(preferences)
|
|
user.preferences = merged
|
|
db.add(user)
|
|
db.commit()
|
|
return {"preferences": merged}
|