|
|
@@ -1216,14 +1216,14 @@ async def remove_archives_from_project(
|
|
|
|
|
|
|
|
|
def get_project_attachments_dir(project_id: int) -> Path:
|
|
|
- """Get the attachments directory for a project."""
|
|
|
+ """Directory holding a project's cover image (named for the file
|
|
|
+ attachments it once held as well)."""
|
|
|
base_dir = Path(settings.archive_dir)
|
|
|
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 upload accepts only common web-renderable image types (#1155);
|
|
|
+# .svg/.ico don't render well as a card thumbnail.
|
|
|
COVER_IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".webp"}
|
|
|
COVER_IMAGE_CONTENT_TYPES = {
|
|
|
".jpg": "image/jpeg",
|
|
|
@@ -1234,230 +1234,6 @@ COVER_IMAGE_CONTENT_TYPES = {
|
|
|
}
|
|
|
|
|
|
|
|
|
-# Allowed file extensions for attachments
|
|
|
-ALLOWED_ATTACHMENT_EXTENSIONS = {
|
|
|
- # Images
|
|
|
- ".jpg",
|
|
|
- ".jpeg",
|
|
|
- ".png",
|
|
|
- ".gif",
|
|
|
- ".webp",
|
|
|
- ".svg",
|
|
|
- ".bmp",
|
|
|
- ".ico",
|
|
|
- # Documents
|
|
|
- ".pdf",
|
|
|
- ".doc",
|
|
|
- ".docx",
|
|
|
- ".xls",
|
|
|
- ".xlsx",
|
|
|
- ".ppt",
|
|
|
- ".pptx",
|
|
|
- ".odt",
|
|
|
- ".ods",
|
|
|
- ".odp",
|
|
|
- ".txt",
|
|
|
- ".rtf",
|
|
|
- ".csv",
|
|
|
- ".md",
|
|
|
- # 3D/CAD files
|
|
|
- ".stl",
|
|
|
- ".obj",
|
|
|
- ".3mf",
|
|
|
- ".step",
|
|
|
- ".stp",
|
|
|
- ".iges",
|
|
|
- ".igs",
|
|
|
- ".f3d",
|
|
|
- ".scad",
|
|
|
- # Archives
|
|
|
- ".zip",
|
|
|
- ".rar",
|
|
|
- ".7z",
|
|
|
- ".tar",
|
|
|
- ".gz",
|
|
|
- # Code/scripts (for Klipper macros, scripts, etc.)
|
|
|
- ".py",
|
|
|
- ".sh",
|
|
|
- ".cfg",
|
|
|
- ".conf",
|
|
|
- ".gcode",
|
|
|
- ".ini",
|
|
|
- # Other common formats
|
|
|
- ".json",
|
|
|
- ".xml",
|
|
|
- ".yaml",
|
|
|
- ".yml",
|
|
|
-}
|
|
|
-
|
|
|
-
|
|
|
-@router.post("/{project_id}/attachments")
|
|
|
-async def upload_attachment(
|
|
|
- project_id: int,
|
|
|
- file: UploadFile = File(...),
|
|
|
- db: AsyncSession = Depends(get_db),
|
|
|
- _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_UPDATE),
|
|
|
-):
|
|
|
- """Upload an attachment to a project."""
|
|
|
- logger.info("=== UPLOAD START: %s for project %s ===", file.filename, project_id)
|
|
|
-
|
|
|
- # Verify project exists
|
|
|
- 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")
|
|
|
-
|
|
|
- # Validate file extension
|
|
|
- original_name = file.filename or "unknown"
|
|
|
- ext = os.path.splitext(original_name)[1].lower()
|
|
|
- if ext not in ALLOWED_ATTACHMENT_EXTENSIONS:
|
|
|
- raise HTTPException(
|
|
|
- status_code=400,
|
|
|
- detail=f"File type '{ext}' not supported. Allowed: images, PDFs, documents, STL, 3MF, archives.",
|
|
|
- )
|
|
|
-
|
|
|
- # Create attachments directory
|
|
|
- attachments_dir = get_project_attachments_dir(project_id)
|
|
|
- attachments_dir.mkdir(parents=True, exist_ok=True)
|
|
|
-
|
|
|
- # Generate unique filename
|
|
|
- unique_filename = f"{uuid.uuid4().hex}{ext}"
|
|
|
- file_path = attachments_dir / unique_filename # SEC-PATH-OK: unique_filename = uuid.uuid4().hex + ext
|
|
|
-
|
|
|
- # Save file
|
|
|
- try:
|
|
|
- with open(file_path, "wb") as f:
|
|
|
- content = await file.read()
|
|
|
- f.write(content)
|
|
|
- logger.info("=== FILE SAVED: %s, size: %s ===", file_path, len(content))
|
|
|
- except Exception as e:
|
|
|
- logger.error("Failed to save attachment: %s", e)
|
|
|
- raise HTTPException(status_code=500, detail="Failed to save attachment")
|
|
|
-
|
|
|
- # Update project attachments JSON
|
|
|
- attachments = list(project.attachments or [])
|
|
|
- new_attachment = {
|
|
|
- "filename": unique_filename,
|
|
|
- "original_name": original_name,
|
|
|
- "size": len(content),
|
|
|
- "uploaded_at": datetime.now().isoformat(),
|
|
|
- }
|
|
|
- attachments.append(new_attachment)
|
|
|
-
|
|
|
- # Simple ORM update
|
|
|
- project.attachments = attachments
|
|
|
- db.add(project) # Explicitly add to session
|
|
|
-
|
|
|
- logger.info("=== BEFORE COMMIT: %s attachments ===", len(attachments))
|
|
|
-
|
|
|
- await db.flush()
|
|
|
- await db.commit()
|
|
|
-
|
|
|
- logger.info("=== AFTER COMMIT ===")
|
|
|
-
|
|
|
- # Verify by re-querying
|
|
|
- result = await db.execute(select(Project).where(Project.id == project_id))
|
|
|
- fresh_project = result.scalar_one()
|
|
|
-
|
|
|
- logger.info("=== VERIFIED: %s attachments ===", len(fresh_project.attachments or []))
|
|
|
-
|
|
|
- return {
|
|
|
- "status": "success",
|
|
|
- "filename": unique_filename,
|
|
|
- "original_name": original_name,
|
|
|
- "attachments": fresh_project.attachments,
|
|
|
- }
|
|
|
-
|
|
|
-
|
|
|
-@router.get("/{project_id}/attachments/{filename}")
|
|
|
-async def download_attachment(
|
|
|
- project_id: int,
|
|
|
- filename: str,
|
|
|
- db: AsyncSession = Depends(get_db),
|
|
|
- _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_READ),
|
|
|
-):
|
|
|
- """Download an attachment from a project."""
|
|
|
- # Validate filename to prevent path traversal
|
|
|
- if "/" in filename or "\\" in filename or ".." in filename or not filename:
|
|
|
- raise HTTPException(status_code=400, detail="Invalid filename")
|
|
|
-
|
|
|
- # Verify project exists
|
|
|
- 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")
|
|
|
-
|
|
|
- # Verify attachment exists in project
|
|
|
- attachments = project.attachments or []
|
|
|
- attachment = next((a for a in attachments if a.get("filename") == filename), None)
|
|
|
- if not attachment:
|
|
|
- raise HTTPException(status_code=404, detail="Attachment not found")
|
|
|
-
|
|
|
- # Check file exists
|
|
|
- file_path = (
|
|
|
- get_project_attachments_dir(project_id) / filename
|
|
|
- ) # SEC-PATH-OK: filename validated above (no /, \\, .., empty) + attachment membership check
|
|
|
- if not file_path.exists():
|
|
|
- raise HTTPException(status_code=404, detail="Attachment file not found")
|
|
|
-
|
|
|
- return FileResponse(
|
|
|
- file_path,
|
|
|
- filename=attachment.get("original_name", filename),
|
|
|
- media_type="application/octet-stream",
|
|
|
- )
|
|
|
-
|
|
|
-
|
|
|
-@router.delete("/{project_id}/attachments/{filename}")
|
|
|
-async def delete_attachment(
|
|
|
- project_id: int,
|
|
|
- filename: str,
|
|
|
- db: AsyncSession = Depends(get_db),
|
|
|
- _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_UPDATE),
|
|
|
-):
|
|
|
- """Delete an attachment from a project."""
|
|
|
- # Validate filename to prevent path traversal
|
|
|
- if "/" in filename or "\\" in filename or ".." in filename or not filename:
|
|
|
- raise HTTPException(status_code=400, detail="Invalid filename")
|
|
|
-
|
|
|
- # Verify project exists
|
|
|
- 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")
|
|
|
-
|
|
|
- # Find and remove attachment from list
|
|
|
- attachments = project.attachments or []
|
|
|
- attachment = next((a for a in attachments if a.get("filename") == filename), None)
|
|
|
- if not attachment:
|
|
|
- raise HTTPException(status_code=404, detail="Attachment not found")
|
|
|
-
|
|
|
- # Remove from list
|
|
|
- attachments = [a for a in attachments if a.get("filename") != filename]
|
|
|
- project.attachments = attachments if attachments else None
|
|
|
-
|
|
|
- # Delete file
|
|
|
- file_path = (
|
|
|
- get_project_attachments_dir(project_id) / filename
|
|
|
- ) # SEC-PATH-OK: filename validated above (no /, \\, .., empty) + attachment membership check
|
|
|
- if file_path.exists():
|
|
|
- try:
|
|
|
- os.remove(file_path)
|
|
|
- except Exception as e:
|
|
|
- logger.warning("Failed to delete attachment file: %s", e)
|
|
|
-
|
|
|
- await db.flush()
|
|
|
- await db.refresh(project)
|
|
|
-
|
|
|
- return {
|
|
|
- "status": "success",
|
|
|
- "message": "Attachment deleted",
|
|
|
- "attachments": project.attachments,
|
|
|
- }
|
|
|
-
|
|
|
-
|
|
|
-# ============ #1155: Cover image ============
|
|
|
-
|
|
|
-
|
|
|
@router.post("/{project_id}/cover-image")
|
|
|
async def upload_project_cover_image(
|
|
|
project_id: int,
|