from typing import Any from fastapi import APIRouter, BackgroundTasks, Depends, 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, google_enabled, has_read_scope, has_write_scope, optional_current_user, purge_user, ) from app import debugsession, diagnostics from app.issues import gitea 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 router = APIRouter(prefix="/api/me", tags=["me"]) @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, "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). "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"), # Which live-search source runs, so the search button can say what a search costs: "scrape" # spends no API quota, "api" spends 100 units a page. "search_source": sysconfig.get_str(db, "search_source"), # Whether live search and related videos are on: an admin turns either off with a daily # limit of 0, and the UI then leaves out what would only be refused. "search_enabled": sysconfig.get_int(db, "search_daily_limit_per_user") > 0, "related_enabled": sysconfig.get_int(db, "related_daily_limit_per_user") > 0, "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. The client applies # it to its own log and streams that log while it is raised. "diagnostics": diagnostics.state(db, user), # The live debug session, if one is recording — a reload resumes it. "debug_session": _live_session(db, user), # Where a bug report goes: the tracker's repository, or None — download only. "issues": gitea.target(db), } def _live_session(db: Session, user: User) -> dict | None: session = debugsession.open_session(db, user) return None if session is None else debugsession.view(db, session) @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 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") # Full erasure (cascades) + access-request cleanup + Google-grant revocation; shared with the # admin delete path. purge_user(db, user, background) # The session's one account is gone: so is the session. 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 — over the stored row read again under a row # lock: a client saves keys one at a time (several at once on a boot that moves this browser's # copies to the account), and two merges over the same earlier read would keep only the last. # FOR NO KEY UPDATE, as bump_session_epoch: it still orders two merges, and a transaction that # only added a row referring to the account (a playlist pull, then minutes of YouTube) does not # hold the save up. db.refresh(user, with_for_update={"key_share": True}) merged = dict(user.preferences or {}) merged.update(preferences.root) user.preferences = merged db.add(user) db.commit() return {"ok": True, "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.""" 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)}