feat: harden production data and backups
CI / backend-and-migrations (push) Canceled after 0s
CI / astro-build (push) Canceled after 0s
CI / compose-e2e (push) Canceled after 0s

This commit is contained in:
ik
2026-09-06 14:08:07 +07:00
parent a4bd395856
commit 870d9cc7f9
17 changed files with 215 additions and 36 deletions
@@ -0,0 +1,16 @@
"""Protect pending screenshot uploads with a one-time token."""
from alembic import op
import sqlalchemy as sa
revision = "0010"
down_revision = "0009"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("catch_report", sa.Column("screenshot_upload_token_hash", sa.String(64)))
def downgrade() -> None:
op.drop_column("catch_report", "screenshot_upload_token_hash")
+3
View File
@@ -16,6 +16,7 @@ class Settings(BaseSettings):
official_records_region: str = "RU"
official_records_category: str = "records"
official_import_required: bool = False
seed_demo_data: bool = True
import_interval_seconds: int = Field(default=3600, ge=3600)
rate_limit_secret: str = "change-rate-limit-secret"
log_level: str = "INFO"
@@ -39,6 +40,8 @@ class Settings(BaseSettings):
raise ValueError("production CORS_ORIGINS must contain only HTTPS origins")
if not self.s3_public_endpoint_url.startswith("https://"):
raise ValueError("production S3_PUBLIC_ENDPOINT_URL must use HTTPS")
if self.seed_demo_data:
raise ValueError("SEED_DEMO_DATA must be false in production")
return self
+15 -6
View File
@@ -5,6 +5,7 @@ from datetime import datetime, timedelta, timezone
import hashlib
import hmac
import logging
import secrets
import time as time_module
from typing import Annotated, Literal
from uuid import UUID
@@ -25,7 +26,7 @@ from .importer import ImportSourceError, import_records, normalize
from .logging_config import configure_logging
from .models import Bait, BaitKind, CatchReport, ExternalObservation, Fish, ModerationEvent, ModerationStatus, OfficialRecordImport, SourceType, Spot, SubmissionAttempt, Waterbody
from .readiness import readiness_report
from .schemas import ActivityOut, AdminCatchReportOut, BaitOut, CatchOut, CatchReportCreate, CatchReportCreated, ExternalObservationDecision, ExternalObservationMapping, ExternalObservationOut, ExternalObservationPublished, FishOut, ImportRunOut, ModerationUpdate, OfficialRecordOut, SpotOut, WaterbodyOut
from .schemas import ActivityOut, AdminCatchReportOut, BaitOut, CatchOut, CatchReportAccepted, CatchReportCreate, CatchReportCreated, ExternalObservationDecision, ExternalObservationMapping, ExternalObservationOut, ExternalObservationPublished, FishOut, ImportRunOut, ModerationUpdate, OfficialRecordOut, SpotOut, WaterbodyOut
from .storage import ScreenshotError, client as storage_client, delete_screenshot, signed_screenshot_url, upload_screenshot
@@ -273,8 +274,8 @@ def admin_reject_external_observation(
raise HTTPException(status_code=409, detail=str(exc)) from exc
@app.post("/api/v1/catch-reports", response_model=CatchReportCreated, status_code=201)
def create_catch_report(payload: CatchReportCreate, request: Request, db: Db) -> CatchReportCreated:
@app.post("/api/v1/catch-reports", response_model=CatchReportAccepted, status_code=201)
def create_catch_report(payload: CatchReportCreate, request: Request, db: Db) -> CatchReportAccepted:
if payload.website:
raise HTTPException(status_code=400, detail="invalid submission")
_check_rate_limit(request.client.host if request.client else "unknown", db)
@@ -293,17 +294,24 @@ def create_catch_report(payload: CatchReportCreate, request: Request, db: Db) ->
if bait is None:
bait = Bait(name=payload.bait_name.strip(), normalized_name=key, kind=BaitKind.unknown)
db.add(bait)
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)
upload_token = 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)
db.commit()
return CatchReportCreated(id=report.id, moderation_status=report.moderation_status.value)
return CatchReportAccepted(id=report.id, moderation_status=report.moderation_status.value, screenshot_upload_token=upload_token)
@app.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()) -> 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)
@@ -311,6 +319,7 @@ def add_screenshot(report_id: UUID, db: Db, screenshot: UploadFile = File()) ->
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)
+1
View File
@@ -92,6 +92,7 @@ class CatchReport(Base):
source_confidence: Mapped[int]
moderation_status: Mapped[ModerationStatus] = mapped_column(Enum(ModerationStatus))
screenshot_key: Mapped[str | None] = mapped_column(Text)
screenshot_upload_token_hash: Mapped[str | None] = mapped_column(String(64))
deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
raw_payload: Mapped[dict | None] = mapped_column(JSON)
fish: Mapped[Fish] = relationship()
+4
View File
@@ -137,6 +137,10 @@ class CatchReportCreated(BaseModel):
moderation_status: str
class CatchReportAccepted(CatchReportCreated):
screenshot_upload_token: str
class AdminCatchReportOut(BaseModel):
id: UUID
fish: str
+35 -10
View File
@@ -5,6 +5,7 @@ from uuid import UUID
from sqlalchemy import select
from .config import settings
from .database import SessionLocal
from .models import Bait, BaitKind, CatchReport, Fish, ModerationStatus, SourceType, Spot, Waterbody
@@ -23,19 +24,38 @@ IDS = {
def seed() -> None:
with SessionLocal.begin() as db:
if db.scalar(select(Fish.id).limit(1)) is not None:
def entity(model, key: str, value: str, **values):
item = db.scalar(select(model).where(getattr(model, key) == value))
if item is None:
item = model(**values)
db.add(item)
db.flush()
return item
vyunok = entity(Waterbody, "slug", "vyunok", id=IDS["vyunok"], slug="vyunok", name_ru="Вьюнок", unlock_level=1)
kuori = entity(Waterbody, "slug", "kuori", id=IDS["kuori"], slug="kuori", name_ru="Куори", unlock_level=16)
pike = entity(Fish, "slug", "pike", id=IDS["pike"], slug="pike", name_ru="Щука", trophy_weight_g=10_000)
trout = entity(Fish, "slug", "lake-trout", id=IDS["trout"], slug="lake-trout", name_ru="Озёрная форель", trophy_weight_g=10_000)
spiker = entity(Bait, "normalized_name", "spiker #2 01-015", id=IDS["spiker"], name="Spiker #2 01-015", normalized_name="spiker #2 01-015", kind=BaitKind.lure)
shad = entity(Bait, "normalized_name", "salmon t1 shad 12 005", id=IDS["shad"], name="Salmon T1 Shad 12 005", normalized_name="salmon t1 shad 12 005", kind=BaitKind.lure)
def spot(waterbody: Waterbody, identity: str, x: int, y: int, description: str) -> Spot:
item = db.scalar(select(Spot).where(Spot.waterbody_id == waterbody.id, Spot.x == x, Spot.y == y))
if item is None:
item = Spot(id=IDS[identity], waterbody=waterbody, x=x, y=y, description=description)
db.add(item)
db.flush()
return item
spot1 = spot(vyunok, "spot1", 110, 103, "Кромка травы у северного берега")
spot2 = spot(kuori, "spot2", 85, 92, "Свальчик в глубину")
if not settings.seed_demo_data:
return
vyunok = Waterbody(id=IDS["vyunok"], slug="vyunok", name_ru="Вьюнок", unlock_level=1)
kuori = Waterbody(id=IDS["kuori"], slug="kuori", name_ru="Куори", unlock_level=16)
pike = Fish(id=IDS["pike"], slug="pike", name_ru="Щука", trophy_weight_g=10_000)
trout = Fish(id=IDS["trout"], slug="lake-trout", name_ru="Озёрная форель", trophy_weight_g=10_000)
spiker = Bait(id=IDS["spiker"], name="Spiker #2 01-015", normalized_name="spiker #2 01-015", kind=BaitKind.lure)
shad = Bait(id=IDS["shad"], name="Salmon T1 Shad 12 005", normalized_name="salmon t1 shad 12 005", kind=BaitKind.lure)
spot1 = Spot(id=IDS["spot1"], waterbody=vyunok, x=110, y=103, description="Кромка травы у северного берега")
spot2 = Spot(id=IDS["spot2"], waterbody=kuori, x=85, y=92, description="Свальчик в глубину")
db.add_all([vyunok, kuori, pike, trout, spiker, shad, spot1, spot2])
now = datetime.now(timezone.utc)
for index in range(12):
external_id = f"seed:pike:{index}"
if db.scalar(select(CatchReport.id).where(CatchReport.source_external_id == external_id)):
continue
db.add(CatchReport(
fish=pike, spot=spot1, waterbody=vyunok, bait=spiker,
weight_g=2_600 + index * 480, fishing_method="spinning",
@@ -43,9 +63,13 @@ def seed() -> None:
caught_at=now - timedelta(minutes=25 + index * 47),
reported_at=now - timedelta(minutes=20 + index * 47),
player_name=f"DemoPlayer{index % 7 + 1}", source_type=SourceType.manual_import,
source_external_id=external_id,
source_confidence=80 + index % 3 * 5, moderation_status=ModerationStatus.approved,
))
for index in range(5):
external_id = f"seed:trout:{index}"
if db.scalar(select(CatchReport.id).where(CatchReport.source_external_id == external_id)):
continue
db.add(CatchReport(
fish=trout, spot=spot2, waterbody=kuori, bait=shad,
weight_g=4_200 + index * 900, fishing_method="spinning",
@@ -53,6 +77,7 @@ def seed() -> None:
caught_at=now - timedelta(hours=2 + index * 4),
reported_at=now - timedelta(hours=2 + index * 4),
player_name=f"DemoAngler{index + 1}", source_type=SourceType.manual_import,
source_external_id=external_id,
source_confidence=85, moderation_status=ModerationStatus.approved,
))
+6 -3
View File
@@ -187,10 +187,13 @@ 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")
response = client.post(f"/api/v1/catch-reports/{created['id']}/screenshot", files={"screenshot": ("catch.jpg", b"image-bytes", "image/jpeg")})
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
response = client.post(upload_url, headers={"X-Upload-Token": created["screenshot_upload_token"]}, files={"screenshot": ("catch.jpg", b"image-bytes", "image/jpeg")})
assert response.status_code == 204
duplicate = client.post(f"/api/v1/catch-reports/{created['id']}/screenshot", files={"screenshot": ("catch.jpg", b"image-bytes", "image/jpeg")})
assert duplicate.status_code == 409
reused = client.post(upload_url, headers={"X-Upload-Token": created["screenshot_upload_token"]}, files={"screenshot": ("catch.jpg", b"image-bytes", "image/jpeg")})
assert reused.status_code == 401
def test_admin_delete_anonymizes_report_removes_screenshot_and_keeps_audit(monkeypatch) -> None:
+2
View File
@@ -13,6 +13,7 @@ def production_settings(**changes) -> Settings:
"s3_secret_key": "s" * 32,
"s3_public_endpoint_url": "https://files.rf4spotter.ru",
"cors_origins": ["https://rf4spotter.ru"],
"seed_demo_data": False,
}
return Settings(**(values | changes))
@@ -30,6 +31,7 @@ def test_production_settings_accept_real_domains_and_secrets() -> None:
("s3_secret_key", "rf4-local-secret"),
("cors_origins", ["http://rf4spotter.ru"]),
("s3_public_endpoint_url", "http://files.rf4spotter.ru"),
("seed_demo_data", True),
])
def test_production_settings_reject_insecure_values(field: str, value: object) -> None:
with pytest.raises(ValidationError):
+27
View File
@@ -0,0 +1,27 @@
from sqlalchemy import create_engine, func, select
from sqlalchemy.orm import Session, sessionmaker
from sqlalchemy.pool import StaticPool
from app.database import Base
from app.models import CatchReport, Fish, Spot, Waterbody
from app import seed as seed_module
def test_seed_repairs_partial_database_and_is_idempotent(monkeypatch) -> None:
engine = create_engine("sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool)
Base.metadata.create_all(engine)
sessions = sessionmaker(bind=engine, expire_on_commit=False)
with Session(engine) as db:
db.add(Fish(slug="pike", name_ru="Щука", trophy_weight_g=10_000))
db.commit()
monkeypatch.setattr(seed_module, "SessionLocal", sessions)
monkeypatch.setattr(seed_module.settings, "seed_demo_data", False)
seed_module.seed()
seed_module.seed()
with Session(engine) as db:
assert db.scalar(select(func.count()).select_from(Fish)) == 2
assert db.scalar(select(func.count()).select_from(Waterbody)) == 2
assert db.scalar(select(func.count()).select_from(Spot)) == 2
assert db.scalar(select(func.count()).select_from(CatchReport)) == 0