Files
siftlode/backend/app/routes/me.py
T
peter 6d8b7be6d8 feat!: remove the shared demo account
The demo account — one shared user that whitelisted emails entered from
the login page, reset on a schedule — goes end to end (owner decision,
E34): demo.py and the reset job, users.is_demo and demo_whitelist
(migration 0098 deletes the demo user, its audit rows, the reset job's
override and notices, then drops the column and table; the downgrade
restores the empty schema), the /auth/demo entry, the admin whitelist and
reset, the Demo tab, the banner, the login page's "Try the demo", and
every is_demo branch in routes, userscope, scheduler and the UI.

require_human only ever refused the demo account, so its 87 uses become
current_user (owner-approved); require_write_scope keeps its own check.
The login page's "or" divider now shows only beside the Google button:
without the demo entry under it, an instance without Google would have
said "or" to nothing.

A contract test (removedFeatures) fails on the word anywhere in the
backend, frontend, e2e or scripts — seen red on a planted hit. Size
ceilings tightened for the modules that shrank; admin.py is back under
the limit.
2026-10-05 00:19:38 +02:00

243 lines
10 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,
drop_from_wallet,
google_enabled,
has_read_scope,
has_write_scope,
is_allowed,
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
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,
"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 (E22 S5). 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 (E16) — a reload resumes it.
"debug_session": _live_session(db, user),
# Where a bug report goes (E16 S4): 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")
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 {"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)}