Files
app/backend/fluksio/spa.py
T
stroblme dea25ec08d One image for the whole app: engine, API and dashboard on one port
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.
2026-10-16 11:40:02 +02:00

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)