~150 HTTPExceptions carried a hand-written English sentence as their detail,
shown verbatim under the Hungarian UI — in the blocking error dialog and on
every inline surface that printed HttpError.detail (sign-in, setup wizard,
change password, the passphrase gate, live search). Four also quoted an
upstream exception's text back to the user.
- app/refusal.py: `refused(status, reason, **params)` → {code: "refused",
reason, …params}; localizeDetail translates errors.refused.<reason> with the
params interpolated. "Unknown X" 404s use the existing not_found(what).
- sysconfig.set_value raises InvalidSetting (reason + bounds) instead of an
English ValueError; the three writers answer it through setting_refused.
- The Plex connection/write/image failures log what the upstream said and
answer a reason; the search scraper's failure is logged and answered as
youtube/unavailable (it crashed refusal_reason before).
- HttpError.detail now only ever holds a translated reason: a string detail
(only the framework's own can remain) is left out, so reading .detail is
safe everywhere — including destructured, the hole the raw-detail source
scan could not see. That scan and its five-file exception list are gone.
- Enforced: test_http_details.py fails on any HTTPException with a sentence
for a detail; the contract test requires every refused reason and every
not_found "what" in both languages, and no key for one that is gone.
- local/jsx-comment-text: a // inside JSX children renders as text; the rule
is ten lines in eslint.config.js rather than eslint-plugin-react's ~1800
lockfile lines.
241 lines
11 KiB
Python
241 lines
11 KiB
Python
from typing import Any
|
|
|
|
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Request
|
|
from pydantic import BaseModel, RootModel
|
|
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 diagnostics
|
|
from app import email as email_mod
|
|
from app import logs
|
|
from app import sysconfig
|
|
from app.db import get_db
|
|
from app.models import Invite, User
|
|
from app.utils import iso
|
|
from app.refusal import refused
|
|
from app.routes._common import not_found
|
|
|
|
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 refused(403, "not_your_account")
|
|
# An account `_session_user` would refuse — gone, deactivated, suspended, or its session revoked — is
|
|
# refused here too, and dropped from the switch list the way `_session_user` drops it. Made the session
|
|
# default instead, the next request's current_user would refuse it as the DEFAULT and clear the whole
|
|
# wallet session, logging out every account here. (B-core-9)
|
|
u = db.get(User, target)
|
|
if u is None:
|
|
drop_from_wallet(request.session, target)
|
|
raise not_found("account")
|
|
if not is_allowed(db, u.email):
|
|
raise refused(403, "account_no_access")
|
|
if not u.is_active:
|
|
drop_from_wallet(request.session, target)
|
|
raise refused(403, "account_no_access")
|
|
if u.is_suspended:
|
|
drop_from_wallet(request.session, target)
|
|
raise refused(403, "account_suspended")
|
|
# Revoked: a password reset/change or "log out everywhere" bumped the account's epoch.
|
|
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 refused(403, "account_signed_out")
|
|
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,
|
|
# 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).
|
|
"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).
|
|
"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 {},
|
|
# The consented diagnostics level in force and when it lapses (E22 S5). The client applies
|
|
# it to its own log and streams that log while it is raised.
|
|
"diagnostics": diagnostics.state(db, user),
|
|
}
|
|
|
|
|
|
@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 refused(400, "only_admin_delete")
|
|
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}
|
|
|
|
|
|
class PreferencesIn(RootModel[dict[str, Any]]):
|
|
"""Free-form UI preferences — a JSON object of any keys (theme, filters, …), merged into the
|
|
stored ones. A model rather than a bare `dict` parameter so a non-object body is a 422 at the
|
|
boundary like every other request body."""
|
|
|
|
|
|
@router.put("/preferences")
|
|
def update_preferences(
|
|
preferences: PreferencesIn,
|
|
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.root)
|
|
user.preferences = merged
|
|
db.add(user)
|
|
db.commit()
|
|
return {"preferences": merged}
|
|
|
|
|
|
class DiagnosticsIn(BaseModel):
|
|
level: diagnostics.DiagnosticsLevel
|
|
|
|
|
|
@router.put("/diagnostics")
|
|
def update_diagnostics(
|
|
body: DiagnosticsIn,
|
|
user: User = Depends(current_user),
|
|
db: Session = Depends(get_db),
|
|
) -> 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.set_level(user, body.level)
|
|
db.commit()
|
|
# The rest of THIS request, and the process's elevation registry, follow the new level now.
|
|
logs.set_user(user.id, diagnostics.effective_level(user), user.diagnostics_until)
|
|
return {"ok": True, "diagnostics": diagnostics.state(db, user)}
|