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,