diff --git a/firmware/README.md b/firmware/README.md index 7d2bb0c..909db2e 100644 --- a/firmware/README.md +++ b/firmware/README.md @@ -78,8 +78,41 @@ two-step setup screen: 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). Saving reboots the device, which then connects to your -home network and starts its normal fetch/sleep cycle. +Immich server; see below for the `https://` form). Saving reboots the +device, which then connects to your home network and starts its normal +fetch/sleep cycle. + +## HTTP vs HTTPS + +The Tools Server field accepts either: + +- `host:port` (e.g. `192.168.1.50:8420`) -- plain HTTP, talks straight to + the [server](../server/), which never speaks TLS itself. This is the + default and needs nothing extra. +- `https://host[:port]` (e.g. `https://frame.example.com`) -- HTTPS, + for a TLS-terminating reverse proxy (nginx, etc.) sitting in front of + the server. Every URL the device builds (image fetch, config check, + manage-menu overlay data, the QR codes' own links) uses whichever + scheme you enter. + +The firmware only trusts one certificate authority for the HTTPS case: +**Cloudflare's Origin CA** (both the RSA and ECC root, embedded at build +time from [`main/certs/cloudflare_origin_ca.pem`](main/certs/cloudflare_origin_ca.pem), +downloaded directly from Cloudflare's docs). This is the free, long-lived +certificate Cloudflare issues for origin servers -- not a +publicly-trusted cert, so ordinary browsers won't accept it directly, but +it's exactly what a reverse proxy in front of a Cloudflare-proxied +hostname presents. If your reverse proxy uses a different certificate +(a real publicly-trusted one from Let's Encrypt, or a different private +CA), swap the PEM file's contents for that CA's root and rebuild. + +The device does perform normal hostname verification (it's not skipped), +so the Tools Server field's hostname has to match what the certificate +was actually issued for -- a bare LAN IP address (`https://192.168.1.50`) +will fail the handshake even with the right CA trusted, since an Origin +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 +frame's LAN IP, or the same public hostname the proxy is issued for). ## Skipping to the next photo diff --git a/firmware/main/CMakeLists.txt b/firmware/main/CMakeLists.txt index 01caad0..3e17fd4 100644 --- a/firmware/main/CMakeLists.txt +++ b/firmware/main/CMakeLists.txt @@ -1,3 +1,4 @@ idf_component_register(SRCS main.c wifi_provisioning.c frame_client.c qr_onboarding.c status_screen.c epd_draw.c reset_button.c next_button.c manage_button.c manage_qr_overlay.c PRIV_REQUIRES esp_event nvs_flash esp_wifi esp_netif esp_http_server esp_http_client dns_server epd7in3e qrcode epaper_fonts esp_driver_gpio - EMBED_FILES root.html) + EMBED_FILES root.html + EMBED_TXTFILES certs/cloudflare_origin_ca.pem) diff --git a/firmware/main/certs/cloudflare_origin_ca.pem b/firmware/main/certs/cloudflare_origin_ca.pem new file mode 100644 index 0000000..9e69b3f --- /dev/null +++ b/firmware/main/certs/cloudflare_origin_ca.pem @@ -0,0 +1,40 @@ +-----BEGIN CERTIFICATE----- +MIIEADCCAuigAwIBAgIID+rOSdTGfGcwDQYJKoZIhvcNAQELBQAwgYsxCzAJBgNV +BAYTAlVTMRkwFwYDVQQKExBDbG91ZEZsYXJlLCBJbmMuMTQwMgYDVQQLEytDbG91 +ZEZsYXJlIE9yaWdpbiBTU0wgQ2VydGlmaWNhdGUgQXV0aG9yaXR5MRYwFAYDVQQH +Ew1TYW4gRnJhbmNpc2NvMRMwEQYDVQQIEwpDYWxpZm9ybmlhMB4XDTE5MDgyMzIx +MDgwMFoXDTI5MDgxNTE3MDAwMFowgYsxCzAJBgNVBAYTAlVTMRkwFwYDVQQKExBD +bG91ZEZsYXJlLCBJbmMuMTQwMgYDVQQLEytDbG91ZEZsYXJlIE9yaWdpbiBTU0wg +Q2VydGlmaWNhdGUgQXV0aG9yaXR5MRYwFAYDVQQHEw1TYW4gRnJhbmNpc2NvMRMw +EQYDVQQIEwpDYWxpZm9ybmlhMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKC +AQEAwEiVZ/UoQpHmFsHvk5isBxRehukP8DG9JhFev3WZtG76WoTthvLJFRKFCHXm +V6Z5/66Z4S09mgsUuFwvJzMnE6Ej6yIsYNCb9r9QORa8BdhrkNn6kdTly3mdnykb +OomnwbUfLlExVgNdlP0XoRoeMwbQ4598foiHblO2B/LKuNfJzAMfS7oZe34b+vLB +yrP/1bgCSLdc1AxQc1AC0EsQQhgcyTJNgnG4va1c7ogPlwKyhbDyZ4e59N5lbYPJ +SmXI/cAe3jXj1FBLJZkwnoDKe0v13xeF+nF32smSH0qB7aJX2tBMW4TWtFPmzs5I +lwrFSySWAdwYdgxw180yKU0dvwIDAQABo2YwZDAOBgNVHQ8BAf8EBAMCAQYwEgYD +VR0TAQH/BAgwBgEB/wIBAjAdBgNVHQ4EFgQUJOhTV118NECHqeuU27rhFnj8KaQw +HwYDVR0jBBgwFoAUJOhTV118NECHqeuU27rhFnj8KaQwDQYJKoZIhvcNAQELBQAD +ggEBAHwOf9Ur1l0Ar5vFE6PNrZWrDfQIMyEfdgSKofCdTckbqXNTiXdgbHs+TWoQ +wAB0pfJDAHJDXOTCWRyTeXOseeOi5Btj5CnEuw3P0oXqdqevM1/+uWp0CM35zgZ8 +VD4aITxity0djzE6Qnx3Syzz+ZkoBgTnNum7d9A66/V636x4vTeqbZFBr9erJzgz +hhurjcoacvRNhnjtDRM0dPeiCJ50CP3wEYuvUzDHUaowOsnLCjQIkWbR7Ni6KEIk +MOz2U0OBSif3FTkhCgZWQKOOLo1P42jHC3ssUZAtVNXrCk3fw9/E15k8NPkBazZ6 +0iykLhH1trywrKRMVw67F44IE8Y= +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIICiTCCAi6gAwIBAgIUXZP3MWb8MKwBE1Qbawsp1sfA/Y4wCgYIKoZIzj0EAwIw +gY8xCzAJBgNVBAYTAlVTMRMwEQYDVQQIEwpDYWxpZm9ybmlhMRYwFAYDVQQHEw1T +YW4gRnJhbmNpc2NvMRkwFwYDVQQKExBDbG91ZEZsYXJlLCBJbmMuMTgwNgYDVQQL +Ey9DbG91ZEZsYXJlIE9yaWdpbiBTU0wgRUNDIENlcnRpZmljYXRlIEF1dGhvcml0 +eTAeFw0xOTA4MjMyMTA4MDBaFw0yOTA4MTUxNzAwMDBaMIGPMQswCQYDVQQGEwJV +UzETMBEGA1UECBMKQ2FsaWZvcm5pYTEWMBQGA1UEBxMNU2FuIEZyYW5jaXNjbzEZ +MBcGA1UEChMQQ2xvdWRGbGFyZSwgSW5jLjE4MDYGA1UECxMvQ2xvdWRGbGFyZSBP +cmlnaW4gU1NMIEVDQyBDZXJ0aWZpY2F0ZSBBdXRob3JpdHkwWTATBgcqhkjOPQIB +BggqhkjOPQMBBwNCAASR+sGALuaGshnUbcxKry+0LEXZ4NY6JUAtSeA6g87K3jaA +xpIg9G50PokpfWkhbarLfpcZu0UAoYy2su0EhN7wo2YwZDAOBgNVHQ8BAf8EBAMC +AQYwEgYDVR0TAQH/BAgwBgEB/wIBAjAdBgNVHQ4EFgQUhTBdOypw1O3VkmcH/es5 +tBoOOKcwHwYDVR0jBBgwFoAUhTBdOypw1O3VkmcH/es5tBoOOKcwCgYIKoZIzj0E +AwIDSQAwRgIhAKilfntP2ILGZjwajktkBtXE1pB4Y/fjAfLkIRUzrI15AiEA5UCL +XYZZ9m2c3fKwIenMMojL1eqydsgqj/wK4p5kagQ= +-----END CERTIFICATE----- diff --git a/firmware/main/frame_client.c b/firmware/main/frame_client.c index a7211b8..ee1fc62 100644 --- a/firmware/main/frame_client.c +++ b/firmware/main/frame_client.c @@ -25,6 +25,27 @@ static const char *TAG = "frame_client"; static EventGroupHandle_t s_sta_event_group; +/* Cloudflare's Origin CA root certs (RSA + ECC, both concatenated -- + * mbedtls_x509_crt_parse() chains every cert in a PEM buffer into the + * trust store, so either one nginx presents validates), for trusting an + * https tools server whose reverse proxy terminates TLS with a + * Cloudflare-issued origin certificate. See firmware/main/certs/. */ +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). + * 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. */ +static void build_url(char *out, size_t out_size, const char *toolsserver, const char *path) +{ + if (strncmp(toolsserver, "http://", 7) == 0 || strncmp(toolsserver, "https://", 8) == 0) { + snprintf(out, out_size, "%s/%s", toolsserver, path); + } else { + snprintf(out, out_size, "http://%s/%s", toolsserver, path); + } +} + /* wifi_sta_config_t's ssid/password fields are fixed-size byte arrays, not * necessarily null-terminated (a full 32-char SSID fills the field exactly). * snprintf() flags that as a possible truncation at -Werror, so copy by @@ -200,12 +221,13 @@ static frame_server_config_t fetch_frame_config(const char *toolsserver) }; char url[160]; - snprintf(url, sizeof(url), "http://%s/frame/config", toolsserver); + build_url(url, sizeof(url), toolsserver, "frame/config"); esp_http_client_config_t config = { .url = url, .method = HTTP_METHOD_GET, .timeout_ms = CONFIG_FRAME_SERVER_CHECK_TIMEOUT_MS, + .cert_pem = cloudflare_origin_ca_pem_start, }; esp_http_client_handle_t client = esp_http_client_init(&config); @@ -260,12 +282,13 @@ static void fetch_photo_info(const char *toolsserver, char *location_line1, size share_url[0] = '\0'; char url[160]; - snprintf(url, sizeof(url), "http://%s/frame/photo-info", toolsserver); + build_url(url, sizeof(url), toolsserver, "frame/photo-info"); esp_http_client_config_t config = { .url = url, .method = HTTP_METHOD_GET, .timeout_ms = CONFIG_FRAME_SERVER_CHECK_TIMEOUT_MS, + .cert_pem = cloudflare_origin_ca_pem_start, }; esp_http_client_handle_t client = esp_http_client_init(&config); @@ -302,7 +325,9 @@ static void fetch_photo_info(const char *toolsserver, char *location_line1, size char asset_id[48]; if (json_extract_string(body, "asset_id", asset_id, sizeof(asset_id))) { - snprintf(share_url, share_url_size, "http://%s/frame/share/%s", toolsserver, asset_id); + char path[80]; + snprintf(path, sizeof(path), "frame/share/%s", asset_id); + build_url(share_url, share_url_size, toolsserver, path); } } @@ -317,12 +342,13 @@ static void fetch_photo_info(const char *toolsserver, char *location_line1, size static int fetch_face_labels(const char *toolsserver, manage_face_label_t *out, int max_labels) { char url[160]; - snprintf(url, sizeof(url), "http://%s/frame/face-labels", toolsserver); + build_url(url, sizeof(url), toolsserver, "frame/face-labels"); esp_http_client_config_t config = { .url = url, .method = HTTP_METHOD_GET, .timeout_ms = CONFIG_FRAME_SERVER_CHECK_TIMEOUT_MS, + .cert_pem = cloudflare_origin_ca_pem_start, }; esp_http_client_handle_t client = esp_http_client_init(&config); @@ -456,12 +482,13 @@ static esp_err_t fetch_and_display(const frame_config_t *cfg, bool force_advance const manage_overlay_set_t *overlay) { char url[160]; - snprintf(url, sizeof(url), "http://%s/%s", cfg->toolsserver, force_advance ? "frame/advance" : "frame/image"); + build_url(url, sizeof(url), cfg->toolsserver, force_advance ? "frame/advance" : "frame/image"); esp_http_client_config_t config = { .url = url, .method = force_advance ? HTTP_METHOD_POST : HTTP_METHOD_GET, .timeout_ms = CONFIG_FRAME_FETCH_TIMEOUT_MS, + .cert_pem = cloudflare_origin_ca_pem_start, }; esp_http_client_handle_t client = esp_http_client_init(&config); @@ -550,8 +577,14 @@ static bool wait_for_button_press(uint32_t timeout_ms) * photo, so it never re-advances. */ static esp_err_t show_menu_level(const frame_config_t *cfg, bool force_advance, int level) { - char management_url[160]; - snprintf(management_url, sizeof(management_url), "http://%s/", cfg->toolsserver); + char management_url[256]; + build_url(management_url, sizeof(management_url), cfg->toolsserver, ""); + 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_line2[32]; diff --git a/firmware/main/root.html b/firmware/main/root.html index 6614e8d..589ebe9 100644 --- a/firmware/main/root.html +++ b/firmware/main/root.html @@ -90,7 +90,12 @@
- + +
+ +
+ +
diff --git a/firmware/main/wifi_provisioning.c b/firmware/main/wifi_provisioning.c index 3f93021..49155a3 100644 --- a/firmware/main/wifi_provisioning.c +++ b/firmware/main/wifi_provisioning.c @@ -72,6 +72,16 @@ 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; + } + nvs_close(handle); return ESP_OK; } @@ -91,6 +101,9 @@ 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) { /* Fresh (re)provisioning -- the next successful connection should * show the status screen again. */ @@ -139,6 +152,7 @@ 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, "connected_once"); nvs_commit(handle); nvs_close(handle); @@ -311,6 +325,7 @@ 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"); @@ -324,7 +339,8 @@ static esp_err_t save_config_post_handler(httpd_req_t *req) return ESP_FAIL; } - ESP_LOGI(TAG, "Saved config: ssid='%s' toolsserver='%s'", cfg.sta_ssid, cfg.toolsserver); + ESP_LOGI(TAG, "Saved config: ssid='%s' toolsserver='%s' access_token=%s", cfg.sta_ssid, cfg.toolsserver, + strlen(cfg.access_token) ? "set" : "none"); static const char resp[] = "

Saved. Restarting and connecting to your WiFi...

"; diff --git a/firmware/main/wifi_provisioning.h b/firmware/main/wifi_provisioning.h index 1538444..c66290c 100644 --- a/firmware/main/wifi_provisioning.h +++ b/firmware/main/wifi_provisioning.h @@ -7,12 +7,14 @@ #define FRAME_CFG_SSID_MAX_LEN 32 #define FRAME_CFG_PASSWORD_MAX_LEN 64 #define FRAME_CFG_SERVER_MAX_LEN 128 +#define FRAME_CFG_TOKEN_MAX_LEN 64 #define FRAME_AP_PASSWORD_LEN 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; matches the server's MANAGEMENT_TOKEN */ } frame_config_t; /** diff --git a/server/README.md b/server/README.md index 1b533bf..e3aae7a 100644 --- a/server/README.md +++ b/server/README.md @@ -29,7 +29,17 @@ algorithm itself -- it just streams the response straight to the panel. populated from the environment; changing them in the UI has no effect as long as the env vars are set -- they win on every load.) 5. On the ESP32's captive portal setup form, set the **Tools Server** field - to `:8420`. + to `:8420`. This server always speaks plain HTTP itself -- + for HTTPS, put a TLS-terminating reverse proxy (e.g. nginx) in front of + it and enter the proxy's `https://` address instead (see + `firmware/README.md`'s HTTPS section for what the ESP32 side needs). +6. **Optional: set `MANAGEMENT_TOKEN`** in `docker-compose.yml` to gate the + web UI behind a shared secret (leave unset to keep it open, the + previous default -- fine on a trusted LAN). If set, paste the same + value into the ESP32's captive portal setup form's **Access Token** + field so the manage-menu's "scan to manage" QR code embeds it + automatically (`?token=...`); visiting the page without a valid token + in the URL shows a plain token-entry prompt instead of the config UI. ## Endpoints @@ -106,12 +116,15 @@ algorithm itself -- it just streams the response straight to the panel. 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 that lookahead; it doesn't add or remove photos from the album. -- `/frame/image`, `/frame/advance`, `/frame/photo-info`, and - `/frame/share/{asset_id}` aren't authenticated yet. That's fine on a - trusted home LAN for now, but worth revisiting once the ESP32 side is - wired up to send a shared device token. `/frame/share` at least is - scoped to only ever create a link for a photo this frame is actually - showing or has queued, not any Immich asset ID someone might guess. +- `/frame/image`, `/frame/advance`, `/frame/photo-info`, `/frame/face-labels`, + and `/frame/share/{asset_id}` -- the device-facing endpoints -- aren't + authenticated. That's fine on a trusted home LAN for now, but worth + revisiting if this ever needs to sit somewhere less trusted. + `/frame/share` at least is scoped to only ever create a link for a + photo this frame is actually showing or has queued, not any Immich + asset ID someone might guess. The web UI (`/`, `/api/*`) is separately + gated by `MANAGEMENT_TOKEN` if set (see Setup above) -- these are two + independent trust boundaries, not one shared mechanism. - The 6-color palette RGB values in `app/image_pipeline.py` are approximations, not measured values (Waveshare doesn't publish exact color primaries for this panel) -- tune them once you can compare a diff --git a/server/app/config.py b/server/app/config.py index 56d0bbc..89b7043 100644 --- a/server/app/config.py +++ b/server/app/config.py @@ -18,6 +18,7 @@ _lock = Lock() class FrameConfig(BaseModel): immich_url: str = "" immich_api_key: str = "" + management_token: str = "" # gates the web UI (see main.py); empty = no gate, open on trusted LAN album_id: str = "" order: str = "sequential" # or "shuffle" refresh_interval_s: int = 3600 @@ -40,15 +41,19 @@ def load() -> FrameConfig: else: cfg = FrameConfig(**json.loads(CONFIG_PATH.read_text())) - # IMMICH_URL/IMMICH_API_KEY set in the environment (e.g. docker-compose.yml, - # see docker-compose.yml.example) take precedence over whatever's saved - # in CONFIG_PATH, so credentials never need to go through the web UI. + # IMMICH_URL/IMMICH_API_KEY/MANAGEMENT_TOKEN set in the environment + # (e.g. docker-compose.yml, see docker-compose.yml.example) take + # precedence over whatever's saved in CONFIG_PATH, so credentials never + # need to go through the web UI. env_url = os.environ.get("IMMICH_URL") env_key = os.environ.get("IMMICH_API_KEY") + env_token = os.environ.get("MANAGEMENT_TOKEN") if env_url: cfg.immich_url = env_url if env_key: cfg.immich_api_key = env_key + if env_token: + cfg.management_token = env_token return cfg diff --git a/server/app/main.py b/server/app/main.py index f6098cd..6359650 100644 --- a/server/app/main.py +++ b/server/app/main.py @@ -8,7 +8,7 @@ import logging from datetime import datetime import httpx -from fastapi import FastAPI, HTTPException, Form, Request +from fastapi import Depends, FastAPI, HTTPException, Form, Request from fastapi.responses import HTMLResponse, RedirectResponse, Response from fastapi.templating import Jinja2Templates from PIL import Image @@ -29,6 +29,31 @@ MAX_REFRESH_INTERVAL_S = 86400 MIN_QUEUE_TARGET_LEN = 5 MAX_QUEUE_TARGET_LEN = 50 +MANAGEMENT_TOKEN_COOKIE = "mgmt_token" + + +def _token_valid(request: Request, cfg: config.FrameConfig) -> bool: + """No management_token configured (MANAGEMENT_TOKEN env var, see + docker-compose.yml.example) means the management page stays open on a + trusted LAN, matching this project's existing default. Once one's + 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 + valid query-param hit (so the page's own fetch()/ calls, which + carry no query string, stay authorized for the rest of the visit).""" + if not cfg.management_token: + return True + supplied = request.query_params.get("token") or request.cookies.get(MANAGEMENT_TOKEN_COOKIE) + return supplied is not None and supplied == cfg.management_token + + +def require_management_token(request: Request) -> None: + """Dependency for the /api/* routes behind the management page. index() + below handles the unauthorized case itself (a friendlier HTML prompt, + not a bare 401) since that's the one route an unauthorized visitor is + actually meant to land on.""" + if not _token_valid(request, config.load()): + raise HTTPException(401, "Missing or invalid management token") + @app.get("/health") def health() -> dict: @@ -48,10 +73,25 @@ def frame_config(): @app.get("/", response_class=HTMLResponse) def index(request: Request): cfg = config.load() - return templates.TemplateResponse("index.html", {"request": request, "cfg": cfg}) + if not _token_valid(request, cfg): + supplied = request.query_params.get("token") + return templates.TemplateResponse( + "token_prompt.html", {"request": request, "wrong": supplied is not None} + ) + + response = templates.TemplateResponse("index.html", {"request": request, "cfg": cfg}) + supplied = request.query_params.get("token") + if cfg.management_token and supplied == cfg.management_token: + # Query-param access (typically the manage-menu QR code) earns a + # cookie so the rest of this visit's fetch()/ calls -- which + # never carry the query string -- stay authorized too. + response.set_cookie( + MANAGEMENT_TOKEN_COOKIE, supplied, max_age=86400 * 365, httponly=True, samesite="lax" + ) + return response -@app.get("/api/albums") +@app.get("/api/albums", dependencies=[Depends(require_management_token)]) def api_albums(): cfg = config.load() if not cfg.immich_url or not cfg.immich_api_key: @@ -63,7 +103,7 @@ def api_albums(): return [{"id": a["id"], "name": a["albumName"], "count": a.get("assetCount", 0)} for a in albums] -@app.post("/api/config") +@app.post("/api/config", dependencies=[Depends(require_management_token)]) def api_config_save( album_id: str = Form(""), order: str = Form("sequential"), @@ -341,7 +381,7 @@ def frame_face_labels(): return result -@app.get("/api/queue") +@app.get("/api/queue", dependencies=[Depends(require_management_token)]) def api_queue(): cfg = config.load() _require_configured(cfg) @@ -368,7 +408,7 @@ class QueueReorderRequest(BaseModel): queue: list[str] -@app.post("/api/queue/reorder") +@app.post("/api/queue/reorder", dependencies=[Depends(require_management_token)]) def api_queue_reorder(body: QueueReorderRequest): """Applies the client's requested order, tolerating drift between the browser's last-fetched snapshot and the server's current queue (e.g. @@ -389,7 +429,7 @@ class QueuePromoteRequest(BaseModel): asset_id: str -@app.post("/api/queue/promote") +@app.post("/api/queue/promote", dependencies=[Depends(require_management_token)]) def api_queue_promote(body: QueuePromoteRequest): """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 @@ -404,7 +444,7 @@ def api_queue_promote(body: QueuePromoteRequest): return {"status": "saved"} -@app.get("/api/photo-thumbnail/{asset_id}") +@app.get("/api/photo-thumbnail/{asset_id}", dependencies=[Depends(require_management_token)]) def api_photo_thumbnail(asset_id: str): cfg = config.load() _require_configured(cfg) diff --git a/server/app/templates/token_prompt.html b/server/app/templates/token_prompt.html new file mode 100644 index 0000000..fb0f87d --- /dev/null +++ b/server/app/templates/token_prompt.html @@ -0,0 +1,30 @@ + + + + + + ESPresso Frame + + + +

ESPresso Frame

+

This management page needs an access token.

+ {% if wrong %} +
Invalid token.
+ {% endif %} +
+ + + +
+ + diff --git a/server/docker-compose.yml.example b/server/docker-compose.yml.example index acfc03b..01603a8 100644 --- a/server/docker-compose.yml.example +++ b/server/docker-compose.yml.example @@ -10,4 +10,9 @@ services: - CONFIG_PATH=/data/config.json - IMMICH_URL=http://your-immich-host:2283 - IMMICH_API_KEY=your-immich-api-key-here + # Optional: gates the web UI (/, /api/*) behind this shared secret -- + # leave unset to keep it open on a trusted LAN, same as before. Paste + # the same value into the ESP32's captive portal setup form (Access + # Token field) so its manage-menu QR code embeds it automatically. + - MANAGEMENT_TOKEN=changeme restart: unless-stopped