From 1d39e439ffad26bb067a2b7762fb0e5517b93a3e Mon Sep 17 00:00:00 2001 From: Thomas Faour Date: Tue, 4 Aug 2026 18:33:14 +0000 Subject: [PATCH] Drop the last legacy widget-system and shared-token auth scaffolding Server: migration 41 drops the pre-widget-system Frame columns (mode/album_id/current_asset_id/queue/calendar_*/whiteboard_*, etc) docs/widgets.md flagged as the deliberately-deferred Phase 6 cleanup, with a raw-SQL backfill safety net for any frame that still somehow lacks a Widget. Also drops legacy_token_enabled and the shared MANAGEMENT_TOKEN fallback it gated in require_device/require_browser -- the per-frame manage_token/device_token flow (and the /m/ page) fully supersede it now; MANAGEMENT_TOKEN's only remaining role is the optional pre-setup claim gate. Confirmed with the maintainer that the deployed frame is already off the shared token before removing the server-side fallback. Firmware: the captive portal's "Access Token" field and its NVS/ build_url plumbing only ever mattered for pointing new firmware at an old pre-multi-frame server -- gone along with the server-side fallback it fed. Version bump to publish the change. --- CLAUDE.md | 3 +- docs/widgets.md | 30 +- firmware/README.md | 18 +- firmware/main/frame_client.c | 26 +- firmware/main/ota_update.c | 5 +- firmware/main/root.html | 5 - firmware/main/wifi_provisioning.c | 20 +- firmware/main/wifi_provisioning.h | 7 +- firmware/version.txt | 2 +- server/README.md | 19 +- server/app/auth.py | 120 ++--- server/app/main.py | 18 +- server/app/migration.py | 558 ++++++++++++-------- server/app/models.py | 128 +---- server/app/routers/device.py | 9 +- server/app/routers/pages.py | 20 - server/app/templates/admin.html | 8 - server/docker-compose.yml.example | 11 +- server/tests/conftest.py | 13 + server/tests/test_device_widget_dispatch.py | 23 +- server/tests/test_global_actions.py | 17 +- server/tests/test_migrations.py | 134 ++++- server/tests/test_now_displaying.py | 16 +- 23 files changed, 599 insertions(+), 611 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index c5e3dc6..9a431c5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -15,8 +15,7 @@ Start here, don't re-derive from scratch: - [`docs/architecture.md`](docs/architecture.md) -- how firmware and server talk (sequence diagram, boot flow). - [`docs/widgets.md`](docs/widgets.md) -- the server-side widget system - (data model, grid placement, compositor, button-action dispatch). Notes - a known gap at the bottom (legacy `Frame` columns not yet dropped). + (data model, grid placement, compositor, button-action dispatch). - [`docs/hardware.md`](docs/hardware.md) -- wiring. - [`server/README.md`](server/README.md), [`firmware/README.md`](firmware/README.md) -- per-component setup, config, and a lot of accumulated gotchas diff --git a/docs/widgets.md b/docs/widgets.md index a08aaa9..f1b7f2a 100644 --- a/docs/widgets.md +++ b/docs/widgets.md @@ -6,10 +6,10 @@ weather/battery), like arranging icons on an Android home screen. A frame can hold several widgets of the same type (e.g. two photo widgets pointed at different Immich albums side by side). This replaced an earlier design where `Frame.mode` picked exactly one -full-panel renderer; that column (and the other now-dead per-mode `Frame` -columns it left behind -- `album_id`, `calendar_*`, `whiteboard_*`, etc.) -is still physically present but unused, pending a final cleanup migration -(see "Known gaps" below). +full-panel renderer; that column and the other per-mode `Frame` columns +it left behind (`album_id`, `calendar_*`, `whiteboard_*`, etc.) were +dropped in migration 41, once every phase of the rollout had shipped +(see "Known gaps" below for what's still open). The device-facing contract is unchanged by any of this: `GET /frame/image`, `POST /frame/advance`, `POST /frame/back` are the same frozen paths @@ -563,20 +563,16 @@ piece of code with its own fixed small size, not shared with this widget, but drawing from the same thresholds/colors so a battery glyph reads the same wherever one shows up on a panel. -## Known gaps (Phase 6, not yet done) +## Known gaps -The original 8-phase rollout plan's last phase is still open: +The original 8-phase rollout plan's last phase is done: migration 41 +dropped the legacy per-mode `Frame` columns (`mode`, `album_id`, +`current_asset_id`, all `calendar_*`, all `whiteboard_*`, `queue`, etc.) +-- see its own docstring in `app/migration.py` for the raw-SQL backfill +safety net that ran first, and `server/README.md` no longer describes +photos/calendar/whiteboard as per-frame "modes". + +Still open: -- Legacy per-mode `Frame` columns (`mode`, `album_id`, - `current_asset_id`, all `calendar_*`, all `whiteboard_*`, etc.) are - still physically present in the schema but no longer read or written - anywhere -- they need a dedicated final migration to drop them. Left in - place deliberately through the widget-system rollout (a much larger - blast radius cutover than this project's usual same-migration-drop - convention) but there's no reason to keep carrying them now that every - phase has shipped. -- `server/README.md` still describes photos/calendar/whiteboard as - per-frame "modes" in several places rather than widgets -- needs a pass - once the column drop above is safely deployed. - Whiteboard rendering is tagged **(alpha)** in the UI -- not fully reliable yet, treat it as experimental if extending it. diff --git a/firmware/README.md b/firmware/README.md index 3dd62aa..f56aff2 100644 --- a/firmware/README.md +++ b/firmware/README.md @@ -147,10 +147,9 @@ two-step setup screen: portal's config page (`http://192.168.4.1/` by default), for a one-scan shortcut once you've joined the AP. -The config page asks for your home WiFi SSID/password, the "Tools +The config page asks for your home WiFi SSID/password and the "Tools Server" address (`host:port` of the [server](../server/) -- **not** your -Immich server; see below for the `https://` form), and an optional -"Access Token" (see below -- usually blank). Saving hands your browser +Immich server; see below for the `https://` form). Saving hands your browser off to the server's claim page (after ~7 seconds, giving your phone time to rejoin its normal WiFi while the device reboots) so the frame gets linked to your account; the device meanwhile connects to your home @@ -212,19 +211,6 @@ certificate was actually issued for -- a bare LAN IP address (`https://192.168.1.50`) will fail the handshake even against a perfectly valid cert for a different name. -## Access token - -Usually blank. Current servers issue each frame its own private token -automatically on first contact (delivered via `GET /frame/config`, -persisted in NVS, preferred by `build_url()` from then on -- and baked -into the manage-menu/share QR codes so scanning them just works). The -captive portal's "Access Token" field only matters when pointing this -firmware at an *older* (pre-multi-frame) server whose `MANAGEMENT_TOKEN` -is set: paste that shared value and the device sends it (`?token=...`) -until a newer server replaces it with a per-frame one. Re-provisioning -clears any stored per-frame token -- a fresh identity handshake with -whatever server you point it at next. - ## Skipping to the next photo Wire a momentary push button between GPIO2 and GND (internal pull-up, diff --git a/firmware/main/frame_client.c b/firmware/main/frame_client.c index 712ff14..c6d9389 100644 --- a/firmware/main/frame_client.c +++ b/firmware/main/frame_client.c @@ -95,18 +95,15 @@ static void save_wifi_cache(esp_netif_t *netif) } /* Builds a full URL from cfg->toolsserver + a path (no leading slash), - * appending cfg->access_token as ?token= if one's set. toolsserver is - * 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. 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. */ + * appending cfg->device_token as &token= once one's been delivered. + * toolsserver is 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. 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=. 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; @@ -123,9 +120,8 @@ static void build_url(char *out, size_t out_size, const frame_config_t *cfg, con 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); + if (cfg->device_token[0] != '\0' && len < out_size) { + snprintf(out + len, out_size - len, "&token=%s", cfg->device_token); } } diff --git a/firmware/main/ota_update.c b/firmware/main/ota_update.c index 9fd6c7d..3c11fac 100644 --- a/firmware/main/ota_update.c +++ b/firmware/main/ota_update.c @@ -36,9 +36,8 @@ static void build_ota_url(char *out, size_t out_size, const frame_config_t *cfg) 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); + if (cfg->device_token[0] != '\0' && len < out_size) { + snprintf(out + len, out_size - len, "&token=%s", cfg->device_token); } } diff --git a/firmware/main/root.html b/firmware/main/root.html index 4cfdebf..e3785fc 100644 --- a/firmware/main/root.html +++ b/firmware/main/root.html @@ -97,11 +97,6 @@ -
- - -
-

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 7ace5c7..37b6fb3 100644 --- a/firmware/main/wifi_provisioning.c +++ b/firmware/main/wifi_provisioning.c @@ -73,20 +73,10 @@ esp_err_t frame_config_load(frame_config_t *out) return pass_err; } - /* Also optional -- most deployments won't set a server-side - * MANAGEMENT_TOKEN at all, in which case this stays empty and the - * manage-menu QR just links to the page with no ?token=. */ - len = sizeof(out->access_token); - esp_err_t token_err = nvs_get_str(handle, "access_token", out->access_token, &len); - if (token_err != ESP_OK && token_err != ESP_ERR_NVS_NOT_FOUND) { - nvs_close(handle); - 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); + esp_err_t 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; @@ -131,9 +121,6 @@ esp_err_t frame_config_save(const frame_config_t *cfg) if (err == ESP_OK) { err = nvs_set_str(handle, "toolsserver", cfg->toolsserver); } - if (err == ESP_OK) { - 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 @@ -191,7 +178,6 @@ void frame_config_clear(void) nvs_erase_key(handle, "sta_ssid"); 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); @@ -452,7 +438,6 @@ static esp_err_t save_config_post_handler(httpd_req_t *req) extract_form_value(body, "ssid", cfg.sta_ssid, sizeof(cfg.sta_ssid)); extract_form_value(body, "password", cfg.sta_password, sizeof(cfg.sta_password)); extract_form_value(body, "toolsserver", cfg.toolsserver, sizeof(cfg.toolsserver)); - extract_form_value(body, "access_token", cfg.access_token, sizeof(cfg.access_token)); if (strlen(cfg.sta_ssid) == 0 || strlen(cfg.toolsserver) == 0) { httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "SSID and Tools Server are required"); @@ -466,8 +451,7 @@ static esp_err_t save_config_post_handler(httpd_req_t *req) return ESP_FAIL; } - ESP_LOGI(TAG, "Saved config: ssid='%s' toolsserver='%s' access_token=%s", cfg.sta_ssid, cfg.toolsserver, - strlen(cfg.access_token) ? "set" : "none"); + ESP_LOGI(TAG, "Saved config: ssid='%s' toolsserver='%s'", cfg.sta_ssid, cfg.toolsserver); /* 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 diff --git a/firmware/main/wifi_provisioning.h b/firmware/main/wifi_provisioning.h index a9869b6..a6f7e07 100644 --- a/firmware/main/wifi_provisioning.h +++ b/firmware/main/wifi_provisioning.h @@ -17,11 +17,10 @@ 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; 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. */ + * this device first introduces itself by id (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; diff --git a/firmware/version.txt b/firmware/version.txt index 347f583..9df886c 100644 --- a/firmware/version.txt +++ b/firmware/version.txt @@ -1 +1 @@ -1.4.1 +1.4.2 diff --git a/server/README.md b/server/README.md index bbc0fb5..f4af2c3 100644 --- a/server/README.md +++ b/server/README.md @@ -37,13 +37,10 @@ algorithm itself -- it just streams the response straight to the panel. instead (see `firmware/README.md`'s HTTPS section). 5. **Each frame gets its own device token automatically** -- the server issues it on the frame's first check-in, so there's nothing to - configure. The captive portal's **Access Token** field only matters - when pointing new firmware at an old (pre-multi-frame) server. - `MANAGEMENT_TOKEN` in `docker-compose.yml` is likewise now only the - *migration* credential: a frame flashed with pre-multi-frame - firmware authenticates with it until it's updated and bound (the - Admin page shows the migration state per frame and a "Close legacy - window" button for when it's done). + configure. `MANAGEMENT_TOKEN` in `docker-compose.yml` is optional and + only matters pre-setup: if set, it's the credential that gates who + gets to be the one to run first-run setup on a freshly deployed + server, before any admin account exists. 6. **Optional: auto-update firmware from Gitea releases.** If you're pushing this repo to a Gitea instance, `.gitea/workflows/firmware-release-build.yml` builds both supported boards and publishes them as release assets @@ -214,9 +211,9 @@ Pages: `/` (routing hub), `/setup`, `/login`, `/claim`, `/settings`, scan-to-download QR both use the frame's own `manage_token` (device tokens don't work for either -- neither is ever called by firmware, both are opened by a phone that has no way to supply `?id=`/`?token=`), - and `MANAGEMENT_TOKEN` survives only as the migration credential for - pre-multi-frame firmware. -- Calendar frame mode (`app/calendar_feed.py`) expands recurring events + and `MANAGEMENT_TOKEN` is only ever the pre-setup claim gate (see + step 5 above). +- The calendar widget (`app/calendar_feed.py`) expands recurring events (RRULE/EXDATE/DST) via [`recurring-ical-events`](https://pypi.org/project/recurring-ical-events/), which is LGPL-3.0-or-later -- the only non-permissively-licensed dependency here. It's used as an ordinary `pip install` runtime import, @@ -234,7 +231,7 @@ Pages: `/` (routing hub), `/setup`, `/login`, `/claim`, `/settings`, explicit, informed call by the project owner, not a default -- anyone redistributing this project (vs. just self-hosting it) should re-evaluate that tradeoff for their own situation before doing so. -- Whiteboard frame mode (`app/webdav_client.py`, `app/whiteboard.py`) +- The whiteboard widget (`app/webdav_client.py`, `app/whiteboard.py`) fetches a Nextcloud Whiteboard (or any WebDAV server's) `.whiteboard` file -- which turns out to be Excalidraw scene JSON (elements/appState/ files), not an image -- and renders it via `render-service/`, a small diff --git a/server/app/auth.py b/server/app/auth.py index fe960f0..1c2550d 100644 --- a/server/app/auth.py +++ b/server/app/auth.py @@ -1,14 +1,18 @@ -"""Authentication: password hashing, user sessions + CSRF, the legacy -shared-token gate, and device resolution. +"""Authentication: password hashing, user sessions + CSRF, the pre-setup +claim gate, and device resolution. Three independent credential classes: - User sessions (cookie "session", server-side sessions table, per- session CSRF token required on mutating requests) -- humans. -- The legacy shared MANAGEMENT_TOKEN (env-only). Still accepted on - browser routes so the deployed frame's on-panel manage QR (which - embeds ?token=) keeps working until Phase C replaces it with the - limited /m/ page; CSRF doesn't apply to it (it's explicit per-request - credential, not an ambient cookie a cross-site request could ride). +- MANAGEMENT_TOKEN (env-only, optional). Only meaningful before any user + account exists yet (fresh install, or freshly migrated, before + /setup has been run): if set, it gates who gets to be the one to run + /setup and claim the first admin account; once a user exists, sessions + are the only way in. Not a standing bearer credential -- the on-panel + manage QR now embeds a frame's own per-frame manage_token (/m/, see + routers/manage.py) rather than this shared one; CSRF doesn't apply to + it either way (it's an explicit per-request credential, not an ambient + cookie a cross-site request could ride). - Device credentials (?id= + ?token=, see require_device below). """ @@ -250,17 +254,16 @@ def require_frame_control( def management_token() -> str: - """The legacy shared secret. Env-only, never stored -- same as the old - server, where the env var overrode anything on disk on every load.""" + """The pre-setup claim-gate secret. Env-only, never stored -- same as + the old server, where the env var overrode anything on disk on every + load.""" return os.environ.get("MANAGEMENT_TOKEN", "") def browser_token_valid(request: Request) -> bool: - """The legacy shared-token check. No MANAGEMENT_TOKEN configured means - token-holders don't exist -- but unlike Phase A this no longer means - "open": once users exist, sessions are the primary gate and this is - only the compatibility path for the deployed frame's manage QR - (?token=) until Phase C. Empty token => not valid (sessions rule).""" + """Whether the request carries the current MANAGEMENT_TOKEN, via + query param or cookie. Only meaningful pre-setup (see require_browser + below) -- empty configured token => not valid (nothing to match).""" token = management_token() if not token: return False @@ -270,12 +273,12 @@ def browser_token_valid(request: Request) -> bool: def require_browser(request: Request, db: Session = Depends(get_db)) -> User | None: """Dependency for the web UI's /api/* routes: a real user session - (CSRF-checked on mutations, returns the User), or the legacy shared - token (returns None -- token bearers act as an anonymous operator, - exactly the pre-user model). While NO users exist yet (fresh install - or freshly migrated, before /setup has been run) the API stays open - if no MANAGEMENT_TOKEN is set -- the Phase A/legacy behavior -- - since there's nobody to log in as yet.""" + (CSRF-checked on mutations, returns the User). While NO users exist + yet (fresh install, or freshly migrated, before /setup has been run) + the API instead stays open if no MANAGEMENT_TOKEN is set, or opens + to whoever supplies it if one is -- there's nobody to log in as yet, + so this is purely the claim gate for who gets to run /setup. Once a + user exists, only a session gets in.""" session = current_session(request, db) if session is not None: if request.method not in ("GET", "HEAD", "OPTIONS") and not _csrf_ok(request, session): @@ -283,10 +286,9 @@ def require_browser(request: Request, db: Session = Depends(get_db)) -> User | N user = db.get(User, session.user_id) if user is not None: return user - if browser_token_valid(request): - return None - if not users_exist(db) and not management_token(): - return None + if not users_exist(db): + if not management_token() or browser_token_valid(request): + return None raise HTTPException(401, "Not logged in") @@ -326,63 +328,29 @@ def _register_frame(db: Session, device_id: str) -> Frame: def require_device(request: Request, db: Session = Depends(get_db)) -> Frame: """Resolves and authenticates the frame behind a /frame/* request. - - New firmware sends ?id=<12-hex-mac>&token=. - Deployed legacy firmware sends only ?token= - (or nothing, on an open server) -- those requests resolve to the - unique legacy_token_enabled frame for as long as that migration - window stays open. The first id-bearing request arriving with legacy - credentials while the legacy frame has no device_id yet BINDS that id - to it -- that's the moment the deployed frame comes back up on new - firmware after its OTA, and it must not register as a second frame. - """ + Firmware sends ?id=<12-hex-mac>&token=.""" device_id = request.query_params.get("id", "").strip().lower() token = request.query_params.get("token", "") - legacy = management_token() - legacy_ok = not legacy or token == legacy - if device_id: - frame = db.scalars(select(Frame).where(Frame.device_id == device_id)).first() - if frame is None: - legacy_frame = db.scalars( - select(Frame).where(Frame.legacy_token_enabled == True) # noqa: E712 - ).first() - if legacy_frame is not None and legacy_frame.device_id is None and legacy_ok: - legacy_frame.device_id = device_id - frame = legacy_frame - logger.info("Bound device id %s to legacy frame #%d", device_id, frame.id) - else: - frame = _register_frame(db, device_id) - else: - token_ok = bool(token) and token == frame.device_token - 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: - 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") + if not device_id: + raise HTTPException(401, "Missing device id") + + frame = db.scalars(select(Frame).where(Frame.device_id == device_id)).first() + if frame is None: + frame = _register_frame(db, device_id) else: - if not legacy_ok: + token_ok = bool(token) and token == frame.device_token + if token_ok and not frame.device_token_ack: + frame.device_token_ack = True + logger.info("Frame #%d acknowledged its device token", frame.id) + elif not token_ok and frame.device_token_ack: raise HTTPException(401, "Missing or invalid access token") - frame = db.scalars( - select(Frame).where(Frame.legacy_token_enabled == True) # noqa: E712 - ).first() - if frame is None: - # Nothing to resolve a no-id request to. migration.py always - # creates frame #1 at startup, so this only happens if it was - # deleted -- treat like an unknown device. - raise HTTPException(401, "No frame accepts legacy credentials") + # else: 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. frame.last_seen = time.time() db.commit() diff --git a/server/app/main.py b/server/app/main.py index f3be7cc..8221624 100644 --- a/server/app/main.py +++ b/server/app/main.py @@ -110,26 +110,16 @@ def service_worker() -> FileResponse: return FileResponse("app/static/sw.js", media_type="application/javascript") -def _device_credential_redirect(request: Request, db, allow_legacy: bool) -> str | None: +def _device_credential_redirect(request: Request, db) -> str | None: """The on-frame manage QR points at the server root with the device's - own credentials (new firmware: ?id=&token=; deployed firmware: - ?token=). Those scans get the frame's limited - manage page -- never the full UI, which requires a login. - allow_legacy is False before /setup has run: at that point a bare - ?token= hit is the admin coming through the token prompt to do - first-run setup, not a QR scan.""" + own credentials (?id=&token=). Those scans get the frame's limited + manage page -- never the full UI, which requires a login.""" 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 @@ -140,7 +130,7 @@ def index(request: Request): else is walked through setup/login.""" with SessionLocal() as db: have_users = users_exist(db) - manage_redirect = _device_credential_redirect(request, db, allow_legacy=have_users) + manage_redirect = _device_credential_redirect(request, db) if manage_redirect is not None: return RedirectResponse(manage_redirect, status_code=303) diff --git a/server/app/migration.py b/server/app/migration.py index 19bccfb..11b6f24 100644 --- a/server/app/migration.py +++ b/server/app/migration.py @@ -22,13 +22,9 @@ from .db import SessionLocal, engine from .models import ( Base, BatteryLog, - CalendarWidgetConfig, Frame, - FrameTaskList, PhotoWidgetConfig, ServerSettings, - TaskWidgetConfig, - WhiteboardWidgetConfig, Widget, ) from .widgets import default_button_actions @@ -902,6 +898,265 @@ def _migration_40(conn) -> None: conn.execute(text("ALTER TABLE widgets ADD COLUMN font_scale REAL NOT NULL DEFAULT 1.0")) +def _raw_backfill_frame_widgets(conn, frame_row, now: float) -> None: + """Raw-SQL equivalent of the old ORM-based _backfill_frame_widgets -- + called from _migration_41 while the legacy Frame columns it reads + still physically exist, for the rare frame (if any) that somehow + reached this migration without ever getting a Widget during the long + window _ensure_widgets_backfilled ran unconditionally at every + startup between migration 16 and this one. Same mode dispatch, + including the calendar_photo_inlay two-widget split and the legacy + tasks-source carryover. Has to be hand-rolled in raw SQL rather than + reusing the old ORM helpers, since those read these columns off + models.Frame, which no longer declares them as of this migration.""" + frame_id = frame_row["id"] + orientation = frame_row["orientation"] or "landscape" + cols, rows = grid.grid_dims(orientation) + mode = frame_row["mode"] if frame_row["mode"] in ("photos", "calendar", "whiteboard") else "photos" + + def insert_widget(x, y, w, h, widget_type, sort_order): + # border_style/border_thickness/border_color_index/font_scale + # spelled out explicitly (migrations 26/40's own defaults) + # rather than relied on implicitly -- they're real SQL-level + # DEFAULTs in any database that reached this migration through + # the normal upgrade path, but this stays correct even if that + # ever stops being true. + result = conn.execute(text( + "INSERT INTO widgets (frame_id, widget_type, x, y, w, h, sort_order, created_at, " + "border_style, border_thickness, border_color_index, font_scale) " + "VALUES (:frame_id, :widget_type, :x, :y, :w, :h, :sort_order, :created_at, " + "'none', 3, 0, 1.0)" + ), {"frame_id": frame_id, "widget_type": widget_type, "x": x, "y": y, "w": w, "h": h, + "sort_order": sort_order, "created_at": now}) + return result.lastrowid + + def insert_photo_config(widget_id): + conn.execute(text( + "INSERT INTO photo_widget_configs (widget_id, album_id, photo_order, display_mode, " + "queue_target_len, current_asset_id, current_asset_set_at, queue, queue_cursor, history, " + "excluded_asset_ids, locked) VALUES (:widget_id, :album_id, :photo_order, :display_mode, " + ":queue_target_len, :current_asset_id, :current_asset_set_at, :queue, :queue_cursor, " + ":history, :excluded_asset_ids, 0)" + ), {"widget_id": widget_id, "album_id": frame_row["album_id"], "photo_order": frame_row["photo_order"], + "display_mode": frame_row["display_mode"], "queue_target_len": frame_row["queue_target_len"], + "current_asset_id": frame_row["current_asset_id"], + "current_asset_set_at": frame_row["current_asset_set_at"], "queue": frame_row["queue"], + "queue_cursor": frame_row["queue_cursor"], "history": frame_row["history"], + "excluded_asset_ids": frame_row["excluded_asset_ids"]}) + + def insert_calendar_config(widget_id): + conn.execute(text( + "INSERT INTO calendar_widget_configs (widget_id, view, week_start, browse_offset, checked_at, " + "cached_events, fetch_summary, weather_enabled, weather_units, weather_cities, " + "weather_checked_at, weather_cached, week_days, week_layout, week_start_offset, render_style) " + "VALUES (:widget_id, :view, :week_start, :browse_offset, :checked_at, :cached_events, " + ":fetch_summary, :weather_enabled, :weather_units, :weather_cities, :weather_checked_at, " + ":weather_cached, :week_days, :week_layout, :week_start_offset, 'classic')" + ), {"widget_id": widget_id, "view": frame_row["calendar_view"], + "week_start": frame_row["calendar_week_start"], "browse_offset": frame_row["calendar_browse_offset"], + "checked_at": frame_row["calendar_checked_at"], "cached_events": frame_row["calendar_cached_events"], + "fetch_summary": frame_row["calendar_fetch_summary"], + "weather_enabled": frame_row["calendar_weather_enabled"], + "weather_units": frame_row["calendar_weather_units"], + "weather_cities": frame_row["calendar_weather_cities"], + "weather_checked_at": frame_row["calendar_weather_checked_at"], + "weather_cached": frame_row["calendar_weather_cached"], "week_days": frame_row["calendar_week_days"], + "week_layout": frame_row["calendar_week_layout"], + "week_start_offset": frame_row["calendar_week_start_offset"]}) + + def insert_whiteboard_config(widget_id): + conn.execute(text( + "INSERT INTO whiteboard_widget_configs (widget_id, user_id, url, checked_at, cached_image, " + "render_style) VALUES (:widget_id, :user_id, :url, :checked_at, :cached_image, 'classic')" + ), {"widget_id": widget_id, "user_id": frame_row["whiteboard_user_id"], "url": frame_row["whiteboard_url"], + "checked_at": frame_row["whiteboard_checked_at"], "cached_image": frame_row["whiteboard_cached_image"]}) + + def insert_button_actions(widget_id, widget_type): + if widget_type == "whiteboard": + pairs = [("next", "check_now"), ("back", "check_now")] + elif widget_type in ("photos", "calendar"): + pairs = [("next", "advance"), ("back", "back")] + else: + pairs = [] + for button, action in pairs: + conn.execute(text( + "INSERT INTO frame_button_actions (frame_id, button, widget_id, action, sort_order, created_at) " + "VALUES (:frame_id, :button, :widget_id, :action, 0, :created_at)" + ), {"frame_id": frame_id, "button": button, "widget_id": widget_id, "action": action, + "created_at": now}) + + def maybe_add_tasks_widget(existing_rects, next_sort_order): + if not frame_row["calendar_tasks_calendar_key"] or not frame_row["calendar_tasks_user_id"]: + return + min_w, min_h = grid.MIN_FOOTPRINT["tasks"] + rect = grid.find_open_rect(orientation, existing_rects, min_w, min_h) + if rect is None: + logger.warning( + "Frame %d had a legacy task list configured but no open grid space for a " + "standalone tasks widget during backfill -- its task source was dropped", frame_id + ) + return + x, y, w, h = rect + widget_id = insert_widget(x, y, w, h, "tasks", next_sort_order) + conn.execute(text( + "INSERT INTO task_widget_configs (widget_id, checked_at, cached, name, show_completed, " + "render_style) VALUES (:widget_id, :checked_at, :cached, '', 0, 'classic')" + ), {"widget_id": widget_id, "checked_at": frame_row["calendar_tasks_checked_at"], + "cached": frame_row["calendar_tasks_cached"]}) + conn.execute(text( + "INSERT INTO frame_task_lists (widget_id, user_id, calendar_key, included) " + "VALUES (:widget_id, :user_id, :calendar_key, 1)" + ), {"widget_id": widget_id, "user_id": frame_row["calendar_tasks_user_id"], + "calendar_key": frame_row["calendar_tasks_calendar_key"]}) + + if mode == "calendar" and frame_row["calendar_photo_inlay"]: + half = cols // 2 + cal_widget_id = insert_widget(0, 0, cols - half, rows, "calendar", 0) + photo_widget_id = insert_widget(cols - half, 0, half, rows, "photos", 1) + insert_calendar_config(cal_widget_id) + insert_photo_config(photo_widget_id) + insert_button_actions(cal_widget_id, "calendar") + maybe_add_tasks_widget([(0, 0, cols - half, rows), (cols - half, 0, half, rows)], 2) + return + + widget_id = insert_widget(0, 0, cols, rows, mode, 0) + if mode == "photos": + insert_photo_config(widget_id) + elif mode == "calendar": + insert_calendar_config(widget_id) + elif mode == "whiteboard": + insert_whiteboard_config(widget_id) + insert_button_actions(widget_id, mode) + if mode == "calendar": + maybe_add_tasks_widget([(0, 0, cols, rows)], 1) + + +def _migration_41(conn) -> None: + """Drops the legacy per-mode Frame columns the widget system + (migration 16) superseded -- mode, the photo-queue fields (album_id/ + photo_order/display_mode/queue_target_len/current_asset_id/ + current_asset_set_at/queue/queue_cursor/history/excluded_asset_ids), + every calendar_* field, every whiteboard_* field, and + legacy_token_enabled (models.py's own removal, alongside auth.py + dropping the shared MANAGEMENT_TOKEN device/browser fallback it + gated -- see auth.py's module docstring) -- see docs/widgets.md's + Known Gaps, which deliberately left this open as a much larger blast + radius than this project's usual same-migration-drop convention. + + _ensure_widgets_backfilled ran unconditionally at the end of every + startup from migration 16 until this one, so in practice every frame + already has a Widget built from these columns' values by now; the + backfill loop below (_raw_backfill_frame_widgets) is the same safety + net migration 17/18 used for their own column drops, covering the + edge case of a frame that somehow reached this point with none (e.g. + a very old, never-restarted backup). + + Guarded on "mode" existing, same reasoning as migration 26/27/29/ + 30/40's own comments: frames IS dropped/recreated here (unlike + widgets/photo_widget_configs, which those migrations left alone), + but a fresh-install create_all() copy already reflects today's + models.py -- i.e. the post-this-migration shape, missing "mode" + entirely -- so a test replaying migrations 16+ from an old + schema_version without also reconstructing frames' pre-41 columns + would otherwise hit "no such column: mode" here even though it has + nothing to do with what that test is actually exercising.""" + if "mode" not in {c["name"] for c in inspect(conn).get_columns("frames")}: + return + now = time.time() + frame_rows = conn.execute(text( + "SELECT id, mode, orientation, album_id, photo_order, display_mode, queue_target_len, " + "current_asset_id, current_asset_set_at, queue, queue_cursor, history, excluded_asset_ids, " + "calendar_view, calendar_week_start, calendar_photo_inlay, calendar_browse_offset, " + "calendar_checked_at, calendar_cached_events, calendar_fetch_summary, calendar_weather_enabled, " + "calendar_weather_units, calendar_weather_cities, calendar_weather_checked_at, " + "calendar_weather_cached, calendar_week_days, calendar_week_layout, calendar_week_start_offset, " + "calendar_tasks_calendar_key, calendar_tasks_user_id, calendar_tasks_checked_at, " + "calendar_tasks_cached, whiteboard_user_id, whiteboard_url, whiteboard_checked_at, " + "whiteboard_cached_image FROM frames" + )).mappings().all() + + for row in frame_rows: + has_widget = conn.execute( + text("SELECT 1 FROM widgets WHERE frame_id = :fid LIMIT 1"), {"fid": row["id"]} + ).first() + if has_widget is None: + _raw_backfill_frame_widgets(conn, row, now) + + conn.execute(text( + "CREATE TABLE frames_new (" + "id INTEGER PRIMARY KEY, " + "device_id TEXT UNIQUE, " + "name TEXT NOT NULL DEFAULT '', " + "owner_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL, " + "controlled_by_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL, " + "device_token TEXT NOT NULL, " + "device_token_ack INTEGER NOT NULL DEFAULT 0, " + "manage_token TEXT NOT NULL UNIQUE, " + "claimed_at REAL, " + "created_at REAL NOT NULL DEFAULT 0.0, " + "immich_url TEXT NOT NULL DEFAULT '', " + "immich_api_key TEXT NOT NULL DEFAULT '', " + "refresh_interval_s INTEGER NOT NULL DEFAULT 3600, " + "quiet_hours_enabled INTEGER NOT NULL DEFAULT 0, " + "quiet_hours_start TEXT NOT NULL DEFAULT '22:00', " + "quiet_hours_end TEXT NOT NULL DEFAULT '07:00', " + "timezone TEXT NOT NULL DEFAULT 'UTC', " + "orientation TEXT NOT NULL DEFAULT 'landscape', " + "palette_rgb TEXT, " + "color_boost REAL NOT NULL DEFAULT 1.0, " + "contrast_boost REAL NOT NULL DEFAULT 1.0, " + "dither_strength REAL NOT NULL DEFAULT 1.0, " + "photo_palette_rgb TEXT, " + "photo_dither_strength REAL NOT NULL DEFAULT 1.0, " + "theme TEXT NOT NULL DEFAULT 'classic', " + "battery_percent INTEGER NOT NULL DEFAULT -1, " + "battery_as_of REAL NOT NULL DEFAULT 0.0, " + "battery_history TEXT NOT NULL DEFAULT '[]', " + "last_seen REAL NOT NULL DEFAULT 0.0, " + "device_firmware_version TEXT NOT NULL DEFAULT '', " + "device_board_variant TEXT NOT NULL DEFAULT '', " + "battery_alert_threshold_pct INTEGER NOT NULL DEFAULT -1, " + "battery_alert_sent INTEGER NOT NULL DEFAULT 0, " + "firmware_available_version TEXT NOT NULL DEFAULT '', " + "firmware_update_repo_url TEXT NOT NULL DEFAULT '', " + "firmware_auto_update INTEGER NOT NULL DEFAULT 0, " + "firmware_update_token TEXT NOT NULL DEFAULT '', " + "firmware_update_checked_at REAL NOT NULL DEFAULT 0.0, " + "firmware_gitea_latest_version TEXT NOT NULL DEFAULT '', " + "hold_duration_ms INTEGER NOT NULL DEFAULT 3000, " + "next_hold_action TEXT, " + "back_hold_action TEXT, " + "last_cycled_layout_id INTEGER, " + "last_displayed_image BLOB, " + "last_displayed_at REAL NOT NULL DEFAULT 0.0, " + "stats_first_seen REAL NOT NULL DEFAULT 0.0, " + "stats_device_wakes INTEGER NOT NULL DEFAULT 0, " + "stats_photos_displayed INTEGER NOT NULL DEFAULT 0, " + "stats_photos_removed INTEGER NOT NULL DEFAULT 0, " + "stats_battery_reports INTEGER NOT NULL DEFAULT 0, " + "stats_recharge_cycles INTEGER NOT NULL DEFAULT 0, " + "stats_ota_updates_applied INTEGER NOT NULL DEFAULT 0, " + "stats_config_saves INTEGER NOT NULL DEFAULT 0)" + )) + kept_columns = ( + "id, device_id, name, owner_user_id, controlled_by_user_id, device_token, device_token_ack, " + "manage_token, claimed_at, created_at, immich_url, immich_api_key, refresh_interval_s, " + "quiet_hours_enabled, quiet_hours_start, quiet_hours_end, timezone, orientation, palette_rgb, " + "color_boost, contrast_boost, dither_strength, photo_palette_rgb, photo_dither_strength, theme, " + "battery_percent, battery_as_of, battery_history, last_seen, device_firmware_version, " + "device_board_variant, battery_alert_threshold_pct, battery_alert_sent, " + "firmware_available_version, firmware_update_repo_url, firmware_auto_update, " + "firmware_update_token, firmware_update_checked_at, firmware_gitea_latest_version, " + "hold_duration_ms, next_hold_action, back_hold_action, last_cycled_layout_id, " + "last_displayed_image, last_displayed_at, stats_first_seen, stats_device_wakes, " + "stats_photos_displayed, stats_photos_removed, stats_battery_reports, stats_recharge_cycles, " + "stats_ota_updates_applied, stats_config_saves" + ) + conn.execute(text(f"INSERT INTO frames_new ({kept_columns}) SELECT {kept_columns} FROM frames")) + conn.execute(text("DROP TABLE frames")) + conn.execute(text("ALTER TABLE frames_new RENAME TO frames")) + + MIGRATIONS = [ (1, _migration_1), (2, _migration_2), @@ -943,6 +1198,7 @@ MIGRATIONS = [ (38, _migration_38), (39, _migration_39), (40, _migration_40), + (41, _migration_41), ] @@ -959,18 +1215,53 @@ def run_migrations() -> None: # already added (e.g. "duplicate column name"). Jump # straight to the latest version instead. _migration_1(conn) - latest = MIGRATIONS[-1][0] - conn.execute(text("INSERT INTO schema_version (version) VALUES (:v)"), {"v": latest}) + current = MIGRATIONS[-1][0] + conn.execute(text("INSERT INTO schema_version (version) VALUES (:v)"), {"v": current}) else: current = row[0] - for version, fn in MIGRATIONS: - if version > current: - logger.info("Applying schema migration %d", version) - fn(conn) - conn.execute(text("UPDATE schema_version SET version = :v"), {"v": version}) + + # Each migration commits in its own transaction (rather than the + # whole batch sharing one, like this used to) so that _migration_41 + # can get a connection with no transaction pending on it yet -- + # SQLite only honors toggling PRAGMA foreign_keys when issued as a + # connection's literal first statement, and it needs that off for + # its own DROP TABLE frames (frames is an ON DELETE CASCADE target + # for widgets/frame_button_actions/user_frames/etc, so leaving + # enforcement on there would cascade-delete every frame's widgets, + # not just the columns that migration means to drop). A crash + # partway through now simply leaves schema_version at the last + # migration that actually completed, same as it always could + # between separate runs of this function. + for version, fn in MIGRATIONS: + if version <= current: + continue + logger.info("Applying schema migration %d", version) + with engine.connect() as conn: + if fn is _migration_41: + # Executing this before anything else auto-begins + # SQLAlchemy's own Transaction bookkeeping too, so an + # explicit conn.begin() below would conflict with it -- + # fn(conn) and the version UPDATE just ride that same + # auto-begun transaction, committed explicitly at the end. + conn.execute(text("PRAGMA foreign_keys=OFF")) + fn(conn) + conn.execute(text("UPDATE schema_version SET version = :v"), {"v": version}) + conn.commit() + if fn is _migration_41: + # Restore it before this connection goes back to the + # pool -- otherwise a later checkout of the same + # underlying DBAPI connection (the connect-event + # listener in db.py only fires for a genuinely new one) + # would silently run with enforcement off. Has to + # happen AFTER commit(), same "no pending transaction" + # requirement as the OFF toggle above -- issuing it + # before the commit is exactly the mid-transaction + # no-op this migration exists to work around in the + # first place, just in the other direction. + conn.execute(text("PRAGMA foreign_keys=ON")) + _ensure_frame_one() _ensure_server_settings() - _ensure_widgets_backfilled() _ensure_frame_calendars_rekeyed() @@ -985,11 +1276,11 @@ def new_manage_token() -> str: def _ensure_frame_one() -> None: """First boot only (frames table empty): create frame #1 -- imported verbatim from a legacy config.json if one exists, otherwise fresh - defaults. Either way it's the legacy-token frame: the deployed - firmware sends no device id and (at most) the shared MANAGEMENT_TOKEN, - and require_device resolves those requests here. The frames-nonempty - guard makes this idempotent; config.json is left untouched as the - rollback path.""" + defaults -- plus a single full-panel photos widget carrying over + whatever photo-queue state that file had (the widget system's + equivalent of what used to live directly on Frame; see migration + 41). The frames-nonempty guard makes this idempotent; config.json is + left untouched as the rollback path.""" with SessionLocal() as db: if db.scalars(select(Frame).limit(1)).first() is not None: return @@ -1002,26 +1293,15 @@ def _ensure_frame_one() -> None: device_id=None, device_token=new_device_token(), manage_token=new_manage_token(), - legacy_token_enabled=True, created_at=time.time(), immich_url=cfg.immich_url, immich_api_key=cfg.immich_api_key, - album_id=cfg.album_id, - order=cfg.order, refresh_interval_s=cfg.refresh_interval_s, quiet_hours_enabled=cfg.quiet_hours_enabled, quiet_hours_start=cfg.quiet_hours_start, quiet_hours_end=cfg.quiet_hours_end, timezone=cfg.timezone, - display_mode="crop_faces" if cfg.smart_crop_faces else "crop_fill", orientation=cfg.orientation, - queue_target_len=cfg.queue_target_len, - current_asset_id=cfg.current_asset_id, - current_asset_set_at=cfg.current_asset_set_at, - queue=list(cfg.queue), - queue_cursor=cfg.queue_cursor, - history=list(cfg.history), - excluded_asset_ids=list(cfg.excluded_asset_ids), battery_percent=cfg.battery_percent, battery_as_of=cfg.battery_as_of, battery_history=[list(pair) for pair in cfg.battery_history], @@ -1044,11 +1324,31 @@ def _ensure_frame_one() -> None: stats_config_saves=cfg.stats.config_saves, ) db.add(frame) - db.flush() # assign frame.id for the battery log rows + db.flush() # assign frame.id for the battery log rows + widget FK for pair in cfg.battery_log: db.add(BatteryLog(frame_id=frame.id, ts=pair[0], percent=pair[1])) + cols, rows = grid.grid_dims(frame.orientation) + widget = Widget(frame_id=frame.id, widget_type="photos", x=0, y=0, w=cols, h=rows, + sort_order=0, created_at=time.time()) + db.add(widget) + db.flush() # assign widget.id for the config row's FK + db.add(PhotoWidgetConfig( + widget_id=widget.id, + album_id=cfg.album_id, + order=cfg.order, + display_mode="crop_faces" if cfg.smart_crop_faces else "crop_fill", + queue_target_len=cfg.queue_target_len, + current_asset_id=cfg.current_asset_id, + current_asset_set_at=cfg.current_asset_set_at, + queue=list(cfg.queue), + queue_cursor=cfg.queue_cursor, + history=list(cfg.history), + excluded_asset_ids=list(cfg.excluded_asset_ids), + )) + db.add_all(default_button_actions(frame.id, widget.id, "photos")) + db.commit() # The single legacy firmware slot becomes frame #1's per-frame slot. @@ -1078,168 +1378,6 @@ def _ensure_server_settings() -> None: db.commit() -def _photo_config_from_frame(frame: Frame, widget_id: int) -> PhotoWidgetConfig: - return PhotoWidgetConfig( - widget_id=widget_id, - album_id=frame.album_id, - order=frame.order, - display_mode=frame.display_mode, - queue_target_len=frame.queue_target_len, - current_asset_id=frame.current_asset_id, - current_asset_set_at=frame.current_asset_set_at, - queue=list(frame.queue), - queue_cursor=frame.queue_cursor, - history=list(frame.history), - excluded_asset_ids=list(frame.excluded_asset_ids), - ) - - -def _calendar_config_from_frame(frame: Frame, widget_id: int) -> CalendarWidgetConfig: - return CalendarWidgetConfig( - widget_id=widget_id, - view=frame.calendar_view, - week_start=frame.calendar_week_start, - browse_offset=frame.calendar_browse_offset, - checked_at=frame.calendar_checked_at, - cached_events=list(frame.calendar_cached_events) if frame.calendar_cached_events else None, - fetch_summary=frame.calendar_fetch_summary, - weather_enabled=frame.calendar_weather_enabled, - weather_units=frame.calendar_weather_units, - weather_cities=list(frame.calendar_weather_cities) if frame.calendar_weather_cities else None, - weather_checked_at=frame.calendar_weather_checked_at, - weather_cached=list(frame.calendar_weather_cached) if frame.calendar_weather_cached else None, - week_days=frame.calendar_week_days, - week_layout=frame.calendar_week_layout, - week_start_offset=frame.calendar_week_start_offset, - # tasks_* deliberately not carried over -- see - # _task_config_and_list_from_frame, a sibling standalone widget - # now, not part of this config. - ) - - -def _task_config_and_list_from_frame(frame: Frame, widget_id: int) -> tuple[TaskWidgetConfig, FrameTaskList]: - """Only ever called for a frame whose legacy calendar_tasks_* columns - (see Frame's own docstring on those -- a dead pre-widget-system - field set, same status as calendar_photo_inlay below) still carry a - configured source -- i.e. a database jumping straight from before - the widget system existed to after tasks became their own - multi-list widget type in a single upgrade, skipping both - intermediate periods where it would have lived on - CalendarWidgetConfig (_migration_17's extraction) and then a - single-source TaskWidgetConfig (_migration_18's extraction) instead. - Reproduces the same shape those two migrations arrive at directly: - a bare cache-state config plus one included FrameTaskList row.""" - cfg = TaskWidgetConfig( - widget_id=widget_id, - checked_at=frame.calendar_tasks_checked_at, - cached=list(frame.calendar_tasks_cached) if frame.calendar_tasks_cached else None, - ) - task_list = FrameTaskList( - widget_id=widget_id, - user_id=frame.calendar_tasks_user_id, - calendar_key=frame.calendar_tasks_calendar_key, - included=True, - ) - return cfg, task_list - - -def _whiteboard_config_from_frame(frame: Frame, widget_id: int) -> WhiteboardWidgetConfig: - return WhiteboardWidgetConfig( - widget_id=widget_id, - user_id=frame.whiteboard_user_id, - url=frame.whiteboard_url, - checked_at=frame.whiteboard_checked_at, - cached_image=frame.whiteboard_cached_image, - ) - - -def _maybe_add_legacy_tasks_widget(db, frame: Frame, existing: list[grid.Rect], next_sort_order: int) -> None: - """Only relevant for a database jumping straight from before the - widget system existed to after tasks became their own widget type - in one upgrade (see _task_config_and_list_from_frame) -- - frame.calendar_tasks_* is the dead legacy field set otherwise. - Requires both calendar_key and user_id (FrameTaskList.user_id is - NOT NULL) -- same guard _migration_18's own SQL extraction uses. - Auto-placed in whatever open space is left after the widget(s) above - it in _backfill_frame_widgets claimed theirs, same find_open_rect - logic a manual "add widget" uses; silently dropped (logged) if none - fits, same as this migration having nowhere else to put it either.""" - if not frame.calendar_tasks_calendar_key or not frame.calendar_tasks_user_id: - return - min_w, min_h = grid.MIN_FOOTPRINT["tasks"] - rect = grid.find_open_rect(frame.orientation, existing, min_w, min_h) - if rect is None: - logger.warning( - "Frame %d had a legacy task list configured but no open grid space for a " - "standalone tasks widget during backfill -- its task source was dropped", frame.id - ) - return - x, y, w, h = rect - task_widget = Widget(frame_id=frame.id, widget_type="tasks", x=x, y=y, w=w, h=h, - sort_order=next_sort_order, created_at=time.time()) - db.add(task_widget) - db.flush() - cfg, task_list = _task_config_and_list_from_frame(frame, task_widget.id) - db.add(cfg) - db.add(task_list) - - -def _backfill_frame_widgets(db, frame: Frame) -> None: - cols, rows = grid.grid_dims(frame.orientation) - mode = frame.mode if frame.mode in ("photos", "calendar", "whiteboard") else "photos" - - if mode == "calendar" and frame.calendar_photo_inlay: - # Reproduces the old fixed 50/50 inlay split as two independent - # widgets instead of silently dropping half of what the frame was - # showing -- see models.py's CalendarWidgetConfig docstring on why - # "photo inlay" isn't a widget-system concept anymore otherwise. - half = cols // 2 - cal_widget = Widget(frame_id=frame.id, widget_type="calendar", - x=0, y=0, w=cols - half, h=rows, sort_order=0, created_at=time.time()) - photo_widget = Widget(frame_id=frame.id, widget_type="photos", - x=cols - half, y=0, w=half, h=rows, sort_order=1, created_at=time.time()) - db.add_all([cal_widget, photo_widget]) - db.flush() # assign ids before the FK'd config rows reference them - db.add(_calendar_config_from_frame(frame, cal_widget.id)) - db.add(_photo_config_from_frame(frame, photo_widget.id)) - db.add_all(default_button_actions(frame.id, cal_widget.id, "calendar")) - _maybe_add_legacy_tasks_widget( - db, frame, [(0, 0, cols - half, rows), (cols - half, 0, half, rows)], next_sort_order=2 - ) - return - - widget = Widget(frame_id=frame.id, widget_type=mode, x=0, y=0, w=cols, h=rows, - sort_order=0, created_at=time.time()) - db.add(widget) - db.flush() - if mode == "photos": - db.add(_photo_config_from_frame(frame, widget.id)) - elif mode == "calendar": - db.add(_calendar_config_from_frame(frame, widget.id)) - elif mode == "whiteboard": - db.add(_whiteboard_config_from_frame(frame, widget.id)) - db.add_all(default_button_actions(frame.id, widget.id, mode)) - if mode == "calendar": - _maybe_add_legacy_tasks_widget(db, frame, [(0, 0, cols, rows)], next_sort_order=1) - - -def _ensure_widgets_backfilled() -> None: - """Every frame needs at least one Widget once the widget system is - live -- runs unconditionally after every startup (both a from-scratch - _ensure_frame_one() install and an existing-install upgrade past - _migration_16 land here) and is a no-op for any frame that already - has one. Builds a widget that reproduces the frame's current mode/ - settings/state exactly, so upgrading never changes what a frame - displays or what its physical buttons do on its own.""" - with SessionLocal() as db: - for frame in db.scalars(select(Frame)).all(): - has_widget = db.scalars(select(Widget).where(Widget.frame_id == frame.id).limit(1)).first() - if has_widget is not None: - continue - _backfill_frame_widgets(db, frame) - db.commit() - - def _ensure_frame_calendars_rekeyed() -> None: """Re-keys frame_calendars from frame_id to widget_id -- a frame can hold more than one independent calendar widget (see the widget @@ -1252,27 +1390,25 @@ def _ensure_frame_calendars_rekeyed() -> None: and savable even while a frame's old `mode` was "photos"), not real live configuration. - Deliberately NOT a numbered migration: this needs each frame's - calendar widget to already exist to know what to re-key against, and - those widget rows aren't created by a schema migration at all -- - they come from _ensure_widgets_backfilled() above, which (like this - function) runs unconditionally after every startup rather than being - tracked by schema_version. Running this as a numbered migration - would execute it *before* that backfill during a real upgrade (the - numbered-migration loop runs first, see run_migrations), silently - dropping every row -- caught by test_migrations.py actually exercising - the raw-SQL upgrade path instead of the fresh-install create_all() - shortcut every other test in that file takes. + Deliberately NOT a numbered migration: every frame's calendar widget + must already exist to know what to re-key against, and for a genuine + pre-widget-system database those rows only exist once _migration_41's + own backfill has run (a step inside that migration, not before it). + A numbered migration for this would race ahead of that backfill (the + numbered-migration loop runs top to bottom in one pass, see + run_migrations), silently dropping every row -- caught by + test_migrations.py actually exercising the raw-SQL upgrade path + instead of the fresh-install create_all() shortcut every other test + in that file takes. - Runs unconditionally after every startup, like _ensure_widgets_ - backfilled; a no-op the moment frame_calendars is already - widget_id-shaped (every fresh install, and any existing install - after its first run past this code) -- SQLite can't ALTER a column's - FK target or drop a column that's part of an index/FK constraint, so - when it isn't a no-op this is the standard SQLite "rebuild" pattern: - create the new-shape table, copy matching rows across (joining to - find each row's calendar widget), drop the old table, rename the new - one into place.""" + Runs unconditionally after every startup instead; a no-op the moment + frame_calendars is already widget_id-shaped (every fresh install, + and any existing install after its first run past this code) -- + SQLite can't ALTER a column's FK target or drop a column that's part + of an index/FK constraint, so when it isn't a no-op this is the + standard SQLite "rebuild" pattern: create the new-shape table, copy + matching rows across (joining to find each row's calendar widget), + drop the old table, rename the new one into place.""" inspector = inspect(engine) columns = {c["name"] for c in inspector.get_columns("frame_calendars")} if "widget_id" in columns: diff --git a/server/app/models.py b/server/app/models.py index cf01e61..4f2b734 100644 --- a/server/app/models.py +++ b/server/app/models.py @@ -117,14 +117,10 @@ class Frame(Base): __tablename__ = "frames" id: Mapped[int] = mapped_column(primary_key=True) - # 12 lowercase hex chars of the device's full STA MAC. NULL only for - # the migrated legacy frame until its device first reports an id. + # 12 lowercase hex chars of the device's full STA MAC. NULL until its + # device first reports an id. device_id: Mapped[str | None] = mapped_column(String, unique=True, nullable=True) name: Mapped[str] = mapped_column(String, default="") - # Renderer dispatch seam -- "photos", "calendar", or "whiteboard" - # (see routers/device.py RENDERERS/ADVANCE_RENDERERS/BACK_RENDERERS - # and routers/common.py FRAME_MODES). - mode: Mapped[str] = mapped_column(String, default="photos") # Whose Immich library this frame pulls from; NULL = unclaimed. owner_user_id: Mapped[int | None] = mapped_column( ForeignKey("users.id", ondelete="SET NULL"), nullable=True @@ -138,11 +134,6 @@ class Frame(Base): # pushing it in /frame/config responses. device_token_ack: Mapped[bool] = mapped_column(Boolean, default=False) manage_token: Mapped[str] = mapped_column(String, unique=True) - # Migration window: this frame also accepts the legacy shared - # MANAGEMENT_TOKEN (and no-id requests resolve to it). Only ever the - # migrated frame #1; cleared from /admin once the device is on - # per-frame auth. - legacy_token_enabled: Mapped[bool] = mapped_column(Boolean, default=False) claimed_at: Mapped[float | None] = mapped_column(Float, nullable=True) created_at: Mapped[float] = mapped_column(Float, default=time.time) @@ -153,19 +144,13 @@ class Frame(Base): immich_url: Mapped[str] = mapped_column(String, default="") immich_api_key: Mapped[str] = mapped_column(String, default="") - # -- settings (attribute names match the old FrameConfig fields) -- - album_id: Mapped[str] = mapped_column(String, default="") - order: Mapped[str] = mapped_column("photo_order", String, default="sequential") + # -- settings -- refresh_interval_s: Mapped[int] = mapped_column(Integer, default=3600) quiet_hours_enabled: Mapped[bool] = mapped_column(Boolean, default=False) quiet_hours_start: Mapped[str] = mapped_column(String, default="22:00") quiet_hours_end: Mapped[str] = mapped_column(String, default="07:00") timezone: Mapped[str] = mapped_column(String, default="UTC") - # How a photo's aspect ratio is reconciled with the panel's -- see - # image_pipeline.DISPLAY_MODES. - display_mode: Mapped[str] = mapped_column(String, default="crop_faces") orientation: Mapped[str] = mapped_column(String, default="landscape") - queue_target_len: Mapped[int] = mapped_column(Integer, default=20) # Advanced configuration: [[r,g,b], ...] x6 (black/white/yellow/red/ # blue/green, matching image_pipeline.PANEL_CODES order) overriding # DEFAULT_PALETTE_RGB for this frame's actual panel. NULL = use the @@ -192,113 +177,6 @@ class Frame(Base): # Widgets rendered in classic (PIL) style ignore this entirely. theme: Mapped[str] = mapped_column(String, default="classic") - # -- calendar mode (see calendar_feed.py, calendar_render.py, - # routers/device.py's RENDERERS["calendar"]) -- - calendar_view: Mapped[str] = mapped_column(String, default="agenda") # "agenda" | "week" | "month" - # 0=Monday..6=Sunday (matches date.weekday()/calendar.Calendar) -- - # which day week/month views start their grid on. - calendar_week_start: Mapped[int] = mapped_column(Integer, default=0) - # Agenda view only; reuses this frame's existing photos-mode album/ - # queue, not a separate photo setup. - calendar_photo_inlay: Mapped[bool] = mapped_column(Boolean, default=False) - # How many periods (unit depends on calendar_view: days/weeks/months) - # NEXT/BACK have browsed from "today". Reset to 0 by the next normal - # (non-button) /frame/image request, and whenever calendar_view - # itself changes -- a stale offset means something different in a - # different view's units. - calendar_browse_offset: Mapped[int] = mapped_column(Integer, default=0) - # Throttled merge-fetch cache (see routers/common.py's - # get_or_refresh_calendar_events) -- same shape as the - # firmware_update_checked_at/firmware_gitea_latest_version pattern - # below. One shared cache for every included user's merged events, - # not per-user. - calendar_checked_at: Mapped[float] = mapped_column(Float, default=0.0) - calendar_cached_events: Mapped[list | None] = mapped_column(JSON, nullable=True, default=None) - # "" when the last merge-fetch fully succeeded, else e.g. "1 of 2 - # calendars unavailable" -- never names which user's feed failed, a - # shared household display shouldn't call out a specific person's - # outage to everyone who looks at it. - calendar_fetch_summary: Mapped[str] = mapped_column(String, default="") - - # Optional weather strip, agenda/today & tomorrow/week views only -- - # never month, there's no room (see calendar_render.py's _BUILDERS). - # Off by default. - calendar_weather_enabled: Mapped[bool] = mapped_column(Boolean, default=False) - calendar_weather_units: Mapped[str] = mapped_column(String, default="fahrenheit") # "fahrenheit" | "celsius" - # [{"label", "latitude", "longitude"}, ...] -- each geocoded once via - # weather.geocode_city() when added from the Calendar tab. - calendar_weather_cities: Mapped[list | None] = mapped_column(JSON, nullable=True, default=None) - # Throttled per-city forecast cache (see routers/common.py's - # get_or_refresh_weather) -- same shape idiom as - # calendar_checked_at/calendar_cached_events above. - # [{"label", "days": {"YYYY-MM-DD": {"code","high","low"}}}, ...] - calendar_weather_checked_at: Mapped[float] = mapped_column(Float, default=0.0) - calendar_weather_cached: Mapped[list | None] = mapped_column(JSON, nullable=True, default=None) - - # Week view: how many days to show (2-10, default 7 -- the original - # fixed behavior) and whether they're laid out as side-by-side - # columns or stacked bands (see calendar_render.py's _build_week). - calendar_week_days: Mapped[int] = mapped_column(Integer, default=7) - calendar_week_layout: Mapped[str] = mapped_column(String, default="horizontal") # "horizontal" | "vertical" - # Only used when calendar_week_days != 7 -- calendar_week_start's - # fixed-weekday anchor ("start on the most recent Monday") stops - # making sense once the view isn't a literal calendar week, so a - # non-7-day view instead starts this many days from today (0 = - # starts today, negative = starts in the past, positive = starts in - # the future). Ignored (calendar_week_start governs instead) at the - # default 7 days, so this has no effect until someone actually - # changes the day count. - calendar_week_start_offset: Mapped[int] = mapped_column(Integer, default=0) - - # Optional task list, week view only -- takes the space of one day - # slot rather than adding an extra one (see calendar_render.py's - # _draw_tasks). CalDAV only (a task list is a VTODO collection, not - # something a plain ICS subscription meaningfully has); source is - # one specific linked user's own CalDAV calendar, same - # owner-controls-their-own-data permission split as FrameCalendar. - # calendar_tasks_user_id - # SET NULL on the user's deletion clears the source rather than - # leaving a dangling reference (checked_at isn't reset by that, but - # the next refresh attempt finds no source and just returns []). - calendar_tasks_enabled: Mapped[bool] = mapped_column(Boolean, default=False) - calendar_tasks_user_id: Mapped[int | None] = mapped_column( - ForeignKey("users.id", ondelete="SET NULL"), nullable=True - ) - calendar_tasks_calendar_key: Mapped[str | None] = mapped_column(String, nullable=True) - calendar_tasks_checked_at: Mapped[float] = mapped_column(Float, default=0.0) - # [{"summary", "due" (ISO date/datetime string or None)}, ...], - # already filtered to outstanding (not-completed) tasks and sorted - # by due date -- see caldav_client.fetch_tasks. - calendar_tasks_cached: Mapped[list | None] = mapped_column(JSON, nullable=True, default=None) - - # -- whiteboard mode (see webdav_client.py, whiteboard.py, - # routers/device.py's RENDERERS["whiteboard"]) -- a frame-wide - # setting like calendar mode's own frame_calendars source, not - # personal data, but still owner-gated the same way: only - # whiteboard_user_id may point the frame at their own account, since - # it's their credentials being used to fetch it. -- - whiteboard_user_id: Mapped[int | None] = mapped_column( - ForeignKey("users.id", ondelete="SET NULL"), nullable=True - ) - # The specific .whiteboard file's WebDAV URL -- pasted directly, same - # idiom as calendar_ics_url, not discovered/browsed (unlike CalDAV's - # account-has-several-calendars case, a WebDAV account doesn't need - # a picker step here since the user already knows which one file). - whiteboard_url: Mapped[str] = mapped_column(String, default="") - whiteboard_checked_at: Mapped[float] = mapped_column(Float, default=0.0) - # Cached rendered PNG bytes (see whiteboard.fetch_and_render) -- - # BLOB rather than the JSON columns the rest of this cache-pattern - # family uses, since this is binary image data, not JSON-shaped. - whiteboard_cached_image: Mapped[bytes | None] = mapped_column(LargeBinary, nullable=True) - - # -- state -- - current_asset_id: Mapped[str] = mapped_column(String, default="") - current_asset_set_at: Mapped[float] = mapped_column(Float, default=0.0) - queue: Mapped[list] = mapped_column(MutableList.as_mutable(JSON), default=list) - queue_cursor: Mapped[int] = mapped_column(Integer, default=0) - history: Mapped[list] = mapped_column(MutableList.as_mutable(JSON), default=list) - excluded_asset_ids: Mapped[list] = mapped_column(MutableList.as_mutable(JSON), default=list) - # -- telemetry -- battery_percent: Mapped[int] = mapped_column(Integer, default=-1) battery_as_of: Mapped[float] = mapped_column(Float, default=0.0) diff --git a/server/app/routers/device.py b/server/app/routers/device.py index 1350ab0..ee84283 100644 --- a/server/app/routers/device.py +++ b/server/app/routers/device.py @@ -282,11 +282,10 @@ def frame_config(request: Request, frame: Frame = Depends(require_device), db: S # firmware/main/next_button.c, app/global_actions.py). "hold_duration_ms": locked.hold_duration_ms, } - # Per-frame token push: only once the device has introduced itself - # by id (so the response to pure-legacy firmware stays byte- - # compatible with its 256-byte parse buffer), and only until the - # device has authenticated with the token once (device_token_ack). - if locked.device_id is not None and not locked.device_token_ack: + # Per-frame token push: only until the device has authenticated + # with it once (device_token_ack) -- no reason to keep sending it + # on every wake once the device has it. + if not locked.device_token_ack: response["device_token"] = locked.device_token return response diff --git a/server/app/routers/pages.py b/server/app/routers/pages.py index 23aba1a..779bcae 100644 --- a/server/app/routers/pages.py +++ b/server/app/routers/pages.py @@ -707,26 +707,6 @@ def admin_link_user( notice=f"Linked '{target.username}' to frame #{frame_id}.") -@router.post("/admin/frames/{frame_id}/end-legacy", response_class=HTMLResponse) -def admin_end_legacy( - frame_id: int, - request: Request, - csrf_token: str = Form(""), - db: Session = Depends(get_db), -): - """Closes the legacy-token migration window once the device is - confirmed on per-frame auth (device_token_ack + recent last_seen in - the frames table below).""" - admin = _require_admin_page(request, db) - _check_form_csrf(request, db, csrf_token) - frame = db.get(Frame, frame_id) - if frame is None: - return _render_admin(request, db, admin, error="No such frame.") - frame.legacy_token_enabled = False - db.commit() - return _render_admin(request, db, admin, notice=f"Legacy token disabled for frame #{frame_id}.") - - @router.post("/admin/smtp", response_class=HTMLResponse) def admin_smtp_save( request: Request, diff --git a/server/app/templates/admin.html b/server/app/templates/admin.html index d7db74d..e036696 100644 --- a/server/app/templates/admin.html +++ b/server/app/templates/admin.html @@ -109,20 +109,12 @@ · linked: {{ links_by_frame.get(f.id, []) | map(attribute="username") | join(", ") or "nobody" }}
firmware: {{ f.device_firmware_version or "?" }} ({{ f.device_board_variant or "board unknown" }}) · token ack: {{ "yes" if f.device_token_ack else "no" }} - {% if f.legacy_token_enabled %}· legacy token window OPEN{% endif %}

- {% if f.legacy_token_enabled %} -
- - -
- {% endif %}
diff --git a/server/docker-compose.yml.example b/server/docker-compose.yml.example index 07e4f5a..4018d2e 100644 --- a/server/docker-compose.yml.example +++ b/server/docker-compose.yml.example @@ -10,12 +10,11 @@ services: - CONFIG_PATH=/data/config.json - IMMICH_URL=http://your-immich-host:2283 - IMMICH_API_KEY=your-immich-api-key-here - # Optional: gates the entire server -- the web UI (/, /api/*) AND - # every device-facing /frame/* endpoint -- behind this shared secret. - # Leave unset to keep it all open on a trusted LAN, same as before. - # Paste the same value into the ESP32's captive portal setup form - # (Access Token field) so it's sent on every device request and gets - # embedded automatically in the manage-menu/share QR codes. + # Optional: gates first-run /setup on a freshly deployed server -- + # whoever supplies this value is the one who gets to create the + # first admin account. Meaningless once that account exists (every + # other route always requires a real login), so leave unset unless + # you're worried about someone else reaching /setup before you do. - MANAGEMENT_TOKEN=changeme # Optional: only needed if the Gitea repo configured in the web UI's # "Firmware Gitea repo URL" field is private. A read-only PAT is diff --git a/server/tests/conftest.py b/server/tests/conftest.py index 6a9feb7..422bdfb 100644 --- a/server/tests/conftest.py +++ b/server/tests/conftest.py @@ -112,6 +112,19 @@ def link_user(db: Session, user: User, frame: Frame) -> None: db.flush() +def claim_device(db: Session, frame: Frame, device_id: str = "001122334455", + token: str = "devtok-1") -> str: + """Gives `frame` device credentials and returns the "id=...&token=..." + query string real firmware always sends -- require_device has no + fallback for a bare /frame/* request without ?id= (the old shared- + MANAGEMENT_TOKEN/no-id path this project used to resolve to a single + legacy frame is gone), so any device-facing test needs this.""" + frame.device_id = device_id + frame.device_token = token + db.commit() + return f"id={device_id}&token={token}" + + def login(client: TestClient, username: str, password: str = "testpass123") -> None: resp = client.post("/login", data={"username": username, "password": password}) assert resp.status_code == 303, resp.text diff --git a/server/tests/test_device_widget_dispatch.py b/server/tests/test_device_widget_dispatch.py index 531035a..516728b 100644 --- a/server/tests/test_device_widget_dispatch.py +++ b/server/tests/test_device_widget_dispatch.py @@ -26,6 +26,8 @@ from app.models import ( Widget, ) +from .conftest import claim_device + EXPECTED_BYTES = 800 * 480 // 2 _ASSETS = [{"id": "asset-1"}, {"id": "asset-2"}, {"id": "asset-3"}] @@ -41,16 +43,17 @@ def _mock_immich(monkeypatch): def test_unclaimed_frame_shows_placeholder(client, db_session): frame = db_session.get(Frame, 1) frame.owner_user_id = None - db_session.commit() + creds = claim_device(db_session, frame) - resp = client.get("/frame/image") + resp = client.get(f"/frame/image?{creds}") assert resp.status_code == 200 assert len(resp.content) == EXPECTED_BYTES def test_claimed_frame_with_unconfigured_photo_widget_still_renders(client, db_session): client.post("/setup", data={"username": "alice", "password": "hunter22"}) - resp = client.get("/frame/image") + creds = claim_device(db_session, db_session.get(Frame, 1)) + resp = client.get(f"/frame/image?{creds}") assert resp.status_code == 200 assert len(resp.content) == EXPECTED_BYTES @@ -66,24 +69,24 @@ def test_configured_photo_widget_renders_and_advances_via_button(client, db_sess widget = db_session.query(Widget).filter_by(frame_id=frame.id, widget_type="photos").one() cfg = db_session.get(PhotoWidgetConfig, widget.id) cfg.album_id = "album-1" - db_session.commit() + creds = claim_device(db_session, frame) _mock_immich(monkeypatch) - resp = client.get("/frame/image") + resp = client.get(f"/frame/image?{creds}") assert resp.status_code == 200 assert len(resp.content) == EXPECTED_BYTES db_session.refresh(cfg) assert cfg.current_asset_id == "asset-1" - resp = client.post("/frame/advance") + resp = client.post(f"/frame/advance?{creds}") assert resp.status_code == 200 assert len(resp.content) == EXPECTED_BYTES db_session.refresh(cfg) assert cfg.current_asset_id != "asset-1" # the default next->advance binding fired - resp = client.post("/frame/back") + resp = client.post(f"/frame/back?{creds}") assert resp.status_code == 200 db_session.refresh(cfg) assert cfg.current_asset_id == "asset-1" # back undid it @@ -94,11 +97,11 @@ def test_manage_flag_still_returns_a_valid_image(client, db_session, monkeypatch frame = db_session.get(Frame, 1) widget = db_session.query(Widget).filter_by(frame_id=frame.id, widget_type="photos").one() db_session.get(PhotoWidgetConfig, widget.id).album_id = "album-1" - db_session.commit() + creds = claim_device(db_session, frame) _mock_immich(monkeypatch) - plain = client.get("/frame/image").content - with_manage = client.get("/frame/image?manage=1").content + plain = client.get(f"/frame/image?{creds}").content + with_manage = client.get(f"/frame/image?{creds}&manage=1").content assert len(with_manage) == EXPECTED_BYTES assert with_manage != plain # the manage-QR overlay actually got composited in diff --git a/server/tests/test_global_actions.py b/server/tests/test_global_actions.py index 576e6e4..2f934f4 100644 --- a/server/tests/test_global_actions.py +++ b/server/tests/test_global_actions.py @@ -19,7 +19,7 @@ from app.models import ( WhiteboardWidgetConfig, ) -from .conftest import csrf_headers, link_user, login, make_user +from .conftest import claim_device, csrf_headers, link_user, login, make_user EXPECTED_BYTES = 800 * 480 // 2 @@ -113,7 +113,8 @@ def test_save_logged_out_401s(client, db_session): def test_global_next_is_a_noop_when_unset(client, db_session): client.post("/setup", data={"username": "alice", "password": "hunter22"}) - resp = client.post("/frame/global-next") + creds = claim_device(db_session, db_session.get(Frame, 1)) + resp = client.post(f"/frame/global-next?{creds}") assert resp.status_code == 200 assert len(resp.content) == EXPECTED_BYTES @@ -123,9 +124,9 @@ def test_global_next_runs_the_configured_action(client, db_session): frame = db_session.get(Frame, 1) photo_widget_id = db_session.query(Widget).filter_by(frame_id=frame.id, widget_type="photos").one().id frame.next_hold_action = "toggle_all_photo_locks" - db_session.commit() + creds = claim_device(db_session, frame) - resp = client.post("/frame/global-next") + resp = client.post(f"/frame/global-next?{creds}") assert resp.status_code == 200 assert len(resp.content) == EXPECTED_BYTES assert db_session.get(PhotoWidgetConfig, photo_widget_id).locked is True @@ -136,9 +137,9 @@ def test_global_back_runs_the_configured_action(client, db_session): frame = db_session.get(Frame, 1) photo_widget_id = db_session.query(Widget).filter_by(frame_id=frame.id, widget_type="photos").one().id frame.back_hold_action = "toggle_all_photo_locks" - db_session.commit() + creds = claim_device(db_session, frame) - resp = client.post("/frame/global-back") + resp = client.post(f"/frame/global-back?{creds}") assert resp.status_code == 200 assert db_session.get(PhotoWidgetConfig, photo_widget_id).locked is True @@ -149,9 +150,9 @@ def test_global_next_with_an_unrecognized_stored_action_is_a_noop(client, db_ses client.post("/setup", data={"username": "alice", "password": "hunter22"}) frame = db_session.get(Frame, 1) frame.next_hold_action = "no_longer_exists" - db_session.commit() + creds = claim_device(db_session, frame) - resp = client.post("/frame/global-next") + resp = client.post(f"/frame/global-next?{creds}") assert resp.status_code == 200 assert len(resp.content) == EXPECTED_BYTES diff --git a/server/tests/test_migrations.py b/server/tests/test_migrations.py index e6917c1..c4e47fe 100644 --- a/server/tests/test_migrations.py +++ b/server/tests/test_migrations.py @@ -28,6 +28,60 @@ from app.models import ( from .conftest import make_user +# Columns migration 41 drops from `frames` -- a fresh-install create_all() +# copy (what every db_session fixture starts from) already reflects +# today's models.py, i.e. the post-41 shape without these, so a test that +# wants to simulate a pre-41 database has to add them back itself before +# setting schema_version below 41 and calling run_migrations() -- same +# "frames isn't dropped/recreated by these replay tests" situation +# test_migration_29/30's own comments describe, just for columns being +# removed instead of added. +_LEGACY_FRAME_COLUMNS = [ + "mode TEXT NOT NULL DEFAULT 'photos'", + "album_id TEXT NOT NULL DEFAULT ''", + "photo_order TEXT NOT NULL DEFAULT 'sequential'", + "display_mode TEXT NOT NULL DEFAULT 'crop_faces'", + "queue_target_len INTEGER NOT NULL DEFAULT 20", + "current_asset_id TEXT NOT NULL DEFAULT ''", + "current_asset_set_at REAL NOT NULL DEFAULT 0.0", + "queue TEXT NOT NULL DEFAULT '[]'", + "queue_cursor INTEGER NOT NULL DEFAULT 0", + "history TEXT NOT NULL DEFAULT '[]'", + "excluded_asset_ids TEXT NOT NULL DEFAULT '[]'", + "calendar_view TEXT NOT NULL DEFAULT 'agenda'", + "calendar_week_start INTEGER NOT NULL DEFAULT 0", + "calendar_photo_inlay INTEGER NOT NULL DEFAULT 0", + "calendar_browse_offset INTEGER NOT NULL DEFAULT 0", + "calendar_checked_at REAL NOT NULL DEFAULT 0.0", + "calendar_cached_events TEXT", + "calendar_fetch_summary TEXT NOT NULL DEFAULT ''", + "calendar_weather_enabled INTEGER NOT NULL DEFAULT 0", + "calendar_weather_units TEXT NOT NULL DEFAULT 'fahrenheit'", + "calendar_weather_cities TEXT", + "calendar_weather_checked_at REAL NOT NULL DEFAULT 0.0", + "calendar_weather_cached TEXT", + "calendar_week_days INTEGER NOT NULL DEFAULT 7", + "calendar_week_layout TEXT NOT NULL DEFAULT 'horizontal'", + "calendar_week_start_offset INTEGER NOT NULL DEFAULT 0", + "calendar_tasks_enabled INTEGER NOT NULL DEFAULT 0", + "calendar_tasks_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL", + "calendar_tasks_calendar_key TEXT", + "calendar_tasks_checked_at REAL NOT NULL DEFAULT 0.0", + "calendar_tasks_cached TEXT", + "whiteboard_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL", + "whiteboard_url TEXT NOT NULL DEFAULT ''", + "whiteboard_checked_at REAL NOT NULL DEFAULT 0.0", + "whiteboard_cached_image BLOB", + "legacy_token_enabled INTEGER NOT NULL DEFAULT 0", +] + + +def _add_legacy_frame_columns(conn) -> None: + existing = {c["name"] for c in inspect(db_module.engine).get_columns("frames")} + for col_def in _LEGACY_FRAME_COLUMNS: + if col_def.split()[0] not in existing: + conn.execute(text(f"ALTER TABLE frames ADD COLUMN {col_def}")) + def test_migrations_list_is_sequential_and_unique(): versions = [v for v, _ in MIGRATIONS] @@ -72,8 +126,6 @@ def test_expected_columns_exist_on_current_schema(): assert "webdav_base_url" in user_columns # migration 15 assert "webdav_username" in user_columns # migration 14 assert "calendar_caldav_url" in user_columns - assert "whiteboard_cached_image" in frame_columns # migration 14 - assert "calendar_week_start_offset" in frame_columns assert "name" in task_widget_columns # migration 19 assert "static_widget_configs" in inspector.get_table_names() # migration 20 assert "text_widget_configs" in inspector.get_table_names() # migration 21 @@ -106,14 +158,16 @@ def test_expected_columns_exist_on_current_schema(): assert "render_style" in calendar_widget_columns # migration 38 assert "theme" in frame_columns # migration 39 assert "font_scale" in widget_columns # migration 40 + assert not {"mode", "album_id", "current_asset_id", "calendar_view", "whiteboard_url", + "legacy_token_enabled"} & frame_columns # migration 41 -# --- widget system backfill (migration 16 + _ensure_widgets_backfilled) --- +# --- widget system: fresh-install default widget, and migration 41's +# raw-SQL backfill safety net for a pre-widget-system database --- -def test_fresh_install_backfills_one_photos_widget_with_default_buttons(db_session): +def test_fresh_install_creates_a_default_photos_widget_with_default_buttons(db_session): frame = db_session.get(Frame, 1) - assert frame.mode == "photos" widgets = db_session.scalars(select(Widget).where(Widget.frame_id == frame.id)).all() assert len(widgets) == 1 @@ -123,7 +177,7 @@ def test_fresh_install_backfills_one_photos_widget_with_default_buttons(db_sessi config = db_session.get(PhotoWidgetConfig, widget.id) assert config is not None - assert config.album_id == frame.album_id + assert config.album_id == "" actions = db_session.scalars(select(FrameButtonAction).where(FrameButtonAction.frame_id == frame.id)).all() assert {a.button: a.action for a in actions} == {"next": "advance", "back": "back"} @@ -140,17 +194,26 @@ def test_rerunning_migrations_does_not_duplicate_widgets(db_session): def test_calendar_photo_inlay_frame_backfills_into_two_widgets(db_session): """Reproduces the old fixed 50/50 inlay split as two independent, non-overlapping widgets instead of silently dropping the photo half - on upgrade -- see models.py's CalendarWidgetConfig docstring.""" - frame = Frame( - name="Inlay Frame", device_token="tok-inlay", manage_token="mtok-inlay", - mode="calendar", orientation="landscape", calendar_view="week", - calendar_photo_inlay=True, album_id="album-123", - current_asset_id="asset-1", queue=["asset-1", "asset-2"], - created_at=time.time(), - ) + on upgrade -- see models.py's CalendarWidgetConfig docstring. Exercises + _migration_41's raw-SQL backfill safety net: a frame whose legacy + Frame columns (pre-widget-system) still carry real data but which + somehow has no Widget yet.""" + frame = Frame(name="Inlay Frame", device_token="tok-inlay", manage_token="mtok-inlay", + orientation="landscape", created_at=time.time()) db_session.add(frame) + db_session.flush() + frame_id = frame.id db_session.commit() + with db_module.engine.begin() as conn: + _add_legacy_frame_columns(conn) + conn.execute(text( + "UPDATE frames SET mode='calendar', calendar_view='week', calendar_photo_inlay=1, " + "album_id='album-123', current_asset_id='asset-1', queue='[\"asset-1\", \"asset-2\"]' " + "WHERE id = :id" + ), {"id": frame_id}) + conn.execute(text("UPDATE schema_version SET version = 40")) + run_migrations() widgets = db_session.scalars( @@ -180,15 +243,24 @@ def test_calendar_photo_inlay_frame_backfills_into_two_widgets(db_session): def test_whiteboard_frame_backfills_check_now_on_both_buttons(db_session): - frame = Frame( - name="WB Frame", device_token="tok-wb", manage_token="mtok-wb", - mode="whiteboard", orientation="portrait", - whiteboard_url="https://example.com/board.whiteboard", - created_at=time.time(), - ) + """Exercises _migration_41's raw-SQL backfill safety net for a + whiteboard-mode legacy frame -- same shape as the calendar-inlay case + above, just the simpler single-widget mode dispatch branch.""" + frame = Frame(name="WB Frame", device_token="tok-wb", manage_token="mtok-wb", + orientation="portrait", created_at=time.time()) db_session.add(frame) + db_session.flush() + frame_id = frame.id db_session.commit() + with db_module.engine.begin() as conn: + _add_legacy_frame_columns(conn) + conn.execute(text( + "UPDATE frames SET mode='whiteboard', " + "whiteboard_url='https://example.com/board.whiteboard' WHERE id = :id" + ), {"id": frame_id}) + conn.execute(text("UPDATE schema_version SET version = 40")) + run_migrations() widgets = db_session.scalars(select(Widget).where(Widget.frame_id == frame.id)).all() @@ -249,19 +321,23 @@ def test_migration_16_raw_sql_path_applies_to_an_existing_pre_widget_database(db conn.execute(text( "CREATE UNIQUE INDEX ix_frame_calendars_unique ON frame_calendars (frame_id, user_id, calendar_key)" )) + _add_legacy_frame_columns(conn) conn.execute(text("UPDATE schema_version SET version = 15")) - frame = Frame( - name="Upgrading Frame", device_token="tok-up", manage_token="mtok-up", - mode="photos", album_id="legacy-album", current_asset_id="legacy-asset", - queue=["legacy-asset", "next-asset"], created_at=time.time(), - ) + frame = Frame(name="Upgrading Frame", device_token="tok-up", manage_token="mtok-up", + created_at=time.time()) db_session.add(frame) db_session.flush() user = make_user(db_session, "legacy-owner") db_session.commit() frame_id, user_id = frame.id, user.id + with db_module.engine.begin() as conn: + conn.execute(text( + "UPDATE frames SET mode='photos', album_id='legacy-album', current_asset_id='legacy-asset', " + "queue='[\"legacy-asset\", \"next-asset\"]' WHERE id = :id" + ), {"id": frame_id}) + # An orphaned frame_calendars row (this frame's mode was never # "calendar", so it has no calendar widget for _ensure_frame_ # calendars_rekeyed to attach it to) -- exercises that it's dropped @@ -533,12 +609,11 @@ def test_frame_calendars_rekey_attaches_existing_rows_to_their_calendar_widget(d conn.execute(text( "CREATE UNIQUE INDEX ix_frame_calendars_unique ON frame_calendars (frame_id, user_id, calendar_key)" )) + _add_legacy_frame_columns(conn) conn.execute(text("UPDATE schema_version SET version = 15")) - frame = Frame( - name="Calendar Frame", device_token="tok-cal", manage_token="mtok-cal", - mode="calendar", created_at=time.time(), - ) + frame = Frame(name="Calendar Frame", device_token="tok-cal", manage_token="mtok-cal", + created_at=time.time()) db_session.add(frame) db_session.flush() user = make_user(db_session, "cal-owner") @@ -546,6 +621,7 @@ def test_frame_calendars_rekey_attaches_existing_rows_to_their_calendar_widget(d frame_id, user_id = frame.id, user.id with db_module.engine.begin() as conn: + conn.execute(text("UPDATE frames SET mode='calendar' WHERE id = :id"), {"id": frame_id}) conn.execute(text( "INSERT INTO frame_calendars (frame_id, user_id, calendar_key, calendar_label, included, color_index) " "VALUES (:frame_id, :user_id, 'ics', 'Legacy Cal', 1, 3)" diff --git a/server/tests/test_now_displaying.py b/server/tests/test_now_displaying.py index e3e6026..e7cefc4 100644 --- a/server/tests/test_now_displaying.py +++ b/server/tests/test_now_displaying.py @@ -14,7 +14,7 @@ from PIL import Image from app.image_pipeline import logical_render_size from app.models import Frame -from .conftest import link_user, login, make_user +from .conftest import claim_device, link_user, login, make_user def test_now_displaying_404s_before_any_device_fetch(client, db_session): @@ -27,8 +27,9 @@ def test_now_displaying_404s_before_any_device_fetch(client, db_session): def test_frame_image_records_now_displaying(client, db_session): client.post("/setup", data={"username": "alice", "password": "hunter22"}) frame = db_session.get(Frame, 1) + creds = claim_device(db_session, frame) - resp = client.get("/frame/image") + resp = client.get(f"/frame/image?{creds}") assert resp.status_code == 200 resp = client.get("/api/frames/1/now-displaying") @@ -43,18 +44,18 @@ def test_frame_image_records_now_displaying(client, db_session): def test_advance_and_back_also_update_now_displaying(client, db_session): client.post("/setup", data={"username": "alice", "password": "hunter22"}) - db_session.get(Frame, 1) + creds = claim_device(db_session, db_session.get(Frame, 1)) - client.get("/frame/image") + client.get(f"/frame/image?{creds}") first = client.get("/api/frames/1/now-displaying") assert first.status_code == 200 - resp = client.post("/frame/advance") + resp = client.post(f"/frame/advance?{creds}") assert resp.status_code == 200 after_advance = client.get("/api/frames/1/now-displaying") assert after_advance.status_code == 200 - resp = client.post("/frame/back") + resp = client.post(f"/frame/back?{creds}") assert resp.status_code == 200 after_back = client.get("/api/frames/1/now-displaying") assert after_back.status_code == 200 @@ -66,7 +67,8 @@ def test_now_displaying_visible_to_linked_user(client, db_session): bob = make_user(db_session, "bob") frame = db_session.get(Frame, 1) link_user(db_session, bob, frame) - client.get("/frame/image") + creds = claim_device(db_session, frame) + client.get(f"/frame/image?{creds}") client.cookies.clear() login(client, "bob")