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:
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
+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))
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)
+18 -8
View File
@@ -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"
+1 -1
View File
@@ -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,
+12
View File
@@ -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")
+6 -2
View File
@@ -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
View File
@@ -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":
+12
View File
@@ -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()
+2
View File
@@ -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
+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()
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="Тестовое озеро")
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}])
+67 -14
View File
@@ -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
+66 -1
View File
@@ -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"
+1
View File
@@ -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 },
+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) });
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);
}
};
+4 -1
View File
@@ -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 -5
View File
@@ -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}/>}
+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 (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 {
+2
View File
@@ -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}
+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)
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)
+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)
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("")