Files
remnawave-bedolaga-telegram…/app/cabinet/routes/admin_news_media.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

152 lines
4.9 KiB
Python

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