chrysopedia/backend/routers/files.py
jlightner cc60852ac9 feat: Built post CRUD and file upload/download API routers with auth, o…
- "backend/routers/posts.py"
- "backend/routers/files.py"
- "backend/minio_client.py"
- "backend/auth.py"
- "backend/main.py"

GSD-Task: S01/T02
2026-04-04 09:07:35 +00:00

187 lines
5.9 KiB
Python

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