A user can raise their diagnostics level to Részletes (DEBUG) or Teljes (TRACE) for 24 hours from a new Settings tab, after a consent box that says what is recorded, that the server keeps it and for how long (the instance's own retention), and that it lapses. Alap is the base and needs no consent. - users.diagnostics_level/_until (migration 0087), written only by PUT /api/me/diagnostics, which computes the expiry itself; the demo account cannot raise it. /api/me reports the level in force. - The server logs an elevated user's requests at that level and stores every line of them (logs.LevelGate on the stdout handler and the sink; the root is lowered only while an elevation is active). Teljes adds the redacted query string to the request line. - POST /api/diagnostics/events stores the browser's log as source=web rows: account from the session, body capped while streaming, events/attrs capped, re-redacted, events under the consented level dropped, timestamps clamped, 30 batches a minute, 409 once the level lapsed. - lib/diagnostics.ts applies the level to the tab and streams only what happens after consent, in batches, never its own request lines, with a keepalive flush on pagehide; stops on lapse (its own timer), on withdrawal and on 409. - Detail hooks at DEBUG: which control was clicked (test id, icon, app copy in English — never user text, input values or a link's query), visibility/bfcache/resize, filter changes (lib/filterLog.ts; typed search only at TRACE), player events (lib/mediaLog.ts; media errors are warnings at every level). Offline/online at the base level. - "Saját napló" viewer: live, newest first, level groups with counts, search, row details, copy the filtered events as JSON lines, clear. - Privacy policy and Terms exist in Hungarian and English (a contract test holds both languages to the same sections); the policy now describes the server logs and diagnostic logging, self-service deletion and password sign-in. Fixed on the way: - A sync auth dependency ran logs.set_user on a copied context, so the account never reached a handler's own log lines (measured); a per-request mutable scope carries it now. - The Settings tab rail left a phone no room (clipped labels, buttons over text): below `sm` it is a scrolling row above the content. - PUT /api/me/preferences took a bare dict body; it is a Pydantic model now.
240 lines
11 KiB
Python
240 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
|
|
|
|
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.")
|
|
# 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 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 not u.is_active:
|
|
drop_from_wallet(request.session, target)
|
|
raise HTTPException(status_code=403, detail="That account no longer has access.")
|
|
if u.is_suspended:
|
|
drop_from_wallet(request.session, target)
|
|
raise HTTPException(status_code=403, detail="That account is 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 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 {},
|
|
# 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 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}
|
|
|
|
|
|
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)}
|