The backend image now builds the SPA and serves it on the API's own origin (fluksio/spa.py), so an instance is a single container with no proxy, DNS, port 80 or .env: `docker compose up -d` with the new compose.yml runs it on localhost:8080. It starts on fluksio serve (persisted secret key, first admin printed once) and brings its own Redis when REDIS_HOST is unset. Slimmer: python:3.13-slim, no dev group, uv once, fastapi without the cloud CLI. Optional features are per-feature extras (mqtt, influx, email, sentry, ml, server for all) picked with the EXTRAS build argument; numpy moved to `ml`. ffmpeg left the image for imageio-ffmpeg via the Modules screen. The operator stack runs the same image with app. and api. routed to it; the nginx frontend service, the LAN nginx proxy and DOMAIN in the backend are gone, and the MCP issuer is FRONTEND_HOST. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
89 lines
3.6 KiB
Python
89 lines
3.6 KiB
Python
"""The dashboard, served by the API on its own origin.
|
|
|
|
One process and one port: the built SPA comes off disk, and every path the API
|
|
does not own answers with ``index.html``, so a client-side route survives a
|
|
reload. Same origin also means the dashboard calls the API as a path, needs no
|
|
CORS allowance and no second hostname, which is what lets the whole instance
|
|
sit behind a single published port.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import base64
|
|
import hashlib
|
|
import mimetypes
|
|
import re
|
|
from pathlib import Path
|
|
|
|
from starlette.middleware.gzip import GZipMiddleware
|
|
from starlette.responses import FileResponse
|
|
from starlette.types import ASGIApp, Receive, Scope, Send
|
|
|
|
#: What the API answers itself. Everything else is the dashboard's.
|
|
API_PREFIXES = ("/api/", "/hooks/", "/mcp", "/.well-known/", "/docs", "/redoc")
|
|
|
|
# Older mime tables predate the manifest, and it would go out as a download.
|
|
mimetypes.add_type("application/manifest+json", ".webmanifest")
|
|
|
|
|
|
def content_security_policy(index_html: str) -> str:
|
|
"""The policy for the dashboard, admitting its inline scripts by hash.
|
|
|
|
The hash is taken from the file being served, so editing the theme script
|
|
in ``index.html`` cannot leave a stale one behind.
|
|
style-src 'unsafe-inline': Monaco, Radix's scroll lock and the toasts insert
|
|
<style> elements at runtime, which no hash can name. img/frame-src take any
|
|
http(s): a camera stream or website widget points wherever its dashboard
|
|
says.
|
|
"""
|
|
hashes = "".join(
|
|
f" 'sha256-{base64.b64encode(hashlib.sha256(script.encode()).digest()).decode()}'"
|
|
for script in re.findall(r"<script>([\s\S]*?)</script>", index_html)
|
|
)
|
|
return (
|
|
f"default-src 'self'; script-src 'self'{hashes}; "
|
|
"style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: http: https:; "
|
|
"media-src 'self' blob:; font-src 'self'; connect-src 'self'; "
|
|
"worker-src 'self'; frame-src http: https:; frame-ancestors 'self'; "
|
|
"object-src 'none'; base-uri 'self'; form-action 'self'"
|
|
)
|
|
|
|
|
|
class Dashboard:
|
|
"""Serve the built SPA for every GET the API does not own."""
|
|
|
|
def __init__(self, app: ASGIApp, dist: Path) -> None:
|
|
self.app = app
|
|
self.dist = dist.resolve()
|
|
self.index = self.dist / "index.html"
|
|
self.headers = {
|
|
"Content-Security-Policy": content_security_policy(self.index.read_text()),
|
|
"X-Content-Type-Options": "nosniff",
|
|
"Referrer-Policy": "strict-origin-when-cross-origin",
|
|
}
|
|
|
|
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
|
path: str = scope.get("path", "")
|
|
if (
|
|
scope["type"] != "http"
|
|
or scope["method"] not in ("GET", "HEAD")
|
|
or path.startswith(API_PREFIXES)
|
|
):
|
|
await self.app(scope, receive, send)
|
|
return
|
|
|
|
file = (self.dist / path.lstrip("/")).resolve()
|
|
if file.is_file() and file.is_relative_to(self.dist) and file != self.index:
|
|
headers = dict(self.headers)
|
|
# Vite puts a content hash in every asset's name, so one never
|
|
# changes under its URL.
|
|
if path.startswith("/assets/"):
|
|
headers["Cache-Control"] = "public, max-age=31536000, immutable"
|
|
else:
|
|
# The one file that must not be cached: it names the current
|
|
# assets, so a stale copy pins the browser to the previous build.
|
|
file = self.index
|
|
headers = {**self.headers, "Cache-Control": "no-cache"}
|
|
response = FileResponse(file, headers=headers)
|
|
await GZipMiddleware(response, minimum_size=1024)(scope, receive, send)
|