Files
rf4-spotter/apps/api/app/routers/submissions.py
T
ik b1d6d8d122
CI / backend-and-migrations (push) Waiting to run
CI / astro-build (push) Waiting to run
CI / dependency-audit (push) Waiting to run
CI / compose-e2e (push) Waiting to run
fix: harden catch report idempotency
2026-09-22 08:04:11 +07:00

157 lines
8.3 KiB
Python

from __future__ import annotations
from datetime import datetime, timedelta, timezone
import hashlib
import hmac
import json
import logging
import secrets
from typing import Annotated
from uuid import UUID
from fastapi import APIRouter, File, Header, HTTPException, Request, Response, UploadFile
from fastapi.responses import JSONResponse
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from ..config import settings
from ..dependencies import Db
from ..importer import normalize
from ..models import Bait, BaitKind, CatchReport, Fish, ModerationStatus, SourceType, Spot, SubmissionAttempt, Waterbody
from ..schemas import CatchReportAccepted, CatchReportCreate
from ..storage import ScreenshotError, upload_screenshot
from ..submission_security import check_rate_limit
from ..tackle_components import replace_tackle_components
from rf4_research.gear_components import from_catch_fields
router = APIRouter()
logger = logging.getLogger("rf4.api.submissions")
@router.post("/api/v1/catch-reports", response_model=CatchReportAccepted, status_code=201)
def create_catch_report(payload: CatchReportCreate, request: Request, db: Db, idempotency_key: Annotated[str | None, Header()] = None) -> CatchReportAccepted:
if payload.website:
raise HTTPException(status_code=400, detail="invalid submission")
payload_hash = hashlib.sha256(json.dumps(payload.model_dump(mode="json"), sort_keys=True, separators=(",", ":")).encode()).hexdigest()
key_hash = hmac.new(settings.rate_limit_secret.encode(), idempotency_key.encode(), hashlib.sha256).hexdigest() if idempotency_key else None
if key_hash:
cutoff = datetime.now(timezone.utc) - timedelta(minutes=5)
db.expire_all()
existing = db.scalar(select(SubmissionAttempt).where(SubmissionAttempt.idempotency_key == key_hash, SubmissionAttempt.created_at >= cutoff))
if existing is not None:
return _replay_existing(existing, payload_hash, key_hash, cutoff)
logger.info("idempotency check miss", extra={"idempotency_key": idempotency_key[:8]})
check_rate_limit(request, db, settings)
fish = db.scalar(select(Fish).where(Fish.slug == payload.fish_slug))
waterbody = db.scalar(select(Waterbody).where(Waterbody.slug == payload.waterbody_slug))
if fish is None or waterbody is None:
raise HTTPException(status_code=422, detail="unknown fish or waterbody")
spot = _spot(db, waterbody, payload.x, payload.y)
bait = _bait(db, payload.bait_name)
upload_token = _replay_token(key_hash) if key_hash else secrets.token_urlsafe(32)
report = CatchReport(fish=fish, spot=spot, waterbody=waterbody, bait=bait, weight_g=payload.weight_g, fishing_method=payload.fishing_method, rig_type=payload.rig_type, retrieve_method=payload.retrieve_method, retrieve_speed=payload.retrieve_speed, caught_at=payload.caught_at, reported_at=datetime.now(timezone.utc), player_name=payload.player_name, source_type=SourceType.user, source_url=payload.source_url, source_confidence=60, moderation_status=ModerationStatus.pending, raw_payload={"comment": payload.comment} if payload.comment else None, screenshot_upload_token_hash=hashlib.sha256(upload_token.encode()).hexdigest())
db.add(report)
replace_tackle_components(
db,
report,
from_catch_fields(bait=payload.bait_name, rig_type=payload.rig_type),
source_system="user",
source_url=payload.source_url,
raw_payload={"origin": "user_submission"},
)
if key_hash:
db.add(SubmissionAttempt(client_hash="", idempotency_key=key_hash, catch_report=report, payload_hash=payload_hash, created_at=datetime.now(timezone.utc)))
try:
db.commit()
except IntegrityError:
db.rollback()
winner = db.scalar(select(SubmissionAttempt).where(SubmissionAttempt.idempotency_key == key_hash))
if winner is not None:
return _replay_existing(winner, payload_hash, key_hash, cutoff)
raise
logger.info("idempotency key stored", extra={"idempotency_key": idempotency_key[:8]})
else:
db.commit()
return CatchReportAccepted(id=report.id, moderation_status=report.moderation_status.value, screenshot_upload_token=upload_token, idempotent=False)
@router.post("/api/v1/catch-reports/{report_id}/screenshot", status_code=204, response_class=Response)
def add_screenshot(report_id: UUID, db: Db, screenshot: UploadFile = File(), upload_token: Annotated[str | None, Header(alias="X-Upload-Token")] = None) -> Response:
report = db.get(CatchReport, report_id)
if report is None or report.source_type != SourceType.user or report.moderation_status != ModerationStatus.pending:
raise HTTPException(status_code=404, detail="pending catch report not found")
supplied_hash = hashlib.sha256((upload_token or "").encode()).hexdigest()
if not report.screenshot_upload_token_hash or not hmac.compare_digest(report.screenshot_upload_token_hash, supplied_hash):
raise HTTPException(status_code=401, detail="invalid screenshot upload token")
if report.screenshot_key:
raise HTTPException(status_code=409, detail="screenshot already uploaded")
raw = screenshot.file.read(settings.screenshot_max_bytes + 1)
try:
report.screenshot_key = upload_screenshot(raw, filename=screenshot.filename, content_type=screenshot.content_type)
except ScreenshotError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
report.screenshot_upload_token_hash = None
db.commit()
return Response(status_code=204)
def _replay_token(key_hash: str) -> str:
return hmac.new(settings.rate_limit_secret.encode(), (key_hash + ":upload").encode(), hashlib.sha256).hexdigest()
def _accepted(report: CatchReport, token: str, idempotent: bool) -> dict[str, object]:
return {"id": str(report.id), "moderation_status": report.moderation_status.value, "screenshot_upload_token": token, "idempotent": idempotent}
def _replay_existing(existing: SubmissionAttempt, payload_hash: str, key_hash: str, cutoff: datetime) -> JSONResponse:
logger.info("idempotent hit", extra={"idempotency_key": key_hash[:8]})
created_at = existing.created_at if existing.created_at.tzinfo else existing.created_at.replace(tzinfo=timezone.utc)
if created_at < cutoff:
raise HTTPException(status_code=409, detail="Idempotency-Key has expired; retry with a new key")
if existing.payload_hash and not hmac.compare_digest(existing.payload_hash, payload_hash):
raise HTTPException(status_code=409, detail="Idempotency-Key was already used with different payload")
if existing.catch_report is None:
raise HTTPException(status_code=409, detail="idempotency record is incomplete; retry with a new key")
replay_token = _replay_token(key_hash)
if existing.catch_report.screenshot_key is None:
if not hmac.compare_digest(hashlib.sha256(replay_token.encode()).hexdigest(), existing.catch_report.screenshot_upload_token_hash or ""):
raise HTTPException(status_code=409, detail="idempotency record token mismatch; retry with a new key")
return JSONResponse(status_code=200, content=_accepted(existing.catch_report, replay_token, True))
def _spot(db: Db, waterbody: Waterbody, x: int, y: int) -> Spot:
spot = db.scalar(select(Spot).where(Spot.waterbody_id == waterbody.id, Spot.x == x, Spot.y == y))
if spot is not None:
return spot
candidate = Spot(waterbody=waterbody, x=x, y=y)
try:
with db.begin_nested():
db.add(candidate)
db.flush()
return candidate
except IntegrityError:
winner = db.scalar(select(Spot).where(Spot.waterbody_id == waterbody.id, Spot.x == x, Spot.y == y))
if winner is None:
raise
return winner
def _bait(db: Db, value: str | None) -> Bait | None:
if not value or not value.strip():
return None
key = normalize(value)
bait = db.scalar(select(Bait).where(Bait.normalized_name == key))
if bait is not None:
return bait
candidate = Bait(name=value.strip(), normalized_name=key, kind=BaitKind.unknown)
try:
with db.begin_nested():
db.add(candidate)
db.flush()
return candidate
except IntegrityError:
winner = db.scalar(select(Bait).where(Bait.normalized_name == key))
if winner is None:
raise
return winner