From a0d40ad432d858ebfe75485a9597d32e847d5746 Mon Sep 17 00:00:00 2001 From: Fringg Date: Mon, 23 Mar 2026 11:58:07 +0300 Subject: [PATCH] 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. --- Dockerfile | 2 +- app/cabinet/routes/__init__.py | 2 + app/cabinet/routes/admin_news_media.py | 151 ++++++++++++++ app/cabinet/schemas/news_media.py | 15 ++ app/config.py | 10 + app/services/news_media_service.py | 273 +++++++++++++++++++++++++ app/webserver/unified_app.py | 12 ++ docker-compose.local.yml | 1 + docker-compose.yml | 2 + 9 files changed, 467 insertions(+), 1 deletion(-) create mode 100644 app/cabinet/routes/admin_news_media.py create mode 100644 app/cabinet/schemas/news_media.py create mode 100644 app/services/news_media_service.py diff --git a/Dockerfile b/Dockerfile index 19c51128..e2ad9acd 100644 --- a/Dockerfile +++ b/Dockerfile @@ -33,7 +33,7 @@ WORKDIR /app COPY --chown=app:app . . -RUN mkdir -p logs data && chown app:app logs data +RUN mkdir -p logs data uploads && chown app:app logs data uploads USER app diff --git a/app/cabinet/routes/__init__.py b/app/cabinet/routes/__init__.py index a061038e..e42d5f03 100644 --- a/app/cabinet/routes/__init__.py +++ b/app/cabinet/routes/__init__.py @@ -14,6 +14,7 @@ from .admin_email_templates import router as admin_email_templates_router from .admin_landings import router as admin_landings_router from .admin_menu_layout import router as admin_menu_layout_router from .admin_news import router as admin_news_router +from .admin_news_media import router as admin_news_media_router from .admin_partners import router as admin_partners_router from .admin_payment_methods import router as admin_payment_methods_router from .admin_payments import router as admin_payments_router @@ -130,6 +131,7 @@ router.include_router(admin_roles_router) router.include_router(admin_policies_router) router.include_router(admin_audit_log_router) router.include_router(admin_news_router) +router.include_router(admin_news_media_router) # WebSocket route router.include_router(websocket_router) diff --git a/app/cabinet/routes/admin_news_media.py b/app/cabinet/routes/admin_news_media.py new file mode 100644 index 00000000..74295cd6 --- /dev/null +++ b/app/cabinet/routes/admin_news_media.py @@ -0,0 +1,151 @@ +"""Admin routes for managing news article media (images/videos).""" + +from __future__ import annotations + +import asyncio +import re + +import structlog +from fastapi import APIRouter, Depends, HTTPException, Request, UploadFile, status + +from app.config import settings +from app.database.models import User +from app.services.news_media_service import ( + SavedMedia, + delete_media_file, + detect_file_type, + ensure_upload_dirs, + save_image, + save_video, +) + +from ..dependencies import require_permission +from ..schemas.news_media import NewsMediaUploadResponse + + +logger = structlog.get_logger(__name__) + +_BYTES_PER_MB = 1024 * 1024 + +# Only allow UUID-hex filenames with expected extensions (path traversal defense-in-depth) +_SAFE_FILENAME_RE = re.compile(r'^(thumb_)?[0-9a-f]{32}\.(jpg|mp4|webm)$') + +router = APIRouter(prefix='/admin/news/media', tags=['Cabinet Admin News Media']) + + +def _build_media_url(request: Request, relative_path: str) -> str: + """Build a full URL for a media file from the request base URL.""" + base = str(request.base_url).rstrip('/') + return f'{base}/uploads/{relative_path}' + + +def _build_response(request: Request, saved: SavedMedia) -> NewsMediaUploadResponse: + """Convert SavedMedia to API response with full URLs.""" + thumbnail_url = _build_media_url(request, saved.thumbnail_path) if saved.thumbnail_path else None + + return NewsMediaUploadResponse( + url=_build_media_url(request, saved.relative_path), + thumbnail_url=thumbnail_url, + media_type=saved.media_type, + filename=saved.filename, + size_bytes=saved.size_bytes, + width=saved.width, + height=saved.height, + ) + + +@router.post('/upload', response_model=NewsMediaUploadResponse, status_code=status.HTTP_201_CREATED) +async def upload_media( + request: Request, + file: UploadFile, + admin: User = Depends(require_permission('news:edit')), +) -> NewsMediaUploadResponse: + """Upload an image or video for a news article.""" + # Read with a hard budget to prevent memory exhaustion from huge uploads. + # Read slightly over the max allowed size so we can detect oversized files. + absolute_max_bytes = settings.MEDIA_MAX_VIDEO_SIZE_MB * _BYTES_PER_MB + 1 + data = await file.read(absolute_max_bytes) + if not data: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail='Empty file', + ) + + if len(data) >= absolute_max_bytes: + raise HTTPException( + status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, + detail=f'File too large. Absolute maximum: {settings.MEDIA_MAX_VIDEO_SIZE_MB} MB', + ) + + # Detect type from magic bytes + try: + media_type, _ext = detect_file_type(data) + except ValueError: + raise HTTPException( + status_code=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE, + detail='Unsupported file type. Allowed: JPEG, PNG, WebP, MP4, WebM', + ) + + # Enforce per-type size limits + max_size_mb = ( + settings.MEDIA_MAX_IMAGE_SIZE_MB if media_type == 'image' else settings.MEDIA_MAX_VIDEO_SIZE_MB + ) + if len(data) > max_size_mb * _BYTES_PER_MB: + raise HTTPException( + status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, + detail=f'File too large. Maximum size for {media_type}: {max_size_mb} MB', + ) + + upload_path = settings.get_media_upload_path() + ensure_upload_dirs(upload_path) + + try: + if media_type == 'image': + saved = await save_image( + data, + upload_path, + max_dim=settings.MEDIA_IMAGE_MAX_DIMENSION, + quality=settings.MEDIA_JPEG_QUALITY, + ) + else: + saved = await save_video(data, upload_path) + except Exception: + logger.exception('Failed to save uploaded media', media_type=media_type) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail='Failed to process uploaded file', + ) + + logger.info( + 'Media uploaded', + filename=saved.filename, + media_type=saved.media_type, + size_bytes=saved.size_bytes, + admin_id=admin.id, + ) + + return _build_response(request, saved) + + +@router.delete('/{filename}', status_code=status.HTTP_204_NO_CONTENT) +async def delete_media( + filename: str, + admin: User = Depends(require_permission('news:delete')), +) -> None: + """Delete a previously uploaded media file.""" + if not _SAFE_FILENAME_RE.match(filename): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail='Invalid filename', + ) + + upload_path = settings.get_media_upload_path() + + deleted = await asyncio.to_thread(delete_media_file, filename, upload_path) + if not deleted: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail='File not found', + ) + + logger.info('Media deleted', filename=filename, admin_id=admin.id) diff --git a/app/cabinet/schemas/news_media.py b/app/cabinet/schemas/news_media.py new file mode 100644 index 00000000..654efccf --- /dev/null +++ b/app/cabinet/schemas/news_media.py @@ -0,0 +1,15 @@ +"""Schemas for news media upload responses.""" + +from pydantic import BaseModel + + +class NewsMediaUploadResponse(BaseModel): + """Response returned after a successful media upload.""" + + url: str + thumbnail_url: str | None = None + media_type: str # 'image' or 'video' + filename: str + size_bytes: int + width: int | None = None + height: int | None = None diff --git a/app/config.py b/app/config.py index 65bbb744..234a511a 100644 --- a/app/config.py +++ b/app/config.py @@ -582,6 +582,13 @@ class Settings(BaseSettings): CONNECT_BUTTON_MODE: str = 'miniapp_subscription' MINIAPP_CUSTOM_URL: str = '' MINIAPP_STATIC_PATH: str = 'miniapp' + + # Media upload settings (news article images/videos) + MEDIA_UPLOAD_DIR: str = './uploads' + MEDIA_MAX_IMAGE_SIZE_MB: int = 10 + MEDIA_MAX_VIDEO_SIZE_MB: int = 50 + MEDIA_IMAGE_MAX_DIMENSION: int = 2048 + MEDIA_JPEG_QUALITY: int = 85 MINIAPP_PURCHASE_URL: str = '' MINIAPP_SERVICE_NAME_EN: str = 'Bedolaga VPN' MINIAPP_SERVICE_NAME_RU: str = 'Bedolaga VPN' @@ -2626,6 +2633,9 @@ class Settings(BaseSettings): raw_path = 'miniapp' return Path(raw_path) + def get_media_upload_path(self) -> Path: + return Path(self.MEDIA_UPLOAD_DIR) + # Cabinet methods def is_cabinet_enabled(self) -> bool: return bool(self.CABINET_ENABLED) diff --git a/app/services/news_media_service.py b/app/services/news_media_service.py new file mode 100644 index 00000000..c33961bf --- /dev/null +++ b/app/services/news_media_service.py @@ -0,0 +1,273 @@ +"""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 diff --git a/app/webserver/unified_app.py b/app/webserver/unified_app.py index 52be32d7..e63508da 100644 --- a/app/webserver/unified_app.py +++ b/app/webserver/unified_app.py @@ -89,6 +89,17 @@ def _create_base_app() -> FastAPI: return app +def _mount_uploads_static(app: FastAPI) -> None: + """Mount the media uploads directory as a static file server at /uploads.""" + uploads_path = settings.get_media_upload_path() + uploads_path.mkdir(parents=True, exist_ok=True) + try: + app.mount('/uploads', StaticFiles(directory=uploads_path), name='media-uploads') + logger.info('Media uploads static files mounted at /uploads', uploads_path=str(uploads_path)) + except RuntimeError as error: # pragma: no cover - defensive guard + logger.warning('Failed to mount media uploads static files', error=error) + + def _mount_miniapp_static(app: FastAPI) -> tuple[bool, Path]: static_path: Path = settings.get_miniapp_static_path() if not static_path.exists(): @@ -175,6 +186,7 @@ def create_unified_app( await disposable_email_service.stop() miniapp_mounted, miniapp_path = _mount_miniapp_static(app) + _mount_uploads_static(app) unified_health_path = '/health/unified' if settings.is_web_api_enabled() else '/health' diff --git a/docker-compose.local.yml b/docker-compose.local.yml index fb7f1626..037b83b8 100644 --- a/docker-compose.local.yml +++ b/docker-compose.local.yml @@ -69,6 +69,7 @@ services: - ./locales:/app/locales:rw - /etc/timezone:/etc/timezone:ro - /etc/localtime:/etc/localtime:ro + - ./uploads:/app/uploads:rw - ./vpn_logo.png:/app/vpn_logo.png:ro ports: - '${WEB_API_PORT:-8080}:8080' diff --git a/docker-compose.yml b/docker-compose.yml index eb7f3a38..ee0e3138 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -72,6 +72,8 @@ services: # Timezone - /etc/timezone:/etc/timezone:ro - /etc/localtime:/etc/localtime:ro + # Загруженные медиафайлы (изображения/видео для новостей) + - ./uploads:/app/uploads:rw # Логотип для сообщений - ./vpn_logo.png:/app/vpn_logo.png:ro ports: