Add HTTPS support and a management-token gate for the web UI
Build and push server image / build-and-push (push) Successful in 31s

ESP32 side can now reach the tools server over HTTPS: the Tools Server
field accepts an https:// address for a TLS-terminating reverse proxy
in front of the server (which still only ever speaks plain HTTP
itself), trusting Cloudflare's Origin CA root (embedded at build time)
since that's the common way to get a real cert on a private origin.
Every URL the device builds -- image fetch, config check, manage-menu
data, the QR codes' own links -- goes through one build_url() helper
that picks the scheme from what's configured.

Also adds an optional MANAGEMENT_TOKEN (docker-compose.yml) that gates
the web UI (/, /api/*) behind a shared secret -- unset by default, so
existing trusted-LAN deployments are unaffected. The same token is
entered once during the ESP32's captive-portal setup and gets baked
into the manage-menu's QR code (?token=...), so scanning it just works;
visiting the page without a valid token shows a plain entry prompt
instead of the config UI, and a valid query-param hit sets a cookie so
the page's own fetch()/<img> calls stay authorized for the rest of the
visit. Device-facing /frame/* endpoints are unaffected -- a separate,
already-documented trust boundary.
This commit is contained in:
2026-07-19 09:42:38 -04:00
parent 461beed04f
commit 6c7468a36e
12 changed files with 253 additions and 30 deletions
+35 -2
View File
@@ -78,8 +78,41 @@ two-step setup screen:
The config page asks for your home WiFi SSID/password and 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 Server" address (`host:port` of the [server](../server/) -- **not** your
Immich server). Saving reboots the device, which then connects to your Immich server; see below for the `https://` form). Saving reboots the
home network and starts its normal fetch/sleep cycle. 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 ## Skipping to the next photo
+2 -1
View File
@@ -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 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 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)
@@ -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-----
+40 -7
View File
@@ -25,6 +25,27 @@ static const char *TAG = "frame_client";
static EventGroupHandle_t s_sta_event_group; 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 /* 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). * necessarily null-terminated (a full 32-char SSID fills the field exactly).
* snprintf() flags that as a possible truncation at -Werror, so copy by * 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]; 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 = { esp_http_client_config_t config = {
.url = url, .url = url,
.method = HTTP_METHOD_GET, .method = HTTP_METHOD_GET,
.timeout_ms = CONFIG_FRAME_SERVER_CHECK_TIMEOUT_MS, .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); 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'; share_url[0] = '\0';
char url[160]; 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 = { esp_http_client_config_t config = {
.url = url, .url = url,
.method = HTTP_METHOD_GET, .method = HTTP_METHOD_GET,
.timeout_ms = CONFIG_FRAME_SERVER_CHECK_TIMEOUT_MS, .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); 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]; char asset_id[48];
if (json_extract_string(body, "asset_id", asset_id, sizeof(asset_id))) { 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) static int fetch_face_labels(const char *toolsserver, manage_face_label_t *out, int max_labels)
{ {
char url[160]; 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 = { esp_http_client_config_t config = {
.url = url, .url = url,
.method = HTTP_METHOD_GET, .method = HTTP_METHOD_GET,
.timeout_ms = CONFIG_FRAME_SERVER_CHECK_TIMEOUT_MS, .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); 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) const manage_overlay_set_t *overlay)
{ {
char url[160]; 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 = { esp_http_client_config_t config = {
.url = url, .url = url,
.method = force_advance ? HTTP_METHOD_POST : HTTP_METHOD_GET, .method = force_advance ? HTTP_METHOD_POST : HTTP_METHOD_GET,
.timeout_ms = CONFIG_FRAME_FETCH_TIMEOUT_MS, .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); 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. */ * photo, so it never re-advances. */
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[160]; char management_url[256];
snprintf(management_url, sizeof(management_url), "http://%s/", cfg->toolsserver); 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_line1[32];
char location_line2[32]; char location_line2[32];
+6 -1
View File
@@ -90,7 +90,12 @@
<div class="input-group"> <div class="input-group">
<label for="toolsserver">Tools Server</label> <label for="toolsserver">Tools Server</label>
<input type="text" id="toolsserver" name="toolsserver" placeholder="e.g. 192.168.1.50:8080" required> <input type="text" id="toolsserver" name="toolsserver" placeholder="e.g. 192.168.1.50:8080 or https://frame.example.com" required>
</div>
<div class="input-group">
<label for="access_token">Access Token (optional)</label>
<input type="text" id="access_token" name="access_token" placeholder="only if the server's MANAGEMENT_TOKEN is set">
</div> </div>
<button type="submit">Submit</button> <button type="submit">Submit</button>
+17 -1
View File
@@ -72,6 +72,16 @@ esp_err_t frame_config_load(frame_config_t *out)
return pass_err; 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); nvs_close(handle);
return ESP_OK; return ESP_OK;
} }
@@ -91,6 +101,9 @@ esp_err_t frame_config_save(const frame_config_t *cfg)
if (err == ESP_OK) { if (err == ESP_OK) {
err = nvs_set_str(handle, "toolsserver", cfg->toolsserver); 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) { if (err == ESP_OK) {
/* Fresh (re)provisioning -- the next successful connection should /* Fresh (re)provisioning -- the next successful connection should
* show the status screen again. */ * 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_ssid");
nvs_erase_key(handle, "sta_pass"); nvs_erase_key(handle, "sta_pass");
nvs_erase_key(handle, "toolsserver"); nvs_erase_key(handle, "toolsserver");
nvs_erase_key(handle, "access_token");
nvs_erase_key(handle, "connected_once"); nvs_erase_key(handle, "connected_once");
nvs_commit(handle); nvs_commit(handle);
nvs_close(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, "ssid", cfg.sta_ssid, sizeof(cfg.sta_ssid));
extract_form_value(body, "password", cfg.sta_password, sizeof(cfg.sta_password)); 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, "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) { 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"); 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; 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[] = static const char resp[] =
"<html><body><h3>Saved. Restarting and connecting to your WiFi...</h3></body></html>"; "<html><body><h3>Saved. Restarting and connecting to your WiFi...</h3></body></html>";
+2
View File
@@ -7,12 +7,14 @@
#define FRAME_CFG_SSID_MAX_LEN 32 #define FRAME_CFG_SSID_MAX_LEN 32
#define FRAME_CFG_PASSWORD_MAX_LEN 64 #define FRAME_CFG_PASSWORD_MAX_LEN 64
#define FRAME_CFG_SERVER_MAX_LEN 128 #define FRAME_CFG_SERVER_MAX_LEN 128
#define FRAME_CFG_TOKEN_MAX_LEN 64
#define FRAME_AP_PASSWORD_LEN 10 #define FRAME_AP_PASSWORD_LEN 10
typedef struct { typedef struct {
char sta_ssid[FRAME_CFG_SSID_MAX_LEN + 1]; char sta_ssid[FRAME_CFG_SSID_MAX_LEN + 1];
char sta_password[FRAME_CFG_PASSWORD_MAX_LEN + 1]; char sta_password[FRAME_CFG_PASSWORD_MAX_LEN + 1];
char toolsserver[FRAME_CFG_SERVER_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; } frame_config_t;
/** /**
+20 -7
View File
@@ -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 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.) 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 5. On the ESP32's captive portal setup form, set the **Tools Server** field
to `<this-machine>:8420`. to `<this-machine>: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 ## 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 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`, and - `/frame/image`, `/frame/advance`, `/frame/photo-info`, `/frame/face-labels`,
`/frame/share/{asset_id}` aren't authenticated yet. That's fine on a and `/frame/share/{asset_id}` -- the device-facing endpoints -- aren't
trusted home LAN for now, but worth revisiting once the ESP32 side is authenticated. That's fine on a trusted home LAN for now, but worth
wired up to send a shared device token. `/frame/share` at least is revisiting if this ever needs to sit somewhere less trusted.
scoped to only ever create a link for a photo this frame is actually `/frame/share` at least is scoped to only ever create a link for a
showing or has queued, not any Immich asset ID someone might guess. 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 - 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
+8 -3
View File
@@ -18,6 +18,7 @@ _lock = Lock()
class FrameConfig(BaseModel): class FrameConfig(BaseModel):
immich_url: str = "" immich_url: str = ""
immich_api_key: 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 = "" album_id: str = ""
order: str = "sequential" # or "shuffle" order: str = "sequential" # or "shuffle"
refresh_interval_s: int = 3600 refresh_interval_s: int = 3600
@@ -40,15 +41,19 @@ def load() -> FrameConfig:
else: else:
cfg = FrameConfig(**json.loads(CONFIG_PATH.read_text())) cfg = FrameConfig(**json.loads(CONFIG_PATH.read_text()))
# IMMICH_URL/IMMICH_API_KEY set in the environment (e.g. docker-compose.yml, # IMMICH_URL/IMMICH_API_KEY/MANAGEMENT_TOKEN set in the environment
# see docker-compose.yml.example) take precedence over whatever's saved # (e.g. docker-compose.yml, see docker-compose.yml.example) take
# in CONFIG_PATH, so credentials never need to go through the web UI. # 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_url = os.environ.get("IMMICH_URL")
env_key = os.environ.get("IMMICH_API_KEY") env_key = os.environ.get("IMMICH_API_KEY")
env_token = os.environ.get("MANAGEMENT_TOKEN")
if env_url: if env_url:
cfg.immich_url = env_url cfg.immich_url = env_url
if env_key: if env_key:
cfg.immich_api_key = env_key cfg.immich_api_key = env_key
if env_token:
cfg.management_token = env_token
return cfg return cfg
+48 -8
View File
@@ -8,7 +8,7 @@ import logging
from datetime import datetime from datetime import datetime
import httpx 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.responses import HTMLResponse, RedirectResponse, Response
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from PIL import Image from PIL import Image
@@ -29,6 +29,31 @@ MAX_REFRESH_INTERVAL_S = 86400
MIN_QUEUE_TARGET_LEN = 5 MIN_QUEUE_TARGET_LEN = 5
MAX_QUEUE_TARGET_LEN = 50 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()/<img> 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") @app.get("/health")
def health() -> dict: def health() -> dict:
@@ -48,10 +73,25 @@ def frame_config():
@app.get("/", response_class=HTMLResponse) @app.get("/", response_class=HTMLResponse)
def index(request: Request): def index(request: Request):
cfg = config.load() 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()/<img> calls -- which
# never carry the query string -- stay authorized too.
response.set_cookie(
MANAGEMENT_TOKEN_COOKIE, supplied, max_age=86400 * 365, httponly=True, samesite="lax"
)
return response
@app.get("/api/albums") @app.get("/api/albums", dependencies=[Depends(require_management_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:
@@ -63,7 +103,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") @app.post("/api/config", dependencies=[Depends(require_management_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"),
@@ -341,7 +381,7 @@ def frame_face_labels():
return result return result
@app.get("/api/queue") @app.get("/api/queue", dependencies=[Depends(require_management_token)])
def api_queue(): def api_queue():
cfg = config.load() cfg = config.load()
_require_configured(cfg) _require_configured(cfg)
@@ -368,7 +408,7 @@ class QueueReorderRequest(BaseModel):
queue: list[str] queue: list[str]
@app.post("/api/queue/reorder") @app.post("/api/queue/reorder", dependencies=[Depends(require_management_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.
@@ -389,7 +429,7 @@ class QueuePromoteRequest(BaseModel):
asset_id: str asset_id: str
@app.post("/api/queue/promote") @app.post("/api/queue/promote", dependencies=[Depends(require_management_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
@@ -404,7 +444,7 @@ def api_queue_promote(body: QueuePromoteRequest):
return {"status": "saved"} 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): def api_photo_thumbnail(asset_id: str):
cfg = config.load() cfg = config.load()
_require_configured(cfg) _require_configured(cfg)
+30
View File
@@ -0,0 +1,30 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ESPresso Frame</title>
<style>
body { font-family: system-ui, sans-serif; max-width: 360px; margin: 80px auto; padding: 0 16px; color: #222; }
h1 { font-size: 20px; }
p.sub { color: #666; font-size: 14px; }
label { display: block; margin-top: 16px; font-size: 13px; font-weight: 600; }
input { width: 100%; padding: 8px; box-sizing: border-box; margin-top: 4px; border: 1px solid #ccc; border-radius: 4px; font-size: 14px; }
button { margin-top: 20px; padding: 10px 16px; border: none; border-radius: 4px; background: #2563eb; color: white; cursor: pointer; font-size: 14px; width: 100%; }
button:hover { background: #1d4ed8; }
.status.err { margin-top: 16px; padding: 10px; border-radius: 4px; font-size: 14px; background: #fee2e2; color: #991b1b; }
</style>
</head>
<body>
<h1>ESPresso Frame</h1>
<p class="sub">This management page needs an access token.</p>
{% if wrong %}
<div class="status err">Invalid token.</div>
{% endif %}
<form method="get" action="/">
<label for="token">Access token</label>
<input type="text" id="token" name="token" autofocus autocomplete="off">
<button type="submit">Continue</button>
</form>
</body>
</html>
+5
View File
@@ -10,4 +10,9 @@ services:
- CONFIG_PATH=/data/config.json - CONFIG_PATH=/data/config.json
- IMMICH_URL=http://your-immich-host:2283 - IMMICH_URL=http://your-immich-host:2283
- IMMICH_API_KEY=your-immich-api-key-here - 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 restart: unless-stopped