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.
This commit is contained in:
Fringg
2026-03-23 11:58:07 +03:00
parent 3e69efe589
commit a0d40ad432
9 changed files with 467 additions and 1 deletions
+1 -1
View File
@@ -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
+2
View File
@@ -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)
+151
View File
@@ -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)
+15
View File
@@ -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
+10
View File
@@ -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)
+273
View File
@@ -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
+12
View File
@@ -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'
+1
View File
@@ -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'
+2
View File
@@ -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: