Files
MincedPad/backend/app/api/public.py
肉末 175a4ad0d1 feat: per-IP rate limit and write bans
Apply sliding-window 2/s limits and BannedIP checks on write routes.
Also force SVG attachment disposition and claim burn-after-read in DB
before streaming, deleting the file via BackgroundTask after the response.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-14 12:52:45 +08:00

148 lines
4.7 KiB
Python

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)