diff --git a/firmware/main/frame_client.c b/firmware/main/frame_client.c index 0b25e94..f43b726 100644 --- a/firmware/main/frame_client.c +++ b/firmware/main/frame_client.c @@ -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. */ diff --git a/firmware/main/ota_update.c b/firmware/main/ota_update.c index b15c631..9fd6c7d 100644 --- a/firmware/main/ota_update.c +++ b/firmware/main/ota_update.c @@ -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); } } diff --git a/firmware/main/root.html b/firmware/main/root.html index 42c7a08..4cfdebf 100644 --- a/firmware/main/root.html +++ b/firmware/main/root.html @@ -98,10 +98,14 @@
- - + +
+

After saving, this page will + take you to the server to claim your frame — reconnect to + your normal WiFi if it doesn't happen automatically.

+ diff --git a/firmware/main/wifi_provisioning.c b/firmware/main/wifi_provisioning.c index 634c2b8..d224fda 100644 --- a/firmware/main/wifi_provisioning.c +++ b/firmware/main/wifi_provisioning.c @@ -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[] = - "

Saved. Restarting and connecting to your WiFi...

"; + /* 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), + "" + "" + "" + "

Saved — the frame is restarting

" + "

Reconnect to your normal WiFi. You'll be taken to the claim page " + "in a few seconds…

" + "

Continue to claim your frame

" + "" + "", + claim_url, claim_url, '"', claim_url, '"'); httpd_resp_set_type(req, "text/html"); httpd_resp_send(req, resp, HTTPD_RESP_USE_STRLEN); diff --git a/firmware/main/wifi_provisioning.h b/firmware/main/wifi_provisioning.h index f1a6338..b24a7b5 100644 --- a/firmware/main/wifi_provisioning.h +++ b/firmware/main/wifi_provisioning.h @@ -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. diff --git a/server/app/auth.py b/server/app/auth.py index 71c2723..76eac85 100644 --- a/server/app/auth.py +++ b/server/app/auth.py @@ -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") diff --git a/server/app/image_pipeline.py b/server/app/image_pipeline.py index 37a09af..268b4f9 100644 --- a/server/app/image_pipeline.py +++ b/server/app/image_pipeline.py @@ -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) diff --git a/server/app/main.py b/server/app/main.py index 8c26016..97cf24e 100644 --- a/server/app/main.py +++ b/server/app/main.py @@ -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=). 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()/ 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 diff --git a/server/app/routers/device.py b/server/app/routers/device.py index 17ac685..8f161ec 100644 --- a/server/app/routers/device.py +++ b/server/app/routers/device.py @@ -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") diff --git a/server/app/routers/manage.py b/server/app/routers/manage.py new file mode 100644 index 0000000..fe79c8f --- /dev/null +++ b/server/app/routers/manage.py @@ -0,0 +1,119 @@ +"""The limited no-login manage surface behind the on-frame "scan to +manage" QR. The QR resolves to /m/ (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) diff --git a/server/app/routers/pages.py b/server/app/routers/pages.py index d5ea7d3..d0335da 100644 --- a/server/app/routers/pages.py +++ b/server/app/routers/pages.py @@ -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) diff --git a/server/app/templates/claim.html b/server/app/templates/claim.html new file mode 100644 index 0000000..aabb51d --- /dev/null +++ b/server/app/templates/claim.html @@ -0,0 +1,72 @@ +{% extends "base.html" %} + +{% block page_class %}page-narrow{% endblock %} + +{% block subtitle %} +

Claim your frame

+{% endblock %} + +{% block extra_head %} + {% if status == "unregistered" %}{% endif %} +{% endblock %} + +{% block content %} + {% if error %}
{{ error }}
{% endif %} + +
+

Frame {{ device_id }}

+ + {% if status == "claimed_yours" %} +
This frame is linked to your account.
+

It will show up in your frame list. If it was just + provisioned, give it a minute to connect and fetch its first image.

+

Go to your frames

+ + {% elif status == "claimed" %} +

This frame already belongs to someone. If it's yours, + ask them (or an admin) to link your account to it.

+ + {% elif status == "unregistered" %} + {% if pending_yours %} +
Claim recorded.
+

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.

+ {% else %} +

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 %}

+ {% endif %} + {% elif status == "unclaimed" %} +

This frame is connected and ready to be claimed.

+ {% endif %} + + {% if user and status in ("unclaimed", "unregistered") and not pending_yours %} +
+ + + +
+ {% endif %} +
+ + {% if not user and status in ("unclaimed", "unregistered") %} +
+

Create your account

+

A valid frame is your invitation -- set up an account to + claim it. Already have one? + Log in instead.

+
+ + + + +
+
+ {% endif %} +{% endblock %} diff --git a/server/app/templates/login.html b/server/app/templates/login.html index 326ffb1..94510e9 100644 --- a/server/app/templates/login.html +++ b/server/app/templates/login.html @@ -11,6 +11,7 @@

Log in

{% if error %}
{{ error }}
{% endif %}
+ diff --git a/server/app/templates/manage.html b/server/app/templates/manage.html new file mode 100644 index 0000000..a99bf66 --- /dev/null +++ b/server/app/templates/manage.html @@ -0,0 +1,116 @@ +{% extends "base.html" %} + +{% block subtitle %} +

{{ frame.name or "Frame" }} — quick controls

+{% endblock %} + +{% block content %} +
+
+
+

Up next

+

Tap "Show next" to move a photo to the front. The frame + picks it up on its next refresh. Log in for full + settings.

+
+
+
+
+
+

Now displaying

+

Loading...

+
+ + +
+

Changes what the frame shows on + its next wake.

+
+
+
+
+{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/server/requirements.txt b/server/requirements.txt index f4b39c8..e4adb3c 100644 --- a/server/requirements.txt +++ b/server/requirements.txt @@ -6,3 +6,4 @@ pillow==12.3.0 python-multipart==0.0.20 jinja2==3.1.5 sqlalchemy==2.0.51 +qrcode==8.2