"""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 the firmware-facing /frame/* protocol (paths frozen) api_frames.py the web UI's JSON API, /api/frames/{id}/... api_widgets.py widget CRUD + grid placement, /api/frames/{id}/widgets api_layouts.py named, user-owned saved layouts, /api/layouts, /api/frames/{id}/layouts frame_pages.py the per-frame Photos/Configuration/Layout/Stats pages pages.py setup/login/claim/settings/admin manage.py the limited manage-QR surface (/m/, /api/m/) Storage is SQLite via models.py/db.py; migration.py imports 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.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates from sqlalchemy import select from . import migration from .auth import ( browser_token_valid, current_user, management_token, user_frames, users_exist, ) from .db import SessionLocal from .models import Frame from .routers import api_frames, api_layouts, api_widgets, device, frame_pages, manage, pages from .routers.common import shell_context 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.mount("/static", StaticFiles(directory="app/static"), name="static") app.include_router(device.router) app.include_router(api_frames.router) app.include_router(api_widgets.router) app.include_router(api_layouts.router) app.include_router(frame_pages.router) app.include_router(pages.router) app.include_router(manage.router) @app.get("/health") def health() -> dict: return {"status": "ok"} def _device_credential_redirect(request: Request, db, allow_legacy: bool) -> str | None: """The on-frame manage QR points at the server root with the device's own credentials (new firmware: ?id=&token=; deployed firmware: ?token=). Those scans get the frame's limited manage page -- never the full UI, which requires a login. allow_legacy is False before /setup has run: at that point a bare ?token= hit is the admin coming through the token prompt to do first-run setup, not a QR scan.""" device_id = request.query_params.get("id", "").strip().lower() token = request.query_params.get("token", "") if device_id and token: frame = db.scalars(select(Frame).where(Frame.device_id == device_id)).first() if frame is not None and token == frame.device_token: return f"/m/{frame.manage_token}" if allow_legacy and token and management_token() and token == management_token(): frame = db.scalars( select(Frame).where(Frame.legacy_token_enabled == True) # noqa: E712 ).first() if frame is not None: return f"/m/{frame.manage_token}" return None @app.get("/", response_class=HTMLResponse) def index(request: Request): """Routing hub: manage-QR scans go to the limited manage page, users land on their first frame (or an empty-state page), and everyone else is walked through setup/login.""" with SessionLocal() as db: have_users = users_exist(db) manage_redirect = _device_credential_redirect(request, db, allow_legacy=have_users) if manage_redirect is not None: return RedirectResponse(manage_redirect, status_code=303) user = current_user(request, db) if user is None: if not have_users: if management_token() and not browser_token_valid(request): supplied = request.query_params.get("token") return templates.TemplateResponse( "token_prompt.html", {"request": request, "wrong": supplied is not None} ) # Pre-setup: reachable (optionally token-gated), nudge setup. return RedirectResponse("/setup", status_code=303) return RedirectResponse("/login", status_code=303) frames = user_frames(db, user) if frames: return RedirectResponse(f"/frames/{frames[0].id}", status_code=303) return templates.TemplateResponse("frames_empty.html", shell_context(request, db, user))