- Introduced application-wide constants for file uploads, image processing, pagination, and authentication in `constants.py`. - Implemented synchronous and asynchronous board ownership verification functions in `ownership.py`. - Created a base repository class with common CRUD operations in `repository.py`. - Added standard response utilities for error and success messages in `responses.py`. - Refactored image validation to utilize constants for file size and MIME types. - Enhanced frontend components with consistent styling and validation utilities for forms. - Established global styles for buttons, forms, loading indicators, and messages to ensure a cohesive UI experience.
101 lines
2.7 KiB
Python
101 lines
2.7 KiB
Python
"""File validation utilities for image uploads."""
|
|
|
|
import magic
|
|
from fastapi import HTTPException, UploadFile, status
|
|
|
|
from app.core.constants import (
|
|
ALLOWED_EXTENSIONS,
|
|
ALLOWED_MIME_TYPES,
|
|
MAX_IMAGE_SIZE,
|
|
)
|
|
|
|
|
|
async def validate_image_file(file: UploadFile) -> bytes:
|
|
"""
|
|
Validate uploaded image file.
|
|
|
|
Checks:
|
|
- File size within limits
|
|
- MIME type allowed
|
|
- Magic bytes match declared type
|
|
- File extension valid
|
|
|
|
Args:
|
|
file: The uploaded file from FastAPI
|
|
|
|
Returns:
|
|
File contents as bytes
|
|
|
|
Raises:
|
|
HTTPException: If validation fails
|
|
"""
|
|
# Read file contents
|
|
contents = await file.read()
|
|
file_size = len(contents)
|
|
|
|
# Reset file pointer for potential re-reading
|
|
await file.seek(0)
|
|
|
|
# Check file size
|
|
if file_size == 0:
|
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Empty file uploaded")
|
|
|
|
if file_size > MAX_IMAGE_SIZE:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
|
|
detail=f"File too large. Maximum size is {MAX_IMAGE_SIZE / 1_048_576:.1f}MB",
|
|
)
|
|
|
|
# Validate file extension
|
|
if file.filename:
|
|
extension = "." + file.filename.lower().split(".")[-1] if "." in file.filename else ""
|
|
if extension not in ALLOWED_EXTENSIONS:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=f"Invalid file extension. Allowed: {', '.join(ALLOWED_EXTENSIONS)}",
|
|
)
|
|
|
|
# Detect actual MIME type using magic bytes
|
|
mime = magic.from_buffer(contents, mime=True)
|
|
|
|
# Validate MIME type
|
|
if mime not in ALLOWED_MIME_TYPES:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=f"Invalid file type '{mime}'. Allowed types: {', '.join(ALLOWED_MIME_TYPES)}",
|
|
)
|
|
|
|
return contents
|
|
|
|
|
|
def sanitize_filename(filename: str) -> str:
|
|
"""
|
|
Sanitize filename to prevent path traversal and other attacks.
|
|
|
|
Args:
|
|
filename: Original filename
|
|
|
|
Returns:
|
|
Sanitized filename
|
|
"""
|
|
import re
|
|
|
|
# Remove path separators
|
|
filename = filename.replace("/", "_").replace("\\", "_")
|
|
|
|
# Remove any non-alphanumeric characters except dots, dashes, underscores
|
|
filename = re.sub(r"[^a-zA-Z0-9._-]", "_", filename)
|
|
|
|
# Limit length
|
|
max_length = 255
|
|
if len(filename) > max_length:
|
|
# Keep extension
|
|
parts = filename.rsplit(".", 1)
|
|
if len(parts) == 2:
|
|
name, ext = parts
|
|
filename = name[: max_length - len(ext) - 1] + "." + ext
|
|
else:
|
|
filename = filename[:max_length]
|
|
|
|
return filename
|