feat(projects): URL field + cover photo on project cards (#1155)

Two new project fields: a free-text URL rendered as a one-click
  external-link button beside the project name on every card (opens in a
  new tab, click is e.stopPropagation()-guarded so it doesn't enter the
  project), and a cover photo that replaces the status-icon box with a
  square thumbnail.

  URL is plumbed through ProjectCreate/Update/Response/ListResponse,
  including from-template + create-template flows so it inherits between
  a project and its template. Cover photo is not inherited because the
  file would be shared on disk between source and copy.

  Schema validator rejects anything other than http:// or https://
  prefixes -- <a href> rendering would otherwise execute javascript:
  / data: / file: URLs even with React's default escaping. PATCH uses
  model_fields_set for the URL field so users can clear it by sending
  {"url": null}.

  Cover image storage: Project.cover_image_filename references a file
  Cover image storage: Project.cover_image_filename references a file
  inside the existing archives/projects/{id}/attachments/ dir, but it's
  tracked separately from the attachments JSON list so swap/delete on
  the cover doesn't perturb the user's other attachments. Three routes
  (POST/GET/DELETE /projects/{id}/cover-image) accept only .jpg/.jpeg/
  .png/.gif/.webp (no SVG -- SVG can carry script payloads), replace in
  place (prior file deleted before the new one lands so repeat uploads
  can't accumulate orphans), and self-heal when a DB reference points at
  a vanished disk file by clearing the column and 404'ing.

  GET cover-image is gated by RequireCameraStreamTokenIfAuthEnabled
  (accepts ?token=... query string) -- not the bearer-token gate -- so
  <img src> requests work in both auth-on and auth-off configurations.
  The frontend wraps getProjectCoverImageUrl with withStreamToken(),
  matching the existing pattern from getArchiveThumbnail.

  Permissions: PROJECTS_UPDATE for upload/delete/PATCH, PROJECTS_READ
  gate is implicit via the stream-token credential. Migration: 2
  idempotent ALTER TABLE projects ADD COLUMN. Localised across all 8
  UI languages.
This commit is contained in:
maziggy
2026-04-29 07:42:09 +02:00
parent b6a9d56651
commit 57af8a1c19
22 changed files with 876 additions and 127 deletions
@@ -113,6 +113,153 @@ class TestProjectsAPI:
assert response.status_code == 404
class TestProjectUrlAndCoverImage:
"""Tests for #1155 — url field + cover image upload/get/delete."""
@pytest.fixture
async def project_factory(self, db_session):
async def _create(**kwargs):
from backend.app.models.project import Project
defaults = {"name": "URL/Cover Project", "color": "#00ff00"}
defaults.update(kwargs)
project = Project(**defaults)
db_session.add(project)
await db_session.commit()
await db_session.refresh(project)
return project
return _create
@pytest.mark.asyncio
@pytest.mark.integration
async def test_create_project_accepts_https_url(self, async_client: AsyncClient):
response = await async_client.post(
"/api/v1/projects/",
json={"name": "With URL", "url": "https://makerworld.com/models/12345"},
)
assert response.status_code == 200
body = response.json()
assert body["url"] == "https://makerworld.com/models/12345"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_create_project_rejects_javascript_url(self, async_client: AsyncClient):
# `<a href>` rendering would execute javascript: URLs — schema must reject.
response = await async_client.post(
"/api/v1/projects/",
json={"name": "Hostile", "url": "javascript:alert(1)"},
)
assert response.status_code == 422
@pytest.mark.asyncio
@pytest.mark.integration
async def test_create_project_rejects_data_url(self, async_client: AsyncClient):
response = await async_client.post(
"/api/v1/projects/",
json={"name": "Hostile", "url": "data:text/html,<script>alert(1)</script>"},
)
assert response.status_code == 422
@pytest.mark.asyncio
@pytest.mark.integration
async def test_patch_project_clears_url_when_explicitly_null(self, async_client: AsyncClient, project_factory):
project = await project_factory(url="https://example.com")
response = await async_client.patch(f"/api/v1/projects/{project.id}", json={"url": None})
assert response.status_code == 200
assert response.json()["url"] is None
@pytest.mark.asyncio
@pytest.mark.integration
async def test_upload_cover_image_then_serve_then_delete(self, async_client: AsyncClient, project_factory):
project = await project_factory()
# 1x1 PNG (smallest valid PNG bytes)
png_bytes = bytes.fromhex(
"89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4"
"890000000d49444154789c63f80f00000100010000000000000049454e44ae42"
"6082"
)
upload = await async_client.post(
f"/api/v1/projects/{project.id}/cover-image",
files={"file": ("cover.png", png_bytes, "image/png")},
)
assert upload.status_code == 200, upload.text
body = upload.json()
assert body["status"] == "success"
assert body["filename"].endswith(".png")
cover_filename = body["filename"]
# GET should serve the bytes back
served = await async_client.get(f"/api/v1/projects/{project.id}/cover-image")
assert served.status_code == 200
assert served.headers["content-type"] == "image/png"
assert served.content == png_bytes
# Project response should reflect the cover_image_filename field
view = await async_client.get(f"/api/v1/projects/{project.id}")
assert view.json()["cover_image_filename"] == cover_filename
# DELETE should clear the field
deleted = await async_client.delete(f"/api/v1/projects/{project.id}/cover-image")
assert deleted.status_code == 200
view2 = await async_client.get(f"/api/v1/projects/{project.id}")
assert view2.json()["cover_image_filename"] is None
# And subsequent GET should 404
served2 = await async_client.get(f"/api/v1/projects/{project.id}/cover-image")
assert served2.status_code == 404
@pytest.mark.asyncio
@pytest.mark.integration
async def test_upload_cover_image_rejects_non_image(self, async_client: AsyncClient, project_factory):
project = await project_factory()
response = await async_client.post(
f"/api/v1/projects/{project.id}/cover-image",
files={"file": ("evil.exe", b"MZ\x00\x00", "application/octet-stream")},
)
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."""
from fastapi.routing import APIRoute
from backend.app.api.routes.projects import router
# Find the GET cover-image route. The router exposes path/methods/
# dependencies via APIRoute objects.
cover_get = None
for route in router.routes:
if isinstance(route, APIRoute) and route.path.endswith("/cover-image") and "GET" in route.methods:
cover_get = route
break
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())
# — 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 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__
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}"
)
class TestProjectPartsTracking:
"""Tests for project parts tracking feature."""