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
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
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.
Immich server; see below for the `https://` form), and an optional
"Access Token" (see below). Saving reboots the device, which then
connects to your home network and starts its normal fetch/sleep cycle.
## 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
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
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/. */
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)
/* 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. 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) {
snprintf(out, out_size, "%s/%s", toolsserver, path);
len = (size_t)snprintf(out, out_size, "%s/%s", toolsserver, path);
} 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
* check (any completed HTTP response means the socket-level connection
* 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 = {
.reachable = false,
.refresh_interval_s = CONFIG_FRAME_SLEEP_INTERVAL_S,
};
char url[160];
build_url(url, sizeof(url), toolsserver, "frame/config");
char url[256];
build_url(url, sizeof(url), cfg, "frame/config");
esp_http_client_config_t config = {
.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);
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);
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
* treats that as "skip these optional overlay regions", not a hard
* 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 *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';
share_url[0] = '\0';
char url[160];
build_url(url, sizeof(url), toolsserver, "frame/photo-info");
char url[256];
build_url(url, sizeof(url), cfg, "frame/photo-info");
esp_http_client_config_t config = {
.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))) {
char path[80];
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
* (unreachable, malformed response, etc.) just returns 0 -- named faces
* 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];
build_url(url, sizeof(url), toolsserver, "frame/face-labels");
char url[256];
build_url(url, sizeof(url), cfg, "frame/face-labels");
esp_http_client_config_t config = {
.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,
const manage_overlay_set_t *overlay)
{
char url[160];
build_url(url, sizeof(url), cfg->toolsserver, force_advance ? "frame/advance" : "frame/image");
char url[256];
build_url(url, sizeof(url), cfg, force_advance ? "frame/advance" : "frame/image");
esp_http_client_config_t config = {
.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)
{
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);
}
build_url(management_url, sizeof(management_url), cfg, "");
char location_line1[32];
char location_line2[32];
char taken_at[32];
char share_url[160];
fetch_photo_info(cfg->toolsserver, location_line1, sizeof(location_line1), location_line2,
char share_url[256];
fetch_photo_info(cfg, location_line1, sizeof(location_line1), location_line2,
sizeof(location_line2), taken_at, sizeof(taken_at), share_url, sizeof(share_url));
manage_face_label_t face_labels[MANAGE_FACE_LABELS_MAX];
int face_label_count = 0;
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 = {
@@ -727,7 +731,7 @@ void frame_client_run(const frame_config_t *cfg, bool force_advance, bool show_m
* just be discarded. */
uint32_t sleep_seconds = CONFIG_FRAME_RETRY_INTERVAL_S;
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;
}