feat: harden production data and backups
This commit is contained in:
@@ -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")
|
||||
@@ -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
@@ -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)
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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,
|
||||
))
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user