"""Media processing service for news article images and videos. Handles file validation (magic bytes), image resizing via Pillow, thumbnail generation, and atomic file writes with UUID filenames. """ from __future__ import annotations import asyncio import io import uuid from dataclasses import dataclass from pathlib import Path import structlog from PIL import Image, ImageOps logger = structlog.get_logger(__name__) # Hard limit on decompressed image pixels to prevent decompression bombs. # 25M pixels ≈ 5000x5000, roughly 75 MB of raw RGB data — safe for a news editor. Image.MAX_IMAGE_PIXELS = 25_000_000 # Minimum file size to attempt magic byte detection _MIN_MAGIC_BYTES = 12 # --- Magic byte signatures for file type detection --- ALLOWED_IMAGE_SIGNATURES: dict[bytes, str] = { b'\xff\xd8\xff': '.jpg', b'\x89PNG': '.png', # WebP: starts with RIFF....WEBP (bytes 0-3 = RIFF, bytes 8-11 = WEBP) } ALLOWED_VIDEO_SIGNATURES: dict[bytes, str] = { # MP4: bytes 4-7 = 'ftyp' b'\x1a\x45\xdf\xa3': '.webm', } _IMAGES_DIR = 'images' _VIDEOS_DIR = 'videos' _THUMBNAILS_DIR = 'thumbnails' _THUMBNAIL_SIZE = (400, 400) @dataclass(frozen=True, slots=True) class SavedMedia: """Result of saving a media file.""" filename: str relative_path: str thumbnail_path: str | None media_type: str # 'image' or 'video' content_type: str size_bytes: int width: int | None height: int | None def ensure_upload_dirs(upload_path: Path) -> None: """Create images/, videos/, thumbnails/ subdirectories under upload_path.""" for subdir in (_IMAGES_DIR, _VIDEOS_DIR, _THUMBNAILS_DIR): (upload_path / subdir).mkdir(parents=True, exist_ok=True) def detect_file_type(data: bytes) -> tuple[str, str]: """Detect media type and extension from magic bytes. Returns: Tuple of (media_type, extension), e.g. ('image', '.jpg'). Raises: ValueError: If file type is not recognized. """ if len(data) < _MIN_MAGIC_BYTES: msg = 'File too small to identify' raise ValueError(msg) # Check WebP: RIFF at offset 0, WEBP at offset 8 if data[:4] == b'RIFF' and data[8:12] == b'WEBP': return 'image', '.webp' # Check standard image signatures for signature, ext in ALLOWED_IMAGE_SIGNATURES.items(): if data[: len(signature)] == signature: return 'image', ext # Check MP4: bytes 4-7 must be 'ftyp' if len(data) >= 8 and data[4:8] == b'ftyp': return 'video', '.mp4' # Check standard video signatures for signature, ext in ALLOWED_VIDEO_SIGNATURES.items(): if data[: len(signature)] == signature: return 'video', ext msg = 'Unsupported file type: magic bytes do not match any allowed format' raise ValueError(msg) def _process_and_save_image( data: bytes, upload_path: Path, max_dim: int, quality: int, ) -> SavedMedia: """Process image: validate, resize, convert to JPEG, generate thumbnail. This is a CPU-bound function intended to be run via asyncio.to_thread. """ img = Image.open(io.BytesIO(data)) # Double-check pixel count (defense-in-depth alongside Image.MAX_IMAGE_PIXELS) if img.size[0] * img.size[1] > 25_000_000: msg = 'Image dimensions too large' raise ValueError(msg) # Fix EXIF orientation (rotated photos from phones) img = ImageOps.exif_transpose(img) # Convert to RGB (strip alpha for JPEG, handle palette/grayscale modes) if img.mode not in ('RGB', 'L'): img = img.convert('RGB') original_width, original_height = img.size # Resize if any dimension exceeds max_dim (preserving aspect ratio) if original_width > max_dim or original_height > max_dim: img.thumbnail((max_dim, max_dim), Image.LANCZOS) width, height = img.size filename = f'{uuid.uuid4().hex}.jpg' image_dir = upload_path / _IMAGES_DIR target_path = image_dir / filename # Atomic write: save to temp file, then rename tmp_path = target_path.with_suffix('.tmp') try: img.save(tmp_path, format='JPEG', quality=quality, optimize=True) tmp_path.rename(target_path) except Exception: tmp_path.unlink(missing_ok=True) raise size_bytes = target_path.stat().st_size # Generate thumbnail thumbnail_filename = f'thumb_{filename}' thumbnail_dir = upload_path / _THUMBNAILS_DIR thumbnail_target = thumbnail_dir / thumbnail_filename tmp_thumb = thumbnail_target.with_suffix('.tmp') try: thumb = img.copy() thumb.thumbnail(_THUMBNAIL_SIZE, Image.LANCZOS) thumb.save(tmp_thumb, format='JPEG', quality=quality, optimize=True) tmp_thumb.rename(thumbnail_target) except Exception: tmp_thumb.unlink(missing_ok=True) # Non-fatal: log and continue without thumbnail logger.warning('Failed to generate thumbnail', filename=filename) thumbnail_filename = None relative_path = f'{_IMAGES_DIR}/{filename}' thumbnail_path = f'{_THUMBNAILS_DIR}/{thumbnail_filename}' if thumbnail_filename else None return SavedMedia( filename=filename, relative_path=relative_path, thumbnail_path=thumbnail_path, media_type='image', content_type='image/jpeg', size_bytes=size_bytes, width=width, height=height, ) async def save_image( data: bytes, upload_path: Path, max_dim: int, quality: int, ) -> SavedMedia: """Validate, resize, and save an image file. Runs PIL operations in a thread.""" return await asyncio.to_thread(_process_and_save_image, data, upload_path, max_dim, quality) def _save_video_sync(data: bytes, upload_path: Path) -> SavedMedia: """Save a video file. CPU-bound function for asyncio.to_thread.""" media_type, ext = detect_file_type(data) if media_type != 'video': msg = 'Data does not contain a recognized video format' raise ValueError(msg) filename = f'{uuid.uuid4().hex}{ext}' video_dir = upload_path / _VIDEOS_DIR target_path = video_dir / filename content_type_map: dict[str, str] = { '.mp4': 'video/mp4', '.webm': 'video/webm', } # Atomic write tmp_path = target_path.with_suffix('.tmp') try: tmp_path.write_bytes(data) tmp_path.rename(target_path) except Exception: tmp_path.unlink(missing_ok=True) raise size_bytes = target_path.stat().st_size return SavedMedia( filename=filename, relative_path=f'{_VIDEOS_DIR}/{filename}', thumbnail_path=None, media_type='video', content_type=content_type_map.get(ext, 'application/octet-stream'), size_bytes=size_bytes, width=None, height=None, ) async def save_video(data: bytes, upload_path: Path) -> SavedMedia: """Validate and save a video file. Runs I/O in a thread.""" return await asyncio.to_thread(_save_video_sync, data, upload_path) def delete_media_file(filename: str, upload_path: Path) -> bool: """Delete a media file by filename with path traversal protection. Searches images/, videos/, thumbnails/ directories. Returns: True if at least one file was deleted, False otherwise. """ deleted = False for subdir in (_IMAGES_DIR, _VIDEOS_DIR, _THUMBNAILS_DIR): candidate = (upload_path / subdir / filename).resolve() base_dir = (upload_path / subdir).resolve() # Path traversal guard if not candidate.is_relative_to(base_dir): logger.warning( 'Path traversal attempt blocked', filename=filename, resolved=str(candidate), ) continue if candidate.is_file(): candidate.unlink() deleted = True logger.info('Deleted media file', path=str(candidate)) # Also try to delete matching thumbnail if not filename.startswith('thumb_'): thumb_name = f'thumb_{filename}' thumb_path = (upload_path / _THUMBNAILS_DIR / thumb_name).resolve() thumb_base = (upload_path / _THUMBNAILS_DIR).resolve() if thumb_path.is_relative_to(thumb_base) and thumb_path.is_file(): thumb_path.unlink() logger.info('Deleted thumbnail', path=str(thumb_path)) return deleted