Files
remnawave-bedolaga-telegram…/app/services/news_media_service.py
T
Fringg a0d40ad432 feat: add media upload/delete API for news articles
Local filesystem storage with Docker volume mount, magic byte validation,
PIL image resize/thumbnail generation, atomic writes, path traversal guards.
2026-03-23 11:58:07 +03:00

274 lines
8.2 KiB
Python

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