feat: floor plan viewport rendering, per-canvas config, server media upload
- Render floor plan inside React Flow ViewportPortal so it pans/zooms with nodes (was screen-fixed, desynced on pan/zoom); zoom-stable resize handles. - Move floor plan config from the left panel into the canvas (design) edit modal; attach per-design and fix cross-design bleed on load. - Store images via a new generic backend media endpoint (POST/GET/DELETE /api/v1/media) on disk under <data_dir>/uploads, not base64 in the canvas. - Disable floor plans in standalone mode (no backend to upload/serve); drop base64 localStorage persistence. See ADR-001 in CLAUDE.md. - Tests: backend media route, DesignModal floor plan + upload, store floorMap. ha-relevant: maybe
This commit is contained in:
@@ -0,0 +1,89 @@
|
||||
"""Generic media upload/serve endpoint.
|
||||
|
||||
Images are stored on disk (see `Settings.media_dir`) with server-generated
|
||||
UUID filenames — never in the DB, and the client filename is never trusted.
|
||||
Upload/delete require auth; GET is public so plain <img> tags and the read-only
|
||||
live view can load images (filenames are unguessable).
|
||||
|
||||
Currently used by the floor-plan feature; kept deliberately generic so future
|
||||
raw-image uploads reuse the same endpoint.
|
||||
"""
|
||||
|
||||
import re
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, UploadFile, status
|
||||
from fastapi.responses import FileResponse
|
||||
|
||||
from app.api.deps import get_current_user
|
||||
from app.core.config import settings
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# content-type → extension. Also the allowlist of accepted uploads.
|
||||
ALLOWED_TYPES: dict[str, str] = {
|
||||
"image/png": ".png",
|
||||
"image/jpeg": ".jpg",
|
||||
"image/webp": ".webp",
|
||||
}
|
||||
|
||||
# Magic-byte signatures for defense-in-depth (don't trust content-type alone).
|
||||
_MAGIC: dict[str, tuple[bytes, ...]] = {
|
||||
".png": (b"\x89PNG\r\n\x1a\n",),
|
||||
".jpg": (b"\xff\xd8\xff",),
|
||||
".webp": (b"RIFF",), # RIFF....WEBP; RIFF prefix is enough to reject non-images
|
||||
}
|
||||
|
||||
MAX_BYTES = 10 * 1024 * 1024 # 10 MB
|
||||
|
||||
# Only ever serve/delete files we created: 32 hex chars + known extension.
|
||||
_NAME_RE = re.compile(r"^[0-9a-f]{32}\.(png|jpg|webp)$")
|
||||
|
||||
|
||||
@router.post("/upload")
|
||||
async def upload_media(file: UploadFile, _user: str = Depends(get_current_user)) -> dict[str, str]:
|
||||
ext = ALLOWED_TYPES.get(file.content_type or "")
|
||||
if ext is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE,
|
||||
detail="Unsupported media type — PNG, JPEG, or WebP only",
|
||||
)
|
||||
# Read one byte past the cap so we can detect oversize without loading more.
|
||||
data = await file.read(MAX_BYTES + 1)
|
||||
if len(data) > MAX_BYTES:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_413_CONTENT_TOO_LARGE,
|
||||
detail="File too large (max 10 MB)",
|
||||
)
|
||||
if not data:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Empty file")
|
||||
if not any(data.startswith(sig) for sig in _MAGIC[ext]):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE,
|
||||
detail="File content does not match its type",
|
||||
)
|
||||
|
||||
media_dir = settings.media_dir()
|
||||
media_dir.mkdir(parents=True, exist_ok=True)
|
||||
name = f"{uuid.uuid4().hex}{ext}"
|
||||
(media_dir / name).write_bytes(data)
|
||||
return {"filename": name, "url": f"/api/v1/media/{name}"}
|
||||
|
||||
|
||||
@router.get("/{filename}")
|
||||
async def get_media(filename: str) -> FileResponse:
|
||||
if not _NAME_RE.match(filename):
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not found")
|
||||
path = settings.media_dir() / filename
|
||||
if not path.is_file():
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not found")
|
||||
return FileResponse(path)
|
||||
|
||||
|
||||
@router.delete("/{filename}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_media(filename: str, _user: str = Depends(get_current_user)) -> None:
|
||||
if not _NAME_RE.match(filename):
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not found")
|
||||
path = settings.media_dir() / filename
|
||||
if path.is_file():
|
||||
path.unlink()
|
||||
@@ -24,6 +24,10 @@ class Settings(BaseSettings):
|
||||
|
||||
secret_key: str # Required — set SECRET_KEY in .env
|
||||
sqlite_path: str = "./data/homelab.db"
|
||||
# Uploaded media (floor plans, and future raw image uploads) live on disk,
|
||||
# not in the DB. Defaults to a `uploads/` folder next to the SQLite DB so it
|
||||
# sits on the same persistent Docker volume. Override with UPLOAD_DIR.
|
||||
upload_dir: str = ""
|
||||
cors_origins: list[str] = ["http://localhost:5173", "http://localhost:3000"]
|
||||
|
||||
# JWT
|
||||
@@ -79,6 +83,13 @@ class Settings(BaseSettings):
|
||||
def _override_path(self) -> Path:
|
||||
return Path(self.sqlite_path).parent / "scan_config.json"
|
||||
|
||||
def media_dir(self) -> Path:
|
||||
"""On-disk folder for uploaded media. Sits on the same persistent
|
||||
volume as the SQLite DB unless UPLOAD_DIR is set."""
|
||||
if self.upload_dir:
|
||||
return Path(self.upload_dir)
|
||||
return Path(self.sqlite_path).parent / "uploads"
|
||||
|
||||
def load_overrides(self) -> None:
|
||||
"""Load runtime scan config overrides written by the API."""
|
||||
try:
|
||||
|
||||
+2
-1
@@ -7,7 +7,7 @@ from typing import Any
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from app.api.routes import auth, canvas, designs, edges, liveview, nodes, scan, stats, status, zigbee, zwave
|
||||
from app.api.routes import auth, canvas, designs, edges, liveview, media, nodes, scan, stats, status, zigbee, zwave
|
||||
from app.api.routes import settings as settings_routes
|
||||
from app.core.config import settings
|
||||
from app.core.scheduler import start_scheduler, stop_scheduler
|
||||
@@ -59,6 +59,7 @@ app.include_router(liveview.router, prefix="/api/v1/liveview", tags=["liveview"]
|
||||
app.include_router(zigbee.router, prefix="/api/v1/zigbee", tags=["zigbee"])
|
||||
app.include_router(zwave.router, prefix="/api/v1/zwave", tags=["zwave"])
|
||||
app.include_router(stats.router, prefix="/api/v1/stats", tags=["stats"])
|
||||
app.include_router(media.router, prefix="/api/v1/media", tags=["media"])
|
||||
|
||||
|
||||
@app.get("/api/v1/health")
|
||||
|
||||
Reference in New Issue
Block a user