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>
This commit is contained in:
肉末
2026-07-14 12:52:45 +08:00
parent f2a1f3a1d6
commit 175a4ad0d1
6 changed files with 197 additions and 19 deletions
+46 -16
View File
@@ -1,13 +1,15 @@
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 import db as db_module
from app.db import get_db
from app.models import Item
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"])
@@ -16,21 +18,47 @@ _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()
if request.client and request.client.host:
return request.client.host
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),
):
item = items_service.create_text_item(db, payload, _client_ip(request))
ip = _enforce_write_guards(request, db)
item = items_service.create_text_item(db, payload, ip)
return items_service.item_to_dict(item)
@@ -44,6 +72,7 @@ async def upload_item(
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,
@@ -54,7 +83,7 @@ async def upload_item(
is_public=is_public,
burn_after_read=burn_after_read,
ttl=ttl,
created_ip=_client_ip(request),
created_ip=ip,
title=title,
)
return items_service.item_to_dict(item)
@@ -75,14 +104,11 @@ def wall(
)
def _burn_file_after_response(item_id: int) -> None:
db = db_module.SessionLocal()
def _delete_file_path(path: str) -> None:
try:
item = db.get(Item, item_id)
if item is not None and item.burn_after_read and not item.burned:
items_service.burn_file_item(db, item)
finally:
db.close()
Path(path).unlink(missing_ok=True)
except OSError:
pass
@router.get("/{slug}/file")
@@ -96,15 +122,19 @@ def download_file(slug: str, db: Session = Depends(get_db)):
media_type = item.mime or "application/octet-stream"
filename = item.file_name or path.name
inline = media_type.startswith(_INLINE_IMAGE_PREFIXES)
disposition = _content_disposition_type(media_type, filename)
background = None
if item.burn_after_read:
background = BackgroundTask(_burn_file_after_response, item.id)
# 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="inline" if inline else "attachment",
content_disposition_type=disposition,
background=background,
)