fix: audit P0-P1 — T03-T07, D01-D08, U01-U02 (#1788956171115)
This commit is contained in:
+26
-19
@@ -27,7 +27,7 @@ from .logging_config import configure_logging
|
||||
from .models import Bait, BaitKind, CatchReport, CommunityImportRun, DataSource, ExternalObservation, Fish, ModerationEvent, ModerationStatus, OfficialRecordImport, SourceType, Spot, SubmissionAttempt, Waterbody
|
||||
from .readiness import readiness_report
|
||||
from .public_cache import public_cache
|
||||
from .schemas import ActivityOut, AdminCatchReportOut, BaitOut, CatchOut, CatchReportAccepted, CatchReportCreate, CatchReportCreated, ExternalAliasSuggestionOut, ExternalObservationDecision, ExternalObservationMapping, ExternalObservationOut, ExternalObservationPublished, FishOut, ImportRunOut, ModerationUpdate, OfficialRecordOut, PublicObservationOut, SourceStatusOut, SpotOut, WaterbodyOut
|
||||
from .schemas import ActivityOut, AdminCatchReportOut, BaitOut, CatchOut, CatchReportAccepted, CatchReportCreate, CatchReportCreated, ExternalAliasSuggestionOut, ExternalObservationDecision, ExternalObservationMapping, ExternalObservationOut, ExternalObservationPublished, FishOut, ImportRunOut, ImportRunPublicOut, ModerationUpdate, OfficialRecordOut, PaginatedActivityOut, PublicObservationOut, SourceStatusOut, SpotOut, WaterbodyOut
|
||||
from .storage import ScreenshotError, client as storage_client, delete_screenshot, signed_screenshot_url, upload_screenshot
|
||||
|
||||
|
||||
@@ -87,6 +87,7 @@ def ready(db: Db) -> JSONResponse:
|
||||
is_ready, components = readiness_report(
|
||||
db, storage_client(), import_required=settings.official_import_required,
|
||||
import_interval_seconds=settings.import_interval_seconds,
|
||||
community_import_interval_seconds=settings.community_import_interval_seconds,
|
||||
)
|
||||
return JSONResponse(
|
||||
status_code=200 if is_ready else 503,
|
||||
@@ -120,14 +121,14 @@ def public_spot_pages(db: Db, limit: int = Query(500, ge=1, le=500), offset: int
|
||||
(f"/spots/{water}-{x}x{y}", f"/waterbodies/{water}/{fish}")]
|
||||
|
||||
|
||||
@app.get("/api/v1/activity", response_model=list[ActivityOut])
|
||||
@app.get("/api/v1/activity", response_model=PaginatedActivityOut)
|
||||
def activity(
|
||||
db: Db, response: Response, hours: int = Query(24),
|
||||
waterbody: str | None = None, fish: str | None = None,
|
||||
method: str | None = None,
|
||||
sort: Literal["activity", "confidence", "freshness"] = "activity",
|
||||
limit: int = Query(20, ge=1, le=100), offset: int = Query(0, ge=0),
|
||||
) -> list[ActivityOut]:
|
||||
) -> PaginatedActivityOut:
|
||||
if hours not in {6, 12, 24, 72}:
|
||||
raise HTTPException(status_code=422, detail="hours must be one of: 6, 12, 24, 72")
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
@@ -138,14 +139,16 @@ def activity(
|
||||
response.headers["X-Cache"] = "HIT"
|
||||
return cached
|
||||
rows = activity_rows(db, hours=hours, waterbody=waterbody, fish=fish, method=method)
|
||||
total = len(rows)
|
||||
keys = {
|
||||
"activity": lambda r: (r.activity_score, r.confidence_score, r.last_confirmed_at, str(r.spot_id)),
|
||||
"confidence": lambda r: (r.confidence_score, r.activity_score, r.last_confirmed_at, str(r.spot_id)),
|
||||
"freshness": lambda r: (r.last_confirmed_at, r.activity_score, r.confidence_score, str(r.spot_id)),
|
||||
}
|
||||
rows.sort(key=keys[sort], reverse=True)
|
||||
page = rows[offset:offset + limit]
|
||||
response.headers["X-Cache"] = "MISS"
|
||||
return public_cache.set(cache_key, rows[offset:offset + limit], generation=generation)
|
||||
return public_cache.set(cache_key, PaginatedActivityOut(items=page, total=total, limit=limit, offset=offset), generation=generation)
|
||||
|
||||
|
||||
def _spot_or_404(db: Session, spot_id: UUID) -> Spot:
|
||||
@@ -218,19 +221,18 @@ def _report_source(report: CatchReport) -> str:
|
||||
@app.get("/api/v1/community-observations", response_model=list[PublicObservationOut])
|
||||
def community_observations(
|
||||
db: Db, limit: int = Query(12, ge=1, le=50), offset: int = Query(0, ge=0),
|
||||
waterbody: str | None = None, fish: str | None = None,
|
||||
) -> list[PublicObservationOut]:
|
||||
items = list(db.scalars(
|
||||
select(ExternalObservation)
|
||||
.join(ExternalObservation.source)
|
||||
.options(joinedload(ExternalObservation.source))
|
||||
.where(
|
||||
ExternalObservation.catch_report_id.is_(None),
|
||||
ExternalObservation.status != "rejected",
|
||||
DataSource.enabled.is_(True),
|
||||
)
|
||||
.order_by(ExternalObservation.last_seen_at.desc(), ExternalObservation.id.desc())
|
||||
.offset(offset).limit(limit)
|
||||
))
|
||||
query = select(ExternalObservation).join(ExternalObservation.source).options(joinedload(ExternalObservation.source)).where(
|
||||
ExternalObservation.catch_report_id.is_(None),
|
||||
ExternalObservation.status != "rejected",
|
||||
DataSource.enabled.is_(True),
|
||||
)
|
||||
if waterbody:
|
||||
query = query.where(ExternalObservation.waterbody_name == waterbody)
|
||||
if fish:
|
||||
query = query.where(ExternalObservation.fish_name == fish)
|
||||
items = list(db.scalars(query.order_by(ExternalObservation.last_seen_at.desc(), ExternalObservation.id.desc()).offset(offset).limit(limit)))
|
||||
result: list[PublicObservationOut] = []
|
||||
for item in items:
|
||||
missing = []
|
||||
@@ -318,7 +320,7 @@ def admin_diagnostics(db: Db, _: Annotated[str, Depends(_admin)]) -> JSONRespons
|
||||
return JSONResponse(payload, headers={"Content-Disposition": "attachment; filename=rf4spotter-diagnostics.json"})
|
||||
|
||||
|
||||
@app.get("/api/v1/imports", response_model=list[ImportRunOut])
|
||||
@app.get("/api/v1/imports", response_model=list[ImportRunPublicOut])
|
||||
def imports(db: Db, limit: int = Query(20, ge=1, le=100), offset: int = Query(0, ge=0)) -> list[OfficialRecordImport]:
|
||||
return list(db.scalars(select(OfficialRecordImport).order_by(OfficialRecordImport.started_at.desc(), OfficialRecordImport.id.desc()).offset(offset).limit(limit)))
|
||||
|
||||
@@ -448,7 +450,7 @@ def admin_reject_external_observation(
|
||||
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)
|
||||
_check_rate_limit(request, db)
|
||||
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:
|
||||
@@ -536,9 +538,14 @@ def delete_report(report_id: UUID, db: Db, moderator: Annotated[str, Depends(_ad
|
||||
return Response(status_code=204)
|
||||
|
||||
|
||||
def _check_rate_limit(client: str, db: Session) -> None:
|
||||
def _check_rate_limit(request: Request, db: Session) -> None:
|
||||
now = datetime.now(timezone.utc)
|
||||
cutoff = now - timedelta(minutes=10)
|
||||
# Extract real client IP from forwarded headers
|
||||
client = request.client.host if request.client else "unknown"
|
||||
forwarded = request.headers.get("x-forwarded-for")
|
||||
if forwarded:
|
||||
client = forwarded.split(",")[0].strip()
|
||||
client_hash = hmac.new(settings.rate_limit_secret.encode(), client.encode(), hashlib.sha256).hexdigest()
|
||||
if db.get_bind().dialect.name == "postgresql":
|
||||
lock_key = int(client_hash[:16], 16) & 0x7FFF_FFFF_FFFF_FFFF
|
||||
|
||||
Reference in New Issue
Block a user