diff --git a/apps/api/app/main.py b/apps/api/app/main.py index 5f60caf..d6b6f7f 100644 --- a/apps/api/app/main.py +++ b/apps/api/app/main.py @@ -364,8 +364,7 @@ def admin_reject_external_observation( raise HTTPException(status_code=409, detail=str(exc)) from exc -@submissions_router.post("/api/v1/catch-reports", response_model=CatchReportAccepted, status_code=201) -def create_catch_report( +def _legacy_create_catch_report( payload: CatchReportCreate, request: Request, db: Db, idempotency_key: Annotated[str | None, Header()] = None, ) -> CatchReportAccepted: @@ -440,8 +439,7 @@ def create_catch_report( return CatchReportAccepted(id=report.id, moderation_status=report.moderation_status.value, screenshot_upload_token=upload_token, idempotent=False) -@submissions_router.post("/api/v1/catch-reports/{report_id}/screenshot", status_code=204, response_class=Response) -def add_screenshot( +def _legacy_add_screenshot( report_id: UUID, db: Db, screenshot: UploadFile = File(), upload_token: Annotated[str | None, Header(alias="X-Upload-Token")] = None, ) -> Response: diff --git a/apps/api/app/routers/submissions.py b/apps/api/app/routers/submissions.py index af9233c..b5b660b 100644 --- a/apps/api/app/routers/submissions.py +++ b/apps/api/app/routers/submissions.py @@ -1,3 +1,115 @@ -from fastapi import APIRouter +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 diff --git a/apps/api/tests/test_api.py b/apps/api/tests/test_api.py index 58175a9..e9d15b9 100644 --- a/apps/api/tests/test_api.py +++ b/apps/api/tests/test_api.py @@ -414,7 +414,7 @@ def test_admin_can_start_and_list_official_import(monkeypatch) -> None: def test_pending_report_accepts_one_validated_screenshot(monkeypatch) -> None: created = client.post("/api/v1/catch-reports", json={"fish_slug": "pike", "waterbody_slug": "test-lake", "x": 91, "y": 92, "weight_g": 4200}).json() - monkeypatch.setattr("app.main.upload_screenshot", lambda raw, **metadata: "reports/test.jpg" if raw == b"image-bytes" and metadata == {"filename": "catch.jpg", "content_type": "image/jpeg"} else "unexpected") + monkeypatch.setattr("app.routers.submissions.upload_screenshot", lambda raw, **metadata: "reports/test.jpg" if raw == b"image-bytes" and metadata == {"filename": "catch.jpg", "content_type": "image/jpeg"} else "unexpected") upload_url = f"/api/v1/catch-reports/{created['id']}/screenshot" assert client.post(upload_url, files={"screenshot": ("catch.jpg", b"image-bytes", "image/jpeg")}).status_code == 401 assert client.post(upload_url, headers={"X-Upload-Token": "wrong"}, files={"screenshot": ("catch.jpg", b"image-bytes", "image/jpeg")}).status_code == 401 diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 46b55c5..c65441e 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -51,7 +51,7 @@ Аудит выполнен на старой базе `13e04e6`; рекомендации ниже повторно проверены по текущей ветке. Уже реализованные или неприменимые предложения не возвращаются в backlog. -- [ ] **Q11 · Декомпозиция API — в работе.** После фиксации OpenAPI публичные catalog, activity/spots и records/community/status/import-history вынесены в отдельные `APIRouter`; submission endpoints уже принадлежат собственному router, а их security-слой изолирован в `submission_security`. URL, response models и generated contract сохранены. Далее: физически вынести submission handlers из `main.py`, затем admin. +- [ ] **Q11 · Декомпозиция API — в работе.** Публичные catalog, activity/spots и records/community/status/import-history вынесены в отдельные `APIRouter`. Рабочие submission handlers теперь физически находятся в `routers/submissions.py` вместе с idempotency и upload flow; security остаётся в `submission_security`, URL и OpenAPI сохранены. Старые функции в `main.py` сняты с регистрации; следующим локальным пакетом удалить этот мёртвый код и лишние импорты, затем вынести admin. - [ ] **Q12 · Query-plan gate.** В рамках Q07 снять `EXPLAIN (ANALYZE, BUFFERS)` для activity, records, spot detail и public spot pages на реалистичном наборе данных. Существующие индексы миграции `0011_query_indexes` не дублировать; индекс с `fish_id`, SQL-агрегацию или materialized view добавлять только по измеренному плану и p95. - [x] **Q13 · Production bootstrap в CI.** Отдельный workflow запускает `deploy/test-production-bootstrap.sh` вручную или раз в неделю, а не на каждом push. Вывод bootstrap всегда сохраняется 14 дней; при падении добавляются Compose status и Playwright diagnostics. - [ ] **Q14 · Полная CSP — origin-policy внедрена.** Production ограничивает default/connect/form/font/media/manifest текущим доменом, изображения — self/data/`FILES_DOMAIN`, запрещает inline event handlers, eval, wildcard и HTTP. Инвентаризация зафиксировала динамический JSON-LD, page scripts, scoped styles и CSS variables; из-за них `unsafe-inline` временно остаётся только для script/style элементов и style attributes. Далее вынести page scripts, решить nonce/hash JSON-LD и убрать исключения поэтапно с bootstrap-проверкой report/admin/OG/screenshots.