from pathlib import Path from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, Request, UploadFile from fastapi.responses import FileResponse from sqlalchemy.orm import Session from starlette.background import BackgroundTask from app.db import get_db from app.schemas import ItemCreate, ItemOut, TTLOption, WallResponse from app.services import bans as bans_service from app.services import items as items_service from app.services import rate_limit as rate_limit_service from app.services import storage as storage_service router = APIRouter(prefix="/api/items", tags=["items"]) _INLINE_IMAGE_PREFIXES = ("image/",) def _client_ip(request: Request) -> str: """IP used for rate limits, bans, and created_ip. Prefer ``request.client.host`` (TestClient yields ``testclient``; direct connections use the peer address). ``X-Forwarded-For`` is only a fallback when client is missing — trusting XFF without a trusted reverse proxy allows clients to spoof their IP for ban/rate-limit evasion. """ if request.client and request.client.host: return request.client.host forwarded = request.headers.get("x-forwarded-for") if forwarded: return forwarded.split(",")[0].strip() return "" def _enforce_write_guards(request: Request, db: Session) -> str: ip = _client_ip(request) if bans_service.is_banned(db, ip): raise HTTPException(status_code=403, detail="Forbidden") if not rate_limit_service.allow(ip): raise HTTPException(status_code=429, detail="Too Many Requests") return ip def _content_disposition_type(media_type: str, filename: str) -> str: # SVG can carry script; never inline even when mime is image/svg+xml. if media_type == "image/svg+xml" or filename.lower().endswith(".svg"): return "attachment" if media_type.startswith(_INLINE_IMAGE_PREFIXES): return "inline" return "attachment" @router.post("", response_model=ItemOut) def create_item( payload: ItemCreate, request: Request, db: Session = Depends(get_db), ): ip = _enforce_write_guards(request, db) item = items_service.create_text_item(db, payload, ip) return items_service.item_to_dict(item) @router.post("/upload", response_model=ItemOut) async def upload_item( request: Request, db: Session = Depends(get_db), file: UploadFile = File(...), is_public: bool = Form(True), burn_after_read: bool = Form(False), ttl: TTLOption = Form("24h"), title: str | None = Form(None), ): ip = _enforce_write_guards(request, db) rel_path, size, mime, safe_name = await storage_service.save_upload(file) item = items_service.create_file_item( db, file_name=safe_name, file_path=rel_path, mime=mime, size_bytes=size, is_public=is_public, burn_after_read=burn_after_read, ttl=ttl, created_ip=ip, title=title, ) return items_service.item_to_dict(item) @router.get("/wall", response_model=WallResponse) def wall( page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=100), db: Session = Depends(get_db), ): rows, total = items_service.list_wall(db, page=page, page_size=page_size) return WallResponse( items=[ItemOut.model_validate(items_service.item_to_dict(i)) for i in rows], page=page, page_size=page_size, total=total, ) def _delete_file_path(path: str) -> None: try: Path(path).unlink(missing_ok=True) except OSError: pass @router.get("/{slug}/file") def download_file(slug: str, db: Session = Depends(get_db)): item = items_service.get_file_item(db, slug) if item is None: raise HTTPException(status_code=404, detail="Not found") path = items_service.resolve_file_path(item) if path is None or not path.is_file(): raise HTTPException(status_code=404, detail="Not found") media_type = item.mime or "application/octet-stream" filename = item.file_name or path.name disposition = _content_disposition_type(media_type, filename) background = None if item.burn_after_read: # Claim burn before streaming so concurrent fetches cannot re-read. items_service.claim_burn(db, item) background = BackgroundTask(_delete_file_path, str(path)) return FileResponse( path, media_type=media_type, filename=filename, content_disposition_type=disposition, background=background, ) @router.get("/{slug}", response_model=ItemOut) def get_item(slug: str, db: Session = Depends(get_db)): item = items_service.get_item(db, slug) if item is None: raise HTTPException(status_code=404, detail="Not found") return items_service.item_to_dict(item)