Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4f68d6b004 | ||
|
|
3ea08fa706 | ||
|
|
2c7dd27b4f | ||
|
|
f550639456 | ||
|
|
e2bed0db89 | ||
|
|
cc3b42eaf6 | ||
|
|
39f66481be | ||
|
|
6e0077bd4d | ||
|
|
67681ee039 |
+20
-4
@@ -29,10 +29,10 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
python-version: "3.12"
|
python-version: "3.12"
|
||||||
cache: pip
|
cache: pip
|
||||||
- name: Install Python dependencies
|
- name: Install Python dependencies (locked)
|
||||||
run: |
|
run: |
|
||||||
python -m pip install --upgrade pip
|
python -m pip install --upgrade pip
|
||||||
pip install -r apps/api/requirements-dev.txt
|
pip install -r apps/api/requirements-dev-lock.txt
|
||||||
pip install -e .
|
pip install -e .
|
||||||
- name: Apply migrations to clean PostgreSQL
|
- name: Apply migrations to clean PostgreSQL
|
||||||
working-directory: apps/api
|
working-directory: apps/api
|
||||||
@@ -54,9 +54,25 @@ jobs:
|
|||||||
- name: Install web dependencies
|
- name: Install web dependencies
|
||||||
working-directory: apps/web
|
working-directory: apps/web
|
||||||
run: npm ci
|
run: npm ci
|
||||||
- name: Check and build Astro
|
- name: Astro check, build and unit tests
|
||||||
working-directory: apps/web
|
working-directory: apps/web
|
||||||
run: npm run build
|
run: |
|
||||||
|
npm run check
|
||||||
|
npm run build
|
||||||
|
npm run test:unit
|
||||||
|
|
||||||
|
dependency-audit:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: "3.12"
|
||||||
|
- name: Check Python dependencies for security issues
|
||||||
|
run: |
|
||||||
|
pip install --upgrade pip
|
||||||
|
pip install -r apps/api/requirements.txt
|
||||||
|
pip audit --requirement apps/api/requirements.txt || true
|
||||||
|
|
||||||
compose-e2e:
|
compose-e2e:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
# Regenerate Python dependency lock files
|
||||||
|
|
||||||
|
.PHONY: lock lock-dev
|
||||||
|
|
||||||
|
lock:
|
||||||
|
cd apps/api && pip-compile requirements.txt --output-file requirements-lock.txt
|
||||||
|
|
||||||
|
lock-dev:
|
||||||
|
cd apps/api && pip-compile requirements-dev.txt --output-file requirements-dev-lock.txt
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
"""add_import_record_event_table
|
||||||
|
|
||||||
|
Revision ID: 48094a7d1b92
|
||||||
|
Revises: 0013
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision: str = '48094a7d1b92'
|
||||||
|
down_revision: Union[str, None] = '0013'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"import_record_event",
|
||||||
|
sa.Column("id", sa.Uuid(), server_default=sa.func.gen_random_uuid(), primary_key=True),
|
||||||
|
sa.Column("catch_report_id", sa.Uuid(), sa.ForeignKey("catch_report.id"), nullable=False, index=True),
|
||||||
|
sa.Column("import_run_id", sa.Uuid(), sa.ForeignKey("official_record_import.id"), nullable=False, index=True),
|
||||||
|
sa.Column("event_type", sa.String(20), nullable=False),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||||
|
)
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table("import_record_event")
|
||||||
@@ -56,6 +56,11 @@ def activity_rows(
|
|||||||
activity = round(55 * min(1, weighted / 12) + 25 * min(1, len(players) / 6) + 20 * min(1, trophies / 3))
|
activity = round(55 * min(1, weighted / 12) + 25 * min(1, len(players) / 6) + 20 * min(1, trophies / 3))
|
||||||
average_confidence = sum(r.source_confidence for r in items) / len(items)
|
average_confidence = sum(r.source_confidence for r in items) / len(items)
|
||||||
confidence = round(45 * min(1, len(items) / 10) + 35 * min(1, len(players) / 5) + 20 * average_confidence / 100)
|
confidence = round(45 * min(1, len(items) / 10) + 35 * min(1, len(players) / 5) + 20 * average_confidence / 100)
|
||||||
|
# Cap confidence: 1 player → max 50%, 2 players → max 65%
|
||||||
|
if len(players) == 1:
|
||||||
|
confidence = min(confidence, 50)
|
||||||
|
elif len(players) == 2:
|
||||||
|
confidence = min(confidence, 65)
|
||||||
latest = max(_aware(r.reported_at) for r in items)
|
latest = max(_aware(r.reported_at) for r in items)
|
||||||
baits = Counter(r.bait.name for r in items if r.bait)
|
baits = Counter(r.bait.name for r in items if r.bait)
|
||||||
freshness_text = _freshness_text(now - latest)
|
freshness_text = _freshness_text(now - latest)
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ from sqlalchemy import select
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from .community_review import publish_observation
|
from .community_review import publish_observation
|
||||||
from .models import DataSource, ExternalEntityAlias, ExternalObservation, ModerationStatus, Waterbody
|
from .models import DataSource, ExternalEntityAlias, ExternalObservation, Fish, ModerationStatus, Waterbody
|
||||||
|
|
||||||
|
|
||||||
SOURCE_DEFAULTS = {
|
SOURCE_DEFAULTS = {
|
||||||
@@ -112,12 +112,22 @@ def _auto_publish(session: Session, observation: ExternalObservation) -> bool:
|
|||||||
or observation.weight_g is None
|
or observation.weight_g is None
|
||||||
):
|
):
|
||||||
return False
|
return False
|
||||||
fish_alias = session.scalar(select(ExternalEntityAlias).where(
|
# Fish: prefer external alias, fall back to exact name match
|
||||||
ExternalEntityAlias.source_system == observation.source_system,
|
fish = None
|
||||||
ExternalEntityAlias.entity_type == "fish",
|
if observation.fish_external_id is not None:
|
||||||
ExternalEntityAlias.external_id == observation.fish_external_id,
|
fish_alias = session.scalar(select(ExternalEntityAlias).where(
|
||||||
))
|
ExternalEntityAlias.source_system == observation.source_system,
|
||||||
if fish_alias is None or fish_alias.fish is None:
|
ExternalEntityAlias.entity_type == "fish",
|
||||||
|
ExternalEntityAlias.external_id == observation.fish_external_id,
|
||||||
|
))
|
||||||
|
if fish_alias and fish_alias.fish:
|
||||||
|
fish = fish_alias.fish
|
||||||
|
if fish is None:
|
||||||
|
# Fallback: exact name match
|
||||||
|
fish = session.scalar(
|
||||||
|
select(Fish).where(Fish.name_ru == observation.fish_name)
|
||||||
|
)
|
||||||
|
if fish is None:
|
||||||
return False
|
return False
|
||||||
# Waterbody: prefer external alias, fall back to exact name match
|
# Waterbody: prefer external alias, fall back to exact name match
|
||||||
waterbody = None
|
waterbody = None
|
||||||
@@ -136,7 +146,7 @@ def _auto_publish(session: Session, observation: ExternalObservation) -> bool:
|
|||||||
)
|
)
|
||||||
if waterbody is None:
|
if waterbody is None:
|
||||||
return False
|
return False
|
||||||
observation.fish = fish_alias.fish
|
observation.fish = fish
|
||||||
observation.waterbody = waterbody
|
observation.waterbody = waterbody
|
||||||
observation.status = "ready"
|
observation.status = "ready"
|
||||||
observation.review_note = "Automatically matched by previously reviewed source aliases"
|
observation.review_note = "Automatically matched by previously reviewed source aliases"
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ def publish_observation(session: Session, observation: ExternalObservation) -> C
|
|||||||
rig_type=observation.payload.get("rig_type"),
|
rig_type=observation.payload.get("rig_type"),
|
||||||
retrieve_method=observation.payload.get("retrieve_method"),
|
retrieve_method=observation.payload.get("retrieve_method"),
|
||||||
retrieve_speed=observation.payload.get("retrieve_speed"),
|
retrieve_speed=observation.payload.get("retrieve_speed"),
|
||||||
caught_at=observation.published_at,
|
caught_at=None,
|
||||||
reported_at=observation.published_at or observation.first_seen_at,
|
reported_at=observation.published_at or observation.first_seen_at,
|
||||||
player_name=observation.payload.get("player_name"),
|
player_name=observation.payload.get("player_name"),
|
||||||
source_type=SourceType.manual_import,
|
source_type=SourceType.manual_import,
|
||||||
|
|||||||
@@ -33,6 +33,18 @@ class Settings(BaseSettings):
|
|||||||
rate_limit_secret: str = "change-rate-limit-secret"
|
rate_limit_secret: str = "change-rate-limit-secret"
|
||||||
log_level: str = "INFO"
|
log_level: str = "INFO"
|
||||||
cors_origins: list[str] = Field(default_factory=lambda: ["http://localhost:4321", "http://127.0.0.1:4321"])
|
cors_origins: list[str] = Field(default_factory=lambda: ["http://localhost:4321", "http://127.0.0.1:4321"])
|
||||||
|
trusted_proxy_cidrs: list[str] = Field(default_factory=lambda: ["127.0.0.1/32", "::1/128"])
|
||||||
|
|
||||||
|
@model_validator(mode="before")
|
||||||
|
@classmethod
|
||||||
|
def parse_comma_separated_lists(cls, data: dict) -> dict:
|
||||||
|
"""Parse comma-separated string values into lists."""
|
||||||
|
if isinstance(data, dict):
|
||||||
|
for field_name in ["cors_origins", "trusted_proxy_cidrs"]:
|
||||||
|
value = data.get(field_name)
|
||||||
|
if isinstance(value, str) and value:
|
||||||
|
data[field_name] = [item.strip() for item in value.split(",") if item.strip()]
|
||||||
|
return data
|
||||||
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
|
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
|
||||||
|
|
||||||
@model_validator(mode="after")
|
@model_validator(mode="after")
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ from sqlalchemy.orm import Session
|
|||||||
from rf4_research.official_parser import RecordsContractError, parse_official_records
|
from rf4_research.official_parser import RecordsContractError, parse_official_records
|
||||||
|
|
||||||
from .models import (
|
from .models import (
|
||||||
Bait, BaitKind, CatchReport, Fish, ImportStatus, ModerationStatus,
|
Bait, BaitKind, CatchReport, Fish, ImportRecordEvent, ImportStatus, ModerationStatus,
|
||||||
OfficialRecordImport, SourceType, Waterbody,
|
OfficialRecordImport, SourceType, Waterbody,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -185,12 +185,16 @@ def _import_records_locked(session: Session, *, url: str, region: str, category:
|
|||||||
bait = _bait(session, raw.bait) if raw.bait else None
|
bait = _bait(session, raw.bait) if raw.bait else None
|
||||||
payload = asdict(raw) | {"record_date": raw.record_date.isoformat()}
|
payload = asdict(raw) | {"record_date": raw.record_date.isoformat()}
|
||||||
caught = datetime.combine(raw.record_date, time(), tzinfo=timezone.utc)
|
caught = datetime.combine(raw.record_date, time(), tzinfo=timezone.utc)
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
if report is None:
|
if report is None:
|
||||||
session.add(CatchReport(fish=fish, waterbody=waterbody, bait=bait, spot=None, weight_g=raw.weight_g, caught_at=caught, reported_at=datetime.now(timezone.utc), player_name=raw.player, source_type=SourceType.official_record, source_url=url, source_external_id=key, source_confidence=100, moderation_status=ModerationStatus.approved, raw_payload=payload))
|
report = CatchReport(fish=fish, waterbody=waterbody, bait=bait, spot=None, weight_g=raw.weight_g, caught_at=caught, reported_at=now, player_name=raw.player, source_type=SourceType.official_record, source_url=url, source_external_id=key, source_confidence=100, moderation_status=ModerationStatus.approved, raw_payload=payload)
|
||||||
|
session.add(report)
|
||||||
|
session.add(ImportRecordEvent(catch_report=report, import_run=run, event_type="created", created_at=now))
|
||||||
run.rows_created += 1
|
run.rows_created += 1
|
||||||
else:
|
else:
|
||||||
report.raw_payload = payload
|
report.raw_payload = payload
|
||||||
report.source_url = url
|
report.source_url = url
|
||||||
|
session.add(ImportRecordEvent(catch_report=report, import_run=run, event_type="updated", created_at=now))
|
||||||
run.rows_updated += 1
|
run.rows_updated += 1
|
||||||
run.status = ImportStatus.success
|
run.status = ImportStatus.success
|
||||||
run.finished_at = datetime.now(timezone.utc)
|
run.finished_at = datetime.now(timezone.utc)
|
||||||
|
|||||||
+19
-1
@@ -2,6 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from collections import Counter
|
from collections import Counter
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from ipaddress import IPv4Address, IPv6Address, IPv4Network, IPv6Network
|
||||||
import hashlib
|
import hashlib
|
||||||
import hmac
|
import hmac
|
||||||
import logging
|
import logging
|
||||||
@@ -538,13 +539,30 @@ def delete_report(report_id: UUID, db: Db, moderator: Annotated[str, Depends(_ad
|
|||||||
return Response(status_code=204)
|
return Response(status_code=204)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_trusted_proxy(address: str, trusted_cidrs: list[str]) -> bool:
|
||||||
|
"""Check if address is in trusted proxy CIDRs."""
|
||||||
|
try:
|
||||||
|
addr = IPv4Address(address) if ":" not in address else IPv6Address(address)
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
for cidr in trusted_cidrs:
|
||||||
|
try:
|
||||||
|
network = IPv4Network(cidr) if ":" not in cidr else IPv6Network(cidr)
|
||||||
|
if addr in network:
|
||||||
|
return True
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def _check_rate_limit(request: Request, db: Session) -> None:
|
def _check_rate_limit(request: Request, db: Session) -> None:
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
cutoff = now - timedelta(minutes=10)
|
cutoff = now - timedelta(minutes=10)
|
||||||
# Extract real client IP from forwarded headers
|
# Extract real client IP from forwarded headers
|
||||||
client = request.client.host if request.client else "unknown"
|
client = request.client.host if request.client else "unknown"
|
||||||
forwarded = request.headers.get("x-forwarded-for")
|
forwarded = request.headers.get("x-forwarded-for")
|
||||||
if forwarded:
|
# Only trust X-Forwarded-For if connection came from a trusted proxy
|
||||||
|
if forwarded and request.client and _is_trusted_proxy(request.client.host, settings.trusted_proxy_cidrs):
|
||||||
client = forwarded.split(",")[0].strip()
|
client = forwarded.split(",")[0].strip()
|
||||||
client_hash = hmac.new(settings.rate_limit_secret.encode(), client.encode(), hashlib.sha256).hexdigest()
|
client_hash = hmac.new(settings.rate_limit_secret.encode(), client.encode(), hashlib.sha256).hexdigest()
|
||||||
if db.get_bind().dialect.name == "postgresql":
|
if db.get_bind().dialect.name == "postgresql":
|
||||||
|
|||||||
@@ -212,3 +212,15 @@ class ExternalEntityAlias(Base):
|
|||||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
|
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
|
||||||
fish: Mapped[Fish | None] = relationship()
|
fish: Mapped[Fish | None] = relationship()
|
||||||
waterbody: Mapped[Waterbody | None] = relationship()
|
waterbody: Mapped[Waterbody | None] = relationship()
|
||||||
|
|
||||||
|
|
||||||
|
class ImportRecordEvent(Base):
|
||||||
|
"""Track per-record import events for D09 revision history."""
|
||||||
|
__tablename__ = "import_record_event"
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
|
||||||
|
catch_report_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("catch_report.id"), index=True)
|
||||||
|
import_run_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("official_record_import.id"), index=True)
|
||||||
|
event_type: Mapped[str] = mapped_column(String(20)) # created/updated/deleted
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
|
||||||
|
catch_report: Mapped[CatchReport] = relationship()
|
||||||
|
import_run: Mapped[OfficialRecordImport] = relationship()
|
||||||
|
|||||||
@@ -78,7 +78,9 @@ def readiness_report(
|
|||||||
"status": "ready" if healthy else ("stale" if stale else latest_community.status),
|
"status": "ready" if healthy else ("stale" if stale else latest_community.status),
|
||||||
"last_started_at": started.isoformat(),
|
"last_started_at": started.isoformat(),
|
||||||
}
|
}
|
||||||
|
ready = ready and healthy
|
||||||
except Exception:
|
except Exception:
|
||||||
components["community_scheduler"] = {"status": "unknown"}
|
components["community_scheduler"] = {"status": "unknown"}
|
||||||
|
ready = False
|
||||||
|
|
||||||
return ready, components
|
return ready, components
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
#
|
||||||
|
# This file is autogenerated by pip-compile with Python 3.14
|
||||||
|
# by the following command:
|
||||||
|
#
|
||||||
|
# pip-compile --output-file=requirements-dev-lock.txt requirements-dev.txt
|
||||||
|
#
|
||||||
|
alembic==1.16.5
|
||||||
|
# via -r requirements.txt
|
||||||
|
annotated-types==0.8.0
|
||||||
|
# via pydantic
|
||||||
|
anyio==4.15.1
|
||||||
|
# via
|
||||||
|
# httpx
|
||||||
|
# starlette
|
||||||
|
# watchfiles
|
||||||
|
beautifulsoup4==4.15.0
|
||||||
|
# via -r requirements.txt
|
||||||
|
boto3==1.40.35
|
||||||
|
# via -r requirements.txt
|
||||||
|
botocore==1.40.76
|
||||||
|
# via
|
||||||
|
# boto3
|
||||||
|
# s3transfer
|
||||||
|
certifi==2026.7.22
|
||||||
|
# via
|
||||||
|
# httpcore
|
||||||
|
# httpx
|
||||||
|
click==8.5.0
|
||||||
|
# via uvicorn
|
||||||
|
fastapi==0.116.1
|
||||||
|
# via -r requirements.txt
|
||||||
|
h11==0.16.0
|
||||||
|
# via
|
||||||
|
# httpcore
|
||||||
|
# uvicorn
|
||||||
|
httpcore==1.0.9
|
||||||
|
# via httpx
|
||||||
|
httptools==0.8.0
|
||||||
|
# via uvicorn
|
||||||
|
httpx==0.28.1
|
||||||
|
# via -r requirements.txt
|
||||||
|
idna==3.19
|
||||||
|
# via
|
||||||
|
# anyio
|
||||||
|
# httpx
|
||||||
|
iniconfig==2.3.0
|
||||||
|
# via pytest
|
||||||
|
jmespath==1.1.0
|
||||||
|
# via
|
||||||
|
# boto3
|
||||||
|
# botocore
|
||||||
|
mako==1.4.1
|
||||||
|
# via alembic
|
||||||
|
markupsafe==3.0.3
|
||||||
|
# via mako
|
||||||
|
packaging==26.3
|
||||||
|
# via pytest
|
||||||
|
pillow==11.3.0
|
||||||
|
# via -r requirements.txt
|
||||||
|
pluggy==1.6.0
|
||||||
|
# via pytest
|
||||||
|
psycopg[binary]==3.2.10
|
||||||
|
# via -r requirements.txt
|
||||||
|
psycopg-binary==3.2.10
|
||||||
|
# via psycopg
|
||||||
|
pydantic==2.13.5
|
||||||
|
# via
|
||||||
|
# fastapi
|
||||||
|
# pydantic-settings
|
||||||
|
pydantic-core==2.46.5
|
||||||
|
# via pydantic
|
||||||
|
pydantic-settings==2.10.1
|
||||||
|
# via -r requirements.txt
|
||||||
|
pygments==2.21.0
|
||||||
|
# via pytest
|
||||||
|
pytest==8.4.2
|
||||||
|
# via -r requirements-dev.txt
|
||||||
|
python-dateutil==2.9.0.post0
|
||||||
|
# via botocore
|
||||||
|
python-dotenv==1.2.3
|
||||||
|
# via
|
||||||
|
# pydantic-settings
|
||||||
|
# uvicorn
|
||||||
|
python-multipart==0.0.20
|
||||||
|
# via -r requirements.txt
|
||||||
|
pyyaml==6.0.3
|
||||||
|
# via uvicorn
|
||||||
|
s3transfer==0.14.0
|
||||||
|
# via boto3
|
||||||
|
six==1.17.0
|
||||||
|
# via python-dateutil
|
||||||
|
soupsieve==2.9.2
|
||||||
|
# via beautifulsoup4
|
||||||
|
sqlalchemy==2.0.43
|
||||||
|
# via
|
||||||
|
# -r requirements.txt
|
||||||
|
# alembic
|
||||||
|
starlette==0.47.3
|
||||||
|
# via fastapi
|
||||||
|
typing-extensions==4.16.0
|
||||||
|
# via
|
||||||
|
# alembic
|
||||||
|
# anyio
|
||||||
|
# beautifulsoup4
|
||||||
|
# fastapi
|
||||||
|
# pydantic
|
||||||
|
# pydantic-core
|
||||||
|
# sqlalchemy
|
||||||
|
# typing-inspection
|
||||||
|
typing-inspection==0.4.4
|
||||||
|
# via
|
||||||
|
# pydantic
|
||||||
|
# pydantic-settings
|
||||||
|
urllib3==2.7.0
|
||||||
|
# via botocore
|
||||||
|
uvicorn[standard]==0.35.0
|
||||||
|
# via -r requirements.txt
|
||||||
|
uvloop==0.22.1
|
||||||
|
# via uvicorn
|
||||||
|
watchfiles==1.2.0
|
||||||
|
# via uvicorn
|
||||||
|
websockets==17.1
|
||||||
|
# via uvicorn
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
#
|
||||||
|
# This file is autogenerated by pip-compile with Python 3.14
|
||||||
|
# by the following command:
|
||||||
|
#
|
||||||
|
# pip-compile --output-file=requirements-lock.txt requirements.txt
|
||||||
|
#
|
||||||
|
alembic==1.16.5
|
||||||
|
# via -r requirements.txt
|
||||||
|
annotated-types==0.8.0
|
||||||
|
# via pydantic
|
||||||
|
anyio==4.15.1
|
||||||
|
# via
|
||||||
|
# httpx
|
||||||
|
# starlette
|
||||||
|
# watchfiles
|
||||||
|
beautifulsoup4==4.15.0
|
||||||
|
# via -r requirements.txt
|
||||||
|
boto3==1.40.35
|
||||||
|
# via -r requirements.txt
|
||||||
|
botocore==1.40.76
|
||||||
|
# via
|
||||||
|
# boto3
|
||||||
|
# s3transfer
|
||||||
|
certifi==2026.7.22
|
||||||
|
# via
|
||||||
|
# httpcore
|
||||||
|
# httpx
|
||||||
|
click==8.5.0
|
||||||
|
# via uvicorn
|
||||||
|
fastapi==0.116.1
|
||||||
|
# via -r requirements.txt
|
||||||
|
h11==0.16.0
|
||||||
|
# via
|
||||||
|
# httpcore
|
||||||
|
# uvicorn
|
||||||
|
httpcore==1.0.9
|
||||||
|
# via httpx
|
||||||
|
httptools==0.8.0
|
||||||
|
# via uvicorn
|
||||||
|
httpx==0.28.1
|
||||||
|
# via -r requirements.txt
|
||||||
|
idna==3.19
|
||||||
|
# via
|
||||||
|
# anyio
|
||||||
|
# httpx
|
||||||
|
jmespath==1.1.0
|
||||||
|
# via
|
||||||
|
# boto3
|
||||||
|
# botocore
|
||||||
|
mako==1.4.1
|
||||||
|
# via alembic
|
||||||
|
markupsafe==3.0.3
|
||||||
|
# via mako
|
||||||
|
pillow==11.3.0
|
||||||
|
# via -r requirements.txt
|
||||||
|
psycopg[binary]==3.2.10
|
||||||
|
# via -r requirements.txt
|
||||||
|
psycopg-binary==3.2.10
|
||||||
|
# via psycopg
|
||||||
|
pydantic==2.13.5
|
||||||
|
# via
|
||||||
|
# fastapi
|
||||||
|
# pydantic-settings
|
||||||
|
pydantic-core==2.46.5
|
||||||
|
# via pydantic
|
||||||
|
pydantic-settings==2.10.1
|
||||||
|
# via -r requirements.txt
|
||||||
|
python-dateutil==2.9.0.post0
|
||||||
|
# via botocore
|
||||||
|
python-dotenv==1.2.3
|
||||||
|
# via
|
||||||
|
# pydantic-settings
|
||||||
|
# uvicorn
|
||||||
|
python-multipart==0.0.20
|
||||||
|
# via -r requirements.txt
|
||||||
|
pyyaml==6.0.3
|
||||||
|
# via uvicorn
|
||||||
|
s3transfer==0.14.0
|
||||||
|
# via boto3
|
||||||
|
six==1.17.0
|
||||||
|
# via python-dateutil
|
||||||
|
soupsieve==2.9.2
|
||||||
|
# via beautifulsoup4
|
||||||
|
sqlalchemy==2.0.43
|
||||||
|
# via
|
||||||
|
# -r requirements.txt
|
||||||
|
# alembic
|
||||||
|
starlette==0.47.3
|
||||||
|
# via fastapi
|
||||||
|
typing-extensions==4.16.0
|
||||||
|
# via
|
||||||
|
# alembic
|
||||||
|
# anyio
|
||||||
|
# beautifulsoup4
|
||||||
|
# fastapi
|
||||||
|
# pydantic
|
||||||
|
# pydantic-core
|
||||||
|
# sqlalchemy
|
||||||
|
# typing-inspection
|
||||||
|
typing-inspection==0.4.4
|
||||||
|
# via
|
||||||
|
# pydantic
|
||||||
|
# pydantic-settings
|
||||||
|
urllib3==2.7.0
|
||||||
|
# via botocore
|
||||||
|
uvicorn[standard]==0.35.0
|
||||||
|
# via -r requirements.txt
|
||||||
|
uvloop==0.22.1
|
||||||
|
# via uvicorn
|
||||||
|
watchfiles==1.2.0
|
||||||
|
# via uvicorn
|
||||||
|
websockets==17.1
|
||||||
|
# via uvicorn
|
||||||
@@ -129,3 +129,55 @@ def test_reports_without_coordinates_do_not_create_activity_group(db: Session) -
|
|||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
assert activity_rows(db, hours=72, now=NOW) == []
|
assert activity_rows(db, hours=72, now=NOW) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_confidence_capped_at_50_with_single_player() -> None:
|
||||||
|
"""D06: One player cannot artificially inflate confidence above 50%."""
|
||||||
|
engine = create_engine("sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool)
|
||||||
|
Base.metadata.create_all(engine)
|
||||||
|
with Session(engine) as session:
|
||||||
|
waterbody = Waterbody(slug="test-lake", name_ru="Тестовое озеро", unlock_level=1)
|
||||||
|
fish = Fish(slug="pike", name_ru="Щука", trophy_weight_g=10_000)
|
||||||
|
spot = Spot(waterbody=waterbody, x=10, y=20)
|
||||||
|
session.add_all([waterbody, fish, spot])
|
||||||
|
session.flush()
|
||||||
|
# 10 reports from 1 player, all max confidence
|
||||||
|
for _ in range(10):
|
||||||
|
report = CatchReport(
|
||||||
|
fish=fish, spot=spot, waterbody=waterbody, bait=None,
|
||||||
|
weight_g=5_000, fishing_method="spinning",
|
||||||
|
caught_at=NOW - timedelta(hours=1), reported_at=NOW - timedelta(hours=1),
|
||||||
|
player_name="Single Player", source_type=SourceType.user,
|
||||||
|
source_confidence=100, moderation_status=ModerationStatus.approved,
|
||||||
|
)
|
||||||
|
session.add(report)
|
||||||
|
session.commit()
|
||||||
|
row = activity_rows(session, hours=24, now=NOW)[0]
|
||||||
|
assert row.unique_players == 1
|
||||||
|
assert row.confidence_score <= 50, f"Expected max 50 with 1 player, got {row.confidence_score}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_confidence_capped_at_65_with_two_players() -> None:
|
||||||
|
"""D06: Two players cannot get confidence above 65%."""
|
||||||
|
engine = create_engine("sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool)
|
||||||
|
Base.metadata.create_all(engine)
|
||||||
|
with Session(engine) as session:
|
||||||
|
waterbody = Waterbody(slug="test-lake", name_ru="Тестовое озеро", unlock_level=1)
|
||||||
|
fish = Fish(slug="pike", name_ru="Щука", trophy_weight_g=10_000)
|
||||||
|
spot = Spot(waterbody=waterbody, x=10, y=20)
|
||||||
|
session.add_all([waterbody, fish, spot])
|
||||||
|
session.flush()
|
||||||
|
# 10 reports from 2 players, all max confidence
|
||||||
|
for i in range(10):
|
||||||
|
report = CatchReport(
|
||||||
|
fish=fish, spot=spot, waterbody=waterbody, bait=None,
|
||||||
|
weight_g=5_000, fishing_method="spinning",
|
||||||
|
caught_at=NOW - timedelta(hours=1), reported_at=NOW - timedelta(hours=1),
|
||||||
|
player_name=f"Player {i % 2}", source_type=SourceType.user,
|
||||||
|
source_confidence=100, moderation_status=ModerationStatus.approved,
|
||||||
|
)
|
||||||
|
session.add(report)
|
||||||
|
session.commit()
|
||||||
|
row = activity_rows(session, hours=24, now=NOW)[0]
|
||||||
|
assert row.unique_players == 2
|
||||||
|
assert row.confidence_score <= 65, f"Expected max 65 with 2 players, got {row.confidence_score}"
|
||||||
|
|||||||
@@ -116,9 +116,11 @@ def test_changed_published_record_requires_review_and_reuses_report(db: Session)
|
|||||||
water = Waterbody(slug="test-lake", name_ru="Тестовое озеро")
|
water = Waterbody(slug="test-lake", name_ru="Тестовое озеро")
|
||||||
db.add_all([fish, water])
|
db.add_all([fish, water])
|
||||||
db.commit()
|
db.commit()
|
||||||
|
# D04: auto-publish now works with name match fallback
|
||||||
stage_observations(db, [record() | {"weight_g": 5000}])
|
stage_observations(db, [record() | {"weight_g": 5000}])
|
||||||
item = db.scalar(select(ExternalObservation))
|
item = db.scalar(select(ExternalObservation))
|
||||||
map_observation(db, item, fish, water)
|
# Item auto-published via name match fallback (D04)
|
||||||
|
assert item.status == "published"
|
||||||
report = publish_observation(db, item)
|
report = publish_observation(db, item)
|
||||||
report_id = report.id
|
report_id = report.id
|
||||||
stage_observations(db, [record() | {"weight_g": 6000}])
|
stage_observations(db, [record() | {"weight_g": 6000}])
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from unittest.mock import MagicMock
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
@@ -9,7 +9,7 @@ from sqlalchemy import create_engine, select
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.database import Base
|
from app.database import Base
|
||||||
from app.main import _check_rate_limit
|
from app.main import _check_rate_limit, _is_trusted_proxy
|
||||||
from app.models import SubmissionAttempt
|
from app.models import SubmissionAttempt
|
||||||
|
|
||||||
|
|
||||||
@@ -34,18 +34,71 @@ def test_rate_limit_is_persistent_and_does_not_store_raw_client() -> None:
|
|||||||
assert all(item.created_at.replace(tzinfo=timezone.utc) <= datetime.now(timezone.utc) for item in attempts)
|
assert all(item.created_at.replace(tzinfo=timezone.utc) <= datetime.now(timezone.utc) for item in attempts)
|
||||||
|
|
||||||
|
|
||||||
def test_rate_limit_uses_forwarded_for_header() -> None:
|
def test_rate_limit_uses_forwarded_for_header_from_trusted_proxy() -> None:
|
||||||
|
"""X-Forwarded-For should be used when client is trusted proxy."""
|
||||||
engine = create_engine("sqlite://")
|
engine = create_engine("sqlite://")
|
||||||
Base.metadata.create_all(engine)
|
Base.metadata.create_all(engine)
|
||||||
with Session(engine) as db:
|
with Session(engine) as db:
|
||||||
mock_real = MagicMock()
|
with patch("app.main.settings") as mock_settings:
|
||||||
mock_real.client.host = "10.0.0.1"
|
mock_settings.rate_limit_secret = "test-secret-for-testing"
|
||||||
mock_real.headers.get.return_value = "198.51.100.10"
|
mock_settings.trusted_proxy_cidrs = ["127.0.0.1/32"]
|
||||||
for _ in range(5):
|
mock_proxy = MagicMock()
|
||||||
_check_rate_limit(mock_real, db)
|
mock_proxy.client.host = "127.0.0.1"
|
||||||
with pytest.raises(HTTPException) as blocked:
|
mock_proxy.headers.get.return_value = "198.51.100.10"
|
||||||
mock_other = MagicMock()
|
for _ in range(5):
|
||||||
mock_other.client.host = "10.0.0.2"
|
_check_rate_limit(mock_proxy, db)
|
||||||
mock_other.headers.get.return_value = "198.51.100.10"
|
with pytest.raises(HTTPException) as blocked:
|
||||||
_check_rate_limit(mock_other, db)
|
mock_other = MagicMock()
|
||||||
assert blocked.value.status_code == 429
|
mock_other.client.host = "127.0.0.1"
|
||||||
|
mock_other.headers.get.return_value = "198.51.100.10"
|
||||||
|
_check_rate_limit(mock_other, db)
|
||||||
|
assert blocked.value.status_code == 429
|
||||||
|
|
||||||
|
|
||||||
|
def test_trusted_proxy_checks_cidrs() -> None:
|
||||||
|
assert _is_trusted_proxy("127.0.0.1", ["127.0.0.1/32"]) is True
|
||||||
|
assert _is_trusted_proxy("10.0.0.1", ["10.0.0.0/8"]) is True
|
||||||
|
assert _is_trusted_proxy("192.168.1.1", ["192.168.1.0/24"]) is True
|
||||||
|
assert _is_trusted_proxy("::1", ["::1/128"]) is True
|
||||||
|
assert _is_trusted_proxy("203.0.113.5", ["127.0.0.1/32"]) is False
|
||||||
|
assert _is_trusted_proxy("invalid", []) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_rate_limit_ignores_forwarded_for_from_untrusted_client() -> None:
|
||||||
|
"""X-Forwarded-For should be ignored when client is not in trusted CIDRs."""
|
||||||
|
engine = create_engine("sqlite://")
|
||||||
|
Base.metadata.create_all(engine)
|
||||||
|
with Session(engine) as db:
|
||||||
|
# Client 203.0.113.42 is NOT trusted by default
|
||||||
|
mock_untrusted = MagicMock()
|
||||||
|
mock_untrusted.client.host = "203.0.113.42"
|
||||||
|
mock_untrusted.headers.get.return_value = "10.0.0.99"
|
||||||
|
for i in range(3):
|
||||||
|
_check_rate_limit(mock_untrusted, db)
|
||||||
|
# Should use real client 203.0.113.42, not forwarded 10.0.0.99
|
||||||
|
# So 3 attempts from 203.0.113.42 should be allowed (limit is 5)
|
||||||
|
mock_different = MagicMock()
|
||||||
|
mock_different.client.host = "203.0.113.42"
|
||||||
|
mock_different.headers.get.return_value = "10.0.0.88"
|
||||||
|
_check_rate_limit(mock_different, db) # Should succeed, not blocked
|
||||||
|
|
||||||
|
|
||||||
|
def test_rate_limit_uses_forwarded_for_from_trusted_proxy() -> None:
|
||||||
|
"""X-Forwarded-For should be used when client IS in trusted CIDRs."""
|
||||||
|
engine = create_engine("sqlite://")
|
||||||
|
Base.metadata.create_all(engine)
|
||||||
|
with Session(engine) as db:
|
||||||
|
# 127.0.0.1 IS trusted by default
|
||||||
|
with patch("app.main.settings") as mock_settings:
|
||||||
|
mock_settings.rate_limit_secret = "test-secret-for-testing"
|
||||||
|
mock_settings.trusted_proxy_cidrs = ["127.0.0.1/32", "::1/128"]
|
||||||
|
mock_proxy = MagicMock()
|
||||||
|
mock_proxy.client.host = "127.0.0.1"
|
||||||
|
mock_proxy.headers.get.return_value = "203.0.113.100"
|
||||||
|
for _ in range(5):
|
||||||
|
_check_rate_limit(mock_proxy, db)
|
||||||
|
# Should use forwarded IP 203.0.113.100, so a different forwarded IP should be allowed
|
||||||
|
mock_other_forwarded = MagicMock()
|
||||||
|
mock_other_forwarded.client.host = "127.0.0.1"
|
||||||
|
mock_other_forwarded.headers.get.return_value = "198.51.100.50"
|
||||||
|
_check_rate_limit(mock_other_forwarded, db) # Should succeed
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ from sqlalchemy import create_engine
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.database import Base
|
from app.database import Base
|
||||||
from app.models import ImportStatus, OfficialRecordImport
|
from app.models import ImportStatus, CommunityImportRun, OfficialRecordImport
|
||||||
from app.readiness import readiness_report
|
from app.readiness import readiness_report
|
||||||
|
|
||||||
|
|
||||||
@@ -72,3 +72,68 @@ def test_unavailable_storage_and_stale_import_fail_readiness() -> None:
|
|||||||
assert ready is False
|
assert ready is False
|
||||||
assert components["minio"]["status"] == "unavailable"
|
assert components["minio"]["status"] == "unavailable"
|
||||||
assert components["official_import"]["status"] == "stale"
|
assert components["official_import"]["status"] == "stale"
|
||||||
|
|
||||||
|
|
||||||
|
def test_community_scheduler_success_does_not_block_readiness() -> None:
|
||||||
|
engine = create_engine("sqlite://")
|
||||||
|
Base.metadata.create_all(engine)
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
with Session(engine) as session:
|
||||||
|
session.add(CommunityImportRun(
|
||||||
|
source_system="rf4db",
|
||||||
|
started_at=now - timedelta(minutes=30),
|
||||||
|
status="success",
|
||||||
|
source_url="fixture://rf4db",
|
||||||
|
rows_seen=5, rows_created=5, rows_updated=0, error_summary=None,
|
||||||
|
))
|
||||||
|
session.commit()
|
||||||
|
ready, components = readiness_report(
|
||||||
|
session, AvailableStorage(), import_required=False,
|
||||||
|
import_interval_seconds=3600, community_import_interval_seconds=1800, now=now,
|
||||||
|
)
|
||||||
|
assert ready is True
|
||||||
|
assert components["community_scheduler"]["status"] == "ready"
|
||||||
|
|
||||||
|
|
||||||
|
def test_community_scheduler_stale_or_failed_blocks_readiness() -> None:
|
||||||
|
engine = create_engine("sqlite://")
|
||||||
|
Base.metadata.create_all(engine)
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
with Session(engine) as session:
|
||||||
|
# Stale run
|
||||||
|
session.add(CommunityImportRun(
|
||||||
|
source_system="rf4db",
|
||||||
|
started_at=now - timedelta(hours=2),
|
||||||
|
status="success",
|
||||||
|
source_url="fixture://rf4db",
|
||||||
|
rows_seen=5, rows_created=5, rows_updated=0, error_summary=None,
|
||||||
|
))
|
||||||
|
session.commit()
|
||||||
|
ready, components = readiness_report(
|
||||||
|
session, AvailableStorage(), import_required=False,
|
||||||
|
import_interval_seconds=3600, community_import_interval_seconds=1800, now=now,
|
||||||
|
)
|
||||||
|
assert ready is False
|
||||||
|
assert components["community_scheduler"]["status"] == "stale"
|
||||||
|
|
||||||
|
|
||||||
|
def test_community_scheduler_failed_status_blocks_readiness() -> None:
|
||||||
|
engine = create_engine("sqlite://")
|
||||||
|
Base.metadata.create_all(engine)
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
with Session(engine) as session:
|
||||||
|
session.add(CommunityImportRun(
|
||||||
|
source_system="rf4db",
|
||||||
|
started_at=now - timedelta(minutes=30),
|
||||||
|
status="failed",
|
||||||
|
source_url="fixture://rf4db",
|
||||||
|
rows_seen=0, rows_created=0, rows_updated=0,
|
||||||
|
error_summary="ConnectionError",
|
||||||
|
))
|
||||||
|
session.commit()
|
||||||
|
ready, components = readiness_report(
|
||||||
|
session, AvailableStorage(), import_required=False,
|
||||||
|
import_interval_seconds=3600, community_import_interval_seconds=1800, now=now,
|
||||||
|
)
|
||||||
|
assert ready is False
|
||||||
|
assert components["community_scheduler"]["status"] == "failed"
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import node from "@astrojs/node";
|
|||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
site: process.env.PUBLIC_SITE_URL || "https://rf4spotter.ru",
|
site: process.env.PUBLIC_SITE_URL || "https://rf4spotter.ru",
|
||||||
|
trailingSlash: "never",
|
||||||
output: "server",
|
output: "server",
|
||||||
adapter: node({ mode: "standalone" }),
|
adapter: node({ mode: "standalone" }),
|
||||||
server: { host: true, port: 4321 },
|
server: { host: true, port: 4321 },
|
||||||
|
|||||||
@@ -14,5 +14,10 @@ export const POST: APIRoute = async ({ request, redirect, cookies }) => {
|
|||||||
const response = await fetch(`${base}/api/v1/catch-reports/${reportId}/screenshot`, { method: "POST", headers:{"X-Upload-Token":uploadToken}, body: upload, signal: AbortSignal.timeout(60_000) });
|
const response = await fetch(`${base}/api/v1/catch-reports/${reportId}/screenshot`, { method: "POST", headers:{"X-Upload-Token":uploadToken}, body: upload, signal: AbortSignal.timeout(60_000) });
|
||||||
if (response.ok) cookies.delete(cookieName, {path:"/"});
|
if (response.ok) cookies.delete(cookieName, {path:"/"});
|
||||||
return redirect(response.ok ? "/report?state=screenshot_sent" : `/report?state=screenshot_error&report_id=${encodeURIComponent(reportId)}`, 303);
|
return redirect(response.ok ? "/report?state=screenshot_sent" : `/report?state=screenshot_error&report_id=${encodeURIComponent(reportId)}`, 303);
|
||||||
} catch { return redirect(`/report?state=screenshot_error&report_id=${encodeURIComponent(reportId)}`, 303); }
|
} catch (err) {
|
||||||
|
const isTimeout = err instanceof DOMException && err.name === "TimeoutError" ||
|
||||||
|
err instanceof TypeError && err.message.toLowerCase().includes("fetch") ||
|
||||||
|
err instanceof Error && err.message.toLowerCase().includes("abort");
|
||||||
|
return redirect(isTimeout ? `/report?state=timeout&report_id=${encodeURIComponent(reportId)}` : `/report?state=screenshot_error&report_id=${encodeURIComponent(reportId)}`, 303);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -35,7 +35,10 @@ export const POST: APIRoute = async ({ request, redirect, cookies }) => {
|
|||||||
return redirect("/report?state=sent", 303);
|
return redirect("/report?state=sent", 303);
|
||||||
}
|
}
|
||||||
catch (err) {
|
catch (err) {
|
||||||
if (err instanceof TypeError && err.message.includes("abort")) {
|
const isTimeout = err instanceof DOMException && err.name === "TimeoutError" ||
|
||||||
|
err instanceof TypeError && err.message.toLowerCase().includes("fetch") ||
|
||||||
|
err instanceof Error && err.message.toLowerCase().includes("abort");
|
||||||
|
if (isTimeout) {
|
||||||
if (createdId && uploadToken) cookies.set(`rf4-upload-${createdId}`, uploadToken, {httpOnly:true, sameSite:"strict", secure:import.meta.env.PROD, path:"/", maxAge:3600});
|
if (createdId && uploadToken) cookies.set(`rf4-upload-${createdId}`, uploadToken, {httpOnly:true, sameSite:"strict", secure:import.meta.env.PROD, path:"/", maxAge:3600});
|
||||||
return redirect(createdId ? `/report?state=screenshot_error&report_id=${encodeURIComponent(createdId)}` : "/report?state=timeout", 303);
|
return redirect(createdId ? `/report?state=screenshot_error&report_id=${encodeURIComponent(createdId)}` : "/report?state=timeout", 303);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ const hours = params.get("hours") ?? "24";
|
|||||||
const waterbody = params.get("waterbody") ?? "";
|
const waterbody = params.get("waterbody") ?? "";
|
||||||
const fish = params.get("fish") ?? "";
|
const fish = params.get("fish") ?? "";
|
||||||
const sort = params.get("sort") ?? "activity";
|
const sort = params.get("sort") ?? "activity";
|
||||||
|
const requestedOffset = Number(params.get("offset") ?? 0);
|
||||||
|
const offset = Number.isInteger(requestedOffset) && requestedOffset >= 0 ? requestedOffset : 0;
|
||||||
const requestedSignalLimit = Number(params.get("signals") ?? 12);
|
const requestedSignalLimit = Number(params.get("signals") ?? 12);
|
||||||
const signalLimit = Number.isInteger(requestedSignalLimit) ? Math.min(48, Math.max(12, requestedSignalLimit)) : 12;
|
const signalLimit = Number.isInteger(requestedSignalLimit) ? Math.min(48, Math.max(12, requestedSignalLimit)) : 12;
|
||||||
let items: Activity[] = [], signals: PublicObservation[] = [], fishes: DictionaryItem[] = [], waterbodies: DictionaryItem[] = [];
|
let items: Activity[] = [], signals: PublicObservation[] = [], fishes: DictionaryItem[] = [], waterbodies: DictionaryItem[] = [];
|
||||||
@@ -18,7 +20,6 @@ let hasMoreSignals = false, totalItems = 0;
|
|||||||
const filterError = !["6", "12", "24", "72"].includes(hours) || !["activity", "confidence", "freshness"].includes(sort);
|
const filterError = !["6", "12", "24", "72"].includes(hours) || !["activity", "confidence", "freshness"].includes(sort);
|
||||||
let unavailable = false, showNoIndex = false;
|
let unavailable = false, showNoIndex = false;
|
||||||
if (filterError) { showNoIndex = true; Astro.response.status = 422; }
|
if (filterError) { showNoIndex = true; Astro.response.status = 422; }
|
||||||
if (unavailable) { showNoIndex = true; Astro.response.status = 503; Astro.response.headers.set("Retry-After", "60"); }
|
|
||||||
try {
|
try {
|
||||||
let signalRows: PublicObservation[] = [];
|
let signalRows: PublicObservation[] = [];
|
||||||
const signalParams = new URLSearchParams({ limit: String(signalLimit + 1) });
|
const signalParams = new URLSearchParams({ limit: String(signalLimit + 1) });
|
||||||
@@ -28,12 +29,17 @@ try {
|
|||||||
hasMoreSignals = signalRows.length > signalLimit;
|
hasMoreSignals = signalRows.length > signalLimit;
|
||||||
signals = signalRows.slice(0, signalLimit);
|
signals = signalRows.slice(0, signalLimit);
|
||||||
if (!filterError) {
|
if (!filterError) {
|
||||||
const query = new URLSearchParams({ hours, waterbody, fish, sort, limit: "20", offset: "0" });
|
const query = new URLSearchParams({ hours, waterbody, fish, sort, limit: "20", offset: String(offset) });
|
||||||
const paginated = await api<PaginatedActivity>(`/api/v1/activity?${query}`);
|
const paginated = await api<PaginatedActivity>(`/api/v1/activity?${query}`);
|
||||||
items = paginated.items;
|
items = offset > 0 ? [...items, ...paginated.items] : paginated.items;
|
||||||
totalItems = paginated.total;
|
totalItems = paginated.total;
|
||||||
}
|
}
|
||||||
} catch { unavailable = true; }
|
} catch {
|
||||||
|
unavailable = true;
|
||||||
|
showNoIndex = true;
|
||||||
|
Astro.response.status = 503;
|
||||||
|
Astro.response.headers.set("Retry-After", "60");
|
||||||
|
}
|
||||||
const leaderLevel = items.length > 1 && items[0] ? activityLevel(items[0].activity_score) : null;
|
const leaderLevel = items.length > 1 && items[0] ? activityLevel(items[0].activity_score) : null;
|
||||||
const selectedWaterbody = waterbodies.find(item => item.slug === waterbody)?.name_ru ?? "Все водоёмы";
|
const selectedWaterbody = waterbodies.find(item => item.slug === waterbody)?.name_ru ?? "Все водоёмы";
|
||||||
const selectedFish = fishes.find(item => item.slug === fish)?.name_ru ?? "Любая рыба";
|
const selectedFish = fishes.find(item => item.slug === fish)?.name_ru ?? "Любая рыба";
|
||||||
@@ -63,7 +69,7 @@ const datasetJsonLd = {
|
|||||||
<button>⌕ Найти клёв</button>
|
<button>⌕ Найти клёв</button>
|
||||||
</form></section>
|
</form></section>
|
||||||
<div class="active-filters content-grid" aria-label="Применённые фильтры"><span>{selectedWaterbody}</span><span>{selectedFish}</span><span>{periodLabel}</span><span>{sortLabel}</span>{filtersChanged && <a href="/#results">Сбросить</a>}</div>
|
<div class="active-filters content-grid" aria-label="Применённые фильтры"><span>{selectedWaterbody}</span><span>{selectedFish}</span><span>{periodLabel}</span><span>{sortLabel}</span>{filtersChanged && <a href="/#results">Сбросить</a>}</div>
|
||||||
<section class="dashboard content-grid" id="results"><div class="results-column"><div class="section-heading"><div><span class="overline">За выбранный период</span><h2>Горячие точки</h2></div><span class="result-count">{items.length} из {totalItems} {plural(totalItems, ["точка", "точки", "точек"])}</span></div>{filterError ? <div class="state error-state"><h2>Некорректные фильтры</h2><p>Выберите период и сортировку из предложенных значений.</p><a href="/">Сбросить фильтры</a></div> : unavailable ? <div class="state"><h2>Источник временно недоступен</h2><p>Не показываем устаревшие догадки. Попробуйте позже.</p></div> : items.length ? <><div class="spot-list">{items.map(item => <ActivityCard item={item} />)}</div>{items.length < totalItems && <a class="load-more" href={`/?${new URLSearchParams({ ...Object.fromEntries(params), offset: String(items.length) }).toString()}#results`}>Показать ещё <span>{items.length} из {totalItems}</span> ↓</a>}</> : <div class="state"><h2>Пока нет свежих данных</h2><p>Для выбранных фильтров нет одобренных наблюдений. Расширьте период или выберите другой водоём.</p></div>}</div>
|
<section class="dashboard content-grid" id="results"><div class="results-column"><div class="section-heading"><div><span class="overline">За выбранный период</span><h2>Горячие точки</h2></div><span class="result-count">{items.length} из {totalItems} {plural(totalItems, ["точка", "точки", "точек"])}</span></div>{filterError ? <div class="state error-state"><h2>Некорректные фильтры</h2><p>Выберите период и сортировку из предложенных значений.</p><a href="/">Сбросить фильтры</a></div> : unavailable ? <div class="state"><h2>Источник временно недоступен</h2><p>Не показываем устаревшие догадки. Попробуйте позже.</p></div> : items.length ? <><div class="spot-list">{items.map(item => <ActivityCard item={item} />)}</div>{items.length < totalItems && <a class="load-more" href={`/?${new URLSearchParams([...params.entries(), ["offset", String(items.length)]]).toString()}#results`}>Показать ещё <span>{items.length} из {totalItems}</span> ↓</a>}</> : <div class="state"><h2>Пока нет свежих данных</h2><p>Для выбранных фильтров нет одобренных наблюдений. Расширьте период или выберите другой водоём.</p></div>}</div>
|
||||||
{items[0] && leaderLevel && <aside class="detail-card"><div class="detail-head"><div><span class="overline">Лидер активности</span><h2>{items[0].waterbody} <em>{items[0].x}:{items[0].y}</em></h2></div><a href={`/spots/${items[0].spot_id}`} aria-label="Открыть точку"><FishingIcon name="arrow"/></a></div><div class="source-strip">{items[0].sources.map(source => <SourceBadge source={source}/>)}</div><div class="detail-score"><div class="float-gauge" style={`--level:${items[0].activity_score}%`} aria-label={`Индекс активности: ${items[0].activity_score} из 100`}><span class="float-gauge__line"></span><span class="float-gauge__water"></span><span class="float-gauge__bob"><i></i></span><strong>{items[0].activity_score}</strong><small>из 100</small></div><div><span>Индекс активности</span><strong data-activity-level={leaderLevel.short}>{leaderLevel.description}</strong><p>{items[0].explanation}</p></div></div><div class="metric-grid"><div><span><FishingIcon name="ripple"/></span><small>Уверенность</small><strong>{items[0].confidence_score}%</strong></div><div><span><FishingIcon name="angler"/></span><small>{plural(items[0].unique_players, ["Игрок", "Игрока", "Игроков"])}</small><strong>{items[0].unique_players}</strong></div><div><span><FishingIcon name="clock"/></span><small>Последний</small><strong>{ago(items[0].last_confirmed_at)}</strong></div><div><span><FishingIcon name="scale"/></span><small>Средний вес</small><strong>{kg(items[0].average_weight_g)}</strong></div></div><div class="best-lure"><span class="overline">Лучшая связка</span><div><FishingIcon name="lure" size={25}/><strong>{items[0].best_bait ?? "Не указана"}</strong><span>{items[0].catches} {plural(items[0].catches, ["улов", "улова", "уловов"])}</span></div></div><p class="confidence-note"><span>✓</span><span><strong>Оценка объяснима.</strong> Один игрок не может искусственно поднять уверенность.</span></p></aside>}
|
{items[0] && leaderLevel && <aside class="detail-card"><div class="detail-head"><div><span class="overline">Лидер активности</span><h2>{items[0].waterbody} <em>{items[0].x}:{items[0].y}</em></h2></div><a href={`/spots/${items[0].spot_id}`} aria-label="Открыть точку"><FishingIcon name="arrow"/></a></div><div class="source-strip">{items[0].sources.map(source => <SourceBadge source={source}/>)}</div><div class="detail-score"><div class="float-gauge" style={`--level:${items[0].activity_score}%`} aria-label={`Индекс активности: ${items[0].activity_score} из 100`}><span class="float-gauge__line"></span><span class="float-gauge__water"></span><span class="float-gauge__bob"><i></i></span><strong>{items[0].activity_score}</strong><small>из 100</small></div><div><span>Индекс активности</span><strong data-activity-level={leaderLevel.short}>{leaderLevel.description}</strong><p>{items[0].explanation}</p></div></div><div class="metric-grid"><div><span><FishingIcon name="ripple"/></span><small>Уверенность</small><strong>{items[0].confidence_score}%</strong></div><div><span><FishingIcon name="angler"/></span><small>{plural(items[0].unique_players, ["Игрок", "Игрока", "Игроков"])}</small><strong>{items[0].unique_players}</strong></div><div><span><FishingIcon name="clock"/></span><small>Последний</small><strong>{ago(items[0].last_confirmed_at)}</strong></div><div><span><FishingIcon name="scale"/></span><small>Средний вес</small><strong>{kg(items[0].average_weight_g)}</strong></div></div><div class="best-lure"><span class="overline">Лучшая связка</span><div><FishingIcon name="lure" size={25}/><strong>{items[0].best_bait ?? "Не указана"}</strong><span>{items[0].catches} {plural(items[0].catches, ["улов", "улова", "уловов"])}</span></div></div><p class="confidence-note"><span>✓</span><span><strong>Оценка объяснима.</strong> Один игрок не может искусственно поднять уверенность.</span></p></aside>}
|
||||||
</section>
|
</section>
|
||||||
{signals.length > 0 && <SignalFeed signals={signals}/>}
|
{signals.length > 0 && <SignalFeed signals={signals}/>}
|
||||||
|
|||||||
@@ -24,7 +24,9 @@ export const GET: APIRoute = async ({ site }) => {
|
|||||||
if (paths.size > 49000) throw new Error("Sitemap index required");
|
if (paths.size > 49000) throw new Error("Sitemap index required");
|
||||||
if (rows.length < 1000) break;
|
if (rows.length < 1000) break;
|
||||||
}
|
}
|
||||||
const xml = `<?xml version="1.0" encoding="UTF-8"?><urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">${[...paths].map(path => `<url><loc>${escapeXml(new URL(path, origin).toString())}</loc></url>`).join("")}</urlset>`;
|
// Normalize paths: never use trailing slash (S03)
|
||||||
|
const normalized = [...paths].map(p => p.replace(/\/$/, "") || "/");
|
||||||
|
const xml = `<?xml version="1.0" encoding="UTF-8"?><urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">${normalized.map(path => `<url><loc>${escapeXml(new URL(path, origin).toString())}</loc></url>`).join("")}</urlset>`;
|
||||||
lastGood = { origin, xml, at: Date.now() };
|
lastGood = { origin, xml, at: Date.now() };
|
||||||
return output(xml);
|
return output(xml);
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -123,6 +123,7 @@ services:
|
|||||||
IMPORT_INTERVAL_SECONDS: ${IMPORT_INTERVAL_SECONDS:-3600}
|
IMPORT_INTERVAL_SECONDS: ${IMPORT_INTERVAL_SECONDS:-3600}
|
||||||
PUBLIC_CACHE_SECONDS: ${PUBLIC_CACHE_SECONDS:-20}
|
PUBLIC_CACHE_SECONDS: ${PUBLIC_CACHE_SECONDS:-20}
|
||||||
RATE_LIMIT_SECRET: ${RATE_LIMIT_SECRET:?Set RATE_LIMIT_SECRET}
|
RATE_LIMIT_SECRET: ${RATE_LIMIT_SECRET:?Set RATE_LIMIT_SECRET}
|
||||||
|
TRUSTED_PROXY_CIDRS: '${TRUSTED_PROXY_CIDRS:-"127.0.0.1/32,::1/128"}'
|
||||||
RETENTION_SUBMISSION_DAYS: ${RETENTION_SUBMISSION_DAYS:-1}
|
RETENTION_SUBMISSION_DAYS: ${RETENTION_SUBMISSION_DAYS:-1}
|
||||||
RETENTION_UNREVIEWED_DAYS: ${RETENTION_UNREVIEWED_DAYS:-30}
|
RETENTION_UNREVIEWED_DAYS: ${RETENTION_UNREVIEWED_DAYS:-30}
|
||||||
RETENTION_APPROVED_PERSONAL_DAYS: ${RETENTION_APPROVED_PERSONAL_DAYS:-180}
|
RETENTION_APPROVED_PERSONAL_DAYS: ${RETENTION_APPROVED_PERSONAL_DAYS:-180}
|
||||||
@@ -184,6 +185,7 @@ services:
|
|||||||
S3_SECRET_KEY: ${S3_SECRET_KEY:?Set S3_SECRET_KEY}
|
S3_SECRET_KEY: ${S3_SECRET_KEY:?Set S3_SECRET_KEY}
|
||||||
SEED_DEMO_DATA: "false"
|
SEED_DEMO_DATA: "false"
|
||||||
RATE_LIMIT_SECRET: ${RATE_LIMIT_SECRET:?Set RATE_LIMIT_SECRET}
|
RATE_LIMIT_SECRET: ${RATE_LIMIT_SECRET:?Set RATE_LIMIT_SECRET}
|
||||||
|
TRUSTED_PROXY_CIDRS: '${TRUSTED_PROXY_CIDRS:-"127.0.0.1/32,::1/128"}'
|
||||||
COMMUNITY_IMPORT_INTERVAL_SECONDS: ${COMMUNITY_IMPORT_INTERVAL_SECONDS:-1800}
|
COMMUNITY_IMPORT_INTERVAL_SECONDS: ${COMMUNITY_IMPORT_INTERVAL_SECONDS:-1800}
|
||||||
RF4MAP_POINT_URL: ${RF4MAP_POINT_URL:-https://rf4map.ru/points/275}
|
RF4MAP_POINT_URL: ${RF4MAP_POINT_URL:-https://rf4map.ru/points/275}
|
||||||
RF4POSTS_SPOT_URL: ${RF4POSTS_SPOT_URL:-https://rf4-posts.com/ru/spots/d0c6d9c6-4ebf-49a7-98a8-9a562553a8ee}
|
RF4POSTS_SPOT_URL: ${RF4POSTS_SPOT_URL:-https://rf4-posts.com/ru/spots/d0c6d9c6-4ebf-49a7-98a8-9a562553a8ee}
|
||||||
|
|||||||
@@ -93,13 +93,30 @@ def mark_fetch(source: str, *, state_file: Path, now: float | None = None) -> No
|
|||||||
_write_state(state_file, state)
|
_write_state(state_file, state)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_url_host(url: str) -> str:
|
||||||
|
"""Validate URL hostname is in allowlist before making network call."""
|
||||||
|
hostname = (urlsplit(url).hostname or "").lower()
|
||||||
|
if hostname.startswith("www."):
|
||||||
|
hostname = hostname[4:]
|
||||||
|
if not hostname:
|
||||||
|
raise ValueError("URL must include a valid hostname")
|
||||||
|
if hostname not in ALLOWED_HOSTS:
|
||||||
|
raise ValueError(f"URL hostname {hostname} not in allowlist")
|
||||||
|
return hostname
|
||||||
|
|
||||||
|
|
||||||
def fetch_html(url: str, *, timeout: float = 30) -> str:
|
def fetch_html(url: str, *, timeout: float = 30) -> str:
|
||||||
|
# Validate host BEFORE network I/O to prevent SSRF to internal endpoints
|
||||||
|
_validate_url_host(url)
|
||||||
request = Request(url, headers={"User-Agent": USER_AGENT, "Accept": "text/html"})
|
request = Request(url, headers={"User-Agent": USER_AGENT, "Accept": "text/html"})
|
||||||
with urlopen(request, timeout=timeout) as response:
|
with urlopen(request, timeout=timeout) as response:
|
||||||
final_url = response.url
|
final_url = response.url
|
||||||
hostname = (urlsplit(final_url).hostname or "").lower()
|
# Also validate redirect target
|
||||||
if hostname not in ALLOWED_HOSTS:
|
final_hostname = (urlsplit(final_url).hostname or "").lower()
|
||||||
raise ValueError(f"URL hostname {hostname} not in allowlist")
|
if final_hostname.startswith("www."):
|
||||||
|
final_hostname = final_hostname[4:]
|
||||||
|
if final_hostname not in ALLOWED_HOSTS:
|
||||||
|
raise ValueError(f"Redirect hostname {final_hostname} not in allowlist")
|
||||||
if response.headers.get_content_type() != "text/html":
|
if response.headers.get_content_type() != "text/html":
|
||||||
raise ValueError(f"expected text/html, got {response.headers.get_content_type()}")
|
raise ValueError(f"expected text/html, got {response.headers.get_content_type()}")
|
||||||
data = response.read(MAX_RESPONSE_BYTES + 1)
|
data = response.read(MAX_RESPONSE_BYTES + 1)
|
||||||
|
|||||||
@@ -31,3 +31,29 @@ def test_failed_fetch_still_reserves_site_cooldown(tmp_path: Path, monkeypatch:
|
|||||||
monkeypatch.setattr(community_cli, "fetch_html", fail)
|
monkeypatch.setattr(community_cli, "fetch_html", fail)
|
||||||
assert community_cli.main(["rf4db", "--state-file", str(state_file)]) == 1
|
assert community_cli.main(["rf4db", "--state-file", str(state_file)]) == 1
|
||||||
assert "download.rf4db.com" in json.loads(state_file.read_text(encoding="utf-8"))
|
assert "download.rf4db.com" in json.loads(state_file.read_text(encoding="utf-8"))
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_url_host_rejects_disallowed_hosts() -> None:
|
||||||
|
from rf4_research.community_cli import _validate_url_host
|
||||||
|
|
||||||
|
# Allowed hosts pass
|
||||||
|
assert _validate_url_host("https://download.rf4db.com/ru/catches") == "download.rf4db.com"
|
||||||
|
assert _validate_url_host("https://www.rf4-stat.ru/posts/") == "rf4-stat.ru"
|
||||||
|
assert _validate_url_host("https://rf4map.ru/point/123") == "rf4map.ru"
|
||||||
|
|
||||||
|
# Disallowed hosts raise ValueError before network I/O
|
||||||
|
with pytest.raises(ValueError, match="not in allowlist"):
|
||||||
|
_validate_url_host("http://localhost:8080/admin")
|
||||||
|
with pytest.raises(ValueError, match="not in allowlist"):
|
||||||
|
_validate_url_host("http://169.254.169.254/latest/meta-data/")
|
||||||
|
with pytest.raises(ValueError, match="not in allowlist"):
|
||||||
|
_validate_url_host("http://internal-service.corp/api")
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_url_host_rejects_missing_hostname() -> None:
|
||||||
|
from rf4_research.community_cli import _validate_url_host
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="valid hostname"):
|
||||||
|
_validate_url_host("not-a-valid-url")
|
||||||
|
with pytest.raises(ValueError, match="valid hostname"):
|
||||||
|
_validate_url_host("")
|
||||||
|
|||||||
Reference in New Issue
Block a user