Files
TieMeasureFlow/src/backend/api/routers/files.py
T
Adriano 805f41c757 fix(upload): tipo da magic bytes, non da Content-Type dichiarato (#36 punto 2)
Il Content-Type del client non era verificato: un file "x.html" dichiarato
image/png veniva salvato e poi servito per estensione come text/html,
eseguito dal browser. Nuova detect_file_type() (PNG/JPEG/GIF/WebP/PDF) in
files.py, usata anche dal logo in settings.py; l'estensione salvata segue
sempre il tipo rilevato, mai il nome file dichiarato dal client. Il logo
rifiuta SVG a prescindere (puo' portare script). Upload letti a blocchi da
1MB, interrotti appena superano il limite invece di bufferizzare tutto
prima di controllare la dimensione.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 22:48:52 +02:00

347 lines
12 KiB
Python

"""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()