A tab names the account it was claimed for on every request, beacon and upload (X-Siftlode-Expect-Account); current_user and /auth/logout refuse a mismatch with 401 account_changed and keep the session, so a tab left showing the account signed in before reloads instead of writing to the new one. /auth/login clears a connect marker left by an abandoned connect, and a link completes only on a session that still holds. The prefs merge reads the row under a lock. A tab's sessionStorage records its own owner. A page the account loses is given up, address bar and remembered page included. The panel layouts are preferences; the storage contract also refuses a key handed to the Storage API or to usePref outside its kind. A refused sign-in as someone else and a failed sign-out say so. The chromium-only e2e specs are in the gate register, the member walk waits for its requests, and devices.spec checks the save itself.
169 lines
7.5 KiB
Python
169 lines
7.5 KiB
Python
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"),
|
|
"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.
|
|
db.refresh(user, with_for_update=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)}
|