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 . .
|
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
|
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_landings import router as admin_landings_router
|
||||||
from .admin_menu_layout import router as admin_menu_layout_router
|
from .admin_menu_layout import router as admin_menu_layout_router
|
||||||
from .admin_news import router as admin_news_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_partners import router as admin_partners_router
|
||||||
from .admin_payment_methods import router as admin_payment_methods_router
|
from .admin_payment_methods import router as admin_payment_methods_router
|
||||||
from .admin_payments import router as admin_payments_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_policies_router)
|
||||||
router.include_router(admin_audit_log_router)
|
router.include_router(admin_audit_log_router)
|
||||||
router.include_router(admin_news_router)
|
router.include_router(admin_news_router)
|
||||||
|
router.include_router(admin_news_media_router)
|
||||||
|
|
||||||
# WebSocket route
|
# WebSocket route
|
||||||
router.include_router(websocket_router)
|
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'
|
CONNECT_BUTTON_MODE: str = 'miniapp_subscription'
|
||||||
MINIAPP_CUSTOM_URL: str = ''
|
MINIAPP_CUSTOM_URL: str = ''
|
||||||
MINIAPP_STATIC_PATH: str = 'miniapp'
|
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_PURCHASE_URL: str = ''
|
||||||
MINIAPP_SERVICE_NAME_EN: str = 'Bedolaga VPN'
|
MINIAPP_SERVICE_NAME_EN: str = 'Bedolaga VPN'
|
||||||
MINIAPP_SERVICE_NAME_RU: str = 'Bedolaga VPN'
|
MINIAPP_SERVICE_NAME_RU: str = 'Bedolaga VPN'
|
||||||
@@ -2626,6 +2633,9 @@ class Settings(BaseSettings):
|
|||||||
raw_path = 'miniapp'
|
raw_path = 'miniapp'
|
||||||
return Path(raw_path)
|
return Path(raw_path)
|
||||||
|
|
||||||
|
def get_media_upload_path(self) -> Path:
|
||||||
|
return Path(self.MEDIA_UPLOAD_DIR)
|
||||||
|
|
||||||
# Cabinet methods
|
# Cabinet methods
|
||||||
def is_cabinet_enabled(self) -> bool:
|
def is_cabinet_enabled(self) -> bool:
|
||||||
return bool(self.CABINET_ENABLED)
|
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
|
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]:
|
def _mount_miniapp_static(app: FastAPI) -> tuple[bool, Path]:
|
||||||
static_path: Path = settings.get_miniapp_static_path()
|
static_path: Path = settings.get_miniapp_static_path()
|
||||||
if not static_path.exists():
|
if not static_path.exists():
|
||||||
@@ -175,6 +186,7 @@ def create_unified_app(
|
|||||||
await disposable_email_service.stop()
|
await disposable_email_service.stop()
|
||||||
|
|
||||||
miniapp_mounted, miniapp_path = _mount_miniapp_static(app)
|
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'
|
unified_health_path = '/health/unified' if settings.is_web_api_enabled() else '/health'
|
||||||
|
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ services:
|
|||||||
- ./locales:/app/locales:rw
|
- ./locales:/app/locales:rw
|
||||||
- /etc/timezone:/etc/timezone:ro
|
- /etc/timezone:/etc/timezone:ro
|
||||||
- /etc/localtime:/etc/localtime:ro
|
- /etc/localtime:/etc/localtime:ro
|
||||||
|
- ./uploads:/app/uploads:rw
|
||||||
- ./vpn_logo.png:/app/vpn_logo.png:ro
|
- ./vpn_logo.png:/app/vpn_logo.png:ro
|
||||||
ports:
|
ports:
|
||||||
- '${WEB_API_PORT:-8080}:8080'
|
- '${WEB_API_PORT:-8080}:8080'
|
||||||
|
|||||||
@@ -72,6 +72,8 @@ services:
|
|||||||
# Timezone
|
# Timezone
|
||||||
- /etc/timezone:/etc/timezone:ro
|
- /etc/timezone:/etc/timezone:ro
|
||||||
- /etc/localtime:/etc/localtime:ro
|
- /etc/localtime:/etc/localtime:ro
|
||||||
|
# Загруженные медиафайлы (изображения/видео для новостей)
|
||||||
|
- ./uploads:/app/uploads:rw
|
||||||
# Логотип для сообщений
|
# Логотип для сообщений
|
||||||
- ./vpn_logo.png:/app/vpn_logo.png:ro
|
- ./vpn_logo.png:/app/vpn_logo.png:ro
|
||||||
ports:
|
ports:
|
||||||
|
|||||||
Reference in New Issue
Block a user