805f41c757
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>
347 lines
12 KiB
Python
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()
|