Просмотр исходного кода

fix(auth): decouple media routes from the camera stream token (issue #3025)

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.
maziggy 10 часов назад
Родитель
Сommit
816f073a9e

Разница между файлами не показана из-за своего большого размера
+ 3 - 0
CHANGELOG.md


+ 73 - 34
backend/app/api/routes/archives.py

@@ -16,11 +16,11 @@ from sqlalchemy.ext.asyncio import AsyncSession
 
 from backend.app.core import database
 from backend.app.core.auth import (
-    RequireCameraStreamTokenIfAuthEnabled,
     RequirePermissionIfAuthEnabled,
     check_printer_access,
     current_api_key_if_present,
     probe_permissions_if_auth_enabled,
+    require_media_token_ownership,
     require_ownership_permission,
 )
 from backend.app.core.config import settings
@@ -2342,15 +2342,22 @@ async def download_archive_for_slicer(
 async def get_thumbnail(
     archive_id: int,
     db: AsyncSession = Depends(get_db),
-    _: None = RequireCameraStreamTokenIfAuthEnabled,
+    auth_result: tuple[User | None, bool] = Depends(
+        require_media_token_ownership(
+            Permission.ARCHIVES_READ_ALL,
+            Permission.ARCHIVES_READ_OWN,
+        )
+    ),
 ):
     """Get the thumbnail image.
 
-    Requires a stream token query param (?token=xxx) when auth is enabled.
+    Requires a media token query param (?token=xxx) when auth is enabled, and
+    returns 404 for an archive the caller may not read (#3025).
     """
+    user, can_read_all = auth_result
     service = ArchiveService(db)
-    archive = await service.get_archive(archive_id)
-    if not archive or not archive.thumbnail_path:
+    archive = _ensure_archive_visible(await service.get_archive(archive_id), user, can_read_all)
+    if not archive.thumbnail_path:
         raise HTTPException(404, "Thumbnail not found")
 
     thumb_path = settings.base_dir / archive.thumbnail_path
@@ -2571,15 +2578,22 @@ async def download_archive_media_with_token(
 async def get_timelapse(
     archive_id: int,
     db: AsyncSession = Depends(get_db),
-    _: None = RequireCameraStreamTokenIfAuthEnabled,
+    auth_result: tuple[User | None, bool] = Depends(
+        require_media_token_ownership(
+            Permission.ARCHIVES_READ_ALL,
+            Permission.ARCHIVES_READ_OWN,
+        )
+    ),
 ):
     """Get the timelapse video.
 
-    Requires a stream token query param (?token=xxx) when auth is enabled.
+    Requires a media token query param (?token=xxx) when auth is enabled, and
+    returns 404 for an archive the caller may not read (#3025).
     """
+    user, can_read_all = auth_result
     service = ArchiveService(db)
-    archive = await service.get_archive(archive_id)
-    if not archive or not archive.timelapse_path:
+    archive = _ensure_archive_visible(await service.get_archive(archive_id), user, can_read_all)
+    if not archive.timelapse_path:
         raise HTTPException(404, "Timelapse not found")
 
     timelapse_path = settings.base_dir / archive.timelapse_path
@@ -3287,16 +3301,21 @@ async def get_photo(
     archive_id: int,
     filename: str,
     db: AsyncSession = Depends(get_db),
-    _: None = RequireCameraStreamTokenIfAuthEnabled,
+    auth_result: tuple[User | None, bool] = Depends(
+        require_media_token_ownership(
+            Permission.ARCHIVES_READ_ALL,
+            Permission.ARCHIVES_READ_OWN,
+        )
+    ),
 ):
     """Get a specific photo.
 
-    Requires a stream token query param (?token=xxx) when auth is enabled.
+    Requires a media token query param (?token=xxx) when auth is enabled, and
+    returns 404 for an archive the caller may not read (#3025).
     """
+    user, can_read_all = auth_result
     result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
-    archive = result.scalar_one_or_none()
-    if not archive:
-        raise HTTPException(404, "Archive not found")
+    archive = _ensure_archive_visible(result.scalar_one_or_none(), user, can_read_all)
 
     # Membership check first — UUID-generated names on upload mean any URL
     # filename that doesn't appear here is by definition not a real photo.
@@ -3375,12 +3394,19 @@ async def get_qrcode(
     request: Request,
     size: int = 200,
     db: AsyncSession = Depends(get_db),
-    _: None = RequireCameraStreamTokenIfAuthEnabled,
+    auth_result: tuple[User | None, bool] = Depends(
+        require_media_token_ownership(
+            Permission.ARCHIVES_READ_ALL,
+            Permission.ARCHIVES_READ_OWN,
+        )
+    ),
 ):
     """Generate a QR code that links to this archive.
 
-    Requires a stream token query param (?token=xxx) when auth is enabled.
+    Requires a media token query param (?token=xxx) when auth is enabled, and
+    returns 404 for an archive the caller may not read (#3025).
     """
+    user, can_read_all = auth_result
     try:
         import qrcode
         from PIL import Image as PILImage
@@ -3388,9 +3414,7 @@ async def get_qrcode(
         raise HTTPException(500, "QR code generation not available - qrcode package not installed")
 
     result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
-    archive = result.scalar_one_or_none()
-    if not archive:
-        raise HTTPException(404, "Archive not found")
+    archive = _ensure_archive_visible(result.scalar_one_or_none(), user, can_read_all)
 
     # Build URL to archive download
     base_url = str(request.base_url).rstrip("/")
@@ -3716,19 +3740,24 @@ async def get_gcode(
 async def get_plate_preview(
     archive_id: int,
     db: AsyncSession = Depends(get_db),
-    _: None = RequireCameraStreamTokenIfAuthEnabled,
+    auth_result: tuple[User | None, bool] = Depends(
+        require_media_token_ownership(
+            Permission.ARCHIVES_READ_ALL,
+            Permission.ARCHIVES_READ_OWN,
+        )
+    ),
 ):
     """Get the plate preview image from the 3MF file.
 
     Returns the slicer-generated plate thumbnail which shows the model
     with correct colors and positioning.
 
-    Requires a stream token query param (?token=xxx) when auth is enabled.
+    Requires a media token query param (?token=xxx) when auth is enabled, and
+    returns 404 for an archive the caller may not read (#3025).
     """
+    user, can_read_all = auth_result
     service = ArchiveService(db)
-    archive = await service.get_archive(archive_id)
-    if not archive:
-        raise HTTPException(404, "Archive not found")
+    archive = _ensure_archive_visible(await service.get_archive(archive_id), user, can_read_all)
 
     file_path = settings.base_dir / archive.file_path
     if not file_path.is_file():
@@ -4249,16 +4278,21 @@ async def get_plate_thumbnail(
     archive_id: int,
     plate_index: int,
     db: AsyncSession = Depends(get_db),
-    _: None = RequireCameraStreamTokenIfAuthEnabled,
+    auth_result: tuple[User | None, bool] = Depends(
+        require_media_token_ownership(
+            Permission.ARCHIVES_READ_ALL,
+            Permission.ARCHIVES_READ_OWN,
+        )
+    ),
 ):
     """Get the thumbnail image for a specific plate.
 
-    Requires a stream token query param (?token=xxx) when auth is enabled.
+    Requires a media token query param (?token=xxx) when auth is enabled, and
+    returns 404 for an archive the caller may not read (#3025).
     """
+    user, can_read_all = auth_result
     service = ArchiveService(db)
-    archive = await service.get_archive(archive_id)
-    if not archive:
-        raise HTTPException(404, "Archive not found")
+    archive = _ensure_archive_visible(await service.get_archive(archive_id), user, can_read_all)
 
     file_path = settings.base_dir / archive.file_path
     if not file_path.is_file():
@@ -4700,18 +4734,23 @@ async def get_project_image(
     archive_id: int,
     image_path: str,
     db: AsyncSession = Depends(get_db),
-    _: None = RequireCameraStreamTokenIfAuthEnabled,
+    auth_result: tuple[User | None, bool] = Depends(
+        require_media_token_ownership(
+            Permission.ARCHIVES_READ_ALL,
+            Permission.ARCHIVES_READ_OWN,
+        )
+    ),
 ):
     """Get an image from the 3MF project page.
 
-    Requires a stream token query param (?token=xxx) when auth is enabled.
+    Requires a media token query param (?token=xxx) when auth is enabled, and
+    returns 404 for an archive the caller may not read (#3025).
     """
+    user, can_read_all = auth_result
     from backend.app.services.archive import ProjectPageParser
 
     service = ArchiveService(db)
-    archive = await service.get_archive(archive_id)
-    if not archive:
-        raise HTTPException(404, "Archive not found")
+    archive = _ensure_archive_visible(await service.get_archive(archive_id), user, can_read_all)
 
     file_path = settings.base_dir / archive.file_path
     if not file_path.is_file():

+ 26 - 0
backend/app/api/routes/auth.py

@@ -25,12 +25,14 @@ from backend.app.core.auth import (
     authenticate_user,
     authenticate_user_by_email,
     create_access_token,
+    create_media_token,
     create_websocket_token,
     get_current_active_user,
     get_password_hash,
     get_user_by_email,
     get_user_by_username,
     is_jti_revoked,
+    require_auth_if_enabled,
     resolve_apikey_owner,
     resolve_session_max_minutes,
     revoke_jti,
@@ -659,6 +661,30 @@ async def mint_websocket_token(
     return {"token": await create_websocket_token(username)}
 
 
+@router.post("/media-token")
+async def mint_media_token(
+    current_user: User | None = Depends(require_auth_if_enabled),
+):
+    """Mint a short-lived token for ``<img>`` / ``<video>`` media routes (#3025).
+
+    Thumbnails, plate previews, timelapses, cover images and sidebar icons are
+    loaded by the browser as element ``src`` URLs, which cannot carry an
+    ``Authorization`` header. Those routes used to accept the *camera stream*
+    token instead, which made ``camera:view`` a prerequisite for seeing a
+    library thumbnail -- on a home install, handing someone the live feed of
+    the room the printer is in just so their own files render.
+
+    So this mints behind plain authentication: any signed-in user may ask, and
+    what the token can actually reach is decided per request by the same
+    permission and ownership rules as the resource's other routes. It is not a
+    camera credential and does not open the camera routes.
+
+    Returns ``{"token": <opaque string>}``, valid for 60 minutes.
+    """
+    username = current_user.username if current_user is not None else None
+    return {"token": await create_media_token(username)}
+
+
 @router.get("/me", response_model=UserResponse)
 async def get_current_user_info(
     credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(security)] = None,

+ 6 - 3
backend/app/api/routes/external_links.py

@@ -9,7 +9,7 @@ from fastapi.responses import FileResponse
 from sqlalchemy import select
 from sqlalchemy.ext.asyncio import AsyncSession
 
-from backend.app.core.auth import RequireCameraStreamTokenIfAuthEnabled, RequirePermissionIfAuthEnabled
+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
@@ -239,11 +239,14 @@ async def delete_icon(
 async def get_icon(
     link_id: int,
     db: AsyncSession = Depends(get_db),
-    _: None = RequireCameraStreamTokenIfAuthEnabled,
+    _: User | None = Depends(require_media_token_permission(Permission.EXTERNAL_LINKS_READ)),
 ):
     """Get the custom icon for an external link.
 
-    Requires a stream token query param (?token=xxx) when auth is enabled.
+    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()

+ 28 - 13
backend/app/api/routes/library.py

@@ -22,7 +22,7 @@ from sqlalchemy.orm import selectinload
 
 from backend.app.api.routes.cloud import resolve_api_key_cloud_owner
 from backend.app.core.auth import (
-    RequireCameraStreamTokenIfAuthEnabled,
+    require_media_token_ownership,
     require_ownership_permission,
     require_permission_if_auth_enabled,
 )
@@ -3213,16 +3213,22 @@ async def get_library_file_plate_thumbnail(
     file_id: int,
     plate_index: int,
     db: AsyncSession = Depends(get_db),
-    _: None = RequireCameraStreamTokenIfAuthEnabled,
+    auth_result: tuple[User | None, bool] = Depends(
+        require_media_token_ownership(
+            Permission.LIBRARY_READ_ALL,
+            Permission.LIBRARY_READ_OWN,
+        )
+    ),
 ):
-    """Get the thumbnail image for a specific plate from a library file."""
+    """Get the thumbnail image for a specific plate from a library file.
+
+    Ownership-gated on the same terms as the file itself (#3025).
+    """
     from starlette.responses import Response
 
+    user, can_read_all = auth_result
     result = await db.execute(LibraryFile.active().where(LibraryFile.id == file_id))
-    lib_file = result.scalar_one_or_none()
-
-    if not lib_file:
-        raise HTTPException(status_code=404, detail="File not found")
+    lib_file = _ensure_library_file_visible(result.scalar_one_or_none(), user, can_read_all)
 
     file_path = Path(app_settings.base_dir) / lib_file.file_path
     if not file_path.exists():
@@ -5293,14 +5299,23 @@ async def download_library_file_for_slicer(
 async def get_thumbnail(
     file_id: int,
     db: AsyncSession = Depends(get_db),
-    _: None = RequireCameraStreamTokenIfAuthEnabled,
+    auth_result: tuple[User | None, bool] = Depends(
+        require_media_token_ownership(
+            Permission.LIBRARY_READ_ALL,
+            Permission.LIBRARY_READ_OWN,
+        )
+    ),
 ):
-    """Get a file's thumbnail."""
-    result = await db.execute(LibraryFile.active().where(LibraryFile.id == file_id))
-    file = result.scalar_one_or_none()
+    """Get a file's thumbnail.
 
-    if not file:
-        raise HTTPException(status_code=404, detail="File not found")
+    Accepts a media token in ``?token=`` because <img> cannot send headers.
+    Ownership is enforced here rather than assumed from the credential: until
+    #3025 this route took the anonymous camera-stream token, which carried no
+    principal, so any holder could read any user's thumbnail by walking IDs.
+    """
+    user, can_read_all = auth_result
+    result = await db.execute(LibraryFile.active().where(LibraryFile.id == file_id))
+    file = _ensure_library_file_visible(result.scalar_one_or_none(), user, can_read_all)
 
     abs_thumb_path = to_absolute_path(file.thumbnail_path)
     if not abs_thumb_path or not abs_thumb_path.exists():

+ 14 - 3
backend/app/api/routes/print_log.py

@@ -7,8 +7,8 @@ from sqlalchemy import delete, func, nullslast, select
 from sqlalchemy.ext.asyncio import AsyncSession
 
 from backend.app.core.auth import (
-    RequireCameraStreamTokenIfAuthEnabled,
     RequirePermissionIfAuthEnabled,
+    require_media_token_ownership,
     require_ownership_permission,
 )
 from backend.app.core.config import settings
@@ -136,11 +136,19 @@ async def get_print_log(
 async def get_print_log_thumbnail(
     entry_id: int,
     db: AsyncSession = Depends(get_db),
-    _: None = RequireCameraStreamTokenIfAuthEnabled,
+    auth_result: tuple[User | None, bool] = Depends(
+        require_media_token_ownership(
+            Permission.ARCHIVES_READ_ALL,
+            Permission.ARCHIVES_READ_OWN,
+        )
+    ),
 ):
     """Get the thumbnail for a print log entry.
 
-    Requires a stream token query param (?token=xxx) when auth is enabled.
+    Requires a media token query param (?token=xxx) when auth is enabled, and
+    is scoped to the rows the caller can see in the log itself (#3025) -- the
+    same ``created_by_id`` filter ``get_print_log`` applies, including its
+    treatment of an ownerless entry as not-yours.
 
     Self-heals stale entries: when thumbnail_path points to a file that no
     longer exists on disk (archive was deleted, or print failed before the
@@ -149,9 +157,12 @@ async def get_print_log_thumbnail(
     gated on entry.thumbnail_path being truthy, so the next fetch of the
     log list will simply not request this thumbnail again.
     """
+    user, can_read_all = auth_result
     entry = await db.get(PrintLogEntry, entry_id)
     if not entry or not entry.thumbnail_path:
         raise HTTPException(404, "Thumbnail not found")
+    if not can_read_all and (user is None or entry.created_by_id != user.id):
+        raise HTTPException(404, "Thumbnail not found")
 
     thumb_path = settings.base_dir / entry.thumbnail_path
     if not thumb_path.exists():

+ 10 - 3
backend/app/api/routes/printers.py

@@ -13,11 +13,12 @@ from starlette.background import BackgroundTask
 
 from backend.app.core import database
 from backend.app.core.auth import (
-    RequireCameraStreamTokenIfAuthEnabled,
     RequireOverlayTokenIfAuthEnabled,
     RequirePermissionIfAuthEnabled,
     RequirePrinterPermissionIfAuthEnabled,
     is_auth_enabled,
+    require_media_token_permission,
+    require_media_token_printer_permission,
 )
 from backend.app.core.config import settings
 from backend.app.core.database import get_db
@@ -1147,10 +1148,16 @@ async def _running_print_archive_file(printer_id: int, state) -> Path | None:
 async def get_printer_cover(
     printer_id: int,
     view: str | None = None,
-    _: None = RequireCameraStreamTokenIfAuthEnabled,
+    _: User | None = Depends(require_media_token_permission(Permission.PRINTERS_READ)),
 ):
     """Get the cover image for the current print job.
 
+    Requires a media token query param (?token=xxx) when auth is enabled, plus
+    ``printers:read`` -- the permission that governs every other read of this
+    printer. It used to require ``camera:view`` by way of the camera-stream
+    token, which is a different question from "may this user see what is on the
+    plate" (#3025).
+
     Args:
         view: Optional view type. Use "top" for the top-down build plate view or
               "pick" for the slicer's object-ID mask used by skip objects.
@@ -1971,7 +1978,7 @@ async def get_printer_file_plate_thumbnail(
     printer_id: int,
     plate_index: int,
     path: str = Query(..., description="Full path to the 3MF file on the printer"),
-    _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_FILES),
+    _=Depends(require_media_token_printer_permission(Permission.PRINTERS_FILES)),
 ):
     """Get a plate thumbnail image from a printer-stored 3MF file."""
     import io

+ 10 - 4
backend/app/api/routes/projects.py

@@ -16,7 +16,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.orm import selectinload
 
 from backend.app.api.routes.library import get_library_dir
-from backend.app.core.auth import RequireCameraStreamTokenIfAuthEnabled, RequirePermissionIfAuthEnabled
+from backend.app.core.auth import RequirePermissionIfAuthEnabled, require_media_token_permission
 from backend.app.core.config import settings
 from backend.app.core.database import get_db
 from backend.app.core.permissions import Permission
@@ -1412,13 +1412,19 @@ async def upload_project_cover_image(
 async def get_project_cover_image(
     project_id: int,
     db: AsyncSession = Depends(get_db),
-    _: None = RequireCameraStreamTokenIfAuthEnabled,
+    _: User | None = Depends(require_media_token_permission(Permission.PROJECTS_READ)),
 ):
     """Stream the project's cover image (#1155).
 
     Browsers can't attach `Authorization: Bearer ...` to `<img src>` requests,
-    so this route accepts the same `?token=` stream-credential as
-    /archives/{id}/thumbnail. The frontend wraps URLs with `withStreamToken`."""
+    so this route accepts a `?token=` media credential, the same one
+    /archives/{id}/thumbnail takes. The frontend wraps URLs with `withMediaToken`.
+
+    Gated on ``projects:read`` like every other project route. It used to take
+    the camera-stream token, which required ``camera:view`` instead -- an
+    unrelated permission that a user could hold without any project access, and
+    that a project reader could easily lack (#3025). Projects carry no
+    ``created_by_id``, so there is no per-row owner to check beyond that."""
     result = await db.execute(select(Project).where(Project.id == project_id))
     project = result.scalar_one_or_none()
     if not project:

+ 226 - 0
backend/app/core/auth.py

@@ -738,6 +738,11 @@ async def verify_slicer_download_token(token: str, resource_type: str, resource_
 # tags (these cannot send Authorization headers).  Unlike slicer tokens they are
 # NOT single-use — streams reconnect on errors.  Stored in AuthEphemeralToken
 # (token_type="camera_stream") for multi-worker compatibility (M-3).
+#
+# Anonymous by design: the row records no username, so a route guarded by this
+# token knows only "some camera viewer", never which one.  That is fine for a
+# live stream, which is per-printer and not per-user, and is precisely why
+# non-camera media moved to the identified media token in #3025.
 CAMERA_STREAM_TOKEN_EXPIRE_MINUTES = 60
 
 
@@ -893,6 +898,89 @@ async def verify_overlay_token(token: str) -> bool:
         return record is not None
 
 
+# --- Media tokens (#3025) ---
+# Browsers cannot attach ``Authorization`` headers to ``<img src>`` / ``<video
+# src>``, so image routes need a credential that fits in a query parameter.
+# Until #3025 they borrowed the *camera stream* token for that, which had two
+# costs: minting one requires ``camera:view``, so a user could not see a
+# library thumbnail without also being handed the live camera pointed at the
+# operator's room; and a camera-stream token records no principal at all, so
+# the thirteen non-camera routes had no identity to check ownership against
+# and returned any row to any holder.
+#
+# A media token fixes both by following the *websocket* token instead: it
+# stores the username, so ``require_media_token_*`` can resolve the real user
+# and apply the same per-row visibility gate the header-authenticated sibling
+# routes already use. Like the websocket token it is not consumed (a page of
+# thumbnails is many requests) and it outlives a password change by up to its
+# TTL -- acceptable for read-only media at 60 minutes, and identical to the
+# guarantee ``/api/v1/ws`` has made since GHSA-r2qv.
+MEDIA_TOKEN_EXPIRE_MINUTES = 60
+
+
+async def create_media_token(username: str | None) -> str:
+    """Create a reusable token for media (thumbnail / preview / icon) routes.
+
+    Records the issuing principal in ``username`` exactly as
+    :func:`create_websocket_token` does. API-keyed callers reach this with
+    ``None`` and get the empty string, which :func:`verify_media_token`
+    reports back and the dependencies then reject while auth is enabled --
+    an API key has no per-row ownership identity, and it does not need one
+    here because the media routes accept ``X-API-Key`` directly.
+    """
+    now = datetime.now(timezone.utc)
+    expires_at = now + timedelta(minutes=MEDIA_TOKEN_EXPIRE_MINUTES)
+    token = secrets.token_urlsafe(24)
+    async with async_session() as db:
+        # Prune expired tokens opportunistically (same shape as camera/websocket).
+        await db.execute(
+            delete(AuthEphemeralToken).where(
+                AuthEphemeralToken.token_type == "media",
+                AuthEphemeralToken.expires_at < now,
+            )
+        )
+        db.add(
+            AuthEphemeralToken(
+                token=token,
+                token_type="media",
+                username=username or "",
+                expires_at=expires_at,
+            )
+        )
+        await db.commit()
+    return token
+
+
+async def verify_media_token(token: str) -> str | None:
+    """Verify a media token, returning the username it was minted for.
+
+    Returns ``""`` for a token minted by an API key (no per-row identity) and
+    ``None`` when the token is missing / expired / unknown. Not consumed --
+    one token serves every image on a page.
+
+    Deliberately narrower than :func:`verify_camera_stream_token`: no
+    long-lived scope passes here. ``camera_stream`` / ``camwall`` / ``overlay``
+    tokens are handed to kiosks, walls and Home Assistant to display *video*,
+    and are anonymous by construction, so accepting one would reinstate the
+    unowned read this token type exists to close (#3025). The inverse also
+    holds -- see :func:`verify_camwall_token`, which refuses a camera-stream
+    token for the same reason in the other direction.
+    """
+    now = datetime.now(timezone.utc)
+    async with async_session() as db:
+        result = await db.execute(
+            select(AuthEphemeralToken).where(
+                AuthEphemeralToken.token == token,
+                AuthEphemeralToken.token_type == "media",
+                AuthEphemeralToken.expires_at > now,
+            )
+        )
+        row = result.scalar_one_or_none()
+        if row is None:
+            return None
+        return row.username or ""
+
+
 def verify_password(plain_password: str, hashed_password: str) -> bool:
     """Verify a password against a hash.
 
@@ -2037,6 +2125,12 @@ def require_camera_stream_token_if_auth_enabled():
     Used for camera stream/snapshot endpoints that are loaded via <img> tags
     which cannot send Authorization headers. The frontend obtains a token from
     POST /printers/camera/stream-token and appends it as ?token=xxx.
+
+    Camera routes only. Non-camera media (thumbnails, plate previews,
+    timelapses, cover images, icons) takes ``require_media_token_*``: minting a
+    camera-stream token costs ``camera:view``, which no thumbnail should
+    require, and the token names no principal, so a route guarded by it cannot
+    tell one user's rows from another's (#3025).
     """
 
     async def checker(token: str | None = None) -> None:
@@ -2230,3 +2324,135 @@ def require_ownership_permission(
             )
 
     return checker
+
+
+async def _user_from_media_token(token: str) -> User:
+    """Resolve the ``User`` a media token was minted for, or raise 401 (#3025).
+
+    Fail-closed on every miss: an unknown/expired token, a token minted by an
+    API key (empty username -- see :func:`create_media_token`), a username no
+    longer in the table, and a deactivated account all raise rather than fall
+    through to an anonymous read. The 401 detail names the mint endpoint so a
+    stale tab knows how to recover, and the frontend's error handler refreshes
+    the token on the first failed <img> load.
+    """
+    unauthorized = HTTPException(
+        status_code=status.HTTP_401_UNAUTHORIZED,
+        detail="Valid media token required. Obtain one from POST /api/v1/auth/media-token",
+    )
+    username = await verify_media_token(token)
+    if not username:
+        raise unauthorized
+    async with async_session() as db:
+        user = await get_user_by_username(db, username)
+    if user is None or not user.is_active:
+        raise unauthorized
+    return user
+
+
+def require_media_token_permission(*permissions: str | Permission):
+    """Media-route dependency for resources with no per-row ownership (#3025).
+
+    Accepts either a ``?token=`` media token (the ``<img>`` case) or the
+    ordinary ``Authorization`` / ``X-API-Key`` headers, so a ``fetch()`` or an
+    API-keyed integration authenticates here exactly as it does on the
+    resource's sibling routes. Requires ALL of ``permissions``, matching
+    :func:`require_permission_if_auth_enabled`.
+
+    Returns the resolved ``User``, or ``None`` when auth is disabled or the
+    caller is an API key -- the same ``User | None`` contract the header-only
+    dependency has, so handlers need no new branch.
+    """
+    perm_strings = [p.value if isinstance(p, Permission) else p for p in permissions]
+    header_checker = require_permission_if_auth_enabled(*permissions)
+
+    async def checker(
+        token: str | None = None,
+        credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(security)] = None,
+        x_api_key: Annotated[str | None, Header(alias="X-API-Key")] = None,
+    ) -> User | None:
+        async with async_session() as db:
+            if not await is_auth_enabled(db):
+                return None  # Auth disabled, allow access
+        if token:
+            user = await _user_from_media_token(token)
+            missing = [p for p in perm_strings if not user.has_permission(p)]
+            if missing:
+                raise HTTPException(
+                    status_code=status.HTTP_403_FORBIDDEN,
+                    detail=f"Missing required permissions: {', '.join(missing)}",
+                )
+            return user
+        return await header_checker(credentials=credentials, x_api_key=x_api_key)
+
+    return checker
+
+
+def require_media_token_ownership(
+    all_permission: str | Permission,
+    own_permission: str | Permission,
+):
+    """Media-route dependency for ownership-scoped resources (#3025).
+
+    The ownership counterpart of :func:`require_media_token_permission`, and
+    the reason media tokens carry a principal at all: it returns the same
+    ``(user, can_read_all)`` pair as :func:`require_ownership_permission`, so a
+    thumbnail route can hand it straight to the ``_ensure_*_visible`` gate its
+    header-authenticated siblings already use instead of serving any row to any
+    token holder.
+
+    Header callers are delegated to :func:`require_ownership_permission`
+    unchanged -- including its API-key rule, where a key satisfying the ALL
+    permission's scope flag gets ``can_read_all=True`` because keys have no
+    per-row identity.
+    """
+    all_perm = all_permission.value if isinstance(all_permission, Permission) else all_permission
+    own_perm = own_permission.value if isinstance(own_permission, Permission) else own_permission
+    header_checker = require_ownership_permission(all_permission, own_permission)
+
+    async def checker(
+        token: str | None = None,
+        credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(security)] = None,
+        x_api_key: Annotated[str | None, Header(alias="X-API-Key")] = None,
+    ) -> tuple[User | None, bool]:
+        async with async_session() as db:
+            if not await is_auth_enabled(db):
+                return None, True  # Auth disabled, allow all
+        if token:
+            user = await _user_from_media_token(token)
+            if user.has_permission(all_perm):
+                return user, True
+            if user.has_permission(own_perm):
+                return user, False
+            raise HTTPException(
+                status_code=status.HTTP_403_FORBIDDEN,
+                detail=f"Missing permission: {own_perm} or {all_perm}",
+            )
+        return await header_checker(credentials=credentials, x_api_key=x_api_key)
+
+    return checker
+
+
+def require_media_token_printer_permission(permission: str | Permission):
+    """Media-route dependency for per-printer resources (#3025).
+
+    :func:`require_media_token_permission` plus the API key's per-printer
+    allowlist, mirroring :func:`require_printer_permission_if_auth_enabled`.
+    Only the header path can present an API key -- a media token resolves to a
+    real user or to nothing -- so the allowlist check applies there alone.
+    """
+    media_checker = require_media_token_permission(permission)
+
+    async def checker(
+        printer_id: int,
+        token: str | None = None,
+        credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(security)] = None,
+        x_api_key: Annotated[str | None, Header(alias="X-API-Key")] = None,
+    ) -> User | None:
+        user = await media_checker(token=token, credentials=credentials, x_api_key=x_api_key)
+        api_key = await validated_api_key_from_request(credentials, x_api_key)
+        if api_key is not None:
+            check_printer_access(api_key, printer_id)
+        return user
+
+    return checker

+ 425 - 0
backend/tests/integration/test_media_token_3025.py

@@ -0,0 +1,425 @@
+"""Integration tests for the media token (#3025).
+
+Thirteen non-camera media routes -- library and archive thumbnails, plate
+previews, timelapses, print photos, QR codes, project covers, link icons --
+were gated by the *camera stream* token. That had two consequences, and these
+tests pin both fixes:
+
+1. ``camera:view`` was a prerequisite for every image in the app. A user given
+   library access to their own job folder saw broken thumbnails until they were
+   also handed the live feed of the room the printer is in.
+2. A camera stream token records no principal, so those routes had no identity
+   to scope by and returned any row to any holder.
+
+The media token is the replacement: minted behind plain authentication, and
+identified, so each route applies the same permission and ownership rules as
+its header-authenticated siblings.
+"""
+
+from __future__ import annotations
+
+import shutil
+from pathlib import Path
+
+import pytest
+from httpx import AsyncClient
+
+pytestmark = [pytest.mark.asyncio, pytest.mark.integration]
+
+# library:read_own + archives:read_own, and deliberately NOT camera:view --
+# the reporter's exact group in #3025.
+NO_CAMERA_PERMISSIONS = [
+    "library:read_own",
+    "library:upload",
+    "archives:read_own",
+    "projects:read",
+    "external_links:read",
+    "printers:read",
+]
+
+
+async def _admin_token(async_client: AsyncClient, suffix: str) -> str:
+    await async_client.post(
+        "/api/v1/auth/setup",
+        json={
+            "auth_enabled": True,
+            "admin_username": f"mediaadmin{suffix}",
+            "admin_password": "AdminPass1!",
+        },
+    )
+    login = await async_client.post(
+        "/api/v1/auth/login",
+        json={"username": f"mediaadmin{suffix}", "password": "AdminPass1!"},
+    )
+    assert login.status_code == 200, login.text
+    return login.json()["access_token"]
+
+
+async def _make_user(
+    async_client: AsyncClient,
+    admin_jwt: str,
+    *,
+    username: str,
+    permissions: list[str],
+) -> tuple[str, int]:
+    """Create a user in a fresh group holding exactly *permissions*."""
+    group = await async_client.post(
+        "/api/v1/groups/",
+        headers={"Authorization": f"Bearer {admin_jwt}"},
+        json={"name": f"grp_{username}", "permissions": permissions},
+    )
+    assert group.status_code in (200, 201), group.text
+    created = await async_client.post(
+        "/api/v1/users/",
+        headers={"Authorization": f"Bearer {admin_jwt}"},
+        json={
+            "username": username,
+            "password": "UserPass1!",
+            "group_ids": [group.json()["id"]],
+        },
+    )
+    assert created.status_code in (200, 201), created.text
+    login = await async_client.post(
+        "/api/v1/auth/login",
+        json={"username": username, "password": "UserPass1!"},
+    )
+    assert login.status_code == 200, login.text
+    return login.json()["access_token"], created.json()["id"]
+
+
+async def _mint_media_token(async_client: AsyncClient, jwt: str) -> str:
+    response = await async_client.post(
+        "/api/v1/auth/media-token",
+        headers={"Authorization": f"Bearer {jwt}"},
+    )
+    assert response.status_code == 200, response.text
+    return response.json()["token"]
+
+
+async def _mint_camera_token(async_client: AsyncClient, jwt: str) -> str:
+    response = await async_client.post(
+        "/api/v1/printers/camera/stream-token",
+        headers={"Authorization": f"Bearer {jwt}"},
+    )
+    assert response.status_code == 200, response.text
+    return response.json()["token"]
+
+
+# The routes resolve thumbnails relative to ``settings.base_dir``, so the
+# fixtures have to write there rather than into tmp_path. Keep them in one
+# subdirectory and delete it after every test so a run leaves the tree clean.
+_THUMB_DIR = "test_thumbs_3025"
+
+
+@pytest.fixture(autouse=True)
+def _clean_thumbs():
+    from backend.app.core.config import settings
+
+    yield
+    shutil.rmtree(Path(settings.base_dir) / _THUMB_DIR, ignore_errors=True)
+
+
+async def _library_file(db_session, owner_id: int | None, name: str) -> int:
+    """Insert a library row with a real thumbnail on disk."""
+    from backend.app.core.config import settings
+    from backend.app.models.library import LibraryFile
+
+    thumb = Path(settings.base_dir) / _THUMB_DIR / f"{name}.png"
+    thumb.parent.mkdir(parents=True, exist_ok=True)
+    thumb.write_bytes(b"\x89PNG\r\n\x1a\n" + b"0" * 32)
+
+    row = LibraryFile(
+        filename=f"{name}.3mf",
+        file_path=f"library/files/{name}.3mf",
+        thumbnail_path=f"{_THUMB_DIR}/{thumb.name}",
+        file_type="3mf",
+        file_size=1234,
+        created_by_id=owner_id,
+    )
+    db_session.add(row)
+    await db_session.commit()
+    await db_session.refresh(row)
+    return row.id
+
+
+class TestTheUserWhoCouldNotSeeTheirOwnThumbnails:
+    """The reported fault: camera:view was load-bearing for every image."""
+
+    async def test_a_user_without_camera_view_can_mint_a_media_token(self, async_client: AsyncClient):
+        admin = await _admin_token(async_client, "_mint")
+        jwt, _ = await _make_user(async_client, admin, username="nocamera_mint", permissions=NO_CAMERA_PERMISSIONS)
+        response = await async_client.post("/api/v1/auth/media-token", headers={"Authorization": f"Bearer {jwt}"})
+        assert response.status_code == 200, response.text
+        assert response.json()["token"]
+
+    async def test_the_camera_token_is_still_out_of_reach_for_them(self, async_client: AsyncClient):
+        """The permission split is real, not cosmetic: the media token does not
+        smuggle in camera access, and minting a camera token still costs
+        camera:view."""
+        admin = await _admin_token(async_client, "_nocam")
+        jwt, _ = await _make_user(async_client, admin, username="nocamera_still", permissions=NO_CAMERA_PERMISSIONS)
+        response = await async_client.post(
+            "/api/v1/printers/camera/stream-token", headers={"Authorization": f"Bearer {jwt}"}
+        )
+        assert response.status_code == 403
+
+    async def test_they_can_load_their_own_library_thumbnail(self, async_client: AsyncClient, db_session):
+        admin = await _admin_token(async_client, "_own")
+        jwt, user_id = await _make_user(async_client, admin, username="nocamera_own", permissions=NO_CAMERA_PERMISSIONS)
+        file_id = await _library_file(db_session, user_id, "own")
+        token = await _mint_media_token(async_client, jwt)
+
+        response = await async_client.get(f"/api/v1/library/files/{file_id}/thumbnail?token={token}")
+        assert response.status_code == 200, response.text
+        assert response.content.startswith(b"\x89PNG")
+
+
+class TestTheBoundaryBetweenTheTwoTokens:
+    """Neither token is accepted where the other belongs."""
+
+    async def test_a_camera_stream_token_is_refused_on_a_media_route(self, async_client: AsyncClient, db_session):
+        """The inverse of verify_camwall_token's rule. A camera-stream token is
+        anonymous, so honouring it here would reinstate the unowned read."""
+        admin = await _admin_token(async_client, "_xcam")
+        file_id = await _library_file(db_session, None, "xcam")
+        camera_token = await _mint_camera_token(async_client, admin)
+
+        response = await async_client.get(f"/api/v1/library/files/{file_id}/thumbnail?token={camera_token}")
+        assert response.status_code == 401
+
+    async def test_a_media_token_is_refused_on_the_live_camera(self, async_client: AsyncClient):
+        admin = await _admin_token(async_client, "_xmedia")
+        media_token = await _mint_media_token(async_client, admin)
+
+        response = await async_client.get(f"/api/v1/printers/1/camera/snapshot?token={media_token}")
+        assert response.status_code == 401
+
+    async def test_no_token_at_all_is_refused(self, async_client: AsyncClient, db_session):
+        await _admin_token(async_client, "_notok")
+        file_id = await _library_file(db_session, None, "notok")
+
+        response = await async_client.get(f"/api/v1/library/files/{file_id}/thumbnail")
+        assert response.status_code == 401
+
+    async def test_a_garbage_token_is_refused(self, async_client: AsyncClient, db_session):
+        await _admin_token(async_client, "_garbage")
+        file_id = await _library_file(db_session, None, "garbage")
+
+        response = await async_client.get(f"/api/v1/library/files/{file_id}/thumbnail?token=not-a-real-token")
+        assert response.status_code == 401
+
+
+class TestWhoseRowsAMediaTokenCanRead:
+    """The unreported half: the old guard had no principal, so it had nothing
+    to scope by. These fail against the camera-token implementation."""
+
+    async def test_it_cannot_read_another_users_library_thumbnail(self, async_client: AsyncClient, db_session):
+        admin = await _admin_token(async_client, "_cross")
+        _, alice_id = await _make_user(async_client, admin, username="alice_lib", permissions=NO_CAMERA_PERMISSIONS)
+        bob_jwt, _ = await _make_user(async_client, admin, username="bob_lib", permissions=NO_CAMERA_PERMISSIONS)
+        alice_file = await _library_file(db_session, alice_id, "alice")
+        bob_token = await _mint_media_token(async_client, bob_jwt)
+
+        response = await async_client.get(f"/api/v1/library/files/{alice_file}/thumbnail?token={bob_token}")
+        # 404 rather than 403 -- the same id-enumeration-proof answer
+        # _ensure_library_file_visible gives on every other library route.
+        assert response.status_code == 404
+
+    async def test_an_ownerless_file_needs_read_all(self, async_client: AsyncClient, db_session):
+        """Fail-closed, matching _ensure_library_file_visible."""
+        admin = await _admin_token(async_client, "_orphan")
+        jwt, _ = await _make_user(async_client, admin, username="orphan_reader", permissions=NO_CAMERA_PERMISSIONS)
+        file_id = await _library_file(db_session, None, "orphan")
+        token = await _mint_media_token(async_client, jwt)
+
+        response = await async_client.get(f"/api/v1/library/files/{file_id}/thumbnail?token={token}")
+        assert response.status_code == 404
+
+    async def test_an_admin_with_read_all_still_sees_everything(self, async_client: AsyncClient, db_session):
+        """The gate must not over-correct into breaking legitimate access."""
+        admin = await _admin_token(async_client, "_readall")
+        _, alice_id = await _make_user(async_client, admin, username="alice_readall", permissions=NO_CAMERA_PERMISSIONS)
+        alice_file = await _library_file(db_session, alice_id, "readall")
+        admin_token = await _mint_media_token(async_client, admin)
+
+        response = await async_client.get(f"/api/v1/library/files/{alice_file}/thumbnail?token={admin_token}")
+        assert response.status_code == 200
+
+
+class TestWhatTheTokenStillRequires:
+    """A media token is authentication, not authorisation -- each route keeps
+    asking for the permission its resource is governed by."""
+
+    async def test_a_user_without_library_permission_is_refused(self, async_client: AsyncClient, db_session):
+        admin = await _admin_token(async_client, "_noperm")
+        jwt, user_id = await _make_user(async_client, admin, username="noperm_user", permissions=["printers:read"])
+        file_id = await _library_file(db_session, user_id, "noperm")
+        token = await _mint_media_token(async_client, jwt)
+
+        response = await async_client.get(f"/api/v1/library/files/{file_id}/thumbnail?token={token}")
+        assert response.status_code == 403
+
+    async def test_a_deactivated_users_token_stops_working(self, async_client: AsyncClient, db_session):
+        """The token outlives the session it was minted in, so the principal is
+        re-resolved on every request rather than trusted from mint time."""
+        admin = await _admin_token(async_client, "_deact")
+        jwt, user_id = await _make_user(async_client, admin, username="deact_user", permissions=NO_CAMERA_PERMISSIONS)
+        file_id = await _library_file(db_session, user_id, "deact")
+        token = await _mint_media_token(async_client, jwt)
+        assert (await async_client.get(f"/api/v1/library/files/{file_id}/thumbnail?token={token}")).status_code == 200
+
+        deactivate = await async_client.patch(
+            f"/api/v1/users/{user_id}",
+            headers={"Authorization": f"Bearer {admin}"},
+            json={"is_active": False},
+        )
+        assert deactivate.status_code == 200, deactivate.text
+
+        response = await async_client.get(f"/api/v1/library/files/{file_id}/thumbnail?token={token}")
+        assert response.status_code == 401
+
+
+class TestTheHeaderPathStillWorks:
+    """A media route is reachable with ordinary credentials too, so a fetch()
+    or an API-keyed integration does not need a token at all."""
+
+    async def test_a_bearer_jwt_reaches_a_media_route_without_any_token(self, async_client: AsyncClient, db_session):
+        admin = await _admin_token(async_client, "_bearer")
+        jwt, user_id = await _make_user(async_client, admin, username="bearer_user", permissions=NO_CAMERA_PERMISSIONS)
+        file_id = await _library_file(db_session, user_id, "bearer")
+
+        response = await async_client.get(
+            f"/api/v1/library/files/{file_id}/thumbnail",
+            headers={"Authorization": f"Bearer {jwt}"},
+        )
+        assert response.status_code == 200
+
+    async def test_the_header_path_is_ownership_scoped_too(self, async_client: AsyncClient, db_session):
+        admin = await _admin_token(async_client, "_bearerx")
+        _, alice_id = await _make_user(async_client, admin, username="alice_bearer", permissions=NO_CAMERA_PERMISSIONS)
+        bob_jwt, _ = await _make_user(async_client, admin, username="bob_bearer", permissions=NO_CAMERA_PERMISSIONS)
+        alice_file = await _library_file(db_session, alice_id, "alicebearer")
+
+        response = await async_client.get(
+            f"/api/v1/library/files/{alice_file}/thumbnail",
+            headers={"Authorization": f"Bearer {bob_jwt}"},
+        )
+        assert response.status_code == 404
+
+
+class TestAuthDisabled:
+    async def test_media_routes_stay_open_when_auth_is_off(self, async_client: AsyncClient, db_session):
+        """No setup call -- auth is off, and the routes must not start
+        demanding a token that an unauthenticated install cannot mint."""
+        file_id = await _library_file(db_session, None, "authoff")
+
+        response = await async_client.get(f"/api/v1/library/files/{file_id}/thumbnail")
+        assert response.status_code == 200
+
+
+async def _archive(db_session, owner_id: int | None, name: str) -> int:
+    """Insert an archive with a real thumbnail and timelapse on disk."""
+    from backend.app.core.config import settings
+    from backend.app.models.archive import PrintArchive
+
+    base = Path(settings.base_dir) / _THUMB_DIR
+    base.mkdir(parents=True, exist_ok=True)
+    (base / f"{name}_thumb.png").write_bytes(b"\x89PNG\r\n\x1a\n" + b"0" * 32)
+    (base / f"{name}_tl.mp4").write_bytes(b"\x00\x00\x00 ftypisom" + b"0" * 32)
+
+    row = PrintArchive(
+        filename=f"{name}.3mf",
+        file_path=f"archives/{name}.3mf",
+        file_size=1234,
+        thumbnail_path=f"{_THUMB_DIR}/{name}_thumb.png",
+        timelapse_path=f"{_THUMB_DIR}/{name}_tl.mp4",
+        created_by_id=owner_id,
+    )
+    db_session.add(row)
+    await db_session.commit()
+    await db_session.refresh(row)
+    return row.id
+
+
+class TestTheArchiveMediaRoutes:
+    """The seven archive routes are where the sensitive content lives -- a
+    timelapse and the finish photos are a video of someone's room. They are
+    covered separately from library because the existing integration suite runs
+    with auth disabled, so nothing else exercises them with auth on."""
+
+    async def test_an_owner_can_load_their_archive_thumbnail(self, async_client: AsyncClient, db_session):
+        admin = await _admin_token(async_client, "_arcown")
+        jwt, uid = await _make_user(async_client, admin, username="arc_owner", permissions=NO_CAMERA_PERMISSIONS)
+        archive_id = await _archive(db_session, uid, "arcown")
+        token = await _mint_media_token(async_client, jwt)
+
+        response = await async_client.get(f"/api/v1/archives/{archive_id}/thumbnail?token={token}")
+        assert response.status_code == 200, response.text
+
+    async def test_another_user_cannot_load_that_thumbnail(self, async_client: AsyncClient, db_session):
+        admin = await _admin_token(async_client, "_arcx")
+        _, alice_id = await _make_user(async_client, admin, username="alice_arc", permissions=NO_CAMERA_PERMISSIONS)
+        bob_jwt, _ = await _make_user(async_client, admin, username="bob_arc", permissions=NO_CAMERA_PERMISSIONS)
+        archive_id = await _archive(db_session, alice_id, "arcx")
+        bob_token = await _mint_media_token(async_client, bob_jwt)
+
+        response = await async_client.get(f"/api/v1/archives/{archive_id}/thumbnail?token={bob_token}")
+        assert response.status_code == 404
+
+    async def test_another_user_cannot_load_that_timelapse(self, async_client: AsyncClient, db_session):
+        """The one that matters most: a timelapse is footage of the room the
+        printer is in."""
+        admin = await _admin_token(async_client, "_arctl")
+        _, alice_id = await _make_user(async_client, admin, username="alice_tl", permissions=NO_CAMERA_PERMISSIONS)
+        bob_jwt, _ = await _make_user(async_client, admin, username="bob_tl", permissions=NO_CAMERA_PERMISSIONS)
+        archive_id = await _archive(db_session, alice_id, "arctl")
+        bob_token = await _mint_media_token(async_client, bob_jwt)
+
+        assert (await async_client.get(f"/api/v1/archives/{archive_id}/timelapse?token={bob_token}")).status_code == 404
+
+    async def test_a_camera_token_reaches_no_archive_media(self, async_client: AsyncClient, db_session):
+        admin = await _admin_token(async_client, "_arccam")
+        archive_id = await _archive(db_session, None, "arccam")
+        camera_token = await _mint_camera_token(async_client, admin)
+
+        for path in ("thumbnail", "timelapse", "plate-preview", "qrcode"):
+            response = await async_client.get(f"/api/v1/archives/{archive_id}/{path}?token={camera_token}")
+            assert response.status_code == 401, f"{path} accepted a camera token: {response.status_code}"
+
+
+class TestTheFlatPermissionMediaRoutes:
+    """printers/{id}/cover, external-links/{id}/icon and projects/{id}/cover-image
+    have no per-row owner, so they gate on the resource's read permission."""
+
+    async def test_the_link_icon_needs_external_links_read(self, async_client: AsyncClient):
+        admin = await _admin_token(async_client, "_icon")
+        jwt, _ = await _make_user(async_client, admin, username="icon_user", permissions=["printers:read"])
+        token = await _mint_media_token(async_client, jwt)
+
+        response = await async_client.get(f"/api/v1/external-links/1/icon?token={token}")
+        assert response.status_code == 403
+
+    async def test_the_link_icon_is_reachable_with_that_permission(self, async_client: AsyncClient):
+        admin = await _admin_token(async_client, "_icon2")
+        jwt, _ = await _make_user(async_client, admin, username="icon_user2", permissions=NO_CAMERA_PERMISSIONS)
+        token = await _mint_media_token(async_client, jwt)
+
+        # 404 because no such link exists -- the point is that it is not 401/403.
+        response = await async_client.get(f"/api/v1/external-links/1/icon?token={token}")
+        assert response.status_code == 404
+
+    async def test_the_printer_cover_needs_printers_read(self, async_client: AsyncClient):
+        admin = await _admin_token(async_client, "_cover")
+        jwt, _ = await _make_user(async_client, admin, username="cover_user", permissions=["external_links:read"])
+        token = await _mint_media_token(async_client, jwt)
+
+        response = await async_client.get(f"/api/v1/printers/1/cover?token={token}")
+        assert response.status_code == 403
+
+    async def test_the_project_cover_needs_projects_read(self, async_client: AsyncClient):
+        admin = await _admin_token(async_client, "_pcover")
+        jwt, _ = await _make_user(async_client, admin, username="pcover_user", permissions=["printers:read"])
+        token = await _mint_media_token(async_client, jwt)
+
+        response = await async_client.get(f"/api/v1/projects/1/cover-image?token={token}")
+        assert response.status_code == 403

+ 18 - 14
backend/tests/integration/test_projects_api.py

@@ -220,13 +220,18 @@ class TestProjectUrlAndCoverImage:
         assert response.status_code == 400
 
     @pytest.mark.integration
-    def test_cover_image_get_uses_stream_token_gate(self):
-        """Regression guard: GET /projects/{id}/cover-image MUST be gated by
-        ``RequireCameraStreamTokenIfAuthEnabled`` (accepts ``?token=…`` query
-        string) rather than by the bearer-token gate, because browsers can't
-        attach an ``Authorization`` header to ``<img src>`` requests. Swapping
-        back to the bearer gate would silently 401 every cover image when auth
-        is enabled."""
+    def test_cover_image_get_uses_query_token_gate(self):
+        """Regression guard: GET /projects/{id}/cover-image MUST be gated by a
+        dependency that accepts ``?token=…`` in the query string rather than by
+        a header-only bearer gate, because browsers can't attach an
+        ``Authorization`` header to ``<img src>`` requests. Swapping to a
+        header-only gate would silently 401 every cover image when auth is
+        enabled.
+
+        The token type changed in #3025 -- the route took the camera-stream
+        token until then, which made ``camera:view`` a prerequisite for seeing
+        a project cover -- so this pins the media gate. What it is really
+        asserting is unchanged: the credential has to fit in a URL."""
         from fastapi.routing import APIRoute
 
         from backend.app.api.routes.projects import router
@@ -242,21 +247,20 @@ class TestProjectUrlAndCoverImage:
 
         assert cover_get is not None, "GET cover-image route missing"
 
-        # The route's dependant tree includes a Depends(require_camera_stream_token_if_auth_enabled())
+        # The route's dependant tree includes a Depends(require_media_token_permission(...))
         # — its `call` is the inner check function returned by that factory.
         # Walk the dependant tree and assert one of the dependencies came from
-        # the stream-token factory, NOT from require_permission_if_auth_enabled.
-        from backend.app.core.auth import (
-            require_camera_stream_token_if_auth_enabled,
-        )
+        # the media-token factory, NOT from require_permission_if_auth_enabled.
+        from backend.app.core.auth import require_media_token_permission
+        from backend.app.core.permissions import Permission
 
         # The factory returns a fresh closure each call; the most reliable
         # signature is the qualified name of the function in the closure chain.
-        expected_qualname = require_camera_stream_token_if_auth_enabled().__qualname__
+        expected_qualname = require_media_token_permission(Permission.PROJECTS_READ).__qualname__
 
         gate_qualnames = [dep.call.__qualname__ for dep in cover_get.dependant.dependencies if dep.call]
         assert expected_qualname in gate_qualnames, (
-            f"GET cover-image route is not gated by RequireCameraStreamTokenIfAuthEnabled. Found: {gate_qualnames}"
+            f"GET cover-image route is not gated by a media-token dependency. Found: {gate_qualnames}"
         )
 
 

+ 20 - 9
frontend/src/__tests__/api/client.test.ts

@@ -5,7 +5,7 @@
 import { describe, it, expect, afterEach, vi } from 'vitest';
 import { http, HttpResponse } from 'msw';
 import { setupServer } from 'msw/node';
-import { setAuthToken, getAuthToken, api, setStreamToken } from '../../api/client';
+import { setAuthToken, getAuthToken, api, setMediaToken } from '../../api/client';
 
 // Mock sessionStorage (H-5: tokens are stored in sessionStorage, not localStorage)
 const sessionStorageMock = {
@@ -364,33 +364,44 @@ describe('Printer control endpoints', () => {
 });
 
 // #1155 — `<img src>` can't carry an `Authorization: Bearer …` header, so the
-// project cover-image URL must use the same stream-token pattern as
-// /archives/{id}/thumbnail. A regression where `withStreamToken` is removed
+// project cover-image URL must use the same query-token pattern as
+// /archives/{id}/thumbnail. A regression where the token wrapper is removed
 // would break the modal preview AND the card thumbnail when auth is enabled.
+// The token became the media token in #3025; the requirement is unchanged.
 describe('Project cover image URL (#1155)', () => {
   afterEach(() => {
-    setStreamToken(null);
+    setMediaToken(null);
   });
 
-  it('appends the stream token query string when one is set', () => {
-    setStreamToken('abc123');
+  it('appends the media token query string when one is set', () => {
+    setMediaToken('abc123');
     const url = api.getProjectCoverImageUrl(42);
     expect(url).toContain('/projects/42/cover-image');
     expect(url).toContain('token=abc123');
   });
 
-  it('returns the bare URL when no stream token is set', () => {
-    setStreamToken(null);
+  it('returns the bare URL when no media token is set', () => {
+    setMediaToken(null);
     const url = api.getProjectCoverImageUrl(42);
     expect(url).toContain('/projects/42/cover-image');
     expect(url).not.toContain('token=');
   });
 
   it('URL-encodes a token containing query-string-unsafe characters', () => {
-    setStreamToken('a&b=c');
+    setMediaToken('a&b=c');
     const url = api.getProjectCoverImageUrl(7);
     // Decoded back, the token must round-trip exactly.
     const params = new URL(url, 'http://x').searchParams;
     expect(params.get('token')).toBe('a&b=c');
   });
+
+  // #3025 — the cache-buster has to go on before the token does. Callers used
+  // to append their own `?v=` to a URL that already ended in `?token=…`, so the
+  // second `?` landed inside the token value and the image 401'd.
+  it('keeps the token intact when a cache-busting version is requested', () => {
+    setMediaToken('abc123');
+    const params = new URL(api.getProjectCoverImageUrl(42, 'v9'), 'http://x').searchParams;
+    expect(params.get('token')).toBe('abc123');
+    expect(params.get('v')).toBe('v9');
+  });
 });

+ 7 - 5
frontend/src/__tests__/components/ModelViewerModal.test.tsx

@@ -7,7 +7,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
 import { screen, fireEvent, waitFor } from '@testing-library/react';
 import { render } from '../utils';
 import { ModelViewerModal } from '../../components/ModelViewerModal';
-import { setStreamToken } from '../../api/client';
+import { setMediaToken } from '../../api/client';
 import { openInSlicer } from '../../utils/slicer';
 import { http, HttpResponse } from 'msw';
 import { server } from '../mocks/server';
@@ -343,12 +343,14 @@ describe('ModelViewerModal', () => {
       });
     });
 
-    // #2661: plate-thumbnail endpoints are gated behind a camera stream token
+    // #2661: plate-thumbnail endpoints are gated behind a query token
     // (an <img> can't send a Bearer header), so the src must carry ?token=.
     // Without it the 3D Preview thumbnails 401 while the Slice dialog (which
     // already appends the token) shows the same file's thumbnails fine.
-    it('appends the camera stream token to plate thumbnail URLs', async () => {
-      setStreamToken('tok-2661');
+    // #3025 moved these off the camera token onto the media token, so a user
+    // without camera:view can see them; the src requirement is unchanged.
+    it('appends the media token to plate thumbnail URLs', async () => {
+      setMediaToken('tok-2661');
       try {
         render(
           <ModelViewerModal
@@ -366,7 +368,7 @@ describe('ModelViewerModal', () => {
         expect(thumb.src).toContain('/api/v1/archives/1/plates/1/thumbnail');
         expect(thumb.src).toContain('token=tok-2661');
       } finally {
-        setStreamToken(null);
+        setMediaToken(null);
       }
     });
 

+ 69 - 11
frontend/src/__tests__/hooks/useCameraStreamToken.test.ts

@@ -1,11 +1,14 @@
 /**
  * Unit tests for rewriteMediaSrcWithToken — the DOM walker that retrofits a
- * camera stream token onto <img>/<video> src URLs that rendered before the
- * token arrived (regression guard for the post-login blank-thumbnails bug).
+ * query token onto <img>/<video> src URLs that rendered before the token
+ * arrived (regression guard for the post-login blank-thumbnails bug).
+ *
+ * Since #3025 it carries two tokens and picks per URL: live-camera URLs take
+ * the camera stream token, everything else takes the media token.
  */
 
 import { afterEach, beforeEach, describe, expect, it } from 'vitest';
-import { rewriteMediaSrcWithToken } from '../../hooks/useCameraStreamToken';
+import { isCameraUrl, rewriteMediaSrcWithToken } from '../../hooks/useCameraStreamToken';
 
 describe('rewriteMediaSrcWithToken', () => {
   let root: HTMLDivElement;
@@ -35,52 +38,107 @@ describe('rewriteMediaSrcWithToken', () => {
 
   it('appends token to /api/v1/ images that have no query string', () => {
     const img = addImg('/api/v1/library/files/42/thumbnail');
-    const count = rewriteMediaSrcWithToken(root, 'abc123');
+    const count = rewriteMediaSrcWithToken(root, 'abc123', null);
     expect(count).toBe(1);
     expect(img.getAttribute('src')).toBe('/api/v1/library/files/42/thumbnail?token=abc123');
   });
 
   it('appends token to URLs that already have a query string using & separator', () => {
     const img = addImg('/api/v1/archives/5/thumbnail?v=1700000000000');
-    rewriteMediaSrcWithToken(root, 'abc123');
+    rewriteMediaSrcWithToken(root, 'abc123', null);
     expect(img.getAttribute('src')).toBe('/api/v1/archives/5/thumbnail?v=1700000000000&token=abc123');
   });
 
   it('leaves images alone that already carry the current token', () => {
     const img = addImg('/api/v1/library/files/42/thumbnail?token=abc123');
-    const count = rewriteMediaSrcWithToken(root, 'abc123');
+    const count = rewriteMediaSrcWithToken(root, 'abc123', null);
     expect(count).toBe(0);
     expect(img.getAttribute('src')).toBe('/api/v1/library/files/42/thumbnail?token=abc123');
   });
 
   it('replaces a stale token with the current one', () => {
     const img = addImg('/api/v1/library/files/42/thumbnail?token=OLD');
-    rewriteMediaSrcWithToken(root, 'NEW');
+    rewriteMediaSrcWithToken(root, 'NEW', null);
     expect(img.getAttribute('src')).toBe('/api/v1/library/files/42/thumbnail?token=NEW');
   });
 
   it('replaces a stale token that sits in the middle of the query string', () => {
     const img = addImg('/api/v1/archives/5/thumbnail?token=OLD&v=1700000000000');
-    rewriteMediaSrcWithToken(root, 'NEW');
+    rewriteMediaSrcWithToken(root, 'NEW', null);
     // Old token stripped, v preserved, new token appended.
     expect(img.getAttribute('src')).toBe('/api/v1/archives/5/thumbnail?v=1700000000000&token=NEW');
   });
 
   it('ignores images that do not point at /api/v1/', () => {
     const img = addImg('https://cdn.example.com/static/logo.png');
-    rewriteMediaSrcWithToken(root, 'abc123');
+    rewriteMediaSrcWithToken(root, 'abc123', null);
     expect(img.getAttribute('src')).toBe('https://cdn.example.com/static/logo.png');
   });
 
   it('updates <video> elements as well', () => {
     const v = addVideo('/api/v1/printers/7/camera/stream?fps=10');
-    rewriteMediaSrcWithToken(root, 'abc123');
+    rewriteMediaSrcWithToken(root, null, 'abc123');
     expect(v.getAttribute('src')).toBe('/api/v1/printers/7/camera/stream?fps=10&token=abc123');
   });
 
   it('url-encodes tokens containing special characters', () => {
     const img = addImg('/api/v1/library/files/42/thumbnail');
-    rewriteMediaSrcWithToken(root, 'a b/c=d');
+    rewriteMediaSrcWithToken(root, 'a b/c=d', null);
     expect(img.getAttribute('src')).toBe('/api/v1/library/files/42/thumbnail?token=a%20b%2Fc%3Dd');
   });
 });
+
+// #3025 — the two tokens are not interchangeable. A user without camera:view
+// holds a media token and no camera token; sending the media token to a camera
+// route (or the camera token to a thumbnail) would 401 either way.
+describe('rewriteMediaSrcWithToken picks the token per URL (#3025)', () => {
+  let root: HTMLDivElement;
+
+  beforeEach(() => {
+    root = document.createElement('div');
+    document.body.appendChild(root);
+  });
+
+  afterEach(() => {
+    root.remove();
+  });
+
+  const addImg = (src: string) => {
+    const img = document.createElement('img');
+    img.setAttribute('src', src);
+    root.appendChild(img);
+    return img;
+  };
+
+  it('gives a thumbnail the media token, not the camera token', () => {
+    const img = addImg('/api/v1/library/files/42/thumbnail');
+    rewriteMediaSrcWithToken(root, 'media-tok', 'camera-tok');
+    expect(img.getAttribute('src')).toBe('/api/v1/library/files/42/thumbnail?token=media-tok');
+  });
+
+  it('gives a live camera stream the camera token, not the media token', () => {
+    const img = addImg('/api/v1/printers/7/camera/stream?fps=10');
+    rewriteMediaSrcWithToken(root, 'media-tok', 'camera-tok');
+    expect(img.getAttribute('src')).toBe('/api/v1/printers/7/camera/stream?fps=10&token=camera-tok');
+  });
+
+  it('still rewrites thumbnails for a user who has no camera token at all', () => {
+    const thumb = addImg('/api/v1/archives/5/thumbnail');
+    const stream = addImg('/api/v1/printers/7/camera/stream?fps=10');
+    const count = rewriteMediaSrcWithToken(root, 'media-tok', null);
+    expect(count).toBe(1);
+    expect(thumb.getAttribute('src')).toBe('/api/v1/archives/5/thumbnail?token=media-tok');
+    // Left untouched rather than given a token that would not work on it.
+    expect(stream.getAttribute('src')).toBe('/api/v1/printers/7/camera/stream?fps=10');
+  });
+
+  it('classifies the three camera routes as camera and the media routes as media', () => {
+    expect(isCameraUrl('/api/v1/printers/1/camera/stream?fps=10')).toBe(true);
+    expect(isCameraUrl('/api/v1/printers/1/camera/snapshot')).toBe(true);
+    expect(isCameraUrl('/api/v1/printers/1/camera/plate-detection/references/0/thumbnail')).toBe(true);
+    expect(isCameraUrl('/api/v1/library/files/42/thumbnail')).toBe(false);
+    expect(isCameraUrl('/api/v1/archives/5/timelapse')).toBe(false);
+    expect(isCameraUrl('/api/v1/printers/1/cover')).toBe(false);
+    expect(isCameraUrl('/api/v1/external-links/3/icon')).toBe(false);
+  });
+});

+ 117 - 0
frontend/src/__tests__/hooks/useStreamTokenSync3025.test.tsx

@@ -0,0 +1,117 @@
+/**
+ * Tests for the two-token split in useStreamTokenSync (#3025).
+ *
+ * Thumbnails, plate previews, timelapses, cover images and link icons used to
+ * ride on the camera stream token, so a user without camera:view saw broken
+ * images everywhere and got a 403 from the camera mint on every page load.
+ * The hook now fetches a media token for everyone and asks for a camera token
+ * only when the user can actually have one.
+ */
+
+import type { ReactNode } from 'react';
+import { describe, it, expect, afterEach, beforeEach, vi } from 'vitest';
+import { renderHook, waitFor, cleanup } from '@testing-library/react';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { http, HttpResponse } from 'msw';
+import { server } from '../mocks/server';
+import { useStreamTokenSync } from '../../hooks/useCameraStreamToken';
+import { getMediaToken, getStreamToken, setMediaToken, setStreamToken } from '../../api/client';
+
+const auth = {
+  authEnabled: true,
+  user: { id: 7 } as { id: number } | null,
+  loading: false,
+  granted: ['library:read_own'] as string[],
+};
+
+vi.mock('../../contexts/AuthContext', () => ({
+  useAuth: () => ({
+    authEnabled: auth.authEnabled,
+    user: auth.user,
+    loading: auth.loading,
+    hasPermission: (p: string) => auth.granted.includes(p),
+  }),
+}));
+
+function wrapper() {
+  const qc = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } });
+  return ({ children }: { children: ReactNode }) => (
+    <QueryClientProvider client={qc}>{children}</QueryClientProvider>
+  );
+}
+
+let mediaMints = 0;
+let cameraMints = 0;
+
+beforeEach(() => {
+  mediaMints = 0;
+  cameraMints = 0;
+  auth.authEnabled = true;
+  auth.user = { id: 7 };
+  auth.loading = false;
+  auth.granted = ['library:read_own'];
+  server.use(
+    http.post('*/api/v1/auth/media-token', () => {
+      mediaMints += 1;
+      return HttpResponse.json({ token: 'media-tok' });
+    }),
+    http.post('*/api/v1/printers/camera/stream-token', () => {
+      cameraMints += 1;
+      return HttpResponse.json({ token: 'camera-tok' });
+    })
+  );
+});
+
+afterEach(() => {
+  cleanup();
+  setMediaToken(null);
+  setStreamToken(null);
+});
+
+describe('useStreamTokenSync token split (#3025)', () => {
+  it('gives a user without camera:view a media token', async () => {
+    renderHook(() => useStreamTokenSync(), { wrapper: wrapper() });
+    await waitFor(() => expect(getMediaToken()).toBe('media-tok'));
+  });
+
+  it('does not ask the camera mint for a user without camera:view', async () => {
+    renderHook(() => useStreamTokenSync(), { wrapper: wrapper() });
+    await waitFor(() => expect(mediaMints).toBe(1));
+    // The 403 this used to produce on every page load was the reporter's only
+    // clue that thumbnails were gated on the camera at all.
+    expect(cameraMints).toBe(0);
+    expect(getStreamToken()).toBeNull();
+  });
+
+  it('fetches both tokens for a user who does have camera:view', async () => {
+    auth.granted = ['library:read_own', 'camera:view'];
+    renderHook(() => useStreamTokenSync(), { wrapper: wrapper() });
+    await waitFor(() => expect(getMediaToken()).toBe('media-tok'));
+    await waitFor(() => expect(getStreamToken()).toBe('camera-tok'));
+  });
+
+  it('fetches nothing while auth is still bootstrapping', async () => {
+    auth.loading = true;
+    renderHook(() => useStreamTokenSync(), { wrapper: wrapper() });
+    await new Promise((r) => setTimeout(r, 20));
+    expect(mediaMints).toBe(0);
+    expect(cameraMints).toBe(0);
+  });
+
+  it('fetches nothing when auth is enabled and nobody is signed in', async () => {
+    auth.user = null;
+    renderHook(() => useStreamTokenSync(), { wrapper: wrapper() });
+    await new Promise((r) => setTimeout(r, 20));
+    expect(mediaMints).toBe(0);
+    expect(cameraMints).toBe(0);
+  });
+
+  it('fetches both when auth is disabled, where neither mint is gated', async () => {
+    auth.authEnabled = false;
+    auth.user = null;
+    auth.granted = [];
+    renderHook(() => useStreamTokenSync(), { wrapper: wrapper() });
+    await waitFor(() => expect(getMediaToken()).toBe('media-tok'));
+    await waitFor(() => expect(getStreamToken()).toBe('camera-tok'));
+  });
+});

+ 58 - 17
frontend/src/api/client.ts

@@ -64,9 +64,21 @@ export function getAuthToken(): string | null {
   return authToken;
 }
 
-// Stream token for image/video URLs loaded via <img>/<video> tags
-// (these can't send Authorization headers, so a query param token is used)
+// Query-param tokens for <img>/<video> src URLs, which can't carry an
+// Authorization header. There are two, and which one a URL takes is decided
+// by what the URL points at, not by convenience:
+//
+//   streamToken — live camera only. Minting one costs camera:view.
+//   mediaToken  — thumbnails, previews, timelapses, covers, icons. Minted by
+//                 any signed-in user and carries their identity, so those
+//                 routes can apply the same ownership rules as everywhere else.
+//
+// Before #3025 the stream token served both, which meant a user could not see
+// a library thumbnail without also being granted the live feed of the room the
+// printer sits in — and, because a stream token names nobody, those routes had
+// no identity to scope by and served any row to any holder.
 let streamToken: string | null = null;
+let mediaToken: string | null = null;
 
 export function setStreamToken(token: string | null) {
   streamToken = token;
@@ -76,13 +88,28 @@ export function getStreamToken(): string | null {
   return streamToken;
 }
 
-/** Append the stream token to a URL if available (for <img>/<video> src). */
+export function setMediaToken(token: string | null) {
+  mediaToken = token;
+}
+
+export function getMediaToken(): string | null {
+  return mediaToken;
+}
+
+/** Append the camera stream token to a URL if available (live camera only). */
 export function withStreamToken(url: string): string {
   if (!streamToken) return url;
   const sep = url.includes('?') ? '&' : '?';
   return `${url}${sep}token=${encodeURIComponent(streamToken)}`;
 }
 
+/** Append the media token to a URL if available (for <img>/<video> src). */
+export function withMediaToken(url: string): string {
+  if (!mediaToken) return url;
+  const sep = url.includes('?') ? '&' : '?';
+  return `${url}${sep}token=${encodeURIComponent(mediaToken)}`;
+}
+
 function parseContentDispositionFilename(header: string | null): string | null {
   if (!header) return null;
   // RFC 5987: filename*=utf-8''percent-encoded-name
@@ -4890,7 +4917,7 @@ export const api = {
       is_multi_plate: boolean;
     }>(`/printers/${printerId}/files/plates?path=${encodeURIComponent(path)}`),
   getPrinterFilePlateThumbnail: (printerId: number, plateIndex: number, path: string) =>
-    withStreamToken(`${API_BASE}/printers/${printerId}/files/plate-thumbnail/${plateIndex}?path=${encodeURIComponent(path)}`),
+    withMediaToken(`${API_BASE}/printers/${printerId}/files/plate-thumbnail/${plateIndex}?path=${encodeURIComponent(path)}`),
   downloadPrinterFilesAsZip: async (
     printerId: number,
     paths: string[],
@@ -5193,9 +5220,9 @@ export const api = {
     request<{ updated: number; errors: Array<{ id: number; error: string }> }>('/archives/backfill-hashes', {
       method: 'POST',
     }),
-  getArchiveThumbnail: (id: number) => withStreamToken(`${API_BASE}/archives/${id}/thumbnail?v=${Date.now()}`),
+  getArchiveThumbnail: (id: number) => withMediaToken(`${API_BASE}/archives/${id}/thumbnail?v=${Date.now()}`),
   getArchivePlateThumbnail: (id: number, plateIndex: number) =>
-    withStreamToken(`${API_BASE}/archives/${id}/plate-thumbnail/${plateIndex}`),
+    withMediaToken(`${API_BASE}/archives/${id}/plate-thumbnail/${plateIndex}`),
   getArchiveDownload: (id: number) => `${API_BASE}/archives/${id}/download`,
   downloadArchive: async (id: number, filename?: string): Promise<void> => {
     const headers: Record<string, string> = {};
@@ -5220,8 +5247,8 @@ export const api = {
     window.URL.revokeObjectURL(url);
   },
   getArchiveGcode: (id: number) => `${API_BASE}/archives/${id}/gcode`,
-  getArchivePlatePreview: (id: number) => withStreamToken(`${API_BASE}/archives/${id}/plate-preview`),
-  getArchiveTimelapse: (id: number) => withStreamToken(`${API_BASE}/archives/${id}/timelapse?v=${Date.now()}`),
+  getArchivePlatePreview: (id: number) => withMediaToken(`${API_BASE}/archives/${id}/plate-preview`),
+  getArchiveTimelapse: (id: number) => withMediaToken(`${API_BASE}/archives/${id}/timelapse?v=${Date.now()}`),
   downloadArchiveTimelapse: async (id: number, filename: string): Promise<void> => {
     const prepared = await request<{ token: string; filename: string }>(
       `/archives/${id}/media-download-token`,
@@ -5329,7 +5356,7 @@ export const api = {
   },
   // Photos
   getArchivePhotoUrl: (archiveId: number, filename: string) =>
-    withStreamToken(`${API_BASE}/archives/${archiveId}/photos/${encodeURIComponent(filename)}`),
+    withMediaToken(`${API_BASE}/archives/${archiveId}/photos/${encodeURIComponent(filename)}`),
   uploadArchivePhoto: async (archiveId: number, file: File): Promise<{ status: string; filename: string; photos: string[] }> => {
     const formData = new FormData();
     formData.append('file', file);
@@ -5460,7 +5487,7 @@ export const api = {
 
   // QR Code
   getArchiveQRCodeUrl: (archiveId: number, size = 200) =>
-    withStreamToken(`${API_BASE}/archives/${archiveId}/qrcode?size=${size}`),
+    withMediaToken(`${API_BASE}/archives/${archiveId}/qrcode?size=${size}`),
   getArchiveCapabilities: (id: number) =>
     request<{
       has_model: boolean;
@@ -5507,7 +5534,7 @@ export const api = {
       body: JSON.stringify(data),
     }),
   getArchiveProjectImageUrl: (archiveId: number, imagePath: string) =>
-    withStreamToken(`${API_BASE}/archives/${archiveId}/project-image/${encodeURIComponent(imagePath)}`),
+    withMediaToken(`${API_BASE}/archives/${archiveId}/project-image/${encodeURIComponent(imagePath)}`),
   getArchiveForSlicer: (id: number, filename: string) => {
     const safe = filename.replace(/[/\\?#]/g, '_');
     return `${API_BASE}/archives/${id}/file/${encodeURIComponent(safe.endsWith('.3mf') ? safe : safe + '.3mf')}`;
@@ -5620,7 +5647,7 @@ export const api = {
     if (params?.sortDir) searchParams.set('sort_dir', params.sortDir);
     return request<PrintLogResponse>(`/print-log/?${searchParams}`);
   },
-  getPrintLogThumbnail: (id: number) => withStreamToken(`${API_BASE}/print-log/${id}/thumbnail`),
+  getPrintLogThumbnail: (id: number) => withMediaToken(`${API_BASE}/print-log/${id}/thumbnail`),
   clearPrintLog: () =>
     request<{ deleted: number }>('/print-log/', { method: 'DELETE' }),
   deletePrintLogEntry: (id: number) =>
@@ -6787,6 +6814,12 @@ export const api = {
   getCameraStreamToken: () =>
     request<{ token: string }>('/printers/camera/stream-token', { method: 'POST' }),
 
+  // Media token (#3025) — the credential for thumbnails, plate previews,
+  // timelapses, cover images and link icons. Minted behind plain auth rather
+  // than camera:view, and identified, so those routes gate on the resource's
+  // own permission and ownership instead of on the camera.
+  getMediaToken: () => request<{ token: string }>('/auth/media-token', { method: 'POST' }),
+
   // WebSocket auth (GHSA-r2qv follow-up) — mint a short-lived token for
   // the /ws connection. Browsers can't attach Authorization headers to a
   // WebSocket handshake, so the token rides in the ?token= query param.
@@ -6947,7 +6980,7 @@ export const api = {
   },
   deleteExternalLinkIcon: (id: number) =>
     request<ExternalLink>(`/external-links/${id}/icon`, { method: 'DELETE' }),
-  getExternalLinkIconUrl: (id: number) => withStreamToken(`${API_BASE}/external-links/${id}/icon`),
+  getExternalLinkIconUrl: (id: number) => withMediaToken(`${API_BASE}/external-links/${id}/icon`),
 
   // Projects
   getProjects: (status?: string) => {
@@ -7024,8 +7057,16 @@ export const api = {
   // #1155: Cover image
   // Browsers can't attach `Authorization: Bearer ...` to `<img src>`, so we
   // append the stream-token query string the same way archive thumbnails do.
-  getProjectCoverImageUrl: (projectId: number) =>
-    withStreamToken(`${API_BASE}/projects/${projectId}/cover-image`),
+  // `version` cache-busts the browser copy after a re-upload. It has to go on
+  // before the token does: callers used to append their own `?v=` to the
+  // returned URL, which already ended in `?token=…`, so the second `?` landed
+  // inside the token value and the image 401'd whenever auth was enabled.
+  getProjectCoverImageUrl: (projectId: number, version?: string | number) =>
+    withMediaToken(
+      `${API_BASE}/projects/${projectId}/cover-image${
+        version === undefined ? '' : `?v=${encodeURIComponent(String(version))}`
+      }`
+    ),
   uploadProjectCoverImage: async (
     projectId: number,
     file: File
@@ -7409,9 +7450,9 @@ export const api = {
     document.body.removeChild(a);
     window.URL.revokeObjectURL(url);
   },
-  getLibraryFileThumbnailUrl: (id: number) => withStreamToken(`${API_BASE}/library/files/${id}/thumbnail`),
+  getLibraryFileThumbnailUrl: (id: number) => withMediaToken(`${API_BASE}/library/files/${id}/thumbnail`),
   getLibraryFilePlateThumbnail: (id: number, plateIndex: number) =>
-    withStreamToken(`${API_BASE}/library/files/${id}/plate-thumbnail/${plateIndex}`),
+    withMediaToken(`${API_BASE}/library/files/${id}/plate-thumbnail/${plateIndex}`),
   getLibraryFileGcodeUrl: (id: number) => `${API_BASE}/library/files/${id}/gcode`,
   moveLibraryFiles: (fileIds: number[], folderId: number | null) =>
     request<{ status: string; moved: number }>('/library/files/move', {

+ 2 - 2
frontend/src/components/ModelViewerModal.tsx

@@ -4,7 +4,7 @@ import { useQuery } from '@tanstack/react-query';
 import { X, ExternalLink, Box, Cog, Loader2, Layers, Check, Maximize2, Minimize2, ChevronDown } from 'lucide-react';
 import { ModelViewer } from './ModelViewer';
 import { Button } from './Button';
-import { api, withStreamToken } from '../api/client';
+import { api, withMediaToken } from '../api/client';
 import { useToast } from '../contexts/ToastContext';
 import { isApiSliceableFileType, isSliceableFileType, openInSlicer, resolveDesktopSlicer, type SlicerType } from '../utils/slicer';
 import type { ArchivePlatesResponse, LibraryFilePlatesResponse, PlateMetadata } from '../types/plates';
@@ -578,7 +578,7 @@ export function ModelViewerModal({ archiveId, libraryFileId, title, fileType, on
                           >
                             {plate.has_thumbnail && plate.thumbnail_url ? (
                               <img
-                                src={withStreamToken(plate.thumbnail_url)}
+                                src={withMediaToken(plate.thumbnail_url)}
                                 alt={`Plate ${plate.index}`}
                                 className={`${splitFullscreen ? 'w-8 h-8' : 'w-10 h-10'} rounded object-cover bg-bambu-dark-tertiary`}
                               />

+ 2 - 2
frontend/src/components/PlatePickerModal.tsx

@@ -1,7 +1,7 @@
 import { Layers, X } from 'lucide-react';
 import { useTranslation } from 'react-i18next';
 import type { PlateMetadata } from '../types/plates';
-import { withStreamToken } from '../api/client';
+import { withMediaToken } from '../api/client';
 import { formatDuration } from '../utils/date';
 
 interface PlatePickerModalProps {
@@ -47,7 +47,7 @@ export function PlatePickerModal({ plates, onSelect, onClose }: PlatePickerModal
               >
                 {plate.has_thumbnail && plate.thumbnail_url != null ? (
                   <img
-                    src={withStreamToken(plate.thumbnail_url)}
+                    src={withMediaToken(plate.thumbnail_url)}
                     alt={`Plate ${plate.index}`}
                     className="w-12 h-12 rounded object-cover bg-bambu-dark-tertiary flex-shrink-0"
                   />

+ 2 - 2
frontend/src/components/PrintModal/PlateSelector.tsx

@@ -2,7 +2,7 @@ import { Layers, Check, AlertTriangle, Square, CheckSquare } from 'lucide-react'
 import { useTranslation } from 'react-i18next';
 import type { PlateSelectorProps } from './types';
 import { formatDuration } from '../../utils/date';
-import { withStreamToken } from '../../api/client';
+import { withMediaToken } from '../../api/client';
 import { getBedTypeInfo } from '../../utils/bedType';
 
 /**
@@ -90,7 +90,7 @@ export function PlateSelector({
               )}
               {plate.has_thumbnail && plate.thumbnail_url != null ? (
                 <img
-                  src={withStreamToken(plate.thumbnail_url)}
+                  src={withMediaToken(plate.thumbnail_url)}
                   alt={`Plate ${plate.index}`}
                   className="w-10 h-10 rounded object-cover bg-bambu-dark-tertiary"
                 />

+ 3 - 3
frontend/src/components/ProjectPageModal.tsx

@@ -13,7 +13,7 @@ import {
   ChevronLeft,
   ChevronRight,
 } from 'lucide-react';
-import { api } from '../api/client';
+import { api, withMediaToken } from '../api/client';
 import { Button } from './Button';
 import { RichTextEditor } from './RichTextEditor';
 
@@ -355,7 +355,7 @@ export function ProjectPageModal({ archiveId, archiveName, onClose }: ProjectPag
                         className="aspect-square rounded-lg overflow-hidden border border-bambu-dark-tertiary hover:border-bambu-green transition-colors"
                       >
                         <img
-                          src={img.url}
+                          src={withMediaToken(img.url)}
                           alt={img.name}
                           className="w-full h-full object-cover"
                         />
@@ -402,7 +402,7 @@ export function ProjectPageModal({ archiveId, archiveName, onClose }: ProjectPag
           </button>
 
           <img
-            src={allImages[selectedImageIndex].url}
+            src={withMediaToken(allImages[selectedImageIndex].url)}
             alt={allImages[selectedImageIndex].name}
             className="max-w-[90vw] max-h-[90vh] object-contain"
             onClick={(e) => e.stopPropagation()}

+ 3 - 3
frontend/src/components/SkipObjectsModal.tsx

@@ -2,7 +2,7 @@ import { useEffect, useMemo, useRef, useState } from 'react';
 import { useMutation, useQuery } from '@tanstack/react-query';
 import { useTranslation } from 'react-i18next';
 import { AlertCircle, Box, CheckSquare, Loader2, Maximize2, Square, X } from 'lucide-react';
-import { api, withStreamToken } from '../api/client';
+import { api, withMediaToken } from '../api/client';
 import { useToast } from '../contexts/ToastContext';
 import { useAuth } from '../contexts/AuthContext';
 import { pickObjectIdAt, plateClickToMaskPoint } from '../utils/skipObjects';
@@ -60,10 +60,10 @@ export function SkipObjectsModal({ printerId, isOpen, onClose }: SkipObjectsModa
 
   const hasObjects = (objectsData?.objects.length ?? 0) > 0;
   const topViewUrl = hasObjects && status?.cover_url
-    ? withStreamToken(`${status.cover_url}?view=top`)
+    ? withMediaToken(`${status.cover_url}?view=top`)
     : null;
   const pickViewUrl = hasObjects && status?.cover_url
-    ? withStreamToken(`${status.cover_url}?view=pick`)
+    ? withMediaToken(`${status.cover_url}?view=pick`)
     : null;
 
   const activeObjects = useMemo(

+ 94 - 31
frontend/src/hooks/useCameraStreamToken.ts

@@ -1,15 +1,36 @@
 import { useEffect, useRef } from 'react';
 import { useQuery, useQueryClient } from '@tanstack/react-query';
-import { api, setStreamToken, getStreamToken, withStreamToken } from '../api/client';
+import {
+  api,
+  setStreamToken,
+  getStreamToken,
+  setMediaToken,
+  getMediaToken,
+  withStreamToken,
+  withMediaToken,
+} from '../api/client';
 import { useAuth } from '../contexts/AuthContext';
 
+/** True for the three live-camera routes, which take the camera stream token.
+ *  Everything else under /api/v1/ that a browser loads as an element src is
+ *  media and takes the media token (#3025). */
+export function isCameraUrl(src: string): boolean {
+  return src.includes('/camera/');
+}
+
 /**
  * Walks the DOM and updates every <img>/<video> pointing at /api/v1/ so its
- * src carries the current stream token. Exported for unit testing; called
- * from useStreamTokenSync when the token arrives after first render.
+ * src carries the right token: the camera token for live-camera URLs, the
+ * media token for everything else. Either may be null -- a user without
+ * camera:view has no camera token, and their thumbnails must still be
+ * rewritten. Exported for unit testing; called from useStreamTokenSync when a
+ * token arrives after first render.
  */
-export function rewriteMediaSrcWithToken(root: ParentNode, token: string): number {
-  const tokenParam = `token=${encodeURIComponent(token)}`;
+export function rewriteMediaSrcWithToken(
+  root: ParentNode,
+  mediaToken: string | null,
+  cameraToken: string | null
+): number {
   let updated = 0;
   root
     .querySelectorAll<HTMLImageElement | HTMLVideoElement>(
@@ -17,6 +38,9 @@ export function rewriteMediaSrcWithToken(root: ParentNode, token: string): numbe
     )
     .forEach((el) => {
       const src = el.getAttribute('src') || '';
+      const token = isCameraUrl(src) ? cameraToken : mediaToken;
+      if (!token) return;
+      const tokenParam = `token=${encodeURIComponent(token)}`;
       if (src.includes(tokenParam)) return;
       const withoutToken = src.replace(/([?&])token=[^&]*(&|$)/, (_m, pre, post) =>
         post === '&' ? pre : pre === '?' ? '' : ''
@@ -29,23 +53,30 @@ export function rewriteMediaSrcWithToken(root: ParentNode, token: string): numbe
 }
 
 /**
- * Fetches and caches a stream token for <img>/<video> src URLs.
- * Stores the token globally via setStreamToken() so URL generators
- * in client.ts can use withStreamToken() automatically.
+ * Fetches and caches the query-param tokens <img>/<video> src URLs need, and
+ * publishes them through setMediaToken() / setStreamToken() so the URL
+ * generators in client.ts pick them up automatically.
+ *
+ * Two tokens, fetched independently (#3025):
  *
- * Also listens for global image load errors on token-protected URLs
- * and automatically refreshes the token (e.g., after backend restart
- * invalidates in-memory tokens).
+ *   media  — every signed-in user gets one. Thumbnails, plate previews,
+ *            timelapses, cover images and link icons ride on it.
+ *   camera — only users with camera:view, because that is what minting one
+ *            costs. Asking for it unconditionally would 403 on every page
+ *            load for everyone else.
  *
- * Mount this hook once near the app root (e.g., in App.tsx or a layout component).
- * Components that need token-protected URLs can import withStreamToken directly.
+ * Also listens for global image/video load errors on token-protected URLs and
+ * refreshes the matching token (e.g. after a backend restart drops them).
+ *
+ * Mount this hook once near the app root. Components that need token-protected
+ * URLs can import withMediaToken / withStreamToken directly.
  */
 export function useStreamTokenSync() {
-  const { authEnabled, user, loading: authLoading } = useAuth();
+  const { authEnabled, user, loading: authLoading, hasPermission } = useAuth();
   const queryClient = useQueryClient();
   const refreshingRef = useRef(false);
 
-  // Key the token by user id so a login/logout invalidates the cache
+  // Key the tokens by user id so a login/logout invalidates the cache
   // automatically — otherwise a failed anonymous fetch on the login page
   // would be cached and never retried after sign-in.
   //
@@ -55,31 +86,53 @@ export function useStreamTokenSync() {
   // ``true`` on first render because ``authEnabled`` defaults to false,
   // firing a 401 POST on the login page before AuthContext had a chance
   // to settle on ``authEnabled=true, user=null``.
-  const { data } = useQuery({
+  const signedIn = !authLoading && (!authEnabled || user !== null);
+
+  const { data: mediaData } = useQuery({
+    queryKey: ['media-token', user?.id ?? null],
+    queryFn: () => api.getMediaToken(),
+    enabled: signedIn,
+    staleTime: 50 * 60 * 1000, // refresh at 50 min (tokens expire at 60)
+    refetchInterval: 50 * 60 * 1000,
+  });
+
+  // Only ask for a camera token when the user may actually have one. When auth
+  // is disabled hasPermission() is vacuously true, which is correct — the mint
+  // endpoint is open then too.
+  const canViewCamera = !authEnabled || hasPermission('camera:view');
+
+  const { data: cameraData } = useQuery({
     queryKey: ['camera-stream-token', user?.id ?? null],
     queryFn: () => api.getCameraStreamToken(),
-    enabled: !authLoading && (!authEnabled || user !== null),
-    staleTime: 50 * 60 * 1000, // refresh at 50 min (tokens expire at 60)
+    enabled: signedIn && canViewCamera,
+    staleTime: 50 * 60 * 1000,
     refetchInterval: 50 * 60 * 1000,
   });
 
+  const mediaTokenValue = mediaData?.token ?? null;
+  const cameraTokenValue = cameraData?.token ?? null;
+
   useEffect(() => {
-    const newToken = data?.token ?? null;
-    setStreamToken(newToken);
+    setMediaToken(mediaTokenValue);
+    setStreamToken(cameraTokenValue);
 
-    // Images/videos that rendered before the token arrived have src URLs
+    // Images/videos that rendered before a token arrived have src URLs
     // without ?token=…; update them in place so they reload with auth.
-    if (newToken) {
-      rewriteMediaSrcWithToken(document, newToken);
+    if (mediaTokenValue || cameraTokenValue) {
+      rewriteMediaSrcWithToken(document, mediaTokenValue, cameraTokenValue);
     }
 
-    return () => setStreamToken(null);
-  }, [data?.token]);
+    return () => {
+      setMediaToken(null);
+      setStreamToken(null);
+    };
+  }, [mediaTokenValue, cameraTokenValue]);
 
   // Listen for image/video load errors on token-protected URLs.
-  // When the backend restarts, in-memory stream tokens are lost and all
+  // When the backend restarts, in-memory tokens are lost and all
   // thumbnail/stream requests return 401. This handler detects that and
-  // forces a token refresh so images recover without a page reload.
+  // forces a refresh of whichever token the failing URL used, so images
+  // recover without a page reload.
   useEffect(() => {
     if (!authEnabled) return;
 
@@ -88,14 +141,17 @@ export function useStreamTokenSync() {
       if (!(el instanceof HTMLImageElement || el instanceof HTMLVideoElement)) return;
 
       const src = el.src || '';
-      const token = getStreamToken();
+      const camera = isCameraUrl(src);
+      const token = camera ? getStreamToken() : getMediaToken();
       if (!token || !src.includes(`token=${encodeURIComponent(token)}`)) return;
 
-      // This image/video used our stream token and failed — token likely invalid
+      // This image/video used one of our tokens and failed — likely invalid
       if (refreshingRef.current) return;
       refreshingRef.current = true;
 
-      queryClient.invalidateQueries({ queryKey: ['camera-stream-token'] });
+      queryClient.invalidateQueries({
+        queryKey: camera ? ['camera-stream-token'] : ['media-token'],
+      });
 
       // Reset after a delay so future errors can trigger another refresh
       setTimeout(() => {
@@ -110,9 +166,16 @@ export function useStreamTokenSync() {
 }
 
 /**
- * Hook for components that need to wrap URLs with the stream token.
+ * Hook for components that need to wrap camera URLs with the stream token.
  * Returns a withToken function that appends ?token=xxx when auth is enabled.
  */
 export function useCameraStreamToken() {
   return { withToken: withStreamToken };
 }
+
+/**
+ * Hook for components that need to wrap media URLs with the media token.
+ */
+export function useMediaToken() {
+  return { withToken: withMediaToken };
+}

+ 2 - 2
frontend/src/pages/PrintersPage.tsx

@@ -143,7 +143,7 @@ import {
 
 // Aliased: lucide-react already exports a `Link` icon into this module.
 import { Link as RouterLink, useNavigate } from 'react-router-dom';
-import { api, discoveryApi, firmwareApi, withStreamToken, ApiError } from '../api/client';
+import { api, discoveryApi, firmwareApi, withMediaToken, ApiError } from '../api/client';
 import { formatDateOnly, formatDateTime, formatETA, formatDuration, formatDurationFromHours, parseUTCDate } from '../utils/date';
 import type { Printer, PrinterCreate, PrinterStatus, AMSUnit, DiscoveredPrinter, FirmwareUpdateInfo, FirmwareUploadStatus, LinkedSpoolInfo, SpoolAssignment, HMSError, InventorySpool, SmartPlug, PrinterDiagnosticResult } from '../api/client';
 import { Card, CardContent } from '../components/Card';
@@ -1109,7 +1109,7 @@ export function CoverImage({
   const cacheBustedUrl = useMemo(() => {
     if (!url) return null;
     const sep = url.includes('?') ? '&' : '?';
-    return withStreamToken(`${url}${sep}v=${encodeURIComponent(printName || Date.now().toString())}`);
+    return withMediaToken(`${url}${sep}v=${encodeURIComponent(printName || Date.now().toString())}`);
   }, [url, printName]);
 
   // Re-evaluate load state when the image URL changes, and ask the element

+ 1 - 1
frontend/src/pages/ProjectsPage.tsx

@@ -242,7 +242,7 @@ export function ProjectModal({ project, onClose, onSave, isLoading, currencySymb
                 <div className="w-20 h-20 rounded bg-bambu-dark border border-bambu-dark-tertiary overflow-hidden flex items-center justify-center flex-shrink-0">
                   {coverImageFilename ? (
                     <img
-                      src={`${api.getProjectCoverImageUrl(project.id)}?v=${coverCacheKey}`}
+                      src={api.getProjectCoverImageUrl(project.id, coverCacheKey)}
                       alt={t('projects.coverImageAlt')}
                       className="w-full h-full object-cover"
                     />

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/index-BQ97FBEj.js


+ 1 - 1
static/index.html

@@ -26,7 +26,7 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-CRqoMZy0.js"></script>
+    <script type="module" crossorigin src="/assets/index-BQ97FBEj.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-ChscM3lF.css">
   </head>
   <body>

Некоторые файлы не были показаны из-за большого количества измененных файлов