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
+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
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 `<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
@@ -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
+8 -3
View File
@@ -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
+48 -8
View File
@@ -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()/<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")
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()/<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():
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)
+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
- 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