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