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:
+1
-1
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user