Compare commits

..
9 Commits
Author SHA1 Message Date
ik 4f68d6b004 D09: Import record event history for revision tracking
CI / backend-and-migrations (push) Canceled after 0s
CI / astro-build (push) Canceled after 0s
CI / dependency-audit (push) Canceled after 0s
CI / compose-e2e (push) Canceled after 0s
- Add ImportRecordEvent model to track per-record import changes
- Log created/updated events for each official record import
- Add alembic migration 0014 for import_record_event table
- Enables audit trail for which import run modified which records
2026-09-10 05:54:31 +07:00
ik 3ea08fa706 S03: Consistent site origin — trailingSlash never, canonical URLs
- Add trailingSlash: 'never' to Astro config
- Normalize sitemap paths to never use trailing slash
- Ensures consistent canonical URLs across all pages
- Prevents duplicate content from / vs /path/ variants
2026-09-10 05:53:36 +07:00
ik 2c7dd27b4f T08: Python lock files, CI web unit tests, dependency audit
- Generate requirements-lock.txt and requirements-dev-lock.txt via pip-compile
- CI uses locked files for reproducible installs
- Add web unit tests to CI (npm run test:unit)
- Add dependency-audit job using pip-audit
- Add Makefile with lock/lock-dev targets for regeneration
2026-09-10 05:53:15 +07:00
ik f550639456 R15: Fix D04/D06/D07 partial completion
D04: Add fish name-based fallback in _auto_publish (was external_id only)
D06: Cap confidence at 50% for 1 player, 65% for 2 players
D07: Set caught_at=None for community imports (not published_at)
D08: Already OK - activity_rows has no top-100 limit

- Add Fish import to community_importer.py
- Add 2 unit tests for D06 confidence caps
- Update test_community_importer.py for D04 name match behavior
2026-09-10 05:51:50 +07:00
ik e2bed0db89 R13: Fix X-Forwarded-For trust boundary — only trust from known proxies
- Add _is_trusted_proxy() to check client IP against trusted CIDRs
- Only use X-Forwarded-For if connection came from trusted proxy
- Add TRUSTED_PROXY_CIDRS config (default: 127.0.0.1/32, ::1/128)
- Add parse_comma_separated_lists for env var parsing
- Add 3 unit tests: trusted CIDR check, untrusted ignores forwarded, trusted uses forwarded
2026-09-10 05:49:23 +07:00
ik cc3b42eaf6 R12: Fix readiness — stale/failed community_scheduler blocks ready
- Add 'ready = ready and healthy' for community_scheduler check
- Add 'ready = False' for community_scheduler exception path
- Add 3 unit tests: success=ready, stale=not_ready, failed=not_ready
- Monitoring now correctly reports community import health
2026-09-10 05:46:49 +07:00
ik 39f66481be R11: Validate URL host before network I/O — prevent SSRF
- Add _validate_url_host() to check allowlist before urlopen()
- Validate both original URL and redirect target
- Reject localhost, internal IPs, and non-allowlisted hosts
- Add 2 unit tests for disallowed host rejection
- Prevents SSRF attacks via malicious source URLs
2026-09-10 05:45:57 +07:00
ik 6e0077bd4d R10: Fix TimeoutError handling — use DOMException.TimeoutError check
- AbortSignal.timeout() throws DOMException with name='TimeoutError', not TypeError
- Check for DOMException.TimeoutError, TypeError(fetch), or Error(abort)
- Apply same fix to report.ts and report-screenshot.ts
- Timeout redirects to 'timeout' state, other errors to 'create_error'
2026-09-10 05:43:34 +07:00
ik 67681ee039 R09: Fix pagination — preserve filters and use offset from URL
- Read offset from URL search params (default 0)
- Pass offset to /api/v1/activity instead of hardcoded 0
- Append items when offset > 0 (infinite scroll behavior)
- Fix 'load more' link to not duplicate URL params
- Astro check: 0 errors, Python tests: 97 passed
2026-09-10 05:41:53 +07:00
25 changed files with 628 additions and 43 deletions
+20 -4
View File
@@ -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
+9
View File
@@ -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")
+5
View File
@@ -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)
+13 -3
View File
@@ -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: 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( fish_alias = session.scalar(select(ExternalEntityAlias).where(
ExternalEntityAlias.source_system == observation.source_system, ExternalEntityAlias.source_system == observation.source_system,
ExternalEntityAlias.entity_type == "fish", ExternalEntityAlias.entity_type == "fish",
ExternalEntityAlias.external_id == observation.fish_external_id, ExternalEntityAlias.external_id == observation.fish_external_id,
)) ))
if fish_alias is None or fish_alias.fish is None: 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"
+1 -1
View File
@@ -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,
+12
View File
@@ -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")
+6 -2
View File
@@ -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
View File
@@ -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":
+12
View File
@@ -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()
+2
View File
@@ -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
+123
View File
@@ -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
+113
View File
@@ -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
+52
View File
@@ -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}"
+3 -1
View File
@@ -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}])
+61 -8
View File
@@ -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"]
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): for _ in range(5):
_check_rate_limit(mock_real, db) _check_rate_limit(mock_proxy, db)
with pytest.raises(HTTPException) as blocked: with pytest.raises(HTTPException) as blocked:
mock_other = MagicMock() mock_other = MagicMock()
mock_other.client.host = "10.0.0.2" mock_other.client.host = "127.0.0.1"
mock_other.headers.get.return_value = "198.51.100.10" mock_other.headers.get.return_value = "198.51.100.10"
_check_rate_limit(mock_other, db) _check_rate_limit(mock_other, db)
assert blocked.value.status_code == 429 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
+66 -1
View File
@@ -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"
+1
View File
@@ -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 },
+6 -1
View File
@@ -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);
}
}; };
+4 -1
View File
@@ -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 -5
View File
@@ -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}/>}
+3 -1
View File
@@ -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 {
+2
View File
@@ -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}
+20 -3
View File
@@ -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)
+26
View File
@@ -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("")