From 2a63ccdbe597e524e01f5a5ec4f4757f7da7ff0e Mon Sep 17 00:00:00 2001 From: jlightner Date: Sat, 4 Apr 2026 09:07:35 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20Built=20post=20CRUD=20and=20file=20uplo?= =?UTF-8?q?ad/download=20API=20routers=20with=20auth,=20o=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - "backend/routers/posts.py" - "backend/routers/files.py" - "backend/minio_client.py" - "backend/auth.py" - "backend/main.py" GSD-Task: S01/T02 --- backend/auth.py | 22 ++++ backend/main.py | 11 +- backend/minio_client.py | 12 +++ backend/routers/files.py | 187 +++++++++++++++++++++++++++++++++ backend/routers/posts.py | 222 +++++++++++++++++++++++++++++++++++++++ 5 files changed, 453 insertions(+), 1 deletion(-) create mode 100644 backend/routers/files.py create mode 100644 backend/routers/posts.py diff --git a/backend/auth.py b/backend/auth.py index f6fd863..01944ae 100644 --- a/backend/auth.py +++ b/backend/auth.py @@ -136,6 +136,28 @@ async def get_current_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): """Return a dependency that checks the current user has the given role.""" diff --git a/backend/main.py b/backend/main.py index 3d6958e..d533432 100644 --- a/backend/main.py +++ b/backend/main.py @@ -12,7 +12,7 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware 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: @@ -50,6 +50,13 @@ async def lifespan(app: FastAPI): # noqa: ARG001 settings.app_env, 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 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(ingest.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(search.router, prefix="/api/v1") app.include_router(stats.router, prefix="/api/v1") diff --git a/backend/minio_client.py b/backend/minio_client.py index d467058..2267267 100644 --- a/backend/minio_client.py +++ b/backend/minio_client.py @@ -102,3 +102,15 @@ def generate_download_url(object_key: str, expires: int = 3600) -> str: expires=timedelta(seconds=expires), ) 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) diff --git a/backend/routers/files.py b/backend/routers/files.py new file mode 100644 index 0000000..0816e60 --- /dev/null +++ b/backend/routers/files.py @@ -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} diff --git a/backend/routers/posts.py b/backend/routers/posts.py new file mode 100644 index 0000000..7408d7b --- /dev/null +++ b/backend/routers/posts.py @@ -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()