Files
espresso_frame/server/app/routers/api.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

364 lines
15 KiB
Python

"""Browser-facing /api/* routes -- Phase A keeps the old single-frame
paths, resolved to the default frame (frame #1), behind the legacy
shared-token gate. Phase B moves auth to sessions; Phase D moves paths
to /api/frames/{id}/... together with the new multi-frame UI."""
from __future__ import annotations
import logging
import time
import httpx
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile
from fastapi.responses import Response
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.orm import Session
from .. import gitea_releases, photo_queue, quiet_hours
from ..auth import require_browser
from ..db import frame_locked, get_db
from ..firmware import firmware_path, parse_app_version
from ..models import BatteryLog, Frame
from .common import (
OVERDUE_FACTOR,
battery_estimate_s,
default_frame,
immich_client_for,
immich_creds,
list_assets,
require_configured,
)
logger = logging.getLogger(__name__)
router = APIRouter(dependencies=[Depends(require_browser)])
MIN_REFRESH_INTERVAL_S = 60
MAX_REFRESH_INTERVAL_S = 86400
MIN_QUEUE_TARGET_LEN = 5
MAX_QUEUE_TARGET_LEN = 5000
ORIENTATIONS = ("landscape", "portrait", "landscape_flipped", "portrait_flipped")
@router.get("/api/albums")
def api_albums(db: Session = Depends(get_db)):
frame = default_frame(db)
url, key = immich_creds(frame)
if not url or not key:
raise HTTPException(400, "Immich URL/API key not configured yet")
try:
albums = immich_client_for(frame).list_albums()
except httpx.HTTPError as e:
raise HTTPException(502, f"Could not reach Immich at {url}: {e}") from e
return [{"id": a["id"], "name": a["albumName"], "count": a.get("assetCount", 0)} for a in albums]
@router.post("/api/config")
def api_config_save(
album_id: str = Form(""),
order: str = Form("sequential"),
refresh_interval_s: int = Form(3600),
smart_crop_faces: bool = Form(True),
queue_target_len: int = Form(20),
orientation: str = Form("landscape"),
quiet_hours_enabled: bool = Form(False),
quiet_hours_start: str = Form("22:00"),
quiet_hours_end: str = Form("07:00"),
timezone: str = Form("UTC"),
firmware_update_repo_url: str = Form(""),
firmware_auto_update: bool = Form(False),
db: Session = Depends(get_db),
):
# Immich creds are per-user (Phase B) / env-fallback -- this handler
# deliberately never touches them, same as the old env-only rule.
frame = default_frame(db)
with frame_locked(db, frame.id) as cfg:
if album_id != cfg.album_id:
# A newly selected album starts clean -- the old current photo and
# queue don't mean anything in the new album's context.
cfg.current_asset_id = ""
cfg.current_asset_set_at = 0.0
cfg.queue = []
cfg.queue_cursor = 0
cfg.history = []
cfg.excluded_asset_ids = []
cfg.album_id = album_id
cfg.order = order if order in ("sequential", "shuffle") else "sequential"
cfg.refresh_interval_s = max(MIN_REFRESH_INTERVAL_S, min(MAX_REFRESH_INTERVAL_S, refresh_interval_s))
cfg.smart_crop_faces = smart_crop_faces
cfg.queue_target_len = max(MIN_QUEUE_TARGET_LEN, min(MAX_QUEUE_TARGET_LEN, queue_target_len))
cfg.orientation = orientation if orientation in ORIENTATIONS else "landscape"
cfg.quiet_hours_enabled = quiet_hours_enabled
if quiet_hours.valid_hhmm(quiet_hours_start):
cfg.quiet_hours_start = quiet_hours_start
if quiet_hours.valid_hhmm(quiet_hours_end):
cfg.quiet_hours_end = quiet_hours_end
if timezone in quiet_hours.ALL_TIMEZONES:
cfg.timezone = timezone
cfg.firmware_update_repo_url = firmware_update_repo_url.strip()
cfg.firmware_auto_update = firmware_auto_update
cfg.stats_config_saves += 1
return {"status": "saved"}
@router.get("/api/stats")
def api_stats(db: Session = Depends(get_db)):
frame = default_frame(db)
return {
"first_seen": frame.stats_first_seen,
"device_wakes": frame.stats_device_wakes,
"photos_displayed": frame.stats_photos_displayed,
"photos_removed": frame.stats_photos_removed,
"battery_reports": frame.stats_battery_reports,
"recharge_cycles": frame.stats_recharge_cycles,
"ota_updates_applied": frame.stats_ota_updates_applied,
"config_saves": frame.stats_config_saves,
}
@router.get("/api/queue")
def api_queue(db: Session = Depends(get_db)):
frame = default_frame(db)
require_configured(frame)
client = immich_client_for(frame)
assets = list_assets(client, frame)
with frame_locked(db, frame.id) as cfg:
photo_queue.get_current(cfg, assets, in_quiet_hours=quiet_hours.in_quiet_hours(cfg))
photo_queue.sync_queue_length(cfg, assets)
snapshot = {
"current_asset_id": cfg.current_asset_id,
"queue": list(cfg.queue),
"last_seen": cfg.last_seen,
"overdue_gap": quiet_hours.max_expected_gap_s(cfg) * OVERDUE_FACTOR,
"firmware_version": cfg.device_firmware_version,
"firmware_available": cfg.firmware_available_version,
"battery_percent": cfg.battery_percent,
"battery_as_of": cfg.battery_as_of,
"on_battery_since": cfg.battery_history[0][0] if cfg.battery_history else None,
"battery_estimate_s": battery_estimate_s(cfg),
}
def entry(asset_id: str) -> dict:
return {"id": asset_id, "thumbnail_url": f"/api/photo-thumbnail/{asset_id}"}
now = time.time()
return {
"current": entry(snapshot["current_asset_id"]) if snapshot["current_asset_id"] else None,
"upcoming": [entry(asset_id) for asset_id in snapshot["queue"]],
"device": {
"last_seen": snapshot["last_seen"] or None,
"overdue": bool(
snapshot["last_seen"] and now - snapshot["last_seen"] > snapshot["overdue_gap"]
),
"firmware_version": snapshot["firmware_version"] or None,
"firmware_available": snapshot["firmware_available"] or None,
"battery": (
{"percent": snapshot["battery_percent"], "as_of": snapshot["battery_as_of"]}
if snapshot["battery_percent"] >= 0
else None
),
"on_battery_since": snapshot["on_battery_since"],
"battery_estimate_s": snapshot["battery_estimate_s"],
},
}
@router.get("/api/battery-log")
def api_battery_log(db: Session = Depends(get_db)):
frame = default_frame(db)
rows = db.execute(
select(BatteryLog.ts, BatteryLog.percent)
.where(BatteryLog.frame_id == frame.id)
.order_by(BatteryLog.ts)
).all()
return {"log": [[ts, percent] for ts, percent in rows]}
class QueueReorderRequest(BaseModel):
queue: list[str]
@router.post("/api/queue/reorder")
def api_queue_reorder(body: QueueReorderRequest, db: Session = Depends(get_db)):
"""Applies the client's requested order, tolerating drift between the
browser's last-fetched snapshot and the server's current queue (e.g.
a top-up/trim landed in between) instead of hard-rejecting: any ID
the client sent that's no longer actually queued is dropped, and any
ID the server has that the client didn't know about is appended
rather than lost."""
frame = default_frame(db)
with frame_locked(db, frame.id) as cfg:
current_set = set(cfg.queue)
reordered = [asset_id for asset_id in body.queue if asset_id in current_set]
reordered += [asset_id for asset_id in cfg.queue if asset_id not in set(reordered)]
cfg.queue = reordered
return {"status": "saved"}
class QueuePromoteRequest(BaseModel):
asset_id: str
@router.post("/api/queue/promote")
def api_queue_promote(body: QueuePromoteRequest, db: Session = Depends(get_db)):
"""Moves a single photo to the front of the queue -- "Show next" in
the web UI. Unlike /api/queue/reorder, this doesn't depend on the
client supplying a full, exactly-current snapshot of the queue at
all, so it can't fail due to the queue having shifted server-side
since the browser's last fetch."""
frame = default_frame(db)
with frame_locked(db, frame.id) as cfg:
if body.asset_id not in cfg.queue:
raise HTTPException(400, "That photo is no longer in the upcoming queue")
cfg.queue = [body.asset_id] + [asset_id for asset_id in cfg.queue if asset_id != body.asset_id]
return {"status": "saved"}
class QueueRemoveRequest(BaseModel):
asset_id: str
@router.post("/api/queue/remove")
def api_queue_remove(body: QueueRemoveRequest, db: Session = Depends(get_db)):
"""Permanently removes a photo from this frame's rotation -- "Remove"
in the web UI, on either an upcoming card or the current photo. Does
NOT touch Immich or the album itself; see photo_queue.remove_from_rotation()."""
frame = default_frame(db)
require_configured(frame)
client = immich_client_for(frame)
assets = list_assets(client, frame)
with frame_locked(db, frame.id) as cfg:
photo_queue.remove_from_rotation(cfg, assets, body.asset_id)
return {"status": "removed"}
@router.get("/api/photo-thumbnail/{asset_id}")
def api_photo_thumbnail(asset_id: str, db: Session = Depends(get_db)):
frame = default_frame(db)
require_configured(frame)
client = immich_client_for(frame)
try:
content, content_type = client.download_asset_thumbnail(asset_id)
except httpx.HTTPError as e:
raise HTTPException(502, f"Could not download thumbnail from Immich: {e}") from e
return Response(content=content, media_type=content_type)
@router.post("/api/firmware")
def api_firmware_upload(file: UploadFile = File(...), db: Session = Depends(get_db)):
"""Uploads a firmware image for OTA. The version is parsed out of the
image itself (esp_app_desc_t) rather than trusted from a filename or
form field, and the project name is checked so an unrelated .bin
can't be pushed to the frame by mistake."""
frame = default_frame(db)
data = file.file.read()
version = parse_app_version(data)
path = firmware_path(frame.id)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(data)
with frame_locked(db, frame.id) as cfg:
cfg.firmware_available_version = version
return {"status": "saved", "version": version, "size": len(data)}
def _fetch_latest_release(frame: Frame) -> dict | None:
try:
return gitea_releases.fetch_latest_release(
frame.firmware_update_repo_url, frame.firmware_update_token
)
except httpx.HTTPError as e:
raise HTTPException(502, f"Could not reach Gitea at {frame.firmware_update_repo_url}: {e}") from e
def _apply_gitea_update(db: Session, frame: Frame) -> str:
"""Downloads the configured Gitea repo's latest release asset for this
frame's board variant and stages it exactly like a manual upload
would. The board comes from the device itself (device_board_variant,
learned from its X-Frame-Board header), not a user picker, so
there's nothing to fetch until the device has checked in at least
once. Network I/O happens before the lock is taken."""
if not frame.device_board_variant:
raise HTTPException(400, "The frame hasn't checked in yet -- can't tell which board's build to fetch")
release = _fetch_latest_release(frame)
if not release:
raise HTTPException(404, "No releases found in the configured Gitea repo")
asset_name = gitea_releases.asset_name_for_board(frame.device_board_variant)
asset_url = release["assets"].get(asset_name)
if not asset_url:
raise HTTPException(404, f"Latest release has no '{asset_name}' asset")
try:
data = gitea_releases.download_asset(asset_url, frame.firmware_update_token)
except httpx.HTTPError as e:
raise HTTPException(502, f"Could not download '{asset_name}' from Gitea: {e}") from e
version = parse_app_version(data) # same validation the manual upload path applies
path = firmware_path(frame.id)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(data)
with frame_locked(db, frame.id) as cfg:
cfg.firmware_available_version = version
cfg.firmware_gitea_latest_version = version
cfg.firmware_update_checked_at = time.time()
return version
@router.get("/api/firmware/check")
def api_firmware_check(force: bool = False, db: Session = Depends(get_db)):
"""Throttled check of the configured Gitea repo's latest release
(gitea_releases.UPDATE_CHECK_INTERVAL_S) -- cheap, since it only reads
the release's tag name, not its binaries. If firmware_auto_update is
on and a newer version is found, applies it immediately; otherwise
just reports it so the web UI can offer the "Update frame" button.
force=true (the "Check now" button) bypasses the throttle and always
hits Gitea -- otherwise a genuinely new release can sit invisible in
the UI for up to the full throttle interval."""
frame = default_frame(db)
if not frame.firmware_update_repo_url:
return {"enabled": False}
now = time.time()
if force or now - frame.firmware_update_checked_at >= gitea_releases.UPDATE_CHECK_INTERVAL_S:
# checked_at only advances on a successful reach, so a Gitea
# outage gets retried every poll instead of waiting out the full
# throttle interval.
release = _fetch_latest_release(frame)
with frame_locked(db, frame.id) as cfg:
cfg.firmware_update_checked_at = now
if release:
cfg.firmware_gitea_latest_version = release["version"]
update_available = (
bool(frame.firmware_gitea_latest_version)
and frame.firmware_gitea_latest_version != frame.firmware_available_version
and bool(frame.device_board_variant)
)
if update_available and frame.firmware_auto_update:
_apply_gitea_update(db, frame)
update_available = False
return {
"enabled": True,
"board": frame.device_board_variant or None,
"latest_version": frame.firmware_gitea_latest_version or None,
"staged_version": frame.firmware_available_version or None,
"update_available": update_available,
}
@router.post("/api/firmware/apply-latest")
def api_firmware_apply_latest(db: Session = Depends(get_db)):
"""The "Update frame" button: applies the latest Gitea release right
now, bypassing the check throttle -- this is an explicit user action,
not a background poll."""
frame = default_frame(db)
if not frame.firmware_update_repo_url:
raise HTTPException(400, "No Gitea firmware repo configured")
version = _apply_gitea_update(db, frame)
return {"status": "saved", "version": version}