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
This commit is contained in:
jlightner 2026-04-04 09:07:35 +00:00
parent c163037a0f
commit 2a63ccdbe5
5 changed files with 453 additions and 1 deletions

View file

@ -136,6 +136,28 @@ async def get_current_user(
return user return user
_optional_oauth2 = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login", auto_error=False)
async def get_optional_user(
token: Annotated[str | None, Depends(_optional_oauth2)],
session: Annotated[AsyncSession, Depends(get_session)],
) -> User | None:
"""Like get_current_user but returns None instead of 401 when no token."""
if token is None:
return None
try:
payload = decode_access_token(token)
except HTTPException:
return None
user_id = payload.get("sub")
result = await session.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if user is None or not user.is_active:
return None
return user
def require_role(required_role: UserRole): def require_role(required_role: UserRole):
"""Return a dependency that checks the current user has the given role.""" """Return a dependency that checks the current user has the given role."""

View file

@ -12,7 +12,7 @@ from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from config import get_settings from config import get_settings
from routers import admin, auth, chat, consent, creator_chapters, creator_dashboard, creator_highlights, creators, follows, health, highlights, ingest, pipeline, reports, search, stats, techniques, topics, videos from routers import admin, auth, chat, consent, creator_chapters, creator_dashboard, creator_highlights, creators, files, follows, health, highlights, ingest, pipeline, posts, reports, search, stats, techniques, topics, videos
def _setup_logging() -> None: def _setup_logging() -> None:
@ -50,6 +50,13 @@ async def lifespan(app: FastAPI): # noqa: ARG001
settings.app_env, settings.app_env,
settings.app_log_level, settings.app_log_level,
) )
# Ensure MinIO bucket exists (best-effort — API still starts if MinIO is down)
try:
from minio_client import ensure_bucket
ensure_bucket()
logger.info("MinIO bucket ready")
except Exception as exc:
logger.warning("MinIO bucket init failed (will retry on first upload): %s", exc)
yield yield
logger.info("Chrysopedia API shutting down") logger.info("Chrysopedia API shutting down")
@ -90,6 +97,8 @@ app.include_router(follows.router, prefix="/api/v1")
app.include_router(highlights.router, prefix="/api/v1") app.include_router(highlights.router, prefix="/api/v1")
app.include_router(ingest.router, prefix="/api/v1") app.include_router(ingest.router, prefix="/api/v1")
app.include_router(pipeline.router, prefix="/api/v1") app.include_router(pipeline.router, prefix="/api/v1")
app.include_router(posts.router, prefix="/api/v1")
app.include_router(files.router, prefix="/api/v1")
app.include_router(reports.router, prefix="/api/v1") app.include_router(reports.router, prefix="/api/v1")
app.include_router(search.router, prefix="/api/v1") app.include_router(search.router, prefix="/api/v1")
app.include_router(stats.router, prefix="/api/v1") app.include_router(stats.router, prefix="/api/v1")

View file

@ -102,3 +102,15 @@ def generate_download_url(object_key: str, expires: int = 3600) -> str:
expires=timedelta(seconds=expires), expires=timedelta(seconds=expires),
) )
return url return url
def delete_file(object_key: str) -> None:
"""Delete a file from MinIO.
Args:
object_key: The storage path within the bucket.
"""
settings = get_settings()
client = get_minio_client()
client.remove_object(settings.minio_bucket, object_key)
logger.info("Deleted %s from MinIO", object_key)

187
backend/routers/files.py Normal file
View file

@ -0,0 +1,187 @@
"""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}

222
backend/routers/posts.py Normal file
View file

@ -0,0 +1,222 @@
"""Post CRUD endpoints for Chrysopedia creator posts.
Creators can write rich text posts with optional file attachments.
Posts are visible on creator profile pages. Auth required for write ops;
public read access for published posts.
"""
from __future__ import annotations
import logging
import uuid
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from auth import get_current_user, get_optional_user
from database import get_session
from minio_client import delete_file, generate_download_url
from models import Creator, Post, PostAttachment, User
from schemas import PostCreate, PostListResponse, PostRead, PostUpdate
logger = logging.getLogger("chrysopedia.posts")
router = APIRouter(prefix="/posts", tags=["posts"])
def _resolve_creator_id(user: User) -> uuid.UUID:
"""Get the user's linked creator_id or raise 403."""
if user.creator_id is None:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="No creator profile linked to this account",
)
return user.creator_id
def _attach_download_urls(post: Post) -> PostRead:
"""Convert a Post ORM instance to PostRead with download URLs on attachments."""
attachment_reads = []
for att in post.attachments:
try:
url = generate_download_url(att.object_key)
except Exception:
logger.warning("Failed to generate download URL for attachment %s", att.id)
url = None
attachment_reads.append(
{
"id": att.id,
"filename": att.filename,
"content_type": att.content_type,
"size_bytes": att.size_bytes,
"download_url": url,
"created_at": att.created_at,
}
)
return PostRead(
id=post.id,
creator_id=post.creator_id,
title=post.title,
body_json=post.body_json,
is_published=post.is_published,
created_at=post.created_at,
updated_at=post.updated_at,
attachments=attachment_reads,
)
@router.post("", response_model=PostRead, status_code=status.HTTP_201_CREATED)
async def create_post(
payload: PostCreate,
current_user: Annotated[User, Depends(get_current_user)],
db: AsyncSession = Depends(get_session),
) -> PostRead:
"""Create a new post for the authenticated creator."""
creator_id = _resolve_creator_id(current_user)
post = Post(
creator_id=creator_id,
title=payload.title,
body_json=payload.body_json,
is_published=payload.is_published,
)
db.add(post)
await db.commit()
await db.refresh(post, attribute_names=["attachments"])
return _attach_download_urls(post)
@router.get("", response_model=PostListResponse)
async def list_posts(
creator_id: Annotated[uuid.UUID, Query(description="Filter by creator")],
page: Annotated[int, Query(ge=1)] = 1,
limit: Annotated[int, Query(ge=1, le=100)] = 20,
current_user: User | None = Depends(get_optional_user),
db: AsyncSession = Depends(get_session),
) -> PostListResponse:
"""List posts by a creator. Non-owners see only published posts."""
# Determine if requester owns this creator
is_owner = (
current_user is not None
and current_user.creator_id == creator_id
)
base_filter = Post.creator_id == creator_id
if not is_owner:
base_filter = base_filter & (Post.is_published == True) # noqa: E712
# Count
count_q = select(func.count()).select_from(Post).where(base_filter)
total = (await db.execute(count_q)).scalar_one()
# Fetch page
offset = (page - 1) * limit
q = (
select(Post)
.where(base_filter)
.options(selectinload(Post.attachments))
.order_by(Post.created_at.desc())
.offset(offset)
.limit(limit)
)
result = await db.execute(q)
posts = result.scalars().all()
return PostListResponse(
items=[_attach_download_urls(p) for p in posts],
total=total,
)
@router.get("/{post_id}", response_model=PostRead)
async def get_post(
post_id: uuid.UUID,
db: AsyncSession = Depends(get_session),
) -> PostRead:
"""Get a single post. Returns 404 for unpublished posts (unless owner check is added later)."""
q = (
select(Post)
.where(Post.id == post_id)
.options(selectinload(Post.attachments))
)
result = await db.execute(q)
post = result.scalar_one_or_none()
if post is None:
raise HTTPException(status_code=404, detail="Post not found")
return _attach_download_urls(post)
@router.put("/{post_id}", response_model=PostRead)
async def update_post(
post_id: uuid.UUID,
payload: PostUpdate,
current_user: Annotated[User, Depends(get_current_user)],
db: AsyncSession = Depends(get_session),
) -> PostRead:
"""Update a post. Only the owning creator can update."""
creator_id = _resolve_creator_id(current_user)
q = (
select(Post)
.where(Post.id == post_id)
.options(selectinload(Post.attachments))
)
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 != creator_id:
logger.warning(
"Ownership violation: user %s (creator %s) tried to update post %s owned by creator %s",
current_user.id, creator_id, post_id, post.creator_id,
)
raise HTTPException(status_code=403, detail="Not your post")
update_data = payload.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(post, field, value)
await db.commit()
await db.refresh(post, attribute_names=["attachments"])
return _attach_download_urls(post)
@router.delete("/{post_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_post(
post_id: uuid.UUID,
current_user: Annotated[User, Depends(get_current_user)],
db: AsyncSession = Depends(get_session),
) -> None:
"""Delete a post and all its attachments (DB + MinIO)."""
creator_id = _resolve_creator_id(current_user)
q = (
select(Post)
.where(Post.id == post_id)
.options(selectinload(Post.attachments))
)
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 != creator_id:
logger.warning(
"Ownership violation: user %s (creator %s) tried to delete post %s owned by creator %s",
current_user.id, creator_id, post_id, post.creator_id,
)
raise HTTPException(status_code=403, detail="Not your post")
# Delete attachment files from MinIO (best-effort)
for att in post.attachments:
try:
delete_file(att.object_key)
except Exception:
logger.error("Failed to delete MinIO object %s for attachment %s", att.object_key, att.id)
await db.delete(post)
await db.commit()