"""File upload/download endpoints for post attachments. Handles multipart file upload proxied to MinIO and signed URL generation for downloads. All uploads require authentication and post ownership. """ from __future__ import annotations import asyncio import logging import os import re import uuid from typing import Annotated from fastapi import APIRouter, Depends, Form, HTTPException, UploadFile, status from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload from auth import get_current_user from database import get_session from minio_client import generate_download_url, upload_file from models import Creator, Post, PostAttachment, User from schemas import PostAttachmentRead logger = logging.getLogger("chrysopedia.files") router = APIRouter(prefix="/files", tags=["files"]) # Max filename length after sanitization _MAX_FILENAME_LEN = 200 def _sanitize_filename(filename: str) -> str: """Strip path separators, limit length, preserve extension. Prevents path traversal and overly long filenames while keeping the original extension for content-type detection downstream. """ # Strip directory components name = os.path.basename(filename) # Remove any remaining path-separator-like chars name = re.sub(r'[/\\]', '_', name) # Replace sequences of whitespace/special chars with underscore name = re.sub(r'[^\w.\-]', '_', name) # Remove leading dots (hidden files) name = name.lstrip('.') if not name: name = "unnamed" # Preserve extension while limiting total length root, ext = os.path.splitext(name) max_root = _MAX_FILENAME_LEN - len(ext) if len(root) > max_root: root = root[:max_root] return root + ext @router.post("/upload", response_model=PostAttachmentRead, status_code=status.HTTP_201_CREATED) async def upload_attachment( file: UploadFile, post_id: Annotated[uuid.UUID, Form()], current_user: Annotated[User, Depends(get_current_user)], db: AsyncSession = Depends(get_session), ) -> PostAttachmentRead: """Upload a file attachment to a post. The file is stored in MinIO and a PostAttachment record is created. Requires auth and ownership of the target post. """ if current_user.creator_id is None: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="No creator profile linked to this account", ) # Validate post exists and user owns it q = ( select(Post) .where(Post.id == post_id) .options(selectinload(Post.creator)) ) result = await db.execute(q) post = result.scalar_one_or_none() if post is None: raise HTTPException(status_code=404, detail="Post not found") if post.creator_id != current_user.creator_id: logger.warning( "Upload ownership violation: user %s tried to upload to post %s owned by creator %s", current_user.id, post_id, post.creator_id, ) raise HTTPException(status_code=403, detail="Not your post") # Validate file was provided if file.filename is None or file.filename == "": raise HTTPException(status_code=400, detail="No file provided") # Read file content content = await file.read() if len(content) == 0: raise HTTPException(status_code=400, detail="Empty file") # Build object key: posts/{creator_slug}/{post_id}/{sanitized_filename} creator_slug = post.creator.slug if post.creator else str(post.creator_id) safe_name = _sanitize_filename(file.filename) object_key = f"posts/{creator_slug}/{post_id}/{safe_name}" content_type = file.content_type or "application/octet-stream" size_bytes = len(content) # Upload to MinIO (sync I/O via run_in_executor) loop = asyncio.get_event_loop() try: await loop.run_in_executor( None, upload_file, object_key, content, size_bytes, content_type, ) except Exception as exc: logger.error("MinIO upload failed for %s: %s", object_key, exc) raise HTTPException( status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="File storage unavailable", ) # Create DB record attachment = PostAttachment( post_id=post_id, filename=safe_name, object_key=object_key, content_type=content_type, size_bytes=size_bytes, ) db.add(attachment) await db.commit() await db.refresh(attachment) # Generate download URL try: url = generate_download_url(object_key) except Exception: logger.warning("Failed to generate download URL for %s after upload", object_key) url = None return PostAttachmentRead( id=attachment.id, filename=attachment.filename, content_type=attachment.content_type, size_bytes=attachment.size_bytes, download_url=url, created_at=attachment.created_at, ) @router.get("/{attachment_id}/download") async def get_download_url( attachment_id: uuid.UUID, db: AsyncSession = Depends(get_session), ): """Generate a signed download URL for an attachment. Returns JSON with the presigned URL (1-hour expiry) so the frontend can control the download UX rather than getting a redirect. """ q = select(PostAttachment).where(PostAttachment.id == attachment_id) result = await db.execute(q) attachment = result.scalar_one_or_none() if attachment is None: raise HTTPException(status_code=404, detail="Attachment not found") try: url = generate_download_url(attachment.object_key) except Exception as exc: logger.error("MinIO presigned URL failed for %s: %s", attachment.object_key, exc) raise HTTPException( status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="File storage unavailable", ) return {"url": url, "filename": attachment.filename}