2026-10-02 00:03:48 +08:00
|
|
|
import logging
|
|
|
|
|
import os
|
|
|
|
|
import re
|
|
|
|
|
from contextlib import asynccontextmanager
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
from typing import Any
|
|
|
|
|
from uuid import uuid4
|
|
|
|
|
|
|
|
|
|
from fastapi import Depends, FastAPI, File, Form, Header, Query, Request, UploadFile
|
|
|
|
|
from fastapi.concurrency import run_in_threadpool
|
|
|
|
|
from fastapi.exceptions import RequestValidationError
|
|
|
|
|
from fastapi.middleware.cors import CORSMiddleware
|
|
|
|
|
from fastapi.openapi.docs import get_swagger_ui_html
|
|
|
|
|
from fastapi.responses import FileResponse, JSONResponse, Response
|
|
|
|
|
from starlette.exceptions import HTTPException
|
|
|
|
|
|
2026-10-02 08:43:07 +08:00
|
|
|
from pigway_frame_flow.core.accounts import install_accounts, seed_accounts
|
2026-10-02 00:03:48 +08:00
|
|
|
from pigway_frame_flow.core.auth import require, seed_client
|
|
|
|
|
from pigway_frame_flow.core.registry import DeviceRegistry
|
|
|
|
|
from pigway_frame_flow.display.service import DisplayService
|
|
|
|
|
from pigway_frame_flow.errors import ErrorCode as E
|
|
|
|
|
from pigway_frame_flow.errors import FlowError
|
|
|
|
|
from pigway_frame_flow.models import (
|
|
|
|
|
Accepted,
|
|
|
|
|
Asset,
|
2026-10-02 09:19:25 +08:00
|
|
|
CurrentDisplay,
|
2026-10-02 00:03:48 +08:00
|
|
|
Device,
|
|
|
|
|
DisplayRequest,
|
|
|
|
|
ErrorResponse,
|
|
|
|
|
Fit,
|
|
|
|
|
GalleryImage,
|
|
|
|
|
Health,
|
|
|
|
|
History,
|
|
|
|
|
ImageContent,
|
2026-10-02 09:19:25 +08:00
|
|
|
ImageURLConfig,
|
2026-10-02 00:03:48 +08:00
|
|
|
Job,
|
|
|
|
|
JobInput,
|
|
|
|
|
Operation,
|
2026-10-02 09:19:25 +08:00
|
|
|
Options,
|
2026-10-02 00:03:48 +08:00
|
|
|
Playlist,
|
|
|
|
|
PlaylistInput,
|
|
|
|
|
Profile,
|
|
|
|
|
Readiness,
|
|
|
|
|
ReconcileRequest,
|
|
|
|
|
Reconciliation,
|
|
|
|
|
SettingsView,
|
|
|
|
|
TemplateConfig,
|
|
|
|
|
TemplateContent,
|
|
|
|
|
TemplateInfo,
|
|
|
|
|
URLContent,
|
|
|
|
|
Version,
|
2026-10-02 09:19:25 +08:00
|
|
|
WeatherConfig,
|
|
|
|
|
WeatherView,
|
2026-10-02 00:03:48 +08:00
|
|
|
)
|
2026-10-02 09:19:25 +08:00
|
|
|
from pigway_frame_flow.providers.weather import ConfigurableWeatherProvider
|
2026-10-02 00:03:48 +08:00
|
|
|
from pigway_frame_flow.renderers.images import Assets, convert
|
|
|
|
|
from pigway_frame_flow.renderers.templates import TemplateRenderer
|
|
|
|
|
from pigway_frame_flow.scheduler.engine import Scheduler
|
|
|
|
|
from pigway_frame_flow.settings import Settings
|
|
|
|
|
from pigway_frame_flow.storage.database import Database, encode
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def create_app(settings=None, start_worker=True):
|
|
|
|
|
settings = settings or Settings()
|
|
|
|
|
for kind in ["images", "generated", "cache"]:
|
|
|
|
|
(settings.data_dir / kind).mkdir(parents=True, exist_ok=True)
|
|
|
|
|
settings.config_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
db = Database(settings.data_dir / "frameflow.db")
|
2026-10-02 09:19:25 +08:00
|
|
|
import json
|
|
|
|
|
|
|
|
|
|
image_config = db.one("SELECT payload FROM app_config WHERE key='image_url_hosts'")
|
|
|
|
|
if image_config:
|
|
|
|
|
settings.image_url_hosts = ImageURLConfig.model_validate(
|
|
|
|
|
json.loads(image_config["payload"])
|
|
|
|
|
).allowed_hosts
|
2026-10-02 00:03:48 +08:00
|
|
|
registry = DeviceRegistry(settings)
|
|
|
|
|
assets = Assets(db, settings)
|
2026-10-02 09:19:25 +08:00
|
|
|
weather = ConfigurableWeatherProvider(db)
|
|
|
|
|
renderer = TemplateRenderer(db, settings, weather, assets)
|
2026-10-02 00:03:48 +08:00
|
|
|
service = DisplayService(db, settings, registry, assets, renderer)
|
|
|
|
|
scheduler = Scheduler(service)
|
|
|
|
|
seed_client(db, settings.api_token)
|
2026-10-02 08:43:07 +08:00
|
|
|
seed_accounts(db, settings.config_dir)
|
2026-10-02 00:03:48 +08:00
|
|
|
for name in ["urllib3", "requests", "httpx", "httpcore"]:
|
|
|
|
|
logging.getLogger(name).setLevel(logging.CRITICAL)
|
|
|
|
|
|
|
|
|
|
@asynccontextmanager
|
|
|
|
|
async def lifespan(app):
|
|
|
|
|
if start_worker:
|
|
|
|
|
scheduler.start()
|
|
|
|
|
yield
|
|
|
|
|
scheduler.stop()
|
|
|
|
|
db.close()
|
|
|
|
|
|
|
|
|
|
error_responses: dict[int | str, dict[str, Any]] = {
|
|
|
|
|
code: {"model": ErrorResponse}
|
|
|
|
|
for code in [400, 401, 403, 404, 409, 413, 422, 429, 500, 502, 503]
|
|
|
|
|
}
|
|
|
|
|
app = FastAPI(
|
|
|
|
|
title="Pigway Frame Flow API",
|
|
|
|
|
docs_url=None,
|
|
|
|
|
version="0.1.0",
|
|
|
|
|
lifespan=lifespan,
|
|
|
|
|
responses=error_responses,
|
|
|
|
|
)
|
2026-10-02 08:43:07 +08:00
|
|
|
install_accounts(app, db)
|
2026-10-02 00:03:48 +08:00
|
|
|
app.state.service = service
|
|
|
|
|
app.state.scheduler = scheduler
|
|
|
|
|
app.add_middleware(
|
|
|
|
|
CORSMiddleware,
|
|
|
|
|
allow_origins=settings.cors_origins,
|
|
|
|
|
allow_methods=["GET", "POST", "PUT", "DELETE"],
|
|
|
|
|
allow_headers=[
|
|
|
|
|
"Authorization",
|
|
|
|
|
"Content-Type",
|
|
|
|
|
"Idempotency-Key",
|
|
|
|
|
"X-Request-ID",
|
2026-10-02 08:43:07 +08:00
|
|
|
"X-CSRF-Token",
|
2026-10-02 00:03:48 +08:00
|
|
|
],
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def error_response(code, message, status, request):
|
|
|
|
|
return JSONResponse(
|
|
|
|
|
status_code=status,
|
|
|
|
|
content={
|
|
|
|
|
"error": {
|
|
|
|
|
"code": code,
|
|
|
|
|
"message": message,
|
|
|
|
|
"request_id": getattr(request.state, "request_id", uuid4().hex),
|
|
|
|
|
"details": {},
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
@app.middleware("http")
|
|
|
|
|
async def request_context(request, call_next):
|
|
|
|
|
supplied = request.headers.get("X-Request-ID", "")
|
|
|
|
|
request.state.request_id = (
|
|
|
|
|
supplied
|
|
|
|
|
if re.fullmatch(r"[A-Za-z0-9_.:-]{1,120}", supplied)
|
|
|
|
|
else uuid4().hex
|
|
|
|
|
)
|
|
|
|
|
if any(
|
|
|
|
|
s and s in request.state.request_id
|
|
|
|
|
for s in [settings.api_key, settings.api_token, *registry.keys]
|
|
|
|
|
):
|
|
|
|
|
request.state.request_id = uuid4().hex
|
|
|
|
|
if request.method in ["POST", "PUT", "DELETE"]:
|
|
|
|
|
try:
|
|
|
|
|
chunks = []
|
|
|
|
|
total = 0
|
|
|
|
|
async for chunk in request.stream():
|
|
|
|
|
total += len(chunk)
|
|
|
|
|
if total > 29_000_000:
|
|
|
|
|
return error_response(
|
|
|
|
|
E.INVALID_REQUEST,
|
|
|
|
|
"Request exceeds size limit",
|
|
|
|
|
413,
|
|
|
|
|
request,
|
|
|
|
|
)
|
|
|
|
|
chunks.append(chunk)
|
|
|
|
|
request._body = b"".join(chunks)
|
|
|
|
|
except Exception:
|
|
|
|
|
return error_response(
|
|
|
|
|
E.INVALID_REQUEST, "Invalid request body", 400, request
|
|
|
|
|
)
|
|
|
|
|
try:
|
|
|
|
|
response = await call_next(request)
|
|
|
|
|
except Exception:
|
|
|
|
|
response = error_response(
|
|
|
|
|
E.INTERNAL_ERROR, "Internal service error", 500, request
|
|
|
|
|
)
|
|
|
|
|
response.headers["X-Request-ID"] = request.state.request_id
|
|
|
|
|
response.headers["X-Content-Type-Options"] = "nosniff"
|
|
|
|
|
response.headers["Referrer-Policy"] = "no-referrer"
|
|
|
|
|
response.headers["Cache-Control"] = "no-store"
|
|
|
|
|
response.headers["Content-Security-Policy"] = (
|
|
|
|
|
"default-src 'self'; img-src 'self' blob: data:; style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; connect-src 'self'; frame-ancestors 'none'"
|
|
|
|
|
)
|
|
|
|
|
return response
|
|
|
|
|
|
|
|
|
|
@app.exception_handler(FlowError)
|
|
|
|
|
async def flow_error(request, exc):
|
|
|
|
|
return error_response(exc.code, exc.message, exc.status, request)
|
|
|
|
|
|
|
|
|
|
@app.exception_handler(RequestValidationError)
|
|
|
|
|
async def validation_error(request, exc):
|
|
|
|
|
return error_response(
|
|
|
|
|
E.INVALID_REQUEST, "Request validation failed", 422, request
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
@app.exception_handler(HTTPException)
|
|
|
|
|
async def http_error(request, exc):
|
|
|
|
|
return error_response(
|
|
|
|
|
E.NOT_FOUND if exc.status_code == 404 else E.INVALID_REQUEST,
|
|
|
|
|
"Resource not found" if exc.status_code == 404 else "HTTP request rejected",
|
|
|
|
|
exc.status_code,
|
|
|
|
|
request,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def accepted(id):
|
|
|
|
|
return Accepted(operation_id=id, job_id=id)
|
|
|
|
|
|
|
|
|
|
def submit(request, action, payload, device, key):
|
|
|
|
|
if key and not re.fullmatch(r"[A-Za-z0-9_.:-]{1,200}", key):
|
|
|
|
|
raise FlowError(E.INVALID_REQUEST, "Invalid Idempotency-Key")
|
|
|
|
|
return accepted(
|
|
|
|
|
service.submit(
|
|
|
|
|
action,
|
|
|
|
|
payload,
|
|
|
|
|
device,
|
|
|
|
|
request.state.client_id,
|
|
|
|
|
request.state.request_id,
|
|
|
|
|
key,
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def write_guard(device):
|
|
|
|
|
registry.get(device)
|
|
|
|
|
if not settings.mock and not settings.write_enabled:
|
|
|
|
|
raise FlowError(E.WRITE_DISABLED, "Real device writes are disabled", 403)
|
|
|
|
|
|
|
|
|
|
@app.get("/health", response_model=Health, tags=["Health"])
|
|
|
|
|
def health():
|
|
|
|
|
return Health(status="ok")
|
|
|
|
|
|
|
|
|
|
@app.get(
|
|
|
|
|
"/ready",
|
|
|
|
|
response_model=Readiness,
|
|
|
|
|
tags=["Health"],
|
|
|
|
|
responses={503: {"model": Readiness}},
|
|
|
|
|
)
|
|
|
|
|
def ready():
|
|
|
|
|
checks = {
|
|
|
|
|
"sqlite": bool(db.one("SELECT 1 AS ok")),
|
|
|
|
|
"device_config": bool(registry.drivers)
|
|
|
|
|
and (settings.mock or all(registry.keys)),
|
|
|
|
|
"scheduler": scheduler.healthy() if start_worker else True,
|
|
|
|
|
"authentication": bool(
|
|
|
|
|
db.one("SELECT client_id FROM clients WHERE enabled=1")
|
2026-10-02 08:43:07 +08:00
|
|
|
or db.one("SELECT username FROM users WHERE enabled=1")
|
2026-10-02 00:03:48 +08:00
|
|
|
),
|
|
|
|
|
}
|
|
|
|
|
good = all(checks.values())
|
|
|
|
|
return JSONResponse(
|
|
|
|
|
status_code=200 if good else 503,
|
|
|
|
|
content={"status": "ready" if good else "not_ready", "checks": checks},
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
@app.get("/api/v1/version", response_model=Version, tags=["System"])
|
|
|
|
|
def version():
|
|
|
|
|
return Version(
|
|
|
|
|
commit=os.getenv("BUILD_COMMIT", "development"),
|
|
|
|
|
build_time=os.getenv("BUILD_TIME", "unknown"),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
@app.get(
|
|
|
|
|
"/api/v1/devices",
|
|
|
|
|
response_model=list[Device],
|
|
|
|
|
tags=["Devices"],
|
|
|
|
|
dependencies=[Depends(require("devices:read"))],
|
|
|
|
|
)
|
|
|
|
|
def devices():
|
2026-10-02 00:11:37 +08:00
|
|
|
return [device(id) for id in registry.drivers]
|
2026-10-02 00:03:48 +08:00
|
|
|
|
|
|
|
|
@app.get(
|
|
|
|
|
"/api/v1/devices/{device_id}",
|
|
|
|
|
response_model=Device,
|
|
|
|
|
tags=["Devices"],
|
|
|
|
|
dependencies=[Depends(require("devices:read"))],
|
|
|
|
|
)
|
|
|
|
|
def device(device_id: str, refresh: bool = False):
|
2026-10-02 00:11:37 +08:00
|
|
|
d = registry.get(device_id).device(refresh)
|
|
|
|
|
state = db.one("SELECT * FROM device_state WHERE device_id=?", (device_id,))
|
|
|
|
|
possible = bool(
|
|
|
|
|
state
|
|
|
|
|
and (
|
|
|
|
|
state["previous_image_id"] is not None
|
|
|
|
|
or state["base_image_id"] is not None
|
|
|
|
|
)
|
|
|
|
|
) and not service.blocked(device_id)
|
|
|
|
|
return d.model_copy(update={"can_restore": possible})
|
2026-10-02 00:03:48 +08:00
|
|
|
|
|
|
|
|
@app.get(
|
|
|
|
|
"/api/v1/devices/{device_id}/images",
|
|
|
|
|
response_model=list[GalleryImage],
|
|
|
|
|
tags=["Images"],
|
|
|
|
|
dependencies=[Depends(require("images:read"))],
|
|
|
|
|
)
|
|
|
|
|
def images(device_id: str):
|
|
|
|
|
return registry.get(device_id).images()
|
|
|
|
|
|
|
|
|
|
@app.get(
|
|
|
|
|
"/api/v1/devices/{device_id}/images/{image_id}/file",
|
|
|
|
|
tags=["Images"],
|
|
|
|
|
dependencies=[Depends(require("images:read"))],
|
|
|
|
|
responses={
|
|
|
|
|
200: {
|
|
|
|
|
"content": {
|
|
|
|
|
"image/jpeg": {"schema": {"type": "string", "format": "binary"}}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
def image_file(device_id: str, image_id: int, thumbnail: bool = False):
|
|
|
|
|
if image_id < 1:
|
|
|
|
|
raise FlowError(E.INVALID_REQUEST, "Image ID must be positive")
|
|
|
|
|
driver = registry.get(device_id)
|
|
|
|
|
cached = settings.data_dir / "cache" / f"{device_id}-{image_id}.original"
|
|
|
|
|
if not cached.exists():
|
|
|
|
|
data = driver.download(image_id)
|
|
|
|
|
# Validate and strip EXIF without changing original geometry.
|
|
|
|
|
from io import BytesIO
|
|
|
|
|
|
|
|
|
|
from PIL import Image
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
with Image.open(BytesIO(data)) as source:
|
|
|
|
|
if source.width * source.height > 25_000_000:
|
|
|
|
|
raise ValueError()
|
|
|
|
|
if source.format not in {"JPEG", "PNG", "WEBP"}:
|
|
|
|
|
raise ValueError()
|
|
|
|
|
source.verify()
|
|
|
|
|
cached.write_bytes(data)
|
|
|
|
|
except Exception:
|
|
|
|
|
raise FlowError(E.INVALID_REQUEST, "Invalid cloud image") from None
|
|
|
|
|
if thumbnail:
|
|
|
|
|
thumb = cached.with_name(cached.stem + "-thumb.jpg")
|
|
|
|
|
if not thumb.exists():
|
|
|
|
|
from PIL import Image
|
|
|
|
|
|
|
|
|
|
with Image.open(cached) as img:
|
|
|
|
|
img.thumbnail((300, 400))
|
|
|
|
|
img.save(thumb, "JPEG")
|
|
|
|
|
return FileResponse(thumb, media_type="image/jpeg")
|
|
|
|
|
from PIL import Image
|
|
|
|
|
|
|
|
|
|
with Image.open(cached) as original:
|
|
|
|
|
media_type = Image.MIME.get(
|
|
|
|
|
original.format or "", "application/octet-stream"
|
|
|
|
|
)
|
|
|
|
|
return FileResponse(cached, media_type=media_type)
|
|
|
|
|
|
|
|
|
|
@app.post(
|
|
|
|
|
"/api/v1/display",
|
|
|
|
|
response_model=Accepted,
|
|
|
|
|
status_code=202,
|
|
|
|
|
tags=["Display"],
|
|
|
|
|
dependencies=[Depends(require("display:write"))],
|
|
|
|
|
)
|
|
|
|
|
@app.post(
|
|
|
|
|
"/api/display",
|
|
|
|
|
response_model=Accepted,
|
|
|
|
|
status_code=202,
|
|
|
|
|
deprecated=True,
|
|
|
|
|
dependencies=[Depends(require("display:write"))],
|
|
|
|
|
)
|
|
|
|
|
def display(
|
|
|
|
|
payload: DisplayRequest,
|
|
|
|
|
request: Request,
|
|
|
|
|
idempotency_key: str | None = Header(default=None),
|
|
|
|
|
):
|
|
|
|
|
service.validate(payload)
|
|
|
|
|
write_guard(payload.device_id)
|
|
|
|
|
return submit(
|
|
|
|
|
request,
|
|
|
|
|
"display",
|
|
|
|
|
payload.model_dump(mode="json"),
|
|
|
|
|
payload.device_id,
|
|
|
|
|
idempotency_key,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
@app.post(
|
|
|
|
|
"/api/v1/devices/{device_id}/display",
|
|
|
|
|
response_model=Accepted,
|
|
|
|
|
status_code=202,
|
|
|
|
|
tags=["Display"],
|
|
|
|
|
dependencies=[Depends(require("display:write"))],
|
|
|
|
|
)
|
|
|
|
|
def device_display(
|
|
|
|
|
device_id: str,
|
|
|
|
|
payload: DisplayRequest,
|
|
|
|
|
request: Request,
|
|
|
|
|
idempotency_key: str | None = Header(default=None),
|
|
|
|
|
):
|
|
|
|
|
payload = payload.model_copy(update={"device_id": device_id})
|
|
|
|
|
return display(payload, request, idempotency_key)
|
|
|
|
|
|
|
|
|
|
@app.post(
|
|
|
|
|
"/api/v1/render",
|
|
|
|
|
response_model=Accepted,
|
|
|
|
|
status_code=202,
|
|
|
|
|
tags=["Render"],
|
|
|
|
|
dependencies=[Depends(require("render:write"))],
|
|
|
|
|
)
|
|
|
|
|
def render(
|
|
|
|
|
payload: TemplateContent,
|
|
|
|
|
request: Request,
|
|
|
|
|
idempotency_key: str | None = Header(default=None),
|
|
|
|
|
):
|
|
|
|
|
renderer.info(payload.template)
|
|
|
|
|
return submit(
|
|
|
|
|
request,
|
|
|
|
|
"render",
|
|
|
|
|
payload.model_dump(mode="json"),
|
|
|
|
|
settings.device_id,
|
|
|
|
|
idempotency_key,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
@app.post(
|
|
|
|
|
"/api/v1/devices/{device_id}/upload",
|
|
|
|
|
response_model=Accepted,
|
|
|
|
|
status_code=202,
|
|
|
|
|
tags=["Images"],
|
|
|
|
|
dependencies=[Depends(require("display:write"))],
|
|
|
|
|
)
|
|
|
|
|
def upload(
|
|
|
|
|
device_id: str,
|
|
|
|
|
payload: ImageContent | URLContent,
|
|
|
|
|
request: Request,
|
|
|
|
|
display_now: bool = False,
|
|
|
|
|
idempotency_key: str | None = Header(default=None),
|
|
|
|
|
):
|
|
|
|
|
registry.get(device_id)
|
|
|
|
|
if isinstance(payload, ImageContent) and (
|
|
|
|
|
payload.image_id or payload.image_name
|
|
|
|
|
):
|
|
|
|
|
raise FlowError(E.INVALID_REQUEST, "Upload needs image bytes or an asset")
|
|
|
|
|
if display_now:
|
|
|
|
|
return display(
|
|
|
|
|
DisplayRequest(device_id=device_id, content=payload),
|
|
|
|
|
request,
|
|
|
|
|
idempotency_key,
|
|
|
|
|
)
|
|
|
|
|
return submit(
|
|
|
|
|
request,
|
|
|
|
|
"local-upload",
|
|
|
|
|
payload.model_dump(mode="json"),
|
|
|
|
|
device_id,
|
|
|
|
|
idempotency_key,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
@app.post(
|
|
|
|
|
"/api/v1/preview",
|
|
|
|
|
response_model=Asset,
|
|
|
|
|
tags=["Render"],
|
|
|
|
|
dependencies=[Depends(require("render:write"))],
|
|
|
|
|
)
|
|
|
|
|
async def preview(
|
|
|
|
|
request: Request,
|
|
|
|
|
file: UploadFile = File(),
|
|
|
|
|
fit: Fit = Form(default=Fit.contain),
|
|
|
|
|
profile: Profile = Form(default=Profile.color),
|
|
|
|
|
):
|
|
|
|
|
data = await file.read(settings.max_bytes + 1)
|
|
|
|
|
if len(data) > settings.max_bytes:
|
|
|
|
|
raise FlowError(E.INVALID_REQUEST, "Image exceeds 20 MiB")
|
|
|
|
|
name = Path(file.filename or "image").name[:120]
|
|
|
|
|
asset = assets.put(
|
|
|
|
|
await run_in_threadpool(convert, data, fit, profile),
|
|
|
|
|
settings.sanitize(name),
|
2026-10-02 00:11:37 +08:00
|
|
|
kind="cache",
|
2026-10-02 00:03:48 +08:00
|
|
|
)
|
|
|
|
|
return asset
|
|
|
|
|
|
|
|
|
|
@app.get(
|
|
|
|
|
"/api/v1/assets",
|
|
|
|
|
response_model=list[Asset],
|
|
|
|
|
tags=["Images"],
|
|
|
|
|
dependencies=[Depends(require("images:read"))],
|
|
|
|
|
)
|
|
|
|
|
def list_assets(limit: int = Query(default=100, ge=1, le=200)):
|
|
|
|
|
return [
|
|
|
|
|
assets.info(r["id"])
|
|
|
|
|
for r in db.all(
|
2026-10-02 00:11:37 +08:00
|
|
|
"SELECT id FROM assets WHERE kind!='cache' ORDER BY created_at DESC LIMIT ?",
|
|
|
|
|
(limit,),
|
2026-10-02 00:03:48 +08:00
|
|
|
)
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
@app.delete(
|
|
|
|
|
"/api/v1/assets/{asset_id}",
|
|
|
|
|
status_code=204,
|
|
|
|
|
tags=["Images"],
|
|
|
|
|
dependencies=[Depends(require("images:delete"))],
|
|
|
|
|
)
|
|
|
|
|
def delete_asset(asset_id: str):
|
|
|
|
|
path = assets.path(asset_id)
|
|
|
|
|
reference = "%" + asset_id + "%"
|
|
|
|
|
if (
|
|
|
|
|
db.one(
|
|
|
|
|
"SELECT id FROM operations WHERE payload LIKE ? AND status IN ('QUEUED','RENDERING','UPLOADING','SYNCING','UNKNOWN')",
|
|
|
|
|
(reference,),
|
|
|
|
|
)
|
|
|
|
|
or db.one("SELECT id FROM jobs WHERE payload LIKE ?", (reference,))
|
|
|
|
|
or db.one(
|
|
|
|
|
"SELECT template_id FROM template_config WHERE payload LIKE ?",
|
|
|
|
|
(reference,),
|
|
|
|
|
)
|
|
|
|
|
):
|
|
|
|
|
raise FlowError(
|
|
|
|
|
E.CONFLICT,
|
|
|
|
|
"Asset is referenced by a task, template or pending operation",
|
|
|
|
|
409,
|
|
|
|
|
)
|
|
|
|
|
path.unlink(missing_ok=True)
|
|
|
|
|
db.execute("DELETE FROM assets WHERE id=?", (asset_id,))
|
|
|
|
|
return Response(status_code=204)
|
|
|
|
|
|
|
|
|
|
@app.get(
|
|
|
|
|
"/api/v1/assets/{asset_id}",
|
|
|
|
|
tags=["Images"],
|
|
|
|
|
dependencies=[Depends(require("images:read"))],
|
|
|
|
|
responses={
|
|
|
|
|
200: {
|
|
|
|
|
"content": {
|
|
|
|
|
"image/jpeg": {"schema": {"type": "string", "format": "binary"}}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
def asset_file(asset_id: str):
|
|
|
|
|
return FileResponse(assets.path(asset_id), media_type="image/jpeg")
|
|
|
|
|
|
|
|
|
|
@app.delete(
|
|
|
|
|
"/api/v1/devices/{device_id}/images/{image_id}",
|
|
|
|
|
response_model=Accepted,
|
|
|
|
|
status_code=202,
|
|
|
|
|
tags=["Images"],
|
|
|
|
|
dependencies=[Depends(require("images:delete"))],
|
|
|
|
|
)
|
|
|
|
|
def delete_image(
|
|
|
|
|
device_id: str,
|
|
|
|
|
image_id: int,
|
|
|
|
|
request: Request,
|
|
|
|
|
confirm_current: bool = False,
|
|
|
|
|
idempotency_key: str | None = Header(default=None),
|
|
|
|
|
):
|
|
|
|
|
write_guard(device_id)
|
|
|
|
|
if image_id < 1:
|
|
|
|
|
raise FlowError(E.INVALID_REQUEST, "Image ID must be positive")
|
|
|
|
|
if (
|
|
|
|
|
registry.get(device_id).get_current_image() == image_id
|
|
|
|
|
and not confirm_current
|
|
|
|
|
):
|
|
|
|
|
raise FlowError(E.CONFLICT, "Confirm deleting the current image", 409)
|
|
|
|
|
return submit(
|
|
|
|
|
request,
|
|
|
|
|
"delete",
|
|
|
|
|
{"image_id": image_id, "confirm_current": confirm_current},
|
|
|
|
|
device_id,
|
|
|
|
|
idempotency_key,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
@app.post(
|
|
|
|
|
"/api/v1/devices/{device_id}/restore",
|
|
|
|
|
response_model=Accepted,
|
|
|
|
|
status_code=202,
|
|
|
|
|
tags=["Display"],
|
|
|
|
|
dependencies=[Depends(require("display:write"))],
|
|
|
|
|
)
|
|
|
|
|
def restore(
|
|
|
|
|
device_id: str,
|
|
|
|
|
request: Request,
|
|
|
|
|
idempotency_key: str | None = Header(default=None),
|
|
|
|
|
):
|
|
|
|
|
write_guard(device_id)
|
|
|
|
|
return submit(request, "restore", {}, device_id, idempotency_key)
|
|
|
|
|
|
|
|
|
|
@app.get(
|
|
|
|
|
"/api/v1/operations",
|
|
|
|
|
response_model=list[Operation],
|
|
|
|
|
tags=["Operations"],
|
|
|
|
|
dependencies=[Depends(require("history:read"))],
|
|
|
|
|
)
|
|
|
|
|
def operations(limit: int = Query(default=50, ge=1, le=200)):
|
|
|
|
|
return [
|
|
|
|
|
service.operation(x["id"])
|
|
|
|
|
for x in db.all(
|
|
|
|
|
"SELECT id FROM operations ORDER BY created_at DESC LIMIT ?", (limit,)
|
|
|
|
|
)
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
@app.get(
|
|
|
|
|
"/api/v1/operations/{operation_id}",
|
|
|
|
|
response_model=Operation,
|
|
|
|
|
tags=["Operations"],
|
|
|
|
|
dependencies=[Depends(require("devices:read"))],
|
|
|
|
|
)
|
|
|
|
|
def operation(operation_id: str, request: Request):
|
|
|
|
|
op = service.operation(operation_id)
|
|
|
|
|
if request.state.client_id not in ("admin", op["client_id"]):
|
|
|
|
|
raise FlowError(E.FORBIDDEN, "Operation belongs to another client", 403)
|
|
|
|
|
return op
|
|
|
|
|
|
|
|
|
|
@app.post(
|
|
|
|
|
"/api/v1/operations/{operation_id}/reconcile",
|
|
|
|
|
response_model=Reconciliation,
|
|
|
|
|
tags=["Operations"],
|
|
|
|
|
dependencies=[Depends(require("admin"))],
|
|
|
|
|
)
|
|
|
|
|
def reconcile(operation_id: str, payload: ReconcileRequest):
|
|
|
|
|
return service.reconcile(operation_id, payload.acknowledge)
|
|
|
|
|
|
|
|
|
|
@app.get(
|
|
|
|
|
"/api/v1/templates",
|
|
|
|
|
response_model=list[TemplateInfo],
|
|
|
|
|
tags=["Templates"],
|
|
|
|
|
dependencies=[Depends(require("templates:read"))],
|
|
|
|
|
)
|
|
|
|
|
def templates():
|
|
|
|
|
return renderer.list()
|
|
|
|
|
|
|
|
|
|
@app.get(
|
|
|
|
|
"/api/v1/templates/{template_id}",
|
|
|
|
|
response_model=TemplateInfo,
|
|
|
|
|
tags=["Templates"],
|
|
|
|
|
dependencies=[Depends(require("templates:read"))],
|
|
|
|
|
)
|
|
|
|
|
def template(template_id: str):
|
|
|
|
|
return renderer.info(template_id)
|
|
|
|
|
|
|
|
|
|
@app.put(
|
|
|
|
|
"/api/v1/templates/{template_id}/config",
|
|
|
|
|
response_model=TemplateConfig,
|
|
|
|
|
tags=["Templates"],
|
|
|
|
|
dependencies=[Depends(require("settings:write"))],
|
|
|
|
|
)
|
|
|
|
|
def template_config(template_id: str, payload: TemplateConfig):
|
|
|
|
|
renderer.info(template_id)
|
|
|
|
|
db.execute(
|
|
|
|
|
"INSERT INTO template_config VALUES(?,?) ON CONFLICT(template_id) DO UPDATE SET payload=excluded.payload",
|
|
|
|
|
(template_id, encode(settings.sanitize(payload.data))),
|
|
|
|
|
)
|
|
|
|
|
return TemplateConfig(data=settings.sanitize(payload.data))
|
|
|
|
|
|
2026-10-02 08:43:07 +08:00
|
|
|
@app.delete(
|
|
|
|
|
"/api/v1/templates/{template_id}/config",
|
|
|
|
|
status_code=204,
|
|
|
|
|
tags=["Templates"],
|
|
|
|
|
dependencies=[Depends(require("settings:write"))],
|
|
|
|
|
)
|
|
|
|
|
def reset_template_config(template_id: str):
|
|
|
|
|
renderer.info(template_id)
|
|
|
|
|
db.execute("DELETE FROM template_config WHERE template_id=?", (template_id,))
|
|
|
|
|
|
2026-10-02 00:03:48 +08:00
|
|
|
@app.get(
|
|
|
|
|
"/api/v1/jobs",
|
|
|
|
|
response_model=list[Job],
|
|
|
|
|
tags=["Automation"],
|
|
|
|
|
dependencies=[Depends(require("jobs:read"))],
|
|
|
|
|
)
|
|
|
|
|
def jobs():
|
|
|
|
|
return scheduler.jobs()
|
|
|
|
|
|
|
|
|
|
@app.post(
|
|
|
|
|
"/api/v1/jobs",
|
|
|
|
|
response_model=Job,
|
|
|
|
|
status_code=201,
|
|
|
|
|
tags=["Automation"],
|
|
|
|
|
dependencies=[Depends(require("jobs:write"))],
|
|
|
|
|
)
|
|
|
|
|
def create_job(payload: JobInput):
|
|
|
|
|
return scheduler.save_job(payload)
|
|
|
|
|
|
|
|
|
|
@app.put(
|
|
|
|
|
"/api/v1/jobs/{job_id}",
|
|
|
|
|
response_model=Job,
|
|
|
|
|
tags=["Automation"],
|
|
|
|
|
dependencies=[Depends(require("jobs:write"))],
|
|
|
|
|
)
|
|
|
|
|
def update_job(job_id: str, payload: JobInput):
|
|
|
|
|
if not db.one("SELECT id FROM jobs WHERE id=?", (job_id,)):
|
|
|
|
|
raise FlowError(E.NOT_FOUND, "Job not found", 404)
|
|
|
|
|
return scheduler.save_job(payload, job_id)
|
|
|
|
|
|
|
|
|
|
@app.delete(
|
|
|
|
|
"/api/v1/jobs/{job_id}",
|
|
|
|
|
status_code=204,
|
|
|
|
|
tags=["Automation"],
|
|
|
|
|
dependencies=[Depends(require("jobs:write"))],
|
|
|
|
|
)
|
|
|
|
|
def delete_job(job_id: str):
|
|
|
|
|
db.execute("DELETE FROM jobs WHERE id=?", (job_id,))
|
|
|
|
|
return Response(status_code=204)
|
|
|
|
|
|
|
|
|
|
@app.get(
|
|
|
|
|
"/api/v1/playlists",
|
|
|
|
|
response_model=list[Playlist],
|
|
|
|
|
tags=["Playlists"],
|
|
|
|
|
dependencies=[Depends(require("jobs:read"))],
|
|
|
|
|
)
|
|
|
|
|
def playlists():
|
|
|
|
|
return scheduler.playlists()
|
|
|
|
|
|
|
|
|
|
@app.post(
|
|
|
|
|
"/api/v1/playlists",
|
|
|
|
|
response_model=Playlist,
|
|
|
|
|
status_code=201,
|
|
|
|
|
tags=["Playlists"],
|
|
|
|
|
dependencies=[Depends(require("jobs:write"))],
|
|
|
|
|
)
|
|
|
|
|
def create_playlist(payload: PlaylistInput):
|
|
|
|
|
return scheduler.save_playlist(payload)
|
|
|
|
|
|
|
|
|
|
@app.put(
|
|
|
|
|
"/api/v1/playlists/{playlist_id}",
|
|
|
|
|
response_model=Playlist,
|
|
|
|
|
tags=["Playlists"],
|
|
|
|
|
dependencies=[Depends(require("jobs:write"))],
|
|
|
|
|
)
|
|
|
|
|
def update_playlist(playlist_id: str, payload: PlaylistInput):
|
|
|
|
|
if not db.one("SELECT id FROM playlists WHERE id=?", (playlist_id,)):
|
|
|
|
|
raise FlowError(E.NOT_FOUND, "Playlist not found", 404)
|
|
|
|
|
return scheduler.save_playlist(payload, playlist_id)
|
|
|
|
|
|
|
|
|
|
@app.delete(
|
|
|
|
|
"/api/v1/playlists/{playlist_id}",
|
|
|
|
|
status_code=204,
|
|
|
|
|
tags=["Playlists"],
|
|
|
|
|
dependencies=[Depends(require("jobs:write"))],
|
|
|
|
|
)
|
|
|
|
|
def delete_playlist(playlist_id: str):
|
|
|
|
|
db.execute("DELETE FROM playlists WHERE id=?", (playlist_id,))
|
|
|
|
|
return Response(status_code=204)
|
|
|
|
|
|
|
|
|
|
@app.get(
|
|
|
|
|
"/api/v1/history",
|
|
|
|
|
response_model=list[History],
|
|
|
|
|
tags=["History"],
|
|
|
|
|
dependencies=[Depends(require("history:read"))],
|
|
|
|
|
)
|
|
|
|
|
def history(limit: int = Query(default=100, ge=1, le=200)):
|
|
|
|
|
return db.all("SELECT * FROM history ORDER BY id DESC LIMIT ?", (limit,))
|
|
|
|
|
|
|
|
|
|
@app.get(
|
|
|
|
|
"/api/v1/settings",
|
|
|
|
|
response_model=SettingsView,
|
|
|
|
|
tags=["Settings"],
|
|
|
|
|
dependencies=[Depends(require("admin"))],
|
|
|
|
|
)
|
|
|
|
|
def settings_view():
|
|
|
|
|
return SettingsView(
|
|
|
|
|
version="0.1.0",
|
|
|
|
|
mock=settings.mock,
|
|
|
|
|
write_enabled=settings.write_enabled,
|
|
|
|
|
api_key_configured=bool(settings.api_key)
|
|
|
|
|
or all(registry.keys)
|
|
|
|
|
and bool(registry.keys),
|
|
|
|
|
api_token_configured=bool(settings.api_token),
|
|
|
|
|
timezone=settings.timezone,
|
|
|
|
|
device_id=settings.device_id,
|
2026-10-02 09:19:25 +08:00
|
|
|
weather_provider=weather.config().provider,
|
2026-10-02 00:03:48 +08:00
|
|
|
)
|
|
|
|
|
|
2026-10-02 16:20:54 +08:00
|
|
|
@app.get(
|
|
|
|
|
"/api/v1/devices/{device_id}/widgets",
|
|
|
|
|
response_model=__import__(
|
|
|
|
|
"pigway_frame_flow.models", fromlist=["WidgetState"]
|
|
|
|
|
).WidgetState,
|
|
|
|
|
tags=["Devices"],
|
|
|
|
|
dependencies=[Depends(require("devices:read"))],
|
|
|
|
|
)
|
|
|
|
|
def official_widgets(device_id: str):
|
2026-10-03 07:03:35 +08:00
|
|
|
return registry.official_widgets(device_id)
|
2026-10-02 16:20:54 +08:00
|
|
|
|
2026-10-02 09:19:25 +08:00
|
|
|
@app.get(
|
|
|
|
|
"/api/v1/devices/{device_id}/current-content",
|
|
|
|
|
response_model=CurrentDisplay,
|
|
|
|
|
tags=["Devices"],
|
|
|
|
|
dependencies=[Depends(require("devices:read"))],
|
|
|
|
|
)
|
|
|
|
|
def current_content(device_id: str):
|
|
|
|
|
return service.current_content(device_id)
|
|
|
|
|
|
|
|
|
|
@app.get(
|
|
|
|
|
"/api/v1/providers/weather",
|
|
|
|
|
response_model=WeatherView,
|
|
|
|
|
tags=["Providers"],
|
|
|
|
|
dependencies=[Depends(require("templates:read"))],
|
|
|
|
|
)
|
|
|
|
|
def weather_view():
|
|
|
|
|
values = weather.fetch()
|
|
|
|
|
return WeatherView(config=weather.config(), **values)
|
|
|
|
|
|
|
|
|
|
@app.put(
|
|
|
|
|
"/api/v1/settings/weather",
|
|
|
|
|
response_model=WeatherConfig,
|
|
|
|
|
tags=["Settings"],
|
|
|
|
|
dependencies=[Depends(require("settings:write"))],
|
|
|
|
|
)
|
|
|
|
|
def weather_config(payload: WeatherConfig):
|
|
|
|
|
return weather.configure(payload)
|
|
|
|
|
|
|
|
|
|
@app.get(
|
|
|
|
|
"/api/v1/assets/{asset_id}/source",
|
|
|
|
|
response_model=dict[str, Any],
|
|
|
|
|
tags=["Templates"],
|
|
|
|
|
dependencies=[Depends(require("templates:read"))],
|
|
|
|
|
)
|
|
|
|
|
def asset_source(asset_id: str):
|
|
|
|
|
assets.info(asset_id)
|
|
|
|
|
row = db.one("SELECT payload FROM asset_sources WHERE asset_id=?", (asset_id,))
|
|
|
|
|
import json
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
"source_known": bool(row),
|
|
|
|
|
"source": json.loads(row["payload"]) if row else None,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@app.get(
|
|
|
|
|
"/api/v1/jobs/{job_id}",
|
|
|
|
|
response_model=Job,
|
|
|
|
|
tags=["Automation"],
|
|
|
|
|
dependencies=[Depends(require("jobs:read"))],
|
|
|
|
|
)
|
|
|
|
|
def get_job(job_id: str):
|
|
|
|
|
job = next((j for j in scheduler.jobs() if j["id"] == job_id), None)
|
|
|
|
|
if not job:
|
|
|
|
|
raise FlowError(E.NOT_FOUND, "Job not found", 404)
|
|
|
|
|
return job
|
|
|
|
|
|
|
|
|
|
@app.post(
|
|
|
|
|
"/api/v1/jobs/{job_id}/run",
|
|
|
|
|
response_model=Accepted,
|
|
|
|
|
status_code=202,
|
|
|
|
|
tags=["Automation"],
|
|
|
|
|
dependencies=[Depends(require("jobs:write"))],
|
|
|
|
|
)
|
|
|
|
|
def run_job(
|
|
|
|
|
job_id: str,
|
|
|
|
|
request: Request,
|
|
|
|
|
idempotency_key: str | None = Header(default=None),
|
|
|
|
|
):
|
|
|
|
|
job = JobInput.model_validate(
|
|
|
|
|
{k: v for k, v in get_job(job_id).items() if k in JobInput.model_fields}
|
|
|
|
|
)
|
|
|
|
|
write_guard(job.device_id)
|
|
|
|
|
if job.source.type == "restore":
|
|
|
|
|
return submit(request, "restore", {}, job.device_id, idempotency_key)
|
|
|
|
|
payload = DisplayRequest(
|
|
|
|
|
device_id=job.device_id,
|
|
|
|
|
content=job.source,
|
|
|
|
|
options=Options(
|
|
|
|
|
duration=job.display_duration,
|
|
|
|
|
restore_previous=job.restore_previous,
|
|
|
|
|
priority=job.priority,
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
service.validate(payload)
|
|
|
|
|
return submit(
|
|
|
|
|
request,
|
|
|
|
|
"display",
|
|
|
|
|
payload.model_dump(mode="json"),
|
|
|
|
|
job.device_id,
|
|
|
|
|
idempotency_key,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
@app.post(
|
|
|
|
|
"/api/v1/jobs/{job_id}/preview",
|
|
|
|
|
response_model=Accepted,
|
|
|
|
|
status_code=202,
|
|
|
|
|
tags=["Automation"],
|
|
|
|
|
dependencies=[Depends(require("render:write"))],
|
|
|
|
|
)
|
|
|
|
|
def preview_job(
|
|
|
|
|
job_id: str,
|
|
|
|
|
request: Request,
|
|
|
|
|
idempotency_key: str | None = Header(default=None),
|
|
|
|
|
):
|
|
|
|
|
source = get_job(job_id)["source"]
|
|
|
|
|
if source["type"] != "template":
|
|
|
|
|
raise FlowError(
|
|
|
|
|
E.UNSUPPORTED_CAPABILITY, "Job preview requires a template source", 422
|
|
|
|
|
)
|
|
|
|
|
return submit(
|
|
|
|
|
request, "render", source, get_job(job_id)["device_id"], idempotency_key
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
@app.get(
|
|
|
|
|
"/api/v1/settings/image-url",
|
|
|
|
|
response_model=ImageURLConfig,
|
|
|
|
|
tags=["Settings"],
|
|
|
|
|
dependencies=[Depends(require("admin"))],
|
|
|
|
|
)
|
|
|
|
|
def image_url_config():
|
|
|
|
|
return ImageURLConfig(allowed_hosts=settings.image_url_hosts)
|
|
|
|
|
|
|
|
|
|
@app.put(
|
|
|
|
|
"/api/v1/settings/image-url",
|
|
|
|
|
response_model=ImageURLConfig,
|
|
|
|
|
tags=["Settings"],
|
|
|
|
|
dependencies=[Depends(require("settings:write"))],
|
|
|
|
|
)
|
|
|
|
|
def set_image_url_config(payload: ImageURLConfig):
|
|
|
|
|
settings.image_url_hosts = payload.allowed_hosts
|
|
|
|
|
db.execute(
|
|
|
|
|
"INSERT OR REPLACE INTO app_config VALUES('image_url_hosts',?)",
|
|
|
|
|
(encode(payload.model_dump()),),
|
|
|
|
|
)
|
|
|
|
|
return payload
|
|
|
|
|
|
2026-10-02 09:57:42 +08:00
|
|
|
@app.get(
|
|
|
|
|
"/api/v1/providers/poetry",
|
|
|
|
|
response_model=dict[str, Any],
|
|
|
|
|
tags=["Providers"],
|
|
|
|
|
dependencies=[Depends(require("templates:read"))],
|
|
|
|
|
)
|
|
|
|
|
def daily_poetry(date: str | None = None, poem_id: str | None = None):
|
|
|
|
|
from pigway_frame_flow.providers.poetry import catalog, get_poem
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
return {"current": get_poem(date, poem_id), "catalog": catalog()}
|
|
|
|
|
except ValueError:
|
|
|
|
|
raise FlowError(
|
|
|
|
|
E.INVALID_REQUEST, "Invalid poetry date or poem selection"
|
|
|
|
|
) from None
|
|
|
|
|
|
2026-10-02 00:03:48 +08:00
|
|
|
@app.get("/docs", include_in_schema=False)
|
|
|
|
|
def api_docs():
|
2026-10-02 09:27:44 +08:00
|
|
|
response = get_swagger_ui_html(
|
2026-10-02 00:03:48 +08:00
|
|
|
openapi_url="/openapi.json",
|
|
|
|
|
title="Pigway Frame Flow · API Docs",
|
|
|
|
|
swagger_js_url="/ui/swagger-ui-bundle.js",
|
|
|
|
|
swagger_css_url="/ui/swagger-ui.css",
|
|
|
|
|
swagger_favicon_url='data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg"/>',
|
|
|
|
|
swagger_ui_parameters={
|
|
|
|
|
"persistAuthorization": False,
|
|
|
|
|
"displayRequestDuration": True,
|
|
|
|
|
},
|
|
|
|
|
)
|
2026-10-02 09:27:44 +08:00
|
|
|
html = (
|
|
|
|
|
bytes(response.body)
|
|
|
|
|
.decode()
|
|
|
|
|
.replace(
|
|
|
|
|
"SwaggerUIBundle({",
|
|
|
|
|
"""SwaggerUIBundle({
|
|
|
|
|
requestInterceptor: async (req) => {
|
|
|
|
|
if (!['GET','HEAD'].includes(req.method.toUpperCase())) {
|
|
|
|
|
const session = await fetch('/api/v1/auth/me');
|
|
|
|
|
if (session.ok) req.headers['X-CSRF-Token'] = (await session.json()).csrf_token;
|
|
|
|
|
}
|
|
|
|
|
return req;
|
|
|
|
|
},""",
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
from fastapi.responses import HTMLResponse
|
|
|
|
|
|
|
|
|
|
return HTMLResponse(html)
|
2026-10-02 00:03:48 +08:00
|
|
|
|
|
|
|
|
root = Path(__file__).resolve().parent.parent / "web"
|
|
|
|
|
|
|
|
|
|
@app.get("/", include_in_schema=False)
|
|
|
|
|
def index():
|
|
|
|
|
return FileResponse(root / "index.html", media_type="text/html")
|
|
|
|
|
|
|
|
|
|
@app.get("/ui/{filename}", include_in_schema=False)
|
|
|
|
|
def static(filename: str):
|
|
|
|
|
if filename not in [
|
|
|
|
|
"app.js",
|
2026-10-02 09:19:25 +08:00
|
|
|
"features.js",
|
2026-10-02 00:03:48 +08:00
|
|
|
"style.css",
|
|
|
|
|
"i18n.js",
|
|
|
|
|
"swagger-ui-bundle.js",
|
|
|
|
|
"swagger-ui.css",
|
|
|
|
|
]:
|
|
|
|
|
raise FlowError(E.NOT_FOUND, "Resource not found", 404)
|
|
|
|
|
return FileResponse(root / filename)
|
|
|
|
|
|
|
|
|
|
return app
|