Extend the access token to every endpoint, not just the web UI
Build and push server image / build-and-push (push) Successful in 33s

The management token only gated / and /api/* -- every device-facing
/frame/* endpoint (including /frame/image, which serves the actual
photo bytes) stayed open regardless. That was fine while the server
was assumed LAN-only, but defeats the point now that HTTPS exists
specifically to let this sit behind a public hostname.

build_url() (frame_client.c) is the one chokepoint all firmware-side
URL construction already went through, so it now appends ?token= to
every request it builds -- device fetches and QR-embedded links alike
-- instead of that being bolted on per-callsite. Server-side, the
former require_management_token dependency (renamed require_access_token)
is applied to /frame/config, /frame/image, /frame/advance,
/frame/photo-info, /frame/face-labels, and /frame/share/{asset_id} too.
/health stays open -- pure liveness, nothing sensitive to protect.
This commit is contained in:
2026-07-19 09:52:01 -04:00
parent 6c7468a36e
commit 62cf907d88
4 changed files with 109 additions and 81 deletions
+16 -4
View File
@@ -76,11 +76,11 @@ two-step setup screen:
portal's config page (`http://192.168.4.1/` by default), for a portal's config page (`http://192.168.4.1/` by default), for a
one-scan shortcut once you've joined the AP. one-scan shortcut once you've joined the AP.
The config page asks for your home WiFi SSID/password and the "Tools The config page asks for your home WiFi SSID/password, the "Tools
Server" address (`host:port` of the [server](../server/) -- **not** your Server" address (`host:port` of the [server](../server/) -- **not** your
Immich server; see below for the `https://` form). Saving reboots the Immich server; see below for the `https://` form), and an optional
device, which then connects to your home network and starts its normal "Access Token" (see below). Saving reboots the device, which then
fetch/sleep cycle. connects to your home network and starts its normal fetch/sleep cycle.
## HTTP vs HTTPS ## HTTP vs HTTPS
@@ -114,6 +114,18 @@ CA cert never covers a raw IP. Use whatever hostname the certificate's
SAN list actually covers (e.g. a local DNS/hosts entry pointing at the SAN list actually covers (e.g. a local DNS/hosts entry pointing at the
frame's LAN IP, or the same public hostname the proxy is issued for). frame's LAN IP, or the same public hostname the proxy is issued for).
## Access token
If the server has `MANAGEMENT_TOKEN` set (see
[`server/README.md`](../server/README.md)), it requires that same value
on every request -- the web UI *and* every device-facing request the
frame itself makes. Paste it into the captive portal's "Access Token"
field and the device sends it (`?token=...`) on every request
automatically, and bakes it into the manage-menu/share QR codes so
scanning them just works too. Leave it blank if the server has no
`MANAGEMENT_TOKEN` configured -- the default, unauthenticated-on-a-
trusted-LAN behavior from before.
## Skipping to the next photo ## Skipping to the next photo
Wire a momentary push button between GPIO2 and GND (internal pull-up, Wire a momentary push button between GPIO2 and GND (internal pull-up,
+36 -32
View File
@@ -32,17 +32,27 @@ static EventGroupHandle_t s_sta_event_group;
* Cloudflare-issued origin certificate. See firmware/main/certs/. */ * Cloudflare-issued origin certificate. See firmware/main/certs/. */
extern const char cloudflare_origin_ca_pem_start[] asm("_binary_cloudflare_origin_ca_pem_start"); extern const char cloudflare_origin_ca_pem_start[] asm("_binary_cloudflare_origin_ca_pem_start");
/* Builds a full URL from cfg->toolsserver + a path (no leading slash). /* Builds a full URL from cfg->toolsserver + a path (no leading slash),
* toolsserver is normally a bare "host:port", defaulting to plain http; * appending cfg->access_token as ?token= if one's set. toolsserver is
* it may instead carry an explicit "http://" or "https://" prefix to * normally a bare "host:port", defaulting to plain http; it may instead
* pick the scheme, e.g. "https://frame.example.com" if a reverse proxy * carry an explicit "http://" or "https://" prefix to pick the scheme,
* is terminating TLS in front of the tools server. */ * e.g. "https://frame.example.com" if a reverse proxy is terminating
static void build_url(char *out, size_t out_size, const char *toolsserver, const char *path) * 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. */
static void build_url(char *out, size_t out_size, const frame_config_t *cfg, const char *path)
{ {
const char *toolsserver = cfg->toolsserver;
size_t len;
if (strncmp(toolsserver, "http://", 7) == 0 || strncmp(toolsserver, "https://", 8) == 0) { if (strncmp(toolsserver, "http://", 7) == 0 || strncmp(toolsserver, "https://", 8) == 0) {
snprintf(out, out_size, "%s/%s", toolsserver, path); len = (size_t)snprintf(out, out_size, "%s/%s", toolsserver, path);
} else { } else {
snprintf(out, out_size, "http://%s/%s", toolsserver, path); 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);
} }
} }
@@ -213,15 +223,15 @@ static bool json_extract_string(const char *json, const char *key, char *out, si
/* GETs the server's /frame/config -- doubles as both the reachability /* GETs the server's /frame/config -- doubles as both the reachability
* check (any completed HTTP response means the socket-level connection * check (any completed HTTP response means the socket-level connection
* succeeded) and the source of the server-configurable refresh interval. */ * succeeded) and the source of the server-configurable refresh interval. */
static frame_server_config_t fetch_frame_config(const char *toolsserver) static frame_server_config_t fetch_frame_config(const frame_config_t *cfg)
{ {
frame_server_config_t result = { frame_server_config_t result = {
.reachable = false, .reachable = false,
.refresh_interval_s = CONFIG_FRAME_SLEEP_INTERVAL_S, .refresh_interval_s = CONFIG_FRAME_SLEEP_INTERVAL_S,
}; };
char url[160]; char url[256];
build_url(url, sizeof(url), toolsserver, "frame/config"); build_url(url, sizeof(url), cfg, "frame/config");
esp_http_client_config_t config = { esp_http_client_config_t config = {
.url = url, .url = url,
@@ -233,7 +243,7 @@ static frame_server_config_t fetch_frame_config(const char *toolsserver)
esp_err_t err = esp_http_client_open(client, 0); esp_err_t err = esp_http_client_open(client, 0);
if (err != ESP_OK) { if (err != ESP_OK) {
ESP_LOGW(TAG, "Server '%s' not reachable: %s", toolsserver, esp_err_to_name(err)); ESP_LOGW(TAG, "Server '%s' not reachable: %s", cfg->toolsserver, esp_err_to_name(err));
esp_http_client_cleanup(client); esp_http_client_cleanup(client);
return result; return result;
} }
@@ -272,7 +282,7 @@ static frame_server_config_t fetch_frame_config(const char *toolsserver)
* current photo, etc.) just leaves all outputs empty -- the caller * current photo, etc.) just leaves all outputs empty -- the caller
* treats that as "skip these optional overlay regions", not a hard * treats that as "skip these optional overlay regions", not a hard
* error, since the base "scan to manage" QR should still show. */ * error, since the base "scan to manage" QR should still show. */
static void fetch_photo_info(const char *toolsserver, char *location_line1, size_t location_line1_size, static void fetch_photo_info(const frame_config_t *cfg, char *location_line1, size_t location_line1_size,
char *location_line2, size_t location_line2_size, char *taken_at, size_t taken_at_size, char *location_line2, size_t location_line2_size, char *taken_at, size_t taken_at_size,
char *share_url, size_t share_url_size) char *share_url, size_t share_url_size)
{ {
@@ -281,8 +291,8 @@ static void fetch_photo_info(const char *toolsserver, char *location_line1, size
taken_at[0] = '\0'; taken_at[0] = '\0';
share_url[0] = '\0'; share_url[0] = '\0';
char url[160]; char url[256];
build_url(url, sizeof(url), toolsserver, "frame/photo-info"); build_url(url, sizeof(url), cfg, "frame/photo-info");
esp_http_client_config_t config = { esp_http_client_config_t config = {
.url = url, .url = url,
@@ -327,7 +337,7 @@ static void fetch_photo_info(const char *toolsserver, char *location_line1, size
if (json_extract_string(body, "asset_id", asset_id, sizeof(asset_id))) { if (json_extract_string(body, "asset_id", asset_id, sizeof(asset_id))) {
char path[80]; char path[80];
snprintf(path, sizeof(path), "frame/share/%s", asset_id); snprintf(path, sizeof(path), "frame/share/%s", asset_id);
build_url(share_url, share_url_size, toolsserver, path); build_url(share_url, share_url_size, cfg, path);
} }
} }
@@ -339,10 +349,10 @@ static void fetch_photo_info(const char *toolsserver, char *location_line1, size
* this file instead of needing an actual array parser. Any failure * this file instead of needing an actual array parser. Any failure
* (unreachable, malformed response, etc.) just returns 0 -- named faces * (unreachable, malformed response, etc.) just returns 0 -- named faces
* are a "nice to have" addition to the menu, not worth failing it over. */ * are a "nice to have" addition to the menu, not worth failing it over. */
static int fetch_face_labels(const char *toolsserver, manage_face_label_t *out, int max_labels) static int fetch_face_labels(const frame_config_t *cfg, manage_face_label_t *out, int max_labels)
{ {
char url[160]; char url[256];
build_url(url, sizeof(url), toolsserver, "frame/face-labels"); build_url(url, sizeof(url), cfg, "frame/face-labels");
esp_http_client_config_t config = { esp_http_client_config_t config = {
.url = url, .url = url,
@@ -481,8 +491,8 @@ static size_t http_read_fn(uint8_t *chunk, size_t chunk_size, void *ctx_)
static esp_err_t fetch_and_display(const frame_config_t *cfg, bool force_advance, static esp_err_t fetch_and_display(const frame_config_t *cfg, bool force_advance,
const manage_overlay_set_t *overlay) const manage_overlay_set_t *overlay)
{ {
char url[160]; char url[256];
build_url(url, sizeof(url), cfg->toolsserver, force_advance ? "frame/advance" : "frame/image"); build_url(url, sizeof(url), cfg, force_advance ? "frame/advance" : "frame/image");
esp_http_client_config_t config = { esp_http_client_config_t config = {
.url = url, .url = url,
@@ -578,25 +588,19 @@ static bool wait_for_button_press(uint32_t timeout_ms)
static esp_err_t show_menu_level(const frame_config_t *cfg, bool force_advance, int level) static esp_err_t show_menu_level(const frame_config_t *cfg, bool force_advance, int level)
{ {
char management_url[256]; char management_url[256];
build_url(management_url, sizeof(management_url), cfg->toolsserver, ""); build_url(management_url, sizeof(management_url), cfg, "");
if (cfg->access_token[0] != '\0') {
/* Embeds the token so scanning the QR just works -- matches the
* server's MANAGEMENT_TOKEN gate on GET / (see server/README.md). */
size_t len = strlen(management_url);
snprintf(management_url + len, sizeof(management_url) - len, "?token=%s", cfg->access_token);
}
char location_line1[32]; char location_line1[32];
char location_line2[32]; char location_line2[32];
char taken_at[32]; char taken_at[32];
char share_url[160]; char share_url[256];
fetch_photo_info(cfg->toolsserver, location_line1, sizeof(location_line1), location_line2, fetch_photo_info(cfg, location_line1, sizeof(location_line1), location_line2,
sizeof(location_line2), taken_at, sizeof(taken_at), share_url, sizeof(share_url)); sizeof(location_line2), taken_at, sizeof(taken_at), share_url, sizeof(share_url));
manage_face_label_t face_labels[MANAGE_FACE_LABELS_MAX]; manage_face_label_t face_labels[MANAGE_FACE_LABELS_MAX];
int face_label_count = 0; int face_label_count = 0;
if (level >= 2) { if (level >= 2) {
face_label_count = fetch_face_labels(cfg->toolsserver, face_labels, MANAGE_FACE_LABELS_MAX); face_label_count = fetch_face_labels(cfg, face_labels, MANAGE_FACE_LABELS_MAX);
} }
manage_overlay_content_t content = { manage_overlay_content_t content = {
@@ -727,7 +731,7 @@ void frame_client_run(const frame_config_t *cfg, bool force_advance, bool show_m
* just be discarded. */ * just be discarded. */
uint32_t sleep_seconds = CONFIG_FRAME_RETRY_INTERVAL_S; uint32_t sleep_seconds = CONFIG_FRAME_RETRY_INTERVAL_S;
if (image_ok) { if (image_ok) {
frame_server_config_t server_cfg = fetch_frame_config(cfg->toolsserver); 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; sleep_seconds = server_cfg.reachable ? server_cfg.refresh_interval_s : CONFIG_FRAME_RETRY_INTERVAL_S;
} }
+20 -16
View File
@@ -33,13 +33,17 @@ algorithm itself -- it just streams the response straight to the panel.
for HTTPS, put a TLS-terminating reverse proxy (e.g. nginx) in front of for HTTPS, put a TLS-terminating reverse proxy (e.g. nginx) in front of
it and enter the proxy's `https://` address instead (see it and enter the proxy's `https://` address instead (see
`firmware/README.md`'s HTTPS section for what the ESP32 side needs). `firmware/README.md`'s HTTPS section for what the ESP32 side needs).
6. **Optional: set `MANAGEMENT_TOKEN`** in `docker-compose.yml` to gate the 6. **Optional: set `MANAGEMENT_TOKEN`** in `docker-compose.yml` to gate
web UI behind a shared secret (leave unset to keep it open, the the *entire server* -- the web UI (`/`, `/api/*`) and every
previous default -- fine on a trusted LAN). If set, paste the same device-facing `/frame/*` endpoint -- behind a shared secret (leave
value into the ESP32's captive portal setup form's **Access Token** unset to keep it all open, the previous default -- fine on a trusted
field so the manage-menu's "scan to manage" QR code embeds it LAN). If set, paste the same value into the ESP32's captive portal
automatically (`?token=...`); visiting the page without a valid token setup form's **Access Token** field: the device then sends it on
in the URL shows a plain token-entry prompt instead of the config UI. every request it makes, and the manage-menu/share QR codes embed it
automatically (`?token=...`) so scanning them just works. Visiting
the web UI without a valid token in the URL shows a plain token-entry
prompt instead of the config UI; `/health` stays open regardless
(pure liveness, nothing sensitive in it).
## Endpoints ## Endpoints
@@ -116,15 +120,15 @@ algorithm itself -- it just streams the response straight to the panel.
in sequential or shuffle order per the Order setting. Dragging photos in sequential or shuffle order per the Order setting. Dragging photos
in the web UI (or using "Show next") only rearranges what's already in in the web UI (or using "Show next") only rearranges what's already in
that lookahead; it doesn't add or remove photos from the album. that lookahead; it doesn't add or remove photos from the album.
- `/frame/image`, `/frame/advance`, `/frame/photo-info`, `/frame/face-labels`, - Every endpoint except `/` and `/health` -- the web UI's `/api/*` and
and `/frame/share/{asset_id}` -- the device-facing endpoints -- aren't every device-facing `/frame/*` -- requires `?token=` (or the
authenticated. That's fine on a trusted home LAN for now, but worth `mgmt_token` cookie the web UI sets after a valid one) once
revisiting if this ever needs to sit somewhere less trusted. `MANAGEMENT_TOKEN` is set (see Setup above); unset, everything stays
`/frame/share` at least is scoped to only ever create a link for a open like before, which is still fine on a trusted home LAN. `/frame/share`
photo this frame is actually showing or has queued, not any Immich additionally stays scoped to only ever create a link for a photo this
asset ID someone might guess. The web UI (`/`, `/api/*`) is separately frame is actually showing or has queued, not any Immich asset ID
gated by `MANAGEMENT_TOKEN` if set (see Setup above) -- these are two someone might guess -- a second layer a leaked token alone wouldn't
independent trust boundaries, not one shared mechanism. bypass.
- The 6-color palette RGB values in `app/image_pipeline.py` are - The 6-color palette RGB values in `app/image_pipeline.py` are
approximations, not measured values (Waveshare doesn't publish exact approximations, not measured values (Waveshare doesn't publish exact
color primaries for this panel) -- tune them once you can compare a color primaries for this panel) -- tune them once you can compare a
+37 -29
View File
@@ -34,25 +34,32 @@ MANAGEMENT_TOKEN_COOKIE = "mgmt_token"
def _token_valid(request: Request, cfg: config.FrameConfig) -> bool: def _token_valid(request: Request, cfg: config.FrameConfig) -> bool:
"""No management_token configured (MANAGEMENT_TOKEN env var, see """No management_token configured (MANAGEMENT_TOKEN env var, see
docker-compose.yml.example) means the management page stays open on a docker-compose.yml.example) means the whole server stays open on a
trusted LAN, matching this project's existing default. Once one's trusted LAN, matching this project's original default. Once one's
set, a request is authorized by either a ?token= query param (what set, a request is authorized by either a ?token= query param (what
the manage-menu QR code embeds) or the cookie index() sets after a the ESP32 sends on every device request, and what the manage-menu/
valid query-param hit (so the page's own fetch()/<img> calls, which share QR codes embed for a human scanning them) or the cookie
carry no query string, stay authorized for the rest of the visit).""" index() sets after a valid query-param hit (so the web UI's own
fetch()/<img> calls, which carry no query string, stay authorized
for the rest of that browsing visit)."""
if not cfg.management_token: if not cfg.management_token:
return True return True
supplied = request.query_params.get("token") or request.cookies.get(MANAGEMENT_TOKEN_COOKIE) supplied = request.query_params.get("token") or request.cookies.get(MANAGEMENT_TOKEN_COOKIE)
return supplied is not None and supplied == cfg.management_token return supplied is not None and supplied == cfg.management_token
def require_management_token(request: Request) -> None: def require_access_token(request: Request) -> None:
"""Dependency for the /api/* routes behind the management page. index() """Dependency for every route except / and /health: the web UI's
below handles the unauthorized case itself (a friendlier HTML prompt, /api/* and every device-facing /frame/*. index() handles the
not a bare 401) since that's the one route an unauthorized visitor is unauthorized case itself (a friendlier HTML prompt, not a bare 401)
actually meant to land on.""" since that's the one route a human is actually meant to land on with
no token yet; the ESP32 sends its token as ?token= on every request
it makes (see frame_client.c's build_url()), so device endpoints
just 401 outright on a missing/wrong one. /health stays open -- it
reveals nothing but process liveness, and gating it would break
plain infra/uptime monitoring for no real security benefit."""
if not _token_valid(request, config.load()): if not _token_valid(request, config.load()):
raise HTTPException(401, "Missing or invalid management token") raise HTTPException(401, "Missing or invalid access token")
@app.get("/health") @app.get("/health")
@@ -60,7 +67,7 @@ def health() -> dict:
return {"status": "ok"} return {"status": "ok"}
@app.get("/frame/config") @app.get("/frame/config", dependencies=[Depends(require_access_token)])
def frame_config(): def frame_config():
"""Device-facing settings, polled by the frame alongside its """Device-facing settings, polled by the frame alongside its
reachability check. Always returns 200 with current settings reachability check. Always returns 200 with current settings
@@ -91,7 +98,7 @@ def index(request: Request):
return response return response
@app.get("/api/albums", dependencies=[Depends(require_management_token)]) @app.get("/api/albums", dependencies=[Depends(require_access_token)])
def api_albums(): def api_albums():
cfg = config.load() cfg = config.load()
if not cfg.immich_url or not cfg.immich_api_key: if not cfg.immich_url or not cfg.immich_api_key:
@@ -103,7 +110,7 @@ def api_albums():
return [{"id": a["id"], "name": a["albumName"], "count": a.get("assetCount", 0)} for a in albums] return [{"id": a["id"], "name": a["albumName"], "count": a.get("assetCount", 0)} for a in albums]
@app.post("/api/config", dependencies=[Depends(require_management_token)]) @app.post("/api/config", dependencies=[Depends(require_access_token)])
def api_config_save( def api_config_save(
album_id: str = Form(""), album_id: str = Form(""),
order: str = Form("sequential"), order: str = Form("sequential"),
@@ -168,7 +175,7 @@ def _render_asset(client: ImmichClient, cfg: config.FrameConfig, asset_id: str)
return render_frame(source, faces=faces) return render_frame(source, faces=faces)
@app.get("/frame/image") @app.get("/frame/image", dependencies=[Depends(require_access_token)])
def frame_image(): def frame_image():
"""Returns the current photo. Idempotent: only actually advances to """Returns the current photo. Idempotent: only actually advances to
the next photo once refresh_interval_s has elapsed since the current the next photo once refresh_interval_s has elapsed since the current
@@ -187,7 +194,7 @@ def frame_image():
return Response(content=_render_asset(client, cfg, cfg.current_asset_id), media_type="application/octet-stream") return Response(content=_render_asset(client, cfg, cfg.current_asset_id), media_type="application/octet-stream")
@app.post("/frame/advance") @app.post("/frame/advance", dependencies=[Depends(require_access_token)])
def frame_advance(): def frame_advance():
"""Forces an immediate advance to the next photo, ignoring """Forces an immediate advance to the next photo, ignoring
refresh_interval_s, and resets the interval clock from now. Used by refresh_interval_s, and resets the interval clock from now. Used by
@@ -274,7 +281,7 @@ def _format_taken_at(exif: dict) -> str | None:
return None return None
@app.get("/frame/photo-info") @app.get("/frame/photo-info", dependencies=[Depends(require_access_token)])
def frame_photo_info(): def frame_photo_info():
"""Location/date-taken text for the manage-button overlay, plus the """Location/date-taken text for the manage-button overlay, plus the
asset id used to build the share-QR's target URL. Read-only, same asset id used to build the share-QR's target URL. Read-only, same
@@ -307,16 +314,17 @@ def frame_photo_info():
} }
@app.get("/frame/share/{asset_id}") @app.get("/frame/share/{asset_id}", dependencies=[Depends(require_access_token)])
def frame_share(asset_id: str): def frame_share(asset_id: str):
"""Creates a 30-minute public Immich share link for asset_id and """Creates a 30-minute public Immich share link for asset_id and
redirects to it -- what the manage overlay's bottom-left QR code redirects to it -- what the manage overlay's bottom-left QR code
points to. The link is created lazily, when this actually gets hit points to (the firmware bakes ?token= into that QR the same way it
(i.e. when someone scans it), not when the manage button was does for the management QR, see frame_client.c's build_url()). The
pressed, so the 30-minute window starts when it's actually used. link is created lazily, when this actually gets hit (i.e. when
Scoped to the photo currently showing or queued -- not any arbitrary someone scans it), not when the manage button was pressed, so the
Immich asset id -- since this is otherwise an unauthenticated 30-minute window starts when it's actually used. Also scoped to the
endpoint (see server/README.md).""" photo currently showing or queued -- not any arbitrary Immich asset
id -- as a second layer even a leaked token wouldn't bypass."""
cfg = config.load() cfg = config.load()
_require_configured(cfg) _require_configured(cfg)
@@ -332,7 +340,7 @@ def frame_share(asset_id: str):
return RedirectResponse(share_url) return RedirectResponse(share_url)
@app.get("/frame/face-labels") @app.get("/frame/face-labels", dependencies=[Depends(require_access_token)])
def frame_face_labels(): def frame_face_labels():
"""Named-face positions for the manage button's escalated "level 2" """Named-face positions for the manage button's escalated "level 2"
menu -- who's in the current photo, per Immich's own face menu -- who's in the current photo, per Immich's own face
@@ -381,7 +389,7 @@ def frame_face_labels():
return result return result
@app.get("/api/queue", dependencies=[Depends(require_management_token)]) @app.get("/api/queue", dependencies=[Depends(require_access_token)])
def api_queue(): def api_queue():
cfg = config.load() cfg = config.load()
_require_configured(cfg) _require_configured(cfg)
@@ -408,7 +416,7 @@ class QueueReorderRequest(BaseModel):
queue: list[str] queue: list[str]
@app.post("/api/queue/reorder", dependencies=[Depends(require_management_token)]) @app.post("/api/queue/reorder", dependencies=[Depends(require_access_token)])
def api_queue_reorder(body: QueueReorderRequest): def api_queue_reorder(body: QueueReorderRequest):
"""Applies the client's requested order, tolerating drift between the """Applies the client's requested order, tolerating drift between the
browser's last-fetched snapshot and the server's current queue (e.g. browser's last-fetched snapshot and the server's current queue (e.g.
@@ -429,7 +437,7 @@ class QueuePromoteRequest(BaseModel):
asset_id: str asset_id: str
@app.post("/api/queue/promote", dependencies=[Depends(require_management_token)]) @app.post("/api/queue/promote", dependencies=[Depends(require_access_token)])
def api_queue_promote(body: QueuePromoteRequest): def api_queue_promote(body: QueuePromoteRequest):
"""Moves a single photo to the front of the queue -- "Show next" in """Moves a single photo to the front of the queue -- "Show next" in
the web UI. Unlike /api/queue/reorder, this doesn't depend on the the web UI. Unlike /api/queue/reorder, this doesn't depend on the
@@ -444,7 +452,7 @@ def api_queue_promote(body: QueuePromoteRequest):
return {"status": "saved"} return {"status": "saved"}
@app.get("/api/photo-thumbnail/{asset_id}", dependencies=[Depends(require_management_token)]) @app.get("/api/photo-thumbnail/{asset_id}", dependencies=[Depends(require_access_token)])
def api_photo_thumbnail(asset_id: str): def api_photo_thumbnail(asset_id: str):
cfg = config.load() cfg = config.load()
_require_configured(cfg) _require_configured(cfg)