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 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: logger.info("idempotent hit", extra={"idempotency_key": idempotency_key[:8]}) 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 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)) 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 = db.scalar(select(Spot).where(Spot.waterbody_id == waterbody.id, Spot.x == payload.x, Spot.y == payload.y)) if spot is None: spot = Spot(waterbody=waterbody, x=payload.x, y=payload.y) db.add(spot) 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) 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 and winner.catch_report: return JSONResponse(status_code=200, content=_accepted(winner.catch_report, _replay_token(key_hash), True)) 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 _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 None: bait = Bait(name=value.strip(), normalized_name=key, kind=BaitKind.unknown) db.add(bait) return bait