"""File upload/download/delete router for images and PDFs.""" import logging import re from pathlib import Path from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status from fastapi.responses import FileResponse from PIL import Image from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from src.backend.config import settings from src.backend.database import get_db from src.backend.api.middleware.api_key import get_current_user, require_maker from src.backend.models.orm.measurement import Measurement from src.backend.models.orm.recipe import Recipe, RecipeVersion from src.backend.models.orm.task import RecipeSubtask, RecipeTask from src.backend.models.orm.user import User logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/files", tags=["files"]) # Allowed MIME types ALLOWED_IMAGE_TYPES = {"image/jpeg", "image/png", "image/gif", "image/webp"} ALLOWED_PDF_TYPE = "application/pdf" ALLOWED_TYPES = ALLOWED_IMAGE_TYPES | {ALLOWED_PDF_TYPE} # Magic-byte signatures: the client-declared Content-Type is only what the # browser claimed and cannot be trusted (#36 point 2 — a file named "x.html" # declared image/png was stored and then served by extension as text/html, # which a browser executes). SVG is intentionally not recognised: it is not # in ALLOWED_TYPES and can itself carry script content. _MAGIC_SIGNATURES: list[tuple[bytes, str, str]] = [ # (signature, mime type, canonical extension) (b"\x89PNG\r\n\x1a\n", "image/png", ".png"), (b"\xff\xd8\xff", "image/jpeg", ".jpg"), (b"GIF87a", "image/gif", ".gif"), (b"GIF89a", "image/gif", ".gif"), (b"%PDF-", "application/pdf", ".pdf"), ] # Read uploads in chunks so a declared-huge body is rejected as soon as it # crosses the limit, instead of being buffered into memory in full first. _UPLOAD_CHUNK_SIZE = 1024 * 1024 def detect_file_type(content: bytes) -> tuple[str, str] | None: """Identify a file's real type from its magic bytes. Returns (mime_type, canonical_extension), or None when the content does not match any type this system serves. """ for signature, mime, ext in _MAGIC_SIGNATURES: if content.startswith(signature): return mime, ext # WebP: "RIFF" + 4-byte little-endian size + "WEBP"; the size varies. if len(content) >= 12 and content[0:4] == b"RIFF" and content[8:12] == b"WEBP": return "image/webp", ".webp" return None # Top-level folders nothing may DELETE through this router: statistics/reports are # audit records, the logo is only ever replaced via POST /api/settings/logo. _UNDELETABLE_PREFIXES = ("statistics/", "reports/", "logos/") # GET under these needs a role that is meant to see production data, not just # any authenticated user (a Maker or MeasurementTec has no business reading # every production's statistics/report file by guessing its name). _RESTRICTED_GET_PREFIXES = ("statistics/", "reports/") def _can_read_restricted(user: User) -> bool: return user.is_admin or user.has_role("Metrologist") or user.has_role("Supervisor") async def _is_recipe_file_protected(db: AsyncSession, relative_path: str) -> bool: """True when `relative_path` (uploads-relative, forward slashes) must not be deleted: it is a recipe's cover image, or a task/subtask file belonging to a version that is either not the current one (kept for history) or already has measurements against it (the audit trail needs it to make sense of them). A path not referenced anywhere is safe to delete (orphaned upload). """ recipe_hit = (await db.execute( select(Recipe.id).where(Recipe.image_path == relative_path) )).scalar_one_or_none() if recipe_hit is not None: return True version_id = (await db.execute( select(RecipeTask.version_id).where(RecipeTask.file_path == relative_path) )).scalar_one_or_none() if version_id is None: version_id = (await db.execute( select(RecipeTask.version_id) .join(RecipeSubtask, RecipeSubtask.task_id == RecipeTask.id) .where(RecipeSubtask.image_path == relative_path) )).scalar_one_or_none() if version_id is None: return False version = (await db.execute( select(RecipeVersion).where(RecipeVersion.id == version_id) )).scalar_one_or_none() if version is None or not version.is_current: return True has_measurements = (await db.execute( select(Measurement.id).where(Measurement.version_id == version_id).limit(1) )).scalar_one_or_none() is not None return has_measurements def validate_file_type(content_type: str) -> bool: """Validate if file type is allowed.""" return content_type in ALLOWED_TYPES def validate_file_size(file_size: int) -> bool: """Validate if file size is within limits.""" max_bytes = settings.max_upload_size_mb * 1024 * 1024 return file_size <= max_bytes def sanitize_filename(filename: str) -> str: """Sanitize an uploaded filename to prevent path traversal and injection. - Strips directory path separators (/ and \\). - Only allows alphanumeric characters, dots, hyphens, and underscores. - Rejects filenames starting with '.' (hidden files). - Rejects empty filenames. Returns: The sanitized filename. Raises: HTTPException: If filename is empty or starts with '.'. """ # Strip any path separators to prevent directory traversal filename = filename.replace("/", "").replace("\\", "") # Remove any characters not in the allowed set filename = re.sub(r"[^a-zA-Z0-9._-]", "", filename) if not filename: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Filename is empty or contains only invalid characters.", ) if filename.startswith("."): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Filenames starting with '.' are not allowed.", ) return filename def resolve_upload_path(file_path: str) -> Path: """Resolve a user-supplied relative path inside the uploads directory. Raises: HTTPException: 404 if the path escapes the uploads directory, cannot be resolved, or does not point to an existing file. """ try: full_path = (settings.upload_path / file_path).resolve() if not full_path.is_relative_to(settings.upload_path.resolve()): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Access denied", ) except HTTPException: raise except (OSError, RuntimeError, ValueError): raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="File not found", ) if not full_path.exists() or not full_path.is_file(): raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="File not found", ) return full_path def generate_thumbnail(image_path: Path, thumbnail_path: Path, max_size: tuple[int, int] = (200, 200)): """Generate a thumbnail for an image.""" try: with Image.open(image_path) as img: img.thumbnail(max_size, Image.Resampling.LANCZOS) img.save(thumbnail_path, quality=85) except Exception as e: # If thumbnail generation fails, we continue without it logger.warning("Thumbnail generation failed for %s: %s", image_path, e) @router.post("/upload") async def upload_file( file: UploadFile = File(...), recipe_id: int | None = None, version_id: int | None = None, user: User = Depends(require_maker), ): """Upload an image or PDF file. Files are stored in uploads/{recipe_id}/{version_id}/ Thumbnails are generated for images. The type is authoritatively determined from the file's magic bytes, not the client-declared Content-Type (#36 point 2): the stored extension always matches what was actually detected, so it can never be served back as something else. """ # Read in chunks, stopping as soon as the size limit is crossed instead # of buffering an oversized body into memory first. max_bytes = settings.max_upload_size_mb * 1024 * 1024 buffer = bytearray() while True: chunk = await file.read(_UPLOAD_CHUNK_SIZE) if not chunk: break buffer.extend(chunk) if len(buffer) > max_bytes: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=f"File size exceeds maximum {settings.max_upload_size_mb}MB", ) content = bytes(buffer) file_size = len(content) # Validate file type from the actual bytes, not file.content_type detected = detect_file_type(content) if detected is None: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="File type not allowed. Must be a recognised image or PDF.", ) detected_mime, detected_ext = detected # Determine storage path if recipe_id is not None and version_id is not None: storage_dir = settings.upload_path / str(recipe_id) / str(version_id) else: storage_dir = settings.upload_path / "general" storage_dir.mkdir(parents=True, exist_ok=True) # Sanitize the uploaded filename, then force its extension to the one # actually detected: a declared/renamed extension is not trusted either. safe_filename = sanitize_filename(file.filename or "") base_name = Path(safe_filename).stem or "file" safe_filename = f"{base_name}{detected_ext}" # Generate unique filename if file already exists file_path = storage_dir / safe_filename counter = 1 while file_path.exists(): file_path = storage_dir / f"{base_name}_{counter}{detected_ext}" counter += 1 # Write file file_path.write_bytes(content) # Generate thumbnail for images thumbnail_path = None if detected_mime in ALLOWED_IMAGE_TYPES: thumbnail_dir = storage_dir / "thumbnails" thumbnail_dir.mkdir(exist_ok=True) thumbnail_path = thumbnail_dir / file_path.name generate_thumbnail(file_path, thumbnail_path) # Return relative path from uploads directory relative_path = file_path.relative_to(settings.upload_path) thumbnail_relative = ( thumbnail_path.relative_to(settings.upload_path) if thumbnail_path else None ) return { "file_path": str(relative_path).replace("\\", "/"), "thumbnail_path": str(thumbnail_relative).replace("\\", "/") if thumbnail_relative else None, "file_type": detected_mime, "file_size": file_size, } @router.get("/{file_path:path}") async def get_file( file_path: str, user: User = Depends(get_current_user), ): """Serve a file from the uploads directory. Requires authentication; statistics/ and reports/ additionally require Metrologist, Supervisor or admin, since those are production data. """ if file_path.startswith(_RESTRICTED_GET_PREFIXES) and not _can_read_restricted(user): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Metrologist, Supervisor or admin role required", ) full_path = resolve_upload_path(file_path) return FileResponse(full_path) @router.delete("/{file_path:path}", status_code=status.HTTP_204_NO_CONTENT) async def delete_file( file_path: str, user: User = Depends(require_maker), db: AsyncSession = Depends(get_db), ): """Delete a file from the uploads directory. Also deletes associated thumbnail if it exists. statistics/, reports/ and logos/ can never be deleted here (403); a recipe file still referenced by its current version or by a version with measurements is kept (409). """ if file_path.startswith(_UNDELETABLE_PREFIXES): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="This folder cannot be deleted through the files API.", ) full_path = resolve_upload_path(file_path) relative_path = str(full_path.relative_to(settings.upload_path)).replace("\\", "/") if await _is_recipe_file_protected(db, relative_path): raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail="File is referenced by a current or measured recipe version.", ) # Delete thumbnail if it exists if full_path.parent.name != "thumbnails": thumbnail_path = full_path.parent / "thumbnails" / full_path.name if thumbnail_path.exists(): thumbnail_path.unlink() # Delete file full_path.unlink()