- "backend/routers/posts.py" - "backend/routers/files.py" - "backend/minio_client.py" - "backend/auth.py" - "backend/main.py" GSD-Task: S01/T02
187 lines
5.9 KiB
Python
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}
|