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.
364 lines
15 KiB
Python
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}
|