Compare commits
9
Commits
51b3eb5e17
...
4f68d6b004
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4f68d6b004 | ||
|
|
3ea08fa706 | ||
|
|
2c7dd27b4f | ||
|
|
f550639456 | ||
|
|
e2bed0db89 | ||
|
|
cc3b42eaf6 | ||
|
|
39f66481be | ||
|
|
6e0077bd4d | ||
|
|
67681ee039 |
+20
-4
@@ -29,10 +29,10 @@ jobs:
|
||||
with:
|
||||
python-version: "3.12"
|
||||
cache: pip
|
||||
- name: Install Python dependencies
|
||||
- name: Install Python dependencies (locked)
|
||||
run: |
|
||||
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 .
|
||||
- name: Apply migrations to clean PostgreSQL
|
||||
working-directory: apps/api
|
||||
@@ -54,9 +54,25 @@ jobs:
|
||||
- name: Install web dependencies
|
||||
working-directory: apps/web
|
||||
run: npm ci
|
||||
- name: Check and build Astro
|
||||
- name: Astro check, build and unit tests
|
||||
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:
|
||||
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))
|
||||
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)
|
||||
# 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)
|
||||
baits = Counter(r.bait.name for r in items if r.bait)
|
||||
freshness_text = _freshness_text(now - latest)
|
||||
|
||||
@@ -9,7 +9,7 @@ from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
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 = {
|
||||
@@ -112,12 +112,22 @@ def _auto_publish(session: Session, observation: ExternalObservation) -> bool:
|
||||
or observation.weight_g is None
|
||||
):
|
||||
return False
|
||||
fish_alias = session.scalar(select(ExternalEntityAlias).where(
|
||||
ExternalEntityAlias.source_system == observation.source_system,
|
||||
ExternalEntityAlias.entity_type == "fish",
|
||||
ExternalEntityAlias.external_id == observation.fish_external_id,
|
||||
))
|
||||
if fish_alias is None or fish_alias.fish is None:
|
||||
# Fish: prefer external alias, fall back to exact name match
|
||||
fish = None
|
||||
if observation.fish_external_id is not None:
|
||||
fish_alias = session.scalar(select(ExternalEntityAlias).where(
|
||||
ExternalEntityAlias.source_system == observation.source_system,
|
||||
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
|
||||
# Waterbody: prefer external alias, fall back to exact name match
|
||||
waterbody = None
|
||||
@@ -136,7 +146,7 @@ def _auto_publish(session: Session, observation: ExternalObservation) -> bool:
|
||||
)
|
||||
if waterbody is None:
|
||||
return False
|
||||
observation.fish = fish_alias.fish
|
||||
observation.fish = fish
|
||||
observation.waterbody = waterbody
|
||||
observation.status = "ready"
|
||||
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"),
|
||||
retrieve_method=observation.payload.get("retrieve_method"),
|
||||
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,
|
||||
player_name=observation.payload.get("player_name"),
|
||||
source_type=SourceType.manual_import,
|
||||
|
||||
@@ -33,6 +33,18 @@ class Settings(BaseSettings):
|
||||
rate_limit_secret: str = "change-rate-limit-secret"
|
||||
log_level: str = "INFO"
|
||||
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_validator(mode="after")
|
||||
|
||||
@@ -13,7 +13,7 @@ from sqlalchemy.orm import Session
|
||||
from rf4_research.official_parser import RecordsContractError, parse_official_records
|
||||
|
||||
from .models import (
|
||||
Bait, BaitKind, CatchReport, Fish, ImportStatus, ModerationStatus,
|
||||
Bait, BaitKind, CatchReport, Fish, ImportRecordEvent, ImportStatus, ModerationStatus,
|
||||
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
|
||||
payload = asdict(raw) | {"record_date": raw.record_date.isoformat()}
|
||||
caught = datetime.combine(raw.record_date, time(), tzinfo=timezone.utc)
|
||||
now = datetime.now(timezone.utc)
|
||||
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
|
||||
else:
|
||||
report.raw_payload = payload
|
||||
report.source_url = url
|
||||
session.add(ImportRecordEvent(catch_report=report, import_run=run, event_type="updated", created_at=now))
|
||||
run.rows_updated += 1
|
||||
run.status = ImportStatus.success
|
||||
run.finished_at = datetime.now(timezone.utc)
|
||||
|
||||
+19
-1
@@ -2,6 +2,7 @@ from __future__ import annotations
|
||||
|
||||
from collections import Counter
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from ipaddress import IPv4Address, IPv6Address, IPv4Network, IPv6Network
|
||||
import hashlib
|
||||
import hmac
|
||||
import logging
|
||||
@@ -538,13 +539,30 @@ def delete_report(report_id: UUID, db: Db, moderator: Annotated[str, Depends(_ad
|
||||
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:
|
||||
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:
|
||||
# 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_hash = hmac.new(settings.rate_limit_secret.encode(), client.encode(), hashlib.sha256).hexdigest()
|
||||
if db.get_bind().dialect.name == "postgresql":
|
||||
|
||||
@@ -212,3 +212,15 @@ class ExternalEntityAlias(Base):
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
|
||||
fish: Mapped[Fish | 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),
|
||||
"last_started_at": started.isoformat(),
|
||||
}
|
||||
ready = ready and healthy
|
||||
except Exception:
|
||||
components["community_scheduler"] = {"status": "unknown"}
|
||||
ready = False
|
||||
|
||||
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()
|
||||
|
||||
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="Тестовое озеро")
|
||||
db.add_all([fish, water])
|
||||
db.commit()
|
||||
# D04: auto-publish now works with name match fallback
|
||||
stage_observations(db, [record() | {"weight_g": 5000}])
|
||||
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_id = report.id
|
||||
stage_observations(db, [record() | {"weight_g": 6000}])
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from unittest.mock import MagicMock
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
@@ -9,7 +9,7 @@ from sqlalchemy import create_engine, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
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://")
|
||||
Base.metadata.create_all(engine)
|
||||
with Session(engine) as db:
|
||||
mock_real = MagicMock()
|
||||
mock_real.client.host = "10.0.0.1"
|
||||
mock_real.headers.get.return_value = "198.51.100.10"
|
||||
for _ in range(5):
|
||||
_check_rate_limit(mock_real, db)
|
||||
with pytest.raises(HTTPException) as blocked:
|
||||
mock_other = MagicMock()
|
||||
mock_other.client.host = "10.0.0.2"
|
||||
mock_other.headers.get.return_value = "198.51.100.10"
|
||||
_check_rate_limit(mock_other, db)
|
||||
assert blocked.value.status_code == 429
|
||||
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"]
|
||||
mock_proxy = MagicMock()
|
||||
mock_proxy.client.host = "127.0.0.1"
|
||||
mock_proxy.headers.get.return_value = "198.51.100.10"
|
||||
for _ in range(5):
|
||||
_check_rate_limit(mock_proxy, db)
|
||||
with pytest.raises(HTTPException) as blocked:
|
||||
mock_other = MagicMock()
|
||||
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 app.database import Base
|
||||
from app.models import ImportStatus, OfficialRecordImport
|
||||
from app.models import ImportStatus, CommunityImportRun, OfficialRecordImport
|
||||
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 components["minio"]["status"] == "unavailable"
|
||||
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({
|
||||
site: process.env.PUBLIC_SITE_URL || "https://rf4spotter.ru",
|
||||
trailingSlash: "never",
|
||||
output: "server",
|
||||
adapter: node({ mode: "standalone" }),
|
||||
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) });
|
||||
if (response.ok) cookies.delete(cookieName, {path:"/"});
|
||||
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);
|
||||
}
|
||||
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});
|
||||
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 fish = params.get("fish") ?? "";
|
||||
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 signalLimit = Number.isInteger(requestedSignalLimit) ? Math.min(48, Math.max(12, requestedSignalLimit)) : 12;
|
||||
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);
|
||||
let unavailable = false, showNoIndex = false;
|
||||
if (filterError) { showNoIndex = true; Astro.response.status = 422; }
|
||||
if (unavailable) { showNoIndex = true; Astro.response.status = 503; Astro.response.headers.set("Retry-After", "60"); }
|
||||
try {
|
||||
let signalRows: PublicObservation[] = [];
|
||||
const signalParams = new URLSearchParams({ limit: String(signalLimit + 1) });
|
||||
@@ -28,12 +29,17 @@ try {
|
||||
hasMoreSignals = signalRows.length > signalLimit;
|
||||
signals = signalRows.slice(0, signalLimit);
|
||||
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}`);
|
||||
items = paginated.items;
|
||||
items = offset > 0 ? [...items, ...paginated.items] : paginated.items;
|
||||
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 selectedWaterbody = waterbodies.find(item => item.slug === waterbody)?.name_ru ?? "Все водоёмы";
|
||||
const selectedFish = fishes.find(item => item.slug === fish)?.name_ru ?? "Любая рыба";
|
||||
@@ -63,7 +69,7 @@ const datasetJsonLd = {
|
||||
<button>⌕ Найти клёв</button>
|
||||
</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>
|
||||
<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>}
|
||||
</section>
|
||||
{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 (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() };
|
||||
return output(xml);
|
||||
} catch {
|
||||
|
||||
@@ -123,6 +123,7 @@ services:
|
||||
IMPORT_INTERVAL_SECONDS: ${IMPORT_INTERVAL_SECONDS:-3600}
|
||||
PUBLIC_CACHE_SECONDS: ${PUBLIC_CACHE_SECONDS:-20}
|
||||
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_UNREVIEWED_DAYS: ${RETENTION_UNREVIEWED_DAYS:-30}
|
||||
RETENTION_APPROVED_PERSONAL_DAYS: ${RETENTION_APPROVED_PERSONAL_DAYS:-180}
|
||||
@@ -184,6 +185,7 @@ services:
|
||||
S3_SECRET_KEY: ${S3_SECRET_KEY:?Set S3_SECRET_KEY}
|
||||
SEED_DEMO_DATA: "false"
|
||||
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}
|
||||
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}
|
||||
|
||||
@@ -93,13 +93,30 @@ def mark_fetch(source: str, *, state_file: Path, now: float | None = None) -> No
|
||||
_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:
|
||||
# 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"})
|
||||
with urlopen(request, timeout=timeout) as response:
|
||||
final_url = response.url
|
||||
hostname = (urlsplit(final_url).hostname or "").lower()
|
||||
if hostname not in ALLOWED_HOSTS:
|
||||
raise ValueError(f"URL hostname {hostname} not in allowlist")
|
||||
# Also validate redirect target
|
||||
final_hostname = (urlsplit(final_url).hostname or "").lower()
|
||||
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":
|
||||
raise ValueError(f"expected text/html, got {response.headers.get_content_type()}")
|
||||
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)
|
||||
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"))
|
||||
|
||||
|
||||
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