Files
espresso_frame/server/app/main.py
T
tfaour 1e8d6803ac Redesign phase B: users, sessions, first-run setup, admin panel
Real identity on top of phase A's schema: scrypt-hashed passwords
(stdlib, no new deps -- parameters baked into each stored hash),
server-side sessions (sha256 of the cookie value stored, 30-day rolling
expiry), and per-session CSRF tokens enforced on every mutating
session-authed request -- via X-CSRF-Token for the JSON API (a fetch()
wrapper in base.html injects it, so the existing page scripts didn't
need touching) and a hidden form field for the HTML forms.

/setup runs once while no users exist: creates admin #1, links every
existing frame to them (owner + controller), and inherits the migrated
Immich creds onto their account -- per-user creds are now the primary
source, with env vars still winning as the operator fallback. /login,
/logout, /settings (display name, Immich creds, password change), and
/admin (enroll users, reset passwords, link users to frames, close a
frame's legacy-token window, delete) round out the pages, all in the
existing template/card style.

The legacy shared token stays accepted on browser routes so the
deployed frame's on-panel manage QR keeps working until phase C swaps
it for the limited manage page; token access renders without nav or
CSRF shim and is exempt from CSRF (explicit credential, not an ambient
cookie). Device routes untouched -- the legacy curl suite passes
verbatim.

Identity is provider-pluggable (identity_provider/provider_subject
already modeled) so OIDC can land later without schema surgery.
2026-07-21 23:28:14 -04:00

97 lines
3.5 KiB
Python

"""ESPresso Frame server: pulls photos from Immich, pre-processes them
for the panel, and serves ESP32 frames ready-to-display images.
This module is assembly only -- routes live in app/routers/ (device.py
for the firmware-facing /frame/* protocol, api.py for the web UI's
/api/*, pages.py for setup/login/settings/admin), storage in SQLite via
models.py/db.py, with migration.py importing a pre-database config.json
deployment on first boot."""
from __future__ import annotations
import logging
from fastapi import FastAPI, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
from . import migration
from .auth import (
MANAGEMENT_TOKEN_COOKIE,
browser_token_valid,
current_session,
current_user,
management_token,
users_exist,
)
from .db import SessionLocal
from .quiet_hours import ALL_TIMEZONES
from .routers import api, device, pages
from .routers.common import default_frame, immich_creds
logger = logging.getLogger(__name__)
# Schema + legacy-config import, before the first request is served.
migration.run_migrations()
app = FastAPI(title="ESPresso Frame Server")
templates = Jinja2Templates(directory="app/templates")
app.include_router(device.router)
app.include_router(api.router)
app.include_router(pages.router)
@app.get("/health")
def health() -> dict:
return {"status": "ok"}
@app.get("/", response_class=HTMLResponse)
def index(request: Request):
"""The web UI (still the single-frame page until Phase D). Access:
a user session (normal path once /setup has run), the legacy shared
token (the deployed frame's manage QR embeds ?token= -- kept working
until Phase C replaces it with the limited manage page), or -- only
while no users exist AND no token is configured -- fully open, the
original trusted-LAN default."""
with SessionLocal() as db:
user = current_user(request, db)
session = current_session(request, db) if user else None
legacy_ok = browser_token_valid(request)
if user is None and not legacy_ok:
if not users_exist(db):
if management_token():
supplied = request.query_params.get("token")
return templates.TemplateResponse(
"token_prompt.html", {"request": request, "wrong": supplied is not None}
)
# Fresh install, nothing configured: open, but nudge setup.
return RedirectResponse("/setup", status_code=303)
return RedirectResponse("/login", status_code=303)
frame = default_frame(db)
immich_url, _ = immich_creds(frame)
response = templates.TemplateResponse(
"index.html",
{
"request": request,
"cfg": frame,
"immich_url": immich_url,
"timezones": ALL_TIMEZONES,
"user": user,
"csrf_token": session.csrf_token if session else None,
},
)
supplied = request.query_params.get("token")
if management_token() and supplied == management_token():
# Query-param access (typically the manage-menu QR code) earns a
# cookie so the rest of this visit's fetch()/<img> calls -- which
# never carry the query string -- stay authorized too.
response.set_cookie(
MANAGEMENT_TOKEN_COOKIE, supplied, max_age=86400 * 365, httponly=True, samesite="lax"
)
return response