Files
Todd 0d7b2b1aab Initial commit
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-29 22:42:55 -04:00

129 lines
3.9 KiB
Python

"""
File serving and thumbnail generation API
Provides endpoints for:
- On-demand thumbnail generation for images and videos
- File serving with proper caching headers
"""
from typing import Dict
from fastapi import APIRouter, Depends, Query, Request
from fastapi.responses import Response
from PIL import Image
from pathlib import Path
import subprocess
import io
from slowapi import Limiter
from slowapi.util import get_remote_address
from ..core.dependencies import get_current_user
from ..core.exceptions import handle_exceptions, NotFoundError, ValidationError
from ..core.utils import validate_file_path
from modules.universal_logger import get_logger
router = APIRouter(prefix="/files", tags=["files"])
logger = get_logger('FilesRouter')
limiter = Limiter(key_func=get_remote_address)
@router.get("/thumbnail")
@limiter.limit("300/minute")
@handle_exceptions
async def get_thumbnail(
request: Request,
path: str = Query(..., description="File path"),
current_user: Dict = Depends(get_current_user)
):
"""
Generate and return thumbnail for image or video
Args:
path: Absolute path to file
Returns:
JPEG thumbnail (200x200px max, maintains aspect ratio)
"""
# Validate file is within allowed directories (prevents path traversal)
file_path = validate_file_path(path, require_exists=True)
file_ext = file_path.suffix.lower()
# Generate thumbnail based on type
if file_ext in ['.jpg', '.jpeg', '.png', '.webp', '.gif', '.heic']:
# Image thumbnail with PIL
img = Image.open(file_path)
# Convert HEIC if needed
if file_ext == '.heic':
img = img.convert('RGB')
# Create thumbnail (maintains aspect ratio)
img.thumbnail((200, 200), Image.Resampling.LANCZOS)
# Convert to JPEG
buffer = io.BytesIO()
if img.mode in ('RGBA', 'LA', 'P'):
# Convert transparency to white background
background = Image.new('RGB', img.size, (255, 255, 255))
if img.mode == 'P':
img = img.convert('RGBA')
background.paste(img, mask=img.split()[-1] if img.mode in ('RGBA', 'LA') else None)
img = background
img.convert('RGB').save(buffer, format='JPEG', quality=85, optimize=True)
buffer.seek(0)
return Response(
content=buffer.read(),
media_type="image/jpeg",
headers={"Cache-Control": "public, max-age=3600"}
)
elif file_ext in ['.mp4', '.webm', '.mov', '.avi', '.mkv']:
# Video thumbnail with ffmpeg
result = subprocess.run(
[
'ffmpeg',
'-ss', '00:00:01', # Seek to 1 second
'-i', str(file_path),
'-vframes', '1', # Extract 1 frame
'-vf', 'scale=200:-1', # Scale to 200px width, maintain aspect
'-f', 'image2pipe', # Output to pipe
'-vcodec', 'mjpeg', # JPEG codec
'pipe:1'
],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
timeout=10,
check=False
)
if result.returncode != 0:
# Try without seeking (for very short videos)
result = subprocess.run(
[
'ffmpeg',
'-i', str(file_path),
'-vframes', '1',
'-vf', 'scale=200:-1',
'-f', 'image2pipe',
'-vcodec', 'mjpeg',
'pipe:1'
],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
timeout=10,
check=True
)
return Response(
content=result.stdout,
media_type="image/jpeg",
headers={"Cache-Control": "public, max-age=3600"}
)
else:
raise ValidationError("Unsupported file type")