Add HTTPS support and a management-token gate for the web UI
Build and push server image / build-and-push (push) Successful in 31s
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:
+48
-8
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user