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
+164 -1
View File
@@ -14,7 +14,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 RequirePermissionIfAuthEnabled
from backend.app.core.auth import RequireCameraStreamTokenIfAuthEnabled, RequirePermissionIfAuthEnabled
from backend.app.core.config import settings
from backend.app.core.database import get_db
from backend.app.core.permissions import Permission
@@ -255,6 +255,8 @@ async def list_projects(
queue_count=queue_count,
progress_percent=progress_percent,
archives=archive_previews,
url=project.url,
cover_image_filename=project.cover_image_filename,
)
)
@@ -289,6 +291,7 @@ async def create_project(
priority=data.priority,
budget=data.budget,
parent_id=data.parent_id,
url=data.url,
)
db.add(project)
await db.flush()
@@ -306,6 +309,8 @@ async def create_project(
target_parts_count=project.target_parts_count,
notes=project.notes,
attachments=project.attachments,
url=project.url,
cover_image_filename=project.cover_image_filename,
tags=project.tags,
due_date=project.due_date,
priority=project.priority,
@@ -355,6 +360,8 @@ async def list_templates(
queue_count=0,
progress_percent=None,
archives=[],
url=project.url,
cover_image_filename=project.cover_image_filename,
)
)
@@ -391,6 +398,7 @@ async def create_project_from_template(
budget=template.budget,
is_template=False,
template_source_id=template.id,
url=template.url,
)
db.add(project)
await db.flush()
@@ -428,6 +436,8 @@ async def create_project_from_template(
target_parts_count=project.target_parts_count,
notes=project.notes,
attachments=project.attachments,
url=project.url,
cover_image_filename=project.cover_image_filename,
tags=project.tags,
due_date=project.due_date,
priority=project.priority,
@@ -511,6 +521,8 @@ async def get_project(
target_parts_count=project.target_parts_count,
notes=project.notes,
attachments=project.attachments,
url=project.url,
cover_image_filename=project.cover_image_filename,
tags=project.tags,
due_date=project.due_date,
priority=project.priority,
@@ -567,6 +579,9 @@ async def update_project(
project.priority = data.priority
if "budget" in data.model_fields_set:
project.budget = data.budget
if "url" in data.model_fields_set:
# Pydantic validator already guarantees http(s) prefix or None.
project.url = data.url
if data.parent_id is not None:
# Verify parent exists and prevent circular reference
if data.parent_id == project_id:
@@ -603,6 +618,8 @@ async def update_project(
target_parts_count=project.target_parts_count,
notes=project.notes,
attachments=project.attachments,
url=project.url,
cover_image_filename=project.cover_image_filename,
tags=project.tags,
due_date=project.due_date,
priority=project.priority,
@@ -768,6 +785,19 @@ def get_project_attachments_dir(project_id: int) -> Path:
return base_dir / "projects" / str(project_id) / "attachments"
# Cover-image upload accepts only common web-renderable image types (#1155).
# Subset of ALLOWED_ATTACHMENT_EXTENSIONS minus .svg/.ico because those don't
# render well as a card thumbnail.
COVER_IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".webp"}
COVER_IMAGE_CONTENT_TYPES = {
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".png": "image/png",
".gif": "image/gif",
".webp": "image/webp",
}
# Allowed file extensions for attachments
ALLOWED_ATTACHMENT_EXTENSIONS = {
# Images
@@ -985,6 +1015,132 @@ async def delete_attachment(
}
# ============ #1155: Cover image ============
@router.post("/{project_id}/cover-image")
async def upload_project_cover_image(
project_id: int,
file: UploadFile = File(...),
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_UPDATE),
):
"""Upload (or replace) the project's cover image (#1155).
Stored alongside other attachments but tracked via Project.cover_image_filename
so swap/delete operations don't touch the attachments list. Replaces any
existing cover image — the prior file is deleted on disk before the new one
lands so a stuck filesystem reference can't accumulate orphaned images.
"""
result = await db.execute(select(Project).where(Project.id == project_id))
project = result.scalar_one_or_none()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
original_name = file.filename or "cover"
ext = os.path.splitext(original_name)[1].lower()
if ext not in COVER_IMAGE_EXTENSIONS:
raise HTTPException(
status_code=400,
detail=f"Cover image must be one of {sorted(COVER_IMAGE_EXTENSIONS)}",
)
attachments_dir = get_project_attachments_dir(project_id)
attachments_dir.mkdir(parents=True, exist_ok=True)
# Remove the previous cover-image file from disk first so we don't accumulate
# orphans when users repeatedly replace it. Best-effort: a missing/locked file
# shouldn't block a successful replacement.
if project.cover_image_filename:
old_path = attachments_dir / project.cover_image_filename
if old_path.exists():
try:
os.remove(old_path)
except OSError as e:
logger.warning("Failed to delete old cover image %s: %s", old_path, e)
unique_filename = f"cover_{uuid.uuid4().hex}{ext}"
file_path = attachments_dir / unique_filename
try:
with open(file_path, "wb") as f:
content = await file.read()
f.write(content)
except OSError as e:
logger.error("Failed to save cover image: %s", e)
raise HTTPException(status_code=500, detail="Failed to save cover image")
project.cover_image_filename = unique_filename
db.add(project)
await db.flush()
await db.commit()
return {
"status": "success",
"filename": unique_filename,
"size": len(content),
}
@router.get("/{project_id}/cover-image")
async def get_project_cover_image(
project_id: int,
db: AsyncSession = Depends(get_db),
_: None = RequireCameraStreamTokenIfAuthEnabled,
):
"""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`."""
result = await db.execute(select(Project).where(Project.id == project_id))
project = result.scalar_one_or_none()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
if not project.cover_image_filename:
raise HTTPException(status_code=404, detail="No cover image set")
file_path = get_project_attachments_dir(project_id) / project.cover_image_filename
if not file_path.exists():
# DB references a file that vanished from disk — clear the dangling
# reference so future GETs get a clean 404 instead of repeatedly
# touching the filesystem.
logger.warning("Cover image file missing for project %s: %s", project_id, file_path)
project.cover_image_filename = None
await db.commit()
raise HTTPException(status_code=404, detail="Cover image file not found")
ext = os.path.splitext(project.cover_image_filename)[1].lower()
media_type = COVER_IMAGE_CONTENT_TYPES.get(ext, "application/octet-stream")
return FileResponse(file_path, media_type=media_type)
@router.delete("/{project_id}/cover-image")
async def delete_project_cover_image(
project_id: int,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_UPDATE),
):
"""Remove the project's cover image (#1155)."""
result = await db.execute(select(Project).where(Project.id == project_id))
project = result.scalar_one_or_none()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
if project.cover_image_filename:
file_path = get_project_attachments_dir(project_id) / project.cover_image_filename
if file_path.exists():
try:
os.remove(file_path)
except OSError as e:
logger.warning("Failed to delete cover image file %s: %s", file_path, e)
project.cover_image_filename = None
db.add(project)
await db.flush()
await db.commit()
return {"status": "success"}
# ============ Phase 7: BOM Endpoints ============
@@ -1213,6 +1369,7 @@ async def create_template_from_project(
budget=source.budget,
is_template=True,
template_source_id=source.id,
url=source.url,
)
db.add(template)
await db.flush()
@@ -1250,6 +1407,8 @@ async def create_template_from_project(
target_parts_count=template.target_parts_count,
notes=template.notes,
attachments=template.attachments,
url=template.url,
cover_image_filename=template.cover_image_filename,
tags=template.tags,
due_date=template.due_date,
priority=template.priority,
@@ -1570,6 +1729,8 @@ async def import_project(
target_parts_count=project.target_parts_count,
notes=project.notes,
attachments=project.attachments,
url=project.url,
cover_image_filename=project.cover_image_filename,
tags=project.tags,
due_date=project.due_date,
priority=project.priority,
@@ -1743,6 +1904,8 @@ async def import_project_file(
target_parts_count=project.target_parts_count,
notes=project.notes,
attachments=project.attachments,
url=project.url,
cover_image_filename=project.cover_image_filename,
tags=project.tags,
due_date=project.due_date,
priority=project.priority,
+7
View File
@@ -640,6 +640,13 @@ async def run_migrations(conn):
# Migration: Add target_parts_count column to projects for tracking total parts needed
await _safe_execute(conn, "ALTER TABLE projects ADD COLUMN target_parts_count INTEGER")
# Migration: Add url + cover_image_filename columns to projects (#1155).
# url: external link rendered next to the project name on the card.
# cover_image_filename: filename of the project's hero image inside the
# existing attachments dir; rendered as a thumbnail on the card.
await _safe_execute(conn, "ALTER TABLE projects ADD COLUMN url VARCHAR(2048)")
await _safe_execute(conn, "ALTER TABLE projects ADD COLUMN cover_image_filename VARCHAR(255)")
# Migration: Make printer_id nullable in print_queue for unassigned queue items
# SQLite doesn't support ALTER COLUMN, so we need to recreate the table
# PostgreSQL gets the correct schema from create_all(), so skip this
+8
View File
@@ -16,6 +16,14 @@ class Project(Base):
description: Mapped[str | None] = mapped_column(Text, nullable=True)
color: Mapped[str | None] = mapped_column(String(20), nullable=True) # Hex color for UI
status: Mapped[str] = mapped_column(String(20), default="active") # active, completed, archived
# External link rendered as a clickable icon next to the project name (#1155).
url: Mapped[str | None] = mapped_column(String(2048), nullable=True)
# Filename of the cover photo inside the project's attachments dir; serves as
# the card's hero image when set (#1155). The file lives alongside other
# attachments but is tracked here separately so users can manage one without
# the other.
cover_image_filename: Mapped[str | None] = mapped_column(String(255), nullable=True)
target_count: Mapped[int | None] = mapped_column(
Integer, nullable=True
) # Optional target number of prints (plates)
+33 -1
View File
@@ -1,6 +1,21 @@
from datetime import datetime
from pydantic import BaseModel
from pydantic import BaseModel, field_validator
def _validate_project_url(value: str | None) -> str | None:
"""Reject anything that isn't an http(s) URL — the URL is rendered as a
clickable `<a href>` so a `javascript:` / `data:` / `file:` value would be
an XSS vector even with React's default escaping (#1155)."""
if value is None:
return value
trimmed = value.strip()
if not trimmed:
return None
lowered = trimmed.lower()
if not (lowered.startswith("http://") or lowered.startswith("https://")):
raise ValueError("url must start with http:// or https://")
return trimmed
class ProjectCreate(BaseModel):
@@ -17,6 +32,12 @@ class ProjectCreate(BaseModel):
priority: str = "normal"
budget: float | None = None
parent_id: int | None = None # For sub-projects
url: str | None = None
@field_validator("url")
@classmethod
def _check_url(cls, v: str | None) -> str | None:
return _validate_project_url(v)
class ProjectUpdate(BaseModel):
@@ -34,6 +55,12 @@ class ProjectUpdate(BaseModel):
priority: str | None = None
budget: float | None = None
parent_id: int | None = None
url: str | None = None
@field_validator("url")
@classmethod
def _check_url(cls, v: str | None) -> str | None:
return _validate_project_url(v)
class ProjectStats(BaseModel):
@@ -95,6 +122,8 @@ class ProjectResponse(BaseModel):
created_at: datetime
updated_at: datetime
stats: ProjectStats | None = None
url: str | None = None
cover_image_filename: str | None = None
class Config:
from_attributes = True
@@ -132,6 +161,9 @@ class ProjectListResponse(BaseModel):
progress_percent: float | None = None
# Preview of archives (up to 5)
archives: list[ArchivePreview] = []
# #1155: card-level metadata
url: str | None = None
cover_image_filename: str | None = None
class Config:
from_attributes = True
@@ -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."""
@@ -32,6 +32,23 @@ def manager():
return PrinterManager()
def _close_unawaited(coro):
"""Side effect for mocked ``_schedule_async``.
``set_awaiting_plate_clear`` evaluates the coroutine expressions
``self._persist_awaiting_plate_clear(...)`` and
``self._broadcast_status_change(...)`` before passing them to
``_schedule_async``. When that target is patched, the coroutine objects
leak — Python's ``__del__`` then emits ``coroutine was never awaited``
during GC, and when GC runs late enough that warning hits the interpreter
shutdown path with ``KeyError: '__import__'``. Closing the coroutine here
prevents both. Returns ``None`` so the mock's call signature is unchanged.
"""
if asyncio.iscoroutine(coro):
coro.close()
return None
def _fake_state(**overrides):
"""Minimal stand-in for a ``PrinterState`` — only the attributes
``printer_state_to_dict`` reads. We use a SimpleNamespace rather than
@@ -58,7 +75,7 @@ class TestSchedulingFromSetAwaitingPlateClear:
manager._loop = MagicMock()
manager._loop.is_running.return_value = True
with patch.object(manager, "_schedule_async") as scheduled:
with patch.object(manager, "_schedule_async", side_effect=_close_unawaited) as scheduled:
manager.set_awaiting_plate_clear(7, True)
# Two coroutines: persist + broadcast. Order doesn't matter.
@@ -96,7 +113,7 @@ class TestSchedulingFromSetAwaitingPlateClear:
manager._loop = MagicMock()
manager._loop.is_running.return_value = True
with patch.object(manager, "_schedule_async") as scheduled:
with patch.object(manager, "_schedule_async", side_effect=_close_unawaited) as scheduled:
manager.set_awaiting_plate_clear(7, True)
scheduled.reset_mock()
manager.set_awaiting_plate_clear(7, False)