Redesign phase C: claim flow, limited manage page, device protocol

The frame-claiming pipeline, end to end. Firmware: every request now
carries ?id=<12-hex STA MAC> via build_url (mirrored in build_ota_url),
and the captive portal's success page became a redirect that hands the
user's browser to <server>/claim?device_id=... after ~7s -- enough time
for the phone to drop the provisioning AP while the device reboots.
The server pushes a per-frame device token through /frame/config during
a one-time handshake; the firmware persists it to NVS (a dedicated
single-key write that deliberately doesn't reset the connected-once
flag or WiFi cache) and prefers it over the provisioned shared token
from the next request on. Config response buffer grows 256->512. Both
board variants compile clean; new firmware also works against an old
server (which ignores ?id=) and old firmware against this server (the
phase A legacy mapping), so either deploy order survives.

Server: /claim lands the captive-portal redirect -- claim-gated signup
(a valid unclaimed/unregistered device id IS the enrollment invitation),
pending claims for the user-beats-the-frame race (auto-attached at
self-registration, 24h expiry), and a waiting page that refreshes until
the frame checks in. Unclaimed/unconfigured frames get a rendered
instruction placeholder with a QR from /frame/image (200, never an
error loop) -- new qrcode dep, placeholder shares the exact
quantize/pack path photos use.

The on-frame manage QR now resolves to a limited no-login page: scans
of / carrying device credentials (new ?id&token or the legacy shared
token) 303 to /m/<manage_token>, which allows exactly view queue,
show-next, advance, back, and scoped thumbnails -- no settings, no
removal, no other frames. Full control means logging in.

One real protocol hole found by simulating full wake cycles: after
self-registration the device could never authenticate again (the wake
cycle fetches the image BEFORE /frame/config delivers its token).
require_device now treats the id itself as the credential until the
first authenticated request flips device_token_ack -- the same trust
level as open registration, closing permanently once the handshake
completes.
This commit is contained in:
2026-07-21 23:44:22 -04:00
parent 1e8d6803ac
commit 683e3881b1
15 changed files with 779 additions and 53 deletions
+43 -8
View File
@@ -99,11 +99,14 @@ static void save_wifi_cache(esp_netif_t *netif)
* normally a bare "host:port", defaulting to plain http; it may instead
* carry an explicit "http://" or "https://" prefix to pick the scheme,
* e.g. "https://frame.example.com" if a reverse proxy is terminating
* TLS in front of the tools server. The token, once the server has
* MANAGEMENT_TOKEN set, is required on every request the server
* receives (device-facing endpoints included, not just the web UI) --
* this is the one chokepoint all of them go through, so every caller
* gets it for free instead of needing to remember to add it. */
* TLS in front of the tools server. Every URL carries ?id= (the device's
* MAC-derived identity -- how a multi-frame server tells frames apart
* and how an unknown frame self-registers) plus &token=: the server-
* issued per-frame device token once one has been delivered via
* /frame/config, else the provisioned access token (the legacy shared
* secret, also what a pre-multi-frame server still expects). This is
* the one chokepoint all requests go through, so every caller gets both
* for free instead of needing to remember to add them. */
static void build_url(char *out, size_t out_size, const frame_config_t *cfg, const char *path)
{
const char *toolsserver = cfg->toolsserver;
@@ -113,8 +116,16 @@ static void build_url(char *out, size_t out_size, const frame_config_t *cfg, con
} else {
len = (size_t)snprintf(out, out_size, "http://%s/%s", toolsserver, path);
}
if (cfg->access_token[0] != '\0' && len < out_size) {
snprintf(out + len, out_size - len, "?token=%s", cfg->access_token);
char device_id[FRAME_DEVICE_ID_LEN + 1];
frame_device_id_get(device_id, sizeof(device_id));
if (len < out_size) {
len += (size_t)snprintf(out + len, out_size - len, "?id=%s", device_id);
}
const char *token = cfg->device_token[0] != '\0' ? cfg->device_token : cfg->access_token;
if (token[0] != '\0' && len < out_size) {
snprintf(out + len, out_size - len, "&token=%s", token);
}
}
@@ -266,6 +277,11 @@ typedef struct {
bool reachable;
uint32_t refresh_interval_s; /* CONFIG_FRAME_SLEEP_INTERVAL_S if absent/unparseable */
char firmware_version[32]; /* server's uploaded OTA image version; empty if none/unreachable */
/* Per-frame token the server pushes until this device has
* authenticated with it once; empty when absent. Persisted via
* frame_config_set_device_token() and used by build_url() from the
* next request on. */
char device_token[FRAME_CFG_TOKEN_MAX_LEN + 1];
} frame_server_config_t;
/* Finds the first integer value associated with "key" in a small JSON
@@ -356,6 +372,7 @@ static frame_server_config_t fetch_frame_config(const frame_config_t *cfg)
.refresh_interval_s = CONFIG_FRAME_SLEEP_INTERVAL_S,
};
result.firmware_version[0] = '\0';
result.device_token[0] = '\0';
char url[256];
build_url(url, sizeof(url), cfg, "frame/config");
@@ -380,7 +397,10 @@ static frame_server_config_t fetch_frame_config(const frame_config_t *cfg)
esp_http_client_fetch_headers(client);
result.reachable = true;
char body[256];
/* 512 (was 256): the response also carries "device_token" during the
* one-time identity handshake -- worst case is still well under half
* of this, the rest is headroom for future fields. */
char body[512];
int total = 0;
int n;
while (total < (int)sizeof(body) - 1 &&
@@ -400,6 +420,7 @@ static frame_server_config_t fetch_frame_config(const frame_config_t *cfg)
(int)result.refresh_interval_s);
}
json_extract_string(body, "firmware_version", result.firmware_version, sizeof(result.firmware_version));
json_extract_string(body, "device_token", result.device_token, sizeof(result.device_token));
return result;
}
@@ -952,6 +973,20 @@ void frame_client_run(const frame_config_t *cfg, fetch_action_t action, bool sho
frame_server_config_t server_cfg = fetch_frame_config(cfg);
sleep_seconds = server_cfg.reachable ? server_cfg.refresh_interval_s : CONFIG_FRAME_RETRY_INTERVAL_S;
/* One-time identity handshake: the server pushes this frame's
* own token until we've authenticated with it once. Persist it
* and use it immediately (the OTA below is part of this same
* cycle) via a local working copy -- cfg itself is const. */
frame_config_t updated_cfg;
if (server_cfg.device_token[0] != '\0' &&
strcmp(server_cfg.device_token, cfg->device_token) != 0) {
frame_config_set_device_token(server_cfg.device_token);
updated_cfg = *cfg;
snprintf(updated_cfg.device_token, sizeof(updated_cfg.device_token), "%s",
server_cfg.device_token);
cfg = &updated_cfg;
}
/* Last, deliberately -- the photo's already on screen and the
* battery report already sent, so a reboot here (whether OTA
* succeeds or the device is mid-update) never loses either. */
+11 -3
View File
@@ -17,7 +17,7 @@ static const char *TAG = "ota_update";
#define OTA_HTTP_TIMEOUT_MS 30000
/* Built the same way as every other tools-server URL -- scheme/cert/
* token handling all come from build_url()'s conventions. Duplicated
* id/token handling all come from build_url()'s conventions. Duplicated
* tiny helper rather than exporting frame_client.c's static build_url();
* kept byte-identical in behavior (see frame_client.c). */
static void build_ota_url(char *out, size_t out_size, const frame_config_t *cfg)
@@ -29,8 +29,16 @@ static void build_ota_url(char *out, size_t out_size, const frame_config_t *cfg)
} else {
len = (size_t)snprintf(out, out_size, "http://%s/frame/firmware", toolsserver);
}
if (cfg->access_token[0] != '\0' && len < out_size) {
snprintf(out + len, out_size - len, "?token=%s", cfg->access_token);
char device_id[FRAME_DEVICE_ID_LEN + 1];
frame_device_id_get(device_id, sizeof(device_id));
if (len < out_size) {
len += (size_t)snprintf(out + len, out_size - len, "?id=%s", device_id);
}
const char *token = cfg->device_token[0] != '\0' ? cfg->device_token : cfg->access_token;
if (token[0] != '\0' && len < out_size) {
snprintf(out + len, out_size - len, "&token=%s", token);
}
}
+6 -2
View File
@@ -98,10 +98,14 @@
</div>
<div class="input-group">
<label for="access_token">Access Token (optional)</label>
<input type="text" id="access_token" name="access_token" placeholder="only if the server's MANAGEMENT_TOKEN is set" maxlength="64">
<label for="access_token">Access Token (optional &mdash; only for older servers)</label>
<input type="text" id="access_token" name="access_token" placeholder="usually blank; current servers issue one automatically" maxlength="64">
</div>
<p style="font-size: 13px; color: #555;">After saving, this page will
take you to the server to claim your frame &mdash; reconnect to
your normal WiFi if it doesn't happen automatically.</p>
<button type="submit">Submit</button>
</form>
+63 -2
View File
@@ -83,10 +83,39 @@ esp_err_t frame_config_load(frame_config_t *out)
return token_err;
}
/* Optional: absent until the server has pushed a per-frame token
* (see frame_config_set_device_token). */
len = sizeof(out->device_token);
token_err = nvs_get_str(handle, "device_token", out->device_token, &len);
if (token_err != ESP_OK && token_err != ESP_ERR_NVS_NOT_FOUND) {
nvs_close(handle);
return token_err;
}
nvs_close(handle);
return ESP_OK;
}
void frame_device_id_get(char *out, size_t out_size)
{
uint8_t mac[6] = {0};
ESP_ERROR_CHECK(esp_read_mac(mac, ESP_MAC_WIFI_STA));
snprintf(out, out_size, "%02x%02x%02x%02x%02x%02x",
mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]);
}
void frame_config_set_device_token(const char *token)
{
nvs_handle_t handle;
if (nvs_open(NVS_NAMESPACE, NVS_READWRITE, &handle) != ESP_OK) {
return;
}
nvs_set_str(handle, "device_token", token);
nvs_commit(handle);
nvs_close(handle);
ESP_LOGI(TAG, "Stored server-issued device token");
}
esp_err_t frame_config_save(const frame_config_t *cfg)
{
nvs_handle_t handle;
@@ -106,6 +135,10 @@ esp_err_t frame_config_save(const frame_config_t *cfg)
err = nvs_set_str(handle, "access_token", cfg->access_token);
}
if (err == ESP_OK) {
/* Re-provisioning restarts the identity handshake: the server
* (possibly a different one now) re-issues a device token when
* the frame next introduces itself. */
nvs_erase_key(handle, "device_token");
/* Fresh (re)provisioning -- the next successful connection should
* show the status screen again. */
err = nvs_set_u8(handle, "connected_once", 0);
@@ -159,6 +192,7 @@ void frame_config_clear(void)
nvs_erase_key(handle, "sta_pass");
nvs_erase_key(handle, "toolsserver");
nvs_erase_key(handle, "access_token");
nvs_erase_key(handle, "device_token");
nvs_erase_key(handle, "connected_once");
nvs_commit(handle);
nvs_close(handle);
@@ -412,8 +446,35 @@ static esp_err_t save_config_post_handler(httpd_req_t *req)
ESP_LOGI(TAG, "Saved config: ssid='%s' toolsserver='%s' access_token=%s", cfg.sta_ssid, cfg.toolsserver,
strlen(cfg.access_token) ? "set" : "none");
static const char resp[] =
"<html><body><h3>Saved. Restarting and connecting to your WiFi...</h3></body></html>";
/* The success page hands the browser off to the server's claim page,
* carrying this device's id -- how a frame gets linked to a user
* account. The ~7s delay covers the phone dropping this softAP (the
* device reboots right after this response) and rejoining its normal
* WiFi before the redirect fires; the visible link is the fallback
* if the phone loses that race. Scheme handling matches
* frame_client.c's build_url(): a bare host gets http://. */
char device_id[FRAME_DEVICE_ID_LEN + 1];
frame_device_id_get(device_id, sizeof(device_id));
char claim_url[FRAME_CFG_SERVER_MAX_LEN + 64];
const char *scheme = "";
if (strncmp(cfg.toolsserver, "http://", 7) != 0 && strncmp(cfg.toolsserver, "https://", 8) != 0) {
scheme = "http://";
}
snprintf(claim_url, sizeof(claim_url), "%s%s/claim?device_id=%s", scheme, cfg.toolsserver, device_id);
char resp[1024];
snprintf(resp, sizeof(resp),
"<!doctype html><html><head>"
"<meta http-equiv=\"refresh\" content=\"7;url=%s\">"
"<style>body{font-family:sans-serif;text-align:center;padding:2em}</style></head>"
"<body><h3>Saved &mdash; the frame is restarting</h3>"
"<p>Reconnect to your normal WiFi. You'll be taken to the claim page "
"in a few seconds&hellip;</p>"
"<p><a href=\"%s\">Continue to claim your frame</a></p>"
"<script>setTimeout(function(){location.href=%c%s%c},7000)</script>"
"</body></html>",
claim_url, claim_url, '"', claim_url, '"');
httpd_resp_set_type(req, "text/html");
httpd_resp_send(req, resp, HTTPD_RESP_USE_STRLEN);
+25 -1
View File
@@ -11,13 +11,37 @@
#define FRAME_CFG_TOKEN_MAX_LEN 64
#define FRAME_AP_PASSWORD_LEN 10
#define FRAME_DEVICE_ID_LEN 12 /* 6-byte STA MAC as lowercase hex */
typedef struct {
char sta_ssid[FRAME_CFG_SSID_MAX_LEN + 1];
char sta_password[FRAME_CFG_PASSWORD_MAX_LEN + 1];
char toolsserver[FRAME_CFG_SERVER_MAX_LEN + 1];
char access_token[FRAME_CFG_TOKEN_MAX_LEN + 1]; /* optional; matches the server's MANAGEMENT_TOKEN */
char access_token[FRAME_CFG_TOKEN_MAX_LEN + 1]; /* optional; legacy shared MANAGEMENT_TOKEN */
/* Per-frame token issued by the server via GET /frame/config after
* this device first introduces itself by id -- preferred over
* access_token once present (see frame_client.c's build_url). Not
* set at the captive portal; empty until the server pushes one. */
char device_token[FRAME_CFG_TOKEN_MAX_LEN + 1];
} frame_config_t;
/**
* This device's stable identity as reported to the server (?id= on every
* request): the full 6-byte STA MAC as 12 lowercase hex chars. Derived
* from the same MAC the provisioning AP SSID suffix comes from; never
* stored. out must hold at least FRAME_DEVICE_ID_LEN + 1 bytes.
*/
void frame_device_id_get(char *out, size_t out_size);
/**
* Persists (only) the server-issued per-frame device token -- called
* from the wake cycle when GET /frame/config delivers one. Deliberately
* touches nothing else: unlike frame_config_save() it must not reset
* the connected-once flag or invalidate the WiFi fast-connect cache,
* since nothing about the network changed.
*/
void frame_config_set_device_token(const char *token);
/**
* Loads the saved home-network config from NVS.
* Returns ESP_ERR_NVS_NOT_FOUND if the device has never been provisioned.
+14 -2
View File
@@ -265,8 +265,20 @@ def require_device(request: Request, db: Session = Depends(get_db)) -> Frame:
if token_ok and not frame.device_token_ack:
frame.device_token_ack = True
logger.info("Frame #%d acknowledged its device token", frame.id)
if not token_ok and not (frame.legacy_token_enabled and legacy_ok):
raise HTTPException(401, "Missing or invalid access token")
if not token_ok:
if frame.legacy_token_enabled and legacy_ok:
pass
elif not frame.device_token_ack:
# Handshake window: the device registered but hasn't
# received its token yet (the wake cycle fetches the
# image BEFORE polling /frame/config, where the token
# is delivered) -- the id stays the credential, same
# trust level as the open registration that created
# the row. Closes permanently on the first
# authenticated request.
pass
else:
raise HTTPException(401, "Missing or invalid access token")
else:
if not legacy_ok:
raise HTTPException(401, "Missing or invalid access token")
+58 -1
View File
@@ -174,7 +174,15 @@ def render_frame(source: Image.Image, faces: list[dict] | None = None,
else:
fitted = ImageOps.fit(fitted, (logical_w, logical_h), method=Image.LANCZOS)
quantized = fitted.quantize(palette=_PALETTE_IMAGE, dither=Image.Dither.FLOYDSTEINBERG)
return _quantize_and_pack(fitted, orientation)
def _quantize_and_pack(logical_img: Image.Image, orientation: str) -> bytes:
"""The shared back half of rendering: 6-color Floyd-Steinberg
quantization, rotation into native panel space, and 2-pixels/byte
packing. Takes an RGB image already composed at logical_render_size()
for the orientation."""
quantized = logical_img.quantize(palette=_PALETTE_IMAGE, dither=Image.Dither.FLOYDSTEINBERG)
transpose = ORIENTATION_TRANSPOSE.get(orientation)
if transpose is not None:
quantized = quantized.transpose(transpose)
@@ -190,3 +198,52 @@ def render_frame(source: Image.Image, faces: list[dict] | None = None,
i += 1
return bytes(out)
def render_placeholder(lines: list[str], qr_url: str | None = None,
orientation: str = "landscape") -> bytes:
"""A readable full-panel message (plus an optional QR code) in the
same packed format as render_frame -- what /frame/image serves for a
frame that isn't claimed or configured yet, so a fresh device shows
instructions instead of an error screen and never error-loops."""
from PIL import ImageDraw, ImageFont
logical_w, logical_h = logical_render_size(orientation)
img = Image.new("RGB", (logical_w, logical_h), (255, 255, 255))
draw = ImageDraw.Draw(img)
title_font = ImageFont.load_default(size=34)
body_font = ImageFont.load_default(size=24)
qr_img = None
if qr_url:
import qrcode
qr = qrcode.QRCode(border=1, box_size=1)
qr.add_data(qr_url)
qr.make(fit=True)
raw = qr.make_image().get_image().convert("RGB")
# Integer upscale with NEAREST keeps modules crisp on the panel.
target = 220
scale = max(1, target // raw.width)
qr_img = raw.resize((raw.width * scale, raw.height * scale), Image.NEAREST)
# Vertical layout: text block, then QR under it, centered as a group.
line_heights = []
for i, line in enumerate(lines):
font = title_font if i == 0 else body_font
bbox = draw.textbbox((0, 0), line, font=font)
line_heights.append((line, font, bbox[2] - bbox[0], bbox[3] - bbox[1]))
gap = 14
text_h = sum(h for _, _, _, h in line_heights) + gap * (len(line_heights) - 1 if line_heights else 0)
total_h = text_h + (qr_img.height + 28 if qr_img else 0)
y = max(20, (logical_h - total_h) // 2)
for line, font, w, h in line_heights:
draw.text(((logical_w - w) // 2, y), line, fill=(0, 0, 0), font=font)
y += h + gap
if qr_img:
img.paste(qr_img, ((logical_w - qr_img.width) // 2, y + 14))
return _quantize_and_pack(img, orientation)
+43 -23
View File
@@ -15,9 +15,10 @@ from fastapi import FastAPI, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
from sqlalchemy import select
from . import migration
from .auth import (
MANAGEMENT_TOKEN_COOKIE,
browser_token_valid,
current_session,
current_user,
@@ -25,8 +26,9 @@ from .auth import (
users_exist,
)
from .db import SessionLocal
from .models import Frame
from .quiet_hours import ALL_TIMEZONES
from .routers import api, device, pages
from .routers import api, device, manage, pages
from .routers.common import default_frame, immich_creds
logger = logging.getLogger(__name__)
@@ -40,6 +42,30 @@ templates = Jinja2Templates(directory="app/templates")
app.include_router(device.router)
app.include_router(api.router)
app.include_router(pages.router)
app.include_router(manage.router)
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=<legacy shared token>). Those scans get the frame's limited
manage page -- never the full UI, which now 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("/health")
@@ -50,30 +76,34 @@ def health() -> dict:
@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."""
a user session (the normal path once /setup has run), or -- only
while no users exist AND no MANAGEMENT_TOKEN is configured -- fully
open, the original trusted-LAN default. A hit carrying device
credentials (the on-frame manage QR) redirects to that frame's
limited manage page instead."""
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)
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():
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}
)
# Fresh install, nothing configured: open, but nudge setup.
# Pre-setup: reachable (optionally token-gated), 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(
return templates.TemplateResponse(
"index.html",
{
"request": request,
@@ -84,13 +114,3 @@ def index(request: Request):
"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
+44 -5
View File
@@ -22,12 +22,14 @@ from ..auth import require_device
from ..db import frame_locked, get_db
from ..face_labels import compute_face_labels
from ..firmware import firmware_path
from ..image_pipeline import render_placeholder
from ..models import BatteryLog, Frame
from .common import (
BATTERY_HISTORY_MAX,
BATTERY_LOG_MAX,
RECHARGE_JUMP_PCT,
immich_client_for,
immich_creds,
list_assets,
render_asset,
require_configured,
@@ -38,10 +40,43 @@ logger = logging.getLogger(__name__)
router = APIRouter()
def _setup_placeholder(frame: Frame, request: Request) -> bytes:
"""What an unclaimed or not-yet-configured frame displays instead of a
photo -- instructions with a QR, rendered at 200 so the device treats
it as a perfectly normal image and never error-loops. The URLs are
built from the request's own base URL: whatever address the device
reached us at is by definition an address that works on this
network."""
base = str(request.base_url).rstrip("/")
if frame.owner_user_id is None and frame.device_id:
claim_url = f"{base}/claim?device_id={frame.device_id}"
return render_placeholder(
["This frame isn't claimed yet", "Scan to link it to your account:"],
qr_url=claim_url,
orientation=frame.orientation,
)
if frame.owner_user_id is None:
return render_placeholder(
["Almost there!", f"Open {base} to finish setting up this frame."],
orientation=frame.orientation,
)
return render_placeholder(
["Almost there!", "Pick an album for this frame:", base],
qr_url=base,
orientation=frame.orientation,
)
def _frame_configured(frame: Frame) -> bool:
url, key = immich_creds(frame)
return bool(url and key and frame.album_id)
# Renderer dispatch seam for future frame modes (calendar, canva, ...):
# /frame/image looks up the frame's mode here. Only photos exists today.
def _render_photos_mode(db: Session, frame: Frame) -> bytes:
require_configured(frame)
def _render_photos_mode(db: Session, frame: Frame, request: Request) -> bytes:
if not _frame_configured(frame):
return _setup_placeholder(frame, request)
client = immich_client_for(frame)
assets = list_assets(client, frame)
@@ -93,14 +128,18 @@ def frame_config(request: Request, frame: Frame = Depends(require_device), db: S
@router.get("/frame/image")
def frame_image(frame: Frame = Depends(require_device), db: Session = Depends(get_db)):
def frame_image(
request: Request, frame: Frame = Depends(require_device), db: Session = Depends(get_db)
):
"""Returns the frame's current image. For photos mode: idempotent --
only actually advances to the next photo once refresh_interval_s has
elapsed since the current one was set (see app/photo_queue.py) --
safe to call as often as the device wants, including after an
unplanned reboot, without skipping ahead in the album."""
unplanned reboot, without skipping ahead in the album. An unclaimed/
unconfigured frame gets a rendered instruction placeholder (200, not
an error) so a fresh device never error-loops."""
renderer = RENDERERS.get(frame.mode, _render_photos_mode)
return Response(content=renderer(db, frame), media_type="application/octet-stream")
return Response(content=renderer(db, frame, request), media_type="application/octet-stream")
@router.post("/frame/advance")
+119
View File
@@ -0,0 +1,119 @@
"""The limited no-login manage surface behind the on-frame "scan to
manage" QR. The QR resolves to /m/<manage_token> (see main.index's
device-credential redirect); the token grants exactly: view the current
photo + upcoming queue, promote ("show next"), advance, back, and
thumbnails. No settings, no removal, no other frames -- full control
requires logging in."""
from __future__ import annotations
import logging
import httpx
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import HTMLResponse, Response
from fastapi.templating import Jinja2Templates
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.orm import Session
from .. import photo_queue, quiet_hours
from ..db import frame_locked, get_db
from ..models import Frame
from .common import immich_client_for, list_assets, require_configured
logger = logging.getLogger(__name__)
router = APIRouter()
templates = Jinja2Templates(directory="app/templates")
def require_manage(manage_token: str, db: Session = Depends(get_db)) -> Frame:
frame = db.scalars(select(Frame).where(Frame.manage_token == manage_token)).first()
if frame is None:
raise HTTPException(404, "Unknown manage link")
return frame
@router.get("/m/{manage_token}", response_class=HTMLResponse)
def manage_page(manage_token: str, request: Request, db: Session = Depends(get_db)):
frame = require_manage(manage_token, db)
return templates.TemplateResponse(
"manage.html",
{"request": request, "frame": frame, "manage_token": manage_token},
)
@router.get("/api/m/{manage_token}/queue")
def manage_queue(frame: Frame = Depends(require_manage), db: Session = Depends(get_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)
current = cfg.current_asset_id
queue = list(cfg.queue)
def entry(asset_id: str) -> dict:
return {"id": asset_id, "thumbnail_url": f"/api/m/{frame.manage_token}/thumbnail/{asset_id}"}
return {
"frame_name": frame.name,
"current": entry(current) if current else None,
"upcoming": [entry(asset_id) for asset_id in queue],
}
class ManagePromoteRequest(BaseModel):
asset_id: str
@router.post("/api/m/{manage_token}/promote")
def manage_promote(
body: ManagePromoteRequest,
frame: Frame = Depends(require_manage),
db: Session = Depends(get_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] + [a for a in cfg.queue if a != body.asset_id]
return {"status": "saved"}
@router.post("/api/m/{manage_token}/advance")
def manage_advance(frame: Frame = Depends(require_manage), db: Session = Depends(get_db)):
"""Advances the server-side current photo; the panel itself updates
on the device's next wake (or its next-photo button)."""
require_configured(frame)
client = immich_client_for(frame)
assets = list_assets(client, frame)
with frame_locked(db, frame.id) as cfg:
photo_queue.advance_forced(cfg, assets)
return {"status": "saved"}
@router.post("/api/m/{manage_token}/back")
def manage_back(frame: Frame = Depends(require_manage), db: Session = Depends(get_db)):
require_configured(frame)
client = immich_client_for(frame)
assets = list_assets(client, frame)
with frame_locked(db, frame.id) as cfg:
photo_queue.back_forced(cfg, assets)
return {"status": "saved"}
@router.get("/api/m/{manage_token}/thumbnail/{asset_id}")
def manage_thumbnail(asset_id: str, frame: Frame = Depends(require_manage)):
"""Thumbnails scoped to what this frame is actually showing/queuing --
the manage token must not become a general Immich proxy."""
if asset_id != frame.current_asset_id and asset_id not in frame.queue:
raise HTTPException(404, "Not on this 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)
+163 -6
View File
@@ -30,7 +30,7 @@ from ..auth import (
verify_password,
)
from ..db import get_db
from ..models import Frame, User, UserFrame
from ..models import Frame, PendingClaim, User, UserFrame
logger = logging.getLogger(__name__)
@@ -39,6 +39,7 @@ templates = Jinja2Templates(directory="app/templates")
USERNAME_MAX_LEN = 64
PASSWORD_MIN_LEN = 8
PENDING_CLAIM_TTL_S = 24 * 3600
def _set_session_cookie(response, cookie_value: str) -> None:
@@ -129,13 +130,23 @@ def setup_submit(
return response
def _safe_next(next_url: str) -> str:
"""Same-site relative paths only -- a login redirect target from a
query param must never become an open redirect."""
if next_url.startswith("/") and not next_url.startswith("//"):
return next_url
return "/"
@router.get("/login", response_class=HTMLResponse)
def login_page(request: Request, db: Session = Depends(get_db)):
def login_page(request: Request, next: str = "", db: Session = Depends(get_db)):
if not users_exist(db):
return RedirectResponse("/setup", status_code=303)
if current_user(request, db) is not None:
return RedirectResponse("/", status_code=303)
return templates.TemplateResponse("login.html", {"request": request, "error": None})
return RedirectResponse(_safe_next(next), status_code=303)
return templates.TemplateResponse(
"login.html", {"request": request, "error": None, "next": next}
)
@router.post("/login")
@@ -143,6 +154,7 @@ def login_submit(
request: Request,
username: str = Form(...),
password: str = Form(...),
next: str = Form(""),
db: Session = Depends(get_db),
):
user = db.scalars(
@@ -151,11 +163,11 @@ def login_submit(
if user is None or not user.password_hash or not verify_password(password, user.password_hash):
return templates.TemplateResponse(
"login.html",
{"request": request, "error": "Wrong username or password."},
{"request": request, "error": "Wrong username or password.", "next": next},
status_code=401,
)
cookie_value, _ = create_session(db, user)
response = RedirectResponse("/", status_code=303)
response = RedirectResponse(_safe_next(next), status_code=303)
_set_session_cookie(response, cookie_value)
return response
@@ -169,6 +181,151 @@ def logout(request: Request, csrf_token: str = Form(""), db: Session = Depends(g
return response
def _normalize_device_id(device_id: str) -> str:
device_id = device_id.strip().lower()
if len(device_id) != 12 or any(c not in "0123456789abcdef" for c in device_id):
raise HTTPException(400, "Invalid device id")
return device_id
def _attempt_claim(db: Session, user: User, device_id: str) -> str:
"""Claims the frame for `user` if it has registered, else records a
pending claim the frame's first check-in will attach (see
auth._register_frame). Returns "claimed" or "pending"."""
frame = db.scalars(select(Frame).where(Frame.device_id == device_id)).first()
now = time.time()
if frame is not None:
if frame.owner_user_id is not None:
raise HTTPException(409, "That frame is already claimed")
frame.owner_user_id = user.id
frame.claimed_at = now
if frame.controlled_by_user_id is None:
frame.controlled_by_user_id = user.id
if db.get(UserFrame, (user.id, frame.id)) is None:
db.add(UserFrame(user_id=user.id, frame_id=frame.id))
pending = db.get(PendingClaim, device_id)
if pending is not None:
db.delete(pending)
db.commit()
logger.info("User '%s' claimed frame #%d (%s)", user.username, frame.id, device_id)
return "claimed"
pending = db.get(PendingClaim, device_id)
if pending is None:
pending = PendingClaim(device_id=device_id, user_id=user.id, created_at=now,
expires_at=now + PENDING_CLAIM_TTL_S)
db.add(pending)
else:
pending.user_id = user.id
pending.expires_at = now + PENDING_CLAIM_TTL_S
db.commit()
logger.info("User '%s' filed a pending claim for device %s", user.username, device_id)
return "pending"
def _render_claim(request: Request, db: Session, device_id: str, error: str | None = None):
user = current_user(request, db)
session = current_session(request, db) if user else None
frame = db.scalars(select(Frame).where(Frame.device_id == device_id)).first()
if frame is None:
status = "unregistered"
pending = db.get(PendingClaim, device_id)
pending_yours = bool(pending and user and pending.user_id == user.id
and pending.expires_at > time.time())
elif frame.owner_user_id is None:
status, pending_yours = "unclaimed", False
elif user is not None and (
frame.owner_user_id == user.id or db.get(UserFrame, (user.id, frame.id)) is not None
):
status, pending_yours = "claimed_yours", False
else:
status, pending_yours = "claimed", False
return templates.TemplateResponse(
"claim.html",
{
"request": request,
"device_id": device_id,
"status": status,
"pending_yours": pending_yours,
"user": user,
"csrf_token": session.csrf_token if session else None,
"error": error,
},
)
@router.get("/claim", response_class=HTMLResponse)
def claim_page(request: Request, device_id: str = "", db: Session = Depends(get_db)):
"""Where the captive portal's post-provisioning redirect lands. Also
the enrollment gate: a valid device id is what entitles a stranger to
create an account (signup form on this page); everyone else gets
enrolled by the admin."""
device_id = _normalize_device_id(device_id)
return _render_claim(request, db, device_id)
@router.post("/claim")
def claim_submit(
request: Request,
device_id: str = Form(...),
csrf_token: str = Form(""),
db: Session = Depends(get_db),
):
device_id = _normalize_device_id(device_id)
user = current_user(request, db)
if user is None:
return RedirectResponse(f"/login?next=/claim%3Fdevice_id%3D{device_id}", status_code=303)
_check_form_csrf(request, db, csrf_token)
try:
_attempt_claim(db, user, device_id)
except HTTPException as e:
if e.status_code == 409:
return _render_claim(request, db, device_id, error=e.detail)
raise
return RedirectResponse(f"/claim?device_id={device_id}", status_code=303)
@router.post("/claim/signup")
def claim_signup(
request: Request,
device_id: str = Form(...),
username: str = Form(...),
password: str = Form(...),
db: Session = Depends(get_db),
):
"""Account creation, gated on a plausible frame claim: the device id
must belong to a frame that is unclaimed (or not yet registered --
the user beat the device here after provisioning). A fabricated id
can create an orphan account whose pending claim expires in 24h --
accepted at household scale, and visible in /admin."""
device_id = _normalize_device_id(device_id)
frame = db.scalars(select(Frame).where(Frame.device_id == device_id)).first()
if frame is not None and frame.owner_user_id is not None:
return _render_claim(request, db, device_id,
error="That frame is already claimed -- log in instead.")
username = _validate_credentials(username, password)
if db.scalars(select(User).where(User.username == username)).first() is not None:
return _render_claim(request, db, device_id,
error=f"Username '{username}' is taken -- log in instead?")
user = User(
username=username,
display_name=username,
password_hash=hash_password(password),
is_admin=False,
created_at=time.time(),
)
db.add(user)
db.commit()
logger.info("User '%s' signed up via claim gate for device %s", username, device_id)
_attempt_claim(db, user, device_id)
cookie_value, _ = create_session(db, user)
response = RedirectResponse(f"/claim?device_id={device_id}", status_code=303)
_set_session_cookie(response, cookie_value)
return response
@router.get("/settings", response_class=HTMLResponse)
def settings_page(request: Request, db: Session = Depends(get_db)):
user = current_user(request, db)
+72
View File
@@ -0,0 +1,72 @@
{% extends "base.html" %}
{% block page_class %}page-narrow{% endblock %}
{% block subtitle %}
<p class="sub">Claim your frame</p>
{% endblock %}
{% block extra_head %}
{% if status == "unregistered" %}<meta http-equiv="refresh" content="6">{% endif %}
{% endblock %}
{% block content %}
{% if error %}<div class="status err">{{ error }}</div>{% endif %}
<section class="card">
<h2 class="card-title">Frame <code>{{ device_id }}</code></h2>
{% if status == "claimed_yours" %}
<div class="status ok">This frame is linked to your account.</div>
<p class="sub">It will show up in your frame list. If it was just
provisioned, give it a minute to connect and fetch its first image.</p>
<p><a href="/">Go to your frames</a></p>
{% elif status == "claimed" %}
<p class="sub">This frame already belongs to someone. If it's yours,
ask them (or an admin) to link your account to it.</p>
{% elif status == "unregistered" %}
{% if pending_yours %}
<div class="status ok">Claim recorded.</div>
<p class="sub">Waiting for the frame to connect for the first time --
it links to your account automatically the moment it checks in.
This page refreshes itself; it's safe to close, too.</p>
{% else %}
<p class="sub">The frame hasn't checked in yet -- it's probably still
restarting and joining your WiFi. This page refreshes itself.
{% if user %}You can claim it now anyway; it'll attach when it
arrives.{% endif %}</p>
{% endif %}
{% elif status == "unclaimed" %}
<p class="sub">This frame is connected and ready to be claimed.</p>
{% endif %}
{% if user and status in ("unclaimed", "unregistered") and not pending_yours %}
<form method="post" action="/claim">
<input type="hidden" name="device_id" value="{{ device_id }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit">Claim this frame</button>
</form>
{% endif %}
</section>
{% if not user and status in ("unclaimed", "unregistered") %}
<section class="card">
<h2 class="card-title">Create your account</h2>
<p class="sub">A valid frame is your invitation -- set up an account to
claim it. Already have one?
<a href="/login?next=/claim%3Fdevice_id%3D{{ device_id }}">Log in instead</a>.</p>
<form method="post" action="/claim/signup">
<input type="hidden" name="device_id" value="{{ device_id }}">
<label>Username
<input type="text" name="username" maxlength="64" required autocomplete="username">
</label>
<label>Password
<input type="password" name="password" minlength="8" required autocomplete="new-password">
</label>
<button type="submit">Create account &amp; claim frame</button>
</form>
</section>
{% endif %}
{% endblock %}
+1
View File
@@ -11,6 +11,7 @@
<h2 class="card-title">Log in</h2>
{% if error %}<div class="status err">{{ error }}</div>{% endif %}
<form method="post" action="/login">
<input type="hidden" name="next" value="{{ next }}">
<label>Username
<input type="text" name="username" maxlength="64" required autofocus autocomplete="username">
</label>
+116
View File
@@ -0,0 +1,116 @@
{% extends "base.html" %}
{% block subtitle %}
<p class="sub">{{ frame.name or "Frame" }} &mdash; quick controls</p>
{% endblock %}
{% block content %}
<div class="layout">
<div class="main-col">
<section class="card">
<h2 class="card-title">Up next</h2>
<p class="sub">Tap "Show next" to move a photo to the front. The frame
picks it up on its next refresh. <a href="/login">Log in</a> for full
settings.</p>
<div id="upcoming-grid" class="photo-grid"></div>
</section>
</div>
<div class="side-col">
<section class="card">
<h2 class="card-title">Now displaying</h2>
<div id="current-thumb"><p class="sub">Loading...</p></div>
<div style="display: flex; gap: 8px;">
<button type="button" class="secondary" id="btn-back">&larr; Previous</button>
<button type="button" class="secondary" id="btn-advance">Next &rarr;</button>
</div>
<p class="sub" style="margin-top: 8px;">Changes what the frame shows on
its next wake.</p>
</section>
</div>
</div>
<div id="result"></div>
{% endblock %}
{% block scripts %}
<script>
const TOKEN = {{ manage_token | tojson }};
const resultEl = document.getElementById('result');
function showStatus(ok, message) {
resultEl.innerHTML = `<div class="status ${ok ? 'ok' : 'err'}">${message}</div>`;
}
async function post(path, body) {
const resp = await fetch(`/api/m/${TOKEN}/${path}`, {
method: 'POST',
headers: body ? { 'Content-Type': 'application/json' } : {},
body: body ? JSON.stringify(body) : undefined,
});
if (!resp.ok) {
throw new Error(await resp.text());
}
}
async function loadQueue() {
const currentEl = document.getElementById('current-thumb');
const grid = document.getElementById('upcoming-grid');
try {
const resp = await fetch(`/api/m/${TOKEN}/queue`);
if (!resp.ok) {
currentEl.innerHTML = '<p class="sub">This frame isn\'t set up yet.</p>';
return;
}
const data = await resp.json();
currentEl.innerHTML = '';
if (data.current) {
const img = document.createElement('img');
img.className = 'thumb';
img.src = data.current.thumbnail_url;
img.alt = '';
currentEl.appendChild(img);
} else {
currentEl.innerHTML = '<p class="sub">Nothing displayed yet.</p>';
}
grid.innerHTML = '';
for (const item of data.upcoming) {
const card = document.createElement('div');
card.className = 'photo-card';
const img = document.createElement('img');
img.src = item.thumbnail_url;
img.alt = '';
img.draggable = false;
card.appendChild(img);
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'show-next';
btn.textContent = 'Show next';
btn.addEventListener('click', async () => {
try {
await post('promote', { asset_id: item.id });
showStatus(true, 'Moved to the front.');
loadQueue();
} catch (e) {
showStatus(false, e.message);
}
});
card.appendChild(btn);
grid.appendChild(card);
}
} catch (e) {
currentEl.innerHTML = '<p class="sub">Could not load.</p>';
}
}
document.getElementById('btn-advance').addEventListener('click', async () => {
try { await post('advance'); showStatus(true, 'Advanced.'); loadQueue(); }
catch (e) { showStatus(false, e.message); }
});
document.getElementById('btn-back').addEventListener('click', async () => {
try { await post('back'); showStatus(true, 'Went back.'); loadQueue(); }
catch (e) { showStatus(false, e.message); }
});
loadQueue();
setInterval(loadQueue, 15000);
</script>
{% endblock %}
+1
View File
@@ -6,3 +6,4 @@ pillow==12.3.0
python-multipart==0.0.20
jinja2==3.1.5
sqlalchemy==2.0.51
qrcode==8.2