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.
97 lines
3.5 KiB
Python
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
|