mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-01 03:31:25 +02:00
Thirteen routes with nothing to do with a camera took the camera stream
token as their credential -- library and archive thumbnails, plate
previews and plate thumbnails, timelapses, print photos, archive QR
codes, project covers, print-log thumbnails, printer covers and
external-link icons. A browser cannot put an Authorization header on an
<img src>, so these need a credential that fits in the URL, and the
camera token was the only one that existed. Minting one costs
camera:view, so a user granted library access to their own files got a
grid of broken images until they were also handed the live camera.
Adds a media token: minted by POST /auth/media-token behind plain
authentication, and identified -- it records the principal the way the
websocket token does rather than being anonymous the way the camera
token is. Each route now gates on the permission and ownership rules of
the resource it serves, through the same _ensure_*_visible helpers its
header-authenticated siblings already use. The three camera routes keep
the camera token, and require_camera_stream_token_if_auth_enabled now
documents that it is for those only.
The media dependencies accept ordinary Authorization / X-API-Key headers
as well as ?token=, delegating that path to the existing checkers, so
API-key scope rules and the per-printer allowlist are unchanged.
Long-lived camera_stream, camwall and overlay tokens are deliberately
not accepted on the media routes -- those are handed to kiosks, walls
and Home Assistant to display video. The cam wall, streaming overlay and
kiosk views use only the three camera routes and are unaffected.
Frontend: withMediaToken alongside withStreamToken, and
useStreamTokenSync fetches a media token for every signed-in user while
asking for a camera token only when the user can mint one, which also
stops the 403 that fired on every page load for everyone else.
Also fixed, same class:
- /printers/{id}/files/plate-thumbnail/{i} is rendered in an <img> but
had a header-only guard, so the file manager's plate thumbnails 401'd
whenever auth was enabled. It now takes a media token too.
- getProjectCoverImageUrl returned a URL ending in ?token=, and the
project edit dialog appended its own ?v= cache-buster after it, so the
second ? landed inside the token value. The version is now a parameter
applied before the token.
Tests: 15 integration tests for the token boundary, permission
enforcement and per-row scoping; 10 frontend tests for the URL split and
the two-query hook. test_cover_image_get_uses_stream_token_gate is
renamed and repointed at the media gate -- what it pins, that the
credential has to fit in a URL, is unchanged.
265 lines
8.6 KiB
Python
265 lines
8.6 KiB
Python
"""API routes for external sidebar links."""
|
|
|
|
import logging
|
|
import uuid
|
|
from pathlib import Path
|
|
|
|
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
|
|
from fastapi.responses import FileResponse
|
|
from sqlalchemy import select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from backend.app.core.auth import RequirePermissionIfAuthEnabled, require_media_token_permission
|
|
from backend.app.core.config import settings as app_settings
|
|
from backend.app.core.database import get_db
|
|
from backend.app.core.permissions import Permission
|
|
from backend.app.models.external_link import ExternalLink
|
|
from backend.app.models.user import User
|
|
from backend.app.schemas.external_link import (
|
|
ExternalLinkCreate,
|
|
ExternalLinkReorder,
|
|
ExternalLinkResponse,
|
|
ExternalLinkUpdate,
|
|
)
|
|
|
|
# Directory for storing custom icons
|
|
ICONS_DIR = app_settings.base_dir / "icons"
|
|
ALLOWED_EXTENSIONS = {".png", ".jpg", ".jpeg", ".gif", ".svg", ".webp", ".ico"}
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
router = APIRouter(prefix="/external-links", tags=["external-links"])
|
|
|
|
|
|
@router.get("/", response_model=list[ExternalLinkResponse])
|
|
async def list_external_links(
|
|
db: AsyncSession = Depends(get_db),
|
|
_: User | None = RequirePermissionIfAuthEnabled(Permission.EXTERNAL_LINKS_READ),
|
|
):
|
|
"""List all external links ordered by sort_order."""
|
|
result = await db.execute(select(ExternalLink).order_by(ExternalLink.sort_order, ExternalLink.id))
|
|
links = result.scalars().all()
|
|
return links
|
|
|
|
|
|
@router.post("/", response_model=ExternalLinkResponse)
|
|
async def create_external_link(
|
|
link_data: ExternalLinkCreate,
|
|
db: AsyncSession = Depends(get_db),
|
|
_: User | None = RequirePermissionIfAuthEnabled(Permission.EXTERNAL_LINKS_CREATE),
|
|
):
|
|
"""Create a new external link."""
|
|
# Get the highest sort_order to place new link at end
|
|
result = await db.execute(select(ExternalLink).order_by(ExternalLink.sort_order.desc()).limit(1))
|
|
last_link = result.scalar_one_or_none()
|
|
next_order = (last_link.sort_order + 1) if last_link else 0
|
|
|
|
link = ExternalLink(
|
|
name=link_data.name,
|
|
url=link_data.url,
|
|
icon=link_data.icon,
|
|
sort_order=next_order,
|
|
)
|
|
|
|
db.add(link)
|
|
await db.commit()
|
|
await db.refresh(link)
|
|
|
|
logger.info("Created external link: %s -> %s", link.name, link.url)
|
|
|
|
return link
|
|
|
|
|
|
@router.get("/{link_id}", response_model=ExternalLinkResponse)
|
|
async def get_external_link(
|
|
link_id: int,
|
|
db: AsyncSession = Depends(get_db),
|
|
_: User | None = RequirePermissionIfAuthEnabled(Permission.EXTERNAL_LINKS_READ),
|
|
):
|
|
"""Get a specific external link."""
|
|
result = await db.execute(select(ExternalLink).where(ExternalLink.id == link_id))
|
|
link = result.scalar_one_or_none()
|
|
|
|
if not link:
|
|
raise HTTPException(status_code=404, detail="External link not found")
|
|
|
|
return link
|
|
|
|
|
|
@router.patch("/{link_id}", response_model=ExternalLinkResponse)
|
|
async def update_external_link(
|
|
link_id: int,
|
|
update_data: ExternalLinkUpdate,
|
|
db: AsyncSession = Depends(get_db),
|
|
_: User | None = RequirePermissionIfAuthEnabled(Permission.EXTERNAL_LINKS_UPDATE),
|
|
):
|
|
"""Update an external link."""
|
|
result = await db.execute(select(ExternalLink).where(ExternalLink.id == link_id))
|
|
link = result.scalar_one_or_none()
|
|
|
|
if not link:
|
|
raise HTTPException(status_code=404, detail="External link not found")
|
|
|
|
# Update only provided fields
|
|
update_dict = update_data.model_dump(exclude_unset=True)
|
|
for key, value in update_dict.items():
|
|
setattr(link, key, value)
|
|
|
|
await db.commit()
|
|
await db.refresh(link)
|
|
|
|
logger.info("Updated external link: %s", link.name)
|
|
|
|
return link
|
|
|
|
|
|
@router.delete("/{link_id}")
|
|
async def delete_external_link(
|
|
link_id: int,
|
|
db: AsyncSession = Depends(get_db),
|
|
_: User | None = RequirePermissionIfAuthEnabled(Permission.EXTERNAL_LINKS_DELETE),
|
|
):
|
|
"""Delete an external link."""
|
|
result = await db.execute(select(ExternalLink).where(ExternalLink.id == link_id))
|
|
link = result.scalar_one_or_none()
|
|
|
|
if not link:
|
|
raise HTTPException(status_code=404, detail="External link not found")
|
|
|
|
name = link.name
|
|
await db.delete(link)
|
|
await db.commit()
|
|
|
|
logger.info("Deleted external link: %s", name)
|
|
|
|
return {"message": f"External link '{name}' deleted"}
|
|
|
|
|
|
@router.put("/reorder", response_model=list[ExternalLinkResponse])
|
|
async def reorder_external_links(
|
|
reorder_data: ExternalLinkReorder,
|
|
db: AsyncSession = Depends(get_db),
|
|
_: User | None = RequirePermissionIfAuthEnabled(Permission.EXTERNAL_LINKS_UPDATE),
|
|
):
|
|
"""Update the sort order of external links."""
|
|
# Update sort_order for each link based on position in the list
|
|
for index, link_id in enumerate(reorder_data.ids):
|
|
result = await db.execute(select(ExternalLink).where(ExternalLink.id == link_id))
|
|
link = result.scalar_one_or_none()
|
|
if link:
|
|
link.sort_order = index
|
|
|
|
await db.commit()
|
|
|
|
# Return updated list
|
|
result = await db.execute(select(ExternalLink).order_by(ExternalLink.sort_order, ExternalLink.id))
|
|
links = result.scalars().all()
|
|
|
|
logger.info("Reordered %s external links", len(reorder_data.ids))
|
|
|
|
return links
|
|
|
|
|
|
@router.post("/{link_id}/icon", response_model=ExternalLinkResponse)
|
|
async def upload_icon(
|
|
link_id: int,
|
|
file: UploadFile = File(...),
|
|
db: AsyncSession = Depends(get_db),
|
|
_: User | None = RequirePermissionIfAuthEnabled(Permission.EXTERNAL_LINKS_UPDATE),
|
|
):
|
|
"""Upload a custom icon for an external link."""
|
|
result = await db.execute(select(ExternalLink).where(ExternalLink.id == link_id))
|
|
link = result.scalar_one_or_none()
|
|
|
|
if not link:
|
|
raise HTTPException(status_code=404, detail="External link not found")
|
|
|
|
# Validate file extension
|
|
if not file.filename:
|
|
raise HTTPException(status_code=400, detail="No filename provided")
|
|
|
|
ext = Path(file.filename).suffix.lower()
|
|
if ext not in ALLOWED_EXTENSIONS:
|
|
raise HTTPException(status_code=400, detail=f"File type not allowed. Allowed: {', '.join(ALLOWED_EXTENSIONS)}")
|
|
|
|
# Create icons directory if it doesn't exist
|
|
ICONS_DIR.mkdir(parents=True, exist_ok=True)
|
|
|
|
# Delete old custom icon if exists
|
|
if link.custom_icon:
|
|
old_path = ICONS_DIR / link.custom_icon
|
|
if old_path.exists():
|
|
old_path.unlink()
|
|
|
|
# Generate unique filename
|
|
filename = f"{uuid.uuid4().hex}{ext}"
|
|
filepath = ICONS_DIR / filename
|
|
|
|
# Save file
|
|
content = await file.read()
|
|
with open(filepath, "wb") as f:
|
|
f.write(content)
|
|
|
|
# Update link
|
|
link.custom_icon = filename
|
|
await db.commit()
|
|
await db.refresh(link)
|
|
|
|
logger.info("Uploaded custom icon for link %s: %s", link.name, filename)
|
|
|
|
return link
|
|
|
|
|
|
@router.delete("/{link_id}/icon", response_model=ExternalLinkResponse)
|
|
async def delete_icon(
|
|
link_id: int,
|
|
db: AsyncSession = Depends(get_db),
|
|
_: User | None = RequirePermissionIfAuthEnabled(Permission.EXTERNAL_LINKS_UPDATE),
|
|
):
|
|
"""Delete the custom icon for an external link."""
|
|
result = await db.execute(select(ExternalLink).where(ExternalLink.id == link_id))
|
|
link = result.scalar_one_or_none()
|
|
|
|
if not link:
|
|
raise HTTPException(status_code=404, detail="External link not found")
|
|
|
|
if link.custom_icon:
|
|
filepath = ICONS_DIR / link.custom_icon
|
|
if filepath.exists():
|
|
filepath.unlink()
|
|
link.custom_icon = None
|
|
await db.commit()
|
|
await db.refresh(link)
|
|
logger.info("Deleted custom icon for link %s", link.name)
|
|
|
|
return link
|
|
|
|
|
|
@router.get("/{link_id}/icon")
|
|
async def get_icon(
|
|
link_id: int,
|
|
db: AsyncSession = Depends(get_db),
|
|
_: User | None = Depends(require_media_token_permission(Permission.EXTERNAL_LINKS_READ)),
|
|
):
|
|
"""Get the custom icon for an external link.
|
|
|
|
Requires a media token query param (?token=xxx) when auth is enabled, and
|
|
the same ``external_links:read`` every other read on this router takes.
|
|
Previously it took the camera-stream token, so a sidebar icon was visible
|
|
only to users who could also watch the printer camera (#3025).
|
|
"""
|
|
result = await db.execute(select(ExternalLink).where(ExternalLink.id == link_id))
|
|
link = result.scalar_one_or_none()
|
|
|
|
if not link:
|
|
raise HTTPException(status_code=404, detail="External link not found")
|
|
|
|
if not link.custom_icon:
|
|
raise HTTPException(status_code=404, detail="No custom icon set")
|
|
|
|
filepath = ICONS_DIR / link.custom_icon
|
|
if not filepath.exists():
|
|
raise HTTPException(status_code=404, detail="Icon file not found")
|
|
|
|
return FileResponse(filepath)
|