Compare commits
4
Commits
a4b51027e2
...
870d9cc7f9
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
870d9cc7f9 | ||
|
|
a4bd395856 | ||
|
|
b45ba38a00 | ||
|
|
366ff83a60 |
@@ -0,0 +1,27 @@
|
|||||||
|
# DNS: A/AAAA records for both names must point to the deployment server.
|
||||||
|
SITE_DOMAIN=rf4spotter.ru
|
||||||
|
FILES_DOMAIN=files.rf4spotter.ru
|
||||||
|
ACME_EMAIL=admin@rf4spotter.ru
|
||||||
|
|
||||||
|
POSTGRES_DB=rf4_spotter
|
||||||
|
POSTGRES_USER=rf4
|
||||||
|
POSTGRES_PASSWORD=replace-with-long-random-value
|
||||||
|
# URL-encode special characters from POSTGRES_PASSWORD in this URL.
|
||||||
|
DATABASE_URL=postgresql+psycopg://rf4:replace-with-url-encoded-password@db:5432/rf4_spotter
|
||||||
|
|
||||||
|
ADMIN_TOKEN=replace-with-at-least-32-random-characters
|
||||||
|
RATE_LIMIT_SECRET=replace-with-at-least-32-random-characters
|
||||||
|
ADMIN_BASIC_USER=rf4admin
|
||||||
|
# Generate with: docker run --rm caddy:2.10.2-alpine caddy hash-password --plaintext 'YOUR PASSWORD'
|
||||||
|
ADMIN_BASIC_PASSWORD_HASH=replace-with-caddy-password-hash
|
||||||
|
|
||||||
|
S3_ACCESS_KEY=replace-with-random-access-key
|
||||||
|
S3_SECRET_KEY=replace-with-at-least-32-random-characters
|
||||||
|
S3_BUCKET=catch-screenshots
|
||||||
|
|
||||||
|
OFFICIAL_RECORDS_URL=https://rf4game.de/records/region/RU/
|
||||||
|
OFFICIAL_RECORDS_REGION=RU
|
||||||
|
OFFICIAL_RECORDS_CATEGORY=records
|
||||||
|
OFFICIAL_IMPORT_REQUIRED=false
|
||||||
|
IMPORT_INTERVAL_SECONDS=3600
|
||||||
|
LOG_LEVEL=INFO
|
||||||
@@ -2,6 +2,7 @@ __pycache__/
|
|||||||
*.py[cod]
|
*.py[cod]
|
||||||
.pytest_cache/
|
.pytest_cache/
|
||||||
.cache/
|
.cache/
|
||||||
|
.env.production
|
||||||
.venv/
|
.venv/
|
||||||
node_modules/
|
node_modules/
|
||||||
dist/
|
dist/
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ RF4 Spotter — неофициальный сервис свежих точек
|
|||||||
|
|
||||||
Подробный план и актуальные чекбоксы находятся в [`docs/ROADMAP.md`](docs/ROADMAP.md). Результаты проверки интерфейса и пять приоритетных UX-пакетов описаны в [`docs/UI_UX_AUDIT.md`](docs/UI_UX_AUDIT.md).
|
Подробный план и актуальные чекбоксы находятся в [`docs/ROADMAP.md`](docs/ROADMAP.md). Результаты проверки интерфейса и пять приоритетных UX-пакетов описаны в [`docs/UI_UX_AUDIT.md`](docs/UI_UX_AUDIT.md).
|
||||||
|
|
||||||
|
Production-контур для домена `rf4spotter.ru`, TLS, секреты, backup/restore и команды первого запуска описаны в [`deploy/README.md`](deploy/README.md). Он использует отдельный `compose.production.yaml`; локальный `compose.yaml` остаётся средой разработки. Production seed добавляет только справочники — демонстрационные уловы отключены.
|
||||||
|
|
||||||
Gitea Actions workflow `.gitea/workflows/ci.yml` на каждый push и pull request проверяет Python, миграции на чистой PostgreSQL, Astro build и полный Compose/Playwright-сценарий. При падении E2E сохраняются логи контейнеров и Playwright-артефакты.
|
Gitea Actions workflow `.gitea/workflows/ci.yml` на каждый push и pull request проверяет Python, миграции на чистой PostgreSQL, Astro build и полный Compose/Playwright-сценарий. При падении E2E сохраняются логи контейнеров и Playwright-артефакты.
|
||||||
|
|
||||||
Актуальная инвентаризация источников и правила подключения адаптеров находятся в [`docs/data-source-audit.md`](docs/data-source-audit.md). Разрешённый технический пилот RF4DB/RF4-STAT описан в [`docs/community-source-pilot.md`](docs/community-source-pilot.md), а статус разрешений и лимитов — в [`docs/data-permissions.md`](docs/data-permissions.md). Данные сохраняются только в промежуточный staging и не влияют на индекс без явной проверки и публикации администратором.
|
Актуальная инвентаризация источников и правила подключения адаптеров находятся в [`docs/data-source-audit.md`](docs/data-source-audit.md). Разрешённый технический пилот RF4DB/RF4-STAT описан в [`docs/community-source-pilot.md`](docs/community-source-pilot.md), а статус разрешений и лимитов — в [`docs/data-permissions.md`](docs/data-permissions.md). Данные сохраняются только в промежуточный staging и не влияют на индекс без явной проверки и публикации администратором.
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
"""Protect pending screenshot uploads with a one-time token."""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision = "0010"
|
||||||
|
down_revision = "0009"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column("catch_report", sa.Column("screenshot_upload_token_hash", sa.String(64)))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("catch_report", "screenshot_upload_token_hash")
|
||||||
+25
-1
@@ -1,8 +1,9 @@
|
|||||||
from pydantic import Field
|
from pydantic import Field, model_validator
|
||||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
|
|
||||||
class Settings(BaseSettings):
|
class Settings(BaseSettings):
|
||||||
|
deployment_environment: str = "development"
|
||||||
database_url: str = "postgresql+psycopg://rf4:rf4_local@localhost:5432/rf4_spotter"
|
database_url: str = "postgresql+psycopg://rf4:rf4_local@localhost:5432/rf4_spotter"
|
||||||
admin_token: str = "change-me-in-production"
|
admin_token: str = "change-me-in-production"
|
||||||
s3_endpoint_url: str = "http://localhost:9000"
|
s3_endpoint_url: str = "http://localhost:9000"
|
||||||
@@ -15,10 +16,33 @@ class Settings(BaseSettings):
|
|||||||
official_records_region: str = "RU"
|
official_records_region: str = "RU"
|
||||||
official_records_category: str = "records"
|
official_records_category: str = "records"
|
||||||
official_import_required: bool = False
|
official_import_required: bool = False
|
||||||
|
seed_demo_data: bool = True
|
||||||
import_interval_seconds: int = Field(default=3600, ge=3600)
|
import_interval_seconds: int = Field(default=3600, ge=3600)
|
||||||
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"])
|
||||||
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
|
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def reject_insecure_production_defaults(self) -> "Settings":
|
||||||
|
if self.deployment_environment != "production":
|
||||||
|
return self
|
||||||
|
insecure = {
|
||||||
|
"ADMIN_TOKEN": self.admin_token == "change-me-in-production" or len(self.admin_token) < 32,
|
||||||
|
"RATE_LIMIT_SECRET": self.rate_limit_secret == "change-rate-limit-secret" or len(self.rate_limit_secret) < 32,
|
||||||
|
"S3_ACCESS_KEY": self.s3_access_key == "rf4-local" or len(self.s3_access_key) < 12,
|
||||||
|
"S3_SECRET_KEY": self.s3_secret_key == "rf4-local-secret" or len(self.s3_secret_key) < 32,
|
||||||
|
}
|
||||||
|
invalid = [name for name, failed in insecure.items() if failed]
|
||||||
|
if invalid:
|
||||||
|
raise ValueError(f"insecure production settings: {', '.join(invalid)}")
|
||||||
|
if not self.cors_origins or any(not origin.startswith("https://") for origin in self.cors_origins):
|
||||||
|
raise ValueError("production CORS_ORIGINS must contain only HTTPS origins")
|
||||||
|
if not self.s3_public_endpoint_url.startswith("https://"):
|
||||||
|
raise ValueError("production S3_PUBLIC_ENDPOINT_URL must use HTTPS")
|
||||||
|
if self.seed_demo_data:
|
||||||
|
raise ValueError("SEED_DEMO_DATA must be false in production")
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
settings = Settings()
|
settings = Settings()
|
||||||
|
|||||||
+21
-7
@@ -5,6 +5,7 @@ from datetime import datetime, timedelta, timezone
|
|||||||
import hashlib
|
import hashlib
|
||||||
import hmac
|
import hmac
|
||||||
import logging
|
import logging
|
||||||
|
import secrets
|
||||||
import time as time_module
|
import time as time_module
|
||||||
from typing import Annotated, Literal
|
from typing import Annotated, Literal
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
@@ -25,7 +26,7 @@ from .importer import ImportSourceError, import_records, normalize
|
|||||||
from .logging_config import configure_logging
|
from .logging_config import configure_logging
|
||||||
from .models import Bait, BaitKind, CatchReport, ExternalObservation, Fish, ModerationEvent, ModerationStatus, OfficialRecordImport, SourceType, Spot, SubmissionAttempt, Waterbody
|
from .models import Bait, BaitKind, CatchReport, ExternalObservation, Fish, ModerationEvent, ModerationStatus, OfficialRecordImport, SourceType, Spot, SubmissionAttempt, Waterbody
|
||||||
from .readiness import readiness_report
|
from .readiness import readiness_report
|
||||||
from .schemas import ActivityOut, AdminCatchReportOut, BaitOut, CatchOut, CatchReportCreate, CatchReportCreated, ExternalObservationDecision, ExternalObservationMapping, ExternalObservationOut, ExternalObservationPublished, FishOut, ImportRunOut, ModerationUpdate, OfficialRecordOut, SpotOut, WaterbodyOut
|
from .schemas import ActivityOut, AdminCatchReportOut, BaitOut, CatchOut, CatchReportAccepted, CatchReportCreate, CatchReportCreated, ExternalObservationDecision, ExternalObservationMapping, ExternalObservationOut, ExternalObservationPublished, FishOut, ImportRunOut, ModerationUpdate, OfficialRecordOut, SpotOut, WaterbodyOut
|
||||||
from .storage import ScreenshotError, client as storage_client, delete_screenshot, signed_screenshot_url, upload_screenshot
|
from .storage import ScreenshotError, client as storage_client, delete_screenshot, signed_screenshot_url, upload_screenshot
|
||||||
|
|
||||||
|
|
||||||
@@ -34,7 +35,7 @@ logger = logging.getLogger("rf4.api")
|
|||||||
app = FastAPI(title="RF4 Spotter API", version="0.1.0")
|
app = FastAPI(title="RF4 Spotter API", version="0.1.0")
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
allow_origins=["http://localhost:4321", "http://127.0.0.1:4321"],
|
allow_origins=settings.cors_origins,
|
||||||
allow_methods=["GET", "POST", "PATCH", "DELETE"],
|
allow_methods=["GET", "POST", "PATCH", "DELETE"],
|
||||||
allow_headers=["Authorization", "Content-Type"],
|
allow_headers=["Authorization", "Content-Type"],
|
||||||
)
|
)
|
||||||
@@ -50,6 +51,11 @@ async def structured_request_log(request: Request, call_next):
|
|||||||
response = await call_next(request)
|
response = await call_next(request)
|
||||||
status_code = response.status_code
|
status_code = response.status_code
|
||||||
response.headers["X-Request-ID"] = request_id
|
response.headers["X-Request-ID"] = request_id
|
||||||
|
response.headers["X-Content-Type-Options"] = "nosniff"
|
||||||
|
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
|
||||||
|
response.headers["Permissions-Policy"] = "camera=(), microphone=(), geolocation=()"
|
||||||
|
if settings.deployment_environment == "production":
|
||||||
|
response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains"
|
||||||
return response
|
return response
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.error("request failed", extra={"request_id": request_id, "error_type": type(exc).__name__})
|
logger.error("request failed", extra={"request_id": request_id, "error_type": type(exc).__name__})
|
||||||
@@ -268,8 +274,8 @@ def admin_reject_external_observation(
|
|||||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/v1/catch-reports", response_model=CatchReportCreated, status_code=201)
|
@app.post("/api/v1/catch-reports", response_model=CatchReportAccepted, status_code=201)
|
||||||
def create_catch_report(payload: CatchReportCreate, request: Request, db: Db) -> CatchReportCreated:
|
def create_catch_report(payload: CatchReportCreate, request: Request, db: Db) -> CatchReportAccepted:
|
||||||
if payload.website:
|
if payload.website:
|
||||||
raise HTTPException(status_code=400, detail="invalid submission")
|
raise HTTPException(status_code=400, detail="invalid submission")
|
||||||
_check_rate_limit(request.client.host if request.client else "unknown", db)
|
_check_rate_limit(request.client.host if request.client else "unknown", db)
|
||||||
@@ -288,17 +294,24 @@ def create_catch_report(payload: CatchReportCreate, request: Request, db: Db) ->
|
|||||||
if bait is None:
|
if bait is None:
|
||||||
bait = Bait(name=payload.bait_name.strip(), normalized_name=key, kind=BaitKind.unknown)
|
bait = Bait(name=payload.bait_name.strip(), normalized_name=key, kind=BaitKind.unknown)
|
||||||
db.add(bait)
|
db.add(bait)
|
||||||
report = CatchReport(fish=fish, spot=spot, waterbody=waterbody, bait=bait, weight_g=payload.weight_g, fishing_method=payload.fishing_method, rig_type=payload.rig_type, retrieve_method=payload.retrieve_method, retrieve_speed=payload.retrieve_speed, caught_at=payload.caught_at, reported_at=datetime.now(timezone.utc), player_name=payload.player_name, source_type=SourceType.user, source_url=payload.source_url, source_confidence=60, moderation_status=ModerationStatus.pending, raw_payload={"comment": payload.comment} if payload.comment else None)
|
upload_token = secrets.token_urlsafe(32)
|
||||||
|
report = CatchReport(fish=fish, spot=spot, waterbody=waterbody, bait=bait, weight_g=payload.weight_g, fishing_method=payload.fishing_method, rig_type=payload.rig_type, retrieve_method=payload.retrieve_method, retrieve_speed=payload.retrieve_speed, caught_at=payload.caught_at, reported_at=datetime.now(timezone.utc), player_name=payload.player_name, source_type=SourceType.user, source_url=payload.source_url, source_confidence=60, moderation_status=ModerationStatus.pending, raw_payload={"comment": payload.comment} if payload.comment else None, screenshot_upload_token_hash=hashlib.sha256(upload_token.encode()).hexdigest())
|
||||||
db.add(report)
|
db.add(report)
|
||||||
db.commit()
|
db.commit()
|
||||||
return CatchReportCreated(id=report.id, moderation_status=report.moderation_status.value)
|
return CatchReportAccepted(id=report.id, moderation_status=report.moderation_status.value, screenshot_upload_token=upload_token)
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/v1/catch-reports/{report_id}/screenshot", status_code=204, response_class=Response)
|
@app.post("/api/v1/catch-reports/{report_id}/screenshot", status_code=204, response_class=Response)
|
||||||
def add_screenshot(report_id: UUID, db: Db, screenshot: UploadFile = File()) -> Response:
|
def add_screenshot(
|
||||||
|
report_id: UUID, db: Db, screenshot: UploadFile = File(),
|
||||||
|
upload_token: Annotated[str | None, Header(alias="X-Upload-Token")] = None,
|
||||||
|
) -> Response:
|
||||||
report = db.get(CatchReport, report_id)
|
report = db.get(CatchReport, report_id)
|
||||||
if report is None or report.source_type != SourceType.user or report.moderation_status != ModerationStatus.pending:
|
if report is None or report.source_type != SourceType.user or report.moderation_status != ModerationStatus.pending:
|
||||||
raise HTTPException(status_code=404, detail="pending catch report not found")
|
raise HTTPException(status_code=404, detail="pending catch report not found")
|
||||||
|
supplied_hash = hashlib.sha256((upload_token or "").encode()).hexdigest()
|
||||||
|
if not report.screenshot_upload_token_hash or not hmac.compare_digest(report.screenshot_upload_token_hash, supplied_hash):
|
||||||
|
raise HTTPException(status_code=401, detail="invalid screenshot upload token")
|
||||||
if report.screenshot_key:
|
if report.screenshot_key:
|
||||||
raise HTTPException(status_code=409, detail="screenshot already uploaded")
|
raise HTTPException(status_code=409, detail="screenshot already uploaded")
|
||||||
raw = screenshot.file.read(settings.screenshot_max_bytes + 1)
|
raw = screenshot.file.read(settings.screenshot_max_bytes + 1)
|
||||||
@@ -306,6 +319,7 @@ def add_screenshot(report_id: UUID, db: Db, screenshot: UploadFile = File()) ->
|
|||||||
report.screenshot_key = upload_screenshot(raw, filename=screenshot.filename, content_type=screenshot.content_type)
|
report.screenshot_key = upload_screenshot(raw, filename=screenshot.filename, content_type=screenshot.content_type)
|
||||||
except ScreenshotError as exc:
|
except ScreenshotError as exc:
|
||||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||||
|
report.screenshot_upload_token_hash = None
|
||||||
db.commit()
|
db.commit()
|
||||||
return Response(status_code=204)
|
return Response(status_code=204)
|
||||||
|
|
||||||
|
|||||||
@@ -92,6 +92,7 @@ class CatchReport(Base):
|
|||||||
source_confidence: Mapped[int]
|
source_confidence: Mapped[int]
|
||||||
moderation_status: Mapped[ModerationStatus] = mapped_column(Enum(ModerationStatus))
|
moderation_status: Mapped[ModerationStatus] = mapped_column(Enum(ModerationStatus))
|
||||||
screenshot_key: Mapped[str | None] = mapped_column(Text)
|
screenshot_key: Mapped[str | None] = mapped_column(Text)
|
||||||
|
screenshot_upload_token_hash: Mapped[str | None] = mapped_column(String(64))
|
||||||
deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
raw_payload: Mapped[dict | None] = mapped_column(JSON)
|
raw_payload: Mapped[dict | None] = mapped_column(JSON)
|
||||||
fish: Mapped[Fish] = relationship()
|
fish: Mapped[Fish] = relationship()
|
||||||
|
|||||||
@@ -137,6 +137,10 @@ class CatchReportCreated(BaseModel):
|
|||||||
moderation_status: str
|
moderation_status: str
|
||||||
|
|
||||||
|
|
||||||
|
class CatchReportAccepted(CatchReportCreated):
|
||||||
|
screenshot_upload_token: str
|
||||||
|
|
||||||
|
|
||||||
class AdminCatchReportOut(BaseModel):
|
class AdminCatchReportOut(BaseModel):
|
||||||
id: UUID
|
id: UUID
|
||||||
fish: str
|
fish: str
|
||||||
|
|||||||
+35
-10
@@ -5,6 +5,7 @@ from uuid import UUID
|
|||||||
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from .config import settings
|
||||||
from .database import SessionLocal
|
from .database import SessionLocal
|
||||||
from .models import Bait, BaitKind, CatchReport, Fish, ModerationStatus, SourceType, Spot, Waterbody
|
from .models import Bait, BaitKind, CatchReport, Fish, ModerationStatus, SourceType, Spot, Waterbody
|
||||||
|
|
||||||
@@ -23,19 +24,38 @@ IDS = {
|
|||||||
|
|
||||||
def seed() -> None:
|
def seed() -> None:
|
||||||
with SessionLocal.begin() as db:
|
with SessionLocal.begin() as db:
|
||||||
if db.scalar(select(Fish.id).limit(1)) is not None:
|
def entity(model, key: str, value: str, **values):
|
||||||
|
item = db.scalar(select(model).where(getattr(model, key) == value))
|
||||||
|
if item is None:
|
||||||
|
item = model(**values)
|
||||||
|
db.add(item)
|
||||||
|
db.flush()
|
||||||
|
return item
|
||||||
|
|
||||||
|
vyunok = entity(Waterbody, "slug", "vyunok", id=IDS["vyunok"], slug="vyunok", name_ru="Вьюнок", unlock_level=1)
|
||||||
|
kuori = entity(Waterbody, "slug", "kuori", id=IDS["kuori"], slug="kuori", name_ru="Куори", unlock_level=16)
|
||||||
|
pike = entity(Fish, "slug", "pike", id=IDS["pike"], slug="pike", name_ru="Щука", trophy_weight_g=10_000)
|
||||||
|
trout = entity(Fish, "slug", "lake-trout", id=IDS["trout"], slug="lake-trout", name_ru="Озёрная форель", trophy_weight_g=10_000)
|
||||||
|
spiker = entity(Bait, "normalized_name", "spiker #2 01-015", id=IDS["spiker"], name="Spiker #2 01-015", normalized_name="spiker #2 01-015", kind=BaitKind.lure)
|
||||||
|
shad = entity(Bait, "normalized_name", "salmon t1 shad 12 005", id=IDS["shad"], name="Salmon T1 Shad 12 005", normalized_name="salmon t1 shad 12 005", kind=BaitKind.lure)
|
||||||
|
|
||||||
|
def spot(waterbody: Waterbody, identity: str, x: int, y: int, description: str) -> Spot:
|
||||||
|
item = db.scalar(select(Spot).where(Spot.waterbody_id == waterbody.id, Spot.x == x, Spot.y == y))
|
||||||
|
if item is None:
|
||||||
|
item = Spot(id=IDS[identity], waterbody=waterbody, x=x, y=y, description=description)
|
||||||
|
db.add(item)
|
||||||
|
db.flush()
|
||||||
|
return item
|
||||||
|
|
||||||
|
spot1 = spot(vyunok, "spot1", 110, 103, "Кромка травы у северного берега")
|
||||||
|
spot2 = spot(kuori, "spot2", 85, 92, "Свальчик в глубину")
|
||||||
|
if not settings.seed_demo_data:
|
||||||
return
|
return
|
||||||
vyunok = Waterbody(id=IDS["vyunok"], slug="vyunok", name_ru="Вьюнок", unlock_level=1)
|
|
||||||
kuori = Waterbody(id=IDS["kuori"], slug="kuori", name_ru="Куори", unlock_level=16)
|
|
||||||
pike = Fish(id=IDS["pike"], slug="pike", name_ru="Щука", trophy_weight_g=10_000)
|
|
||||||
trout = Fish(id=IDS["trout"], slug="lake-trout", name_ru="Озёрная форель", trophy_weight_g=10_000)
|
|
||||||
spiker = Bait(id=IDS["spiker"], name="Spiker #2 01-015", normalized_name="spiker #2 01-015", kind=BaitKind.lure)
|
|
||||||
shad = Bait(id=IDS["shad"], name="Salmon T1 Shad 12 005", normalized_name="salmon t1 shad 12 005", kind=BaitKind.lure)
|
|
||||||
spot1 = Spot(id=IDS["spot1"], waterbody=vyunok, x=110, y=103, description="Кромка травы у северного берега")
|
|
||||||
spot2 = Spot(id=IDS["spot2"], waterbody=kuori, x=85, y=92, description="Свальчик в глубину")
|
|
||||||
db.add_all([vyunok, kuori, pike, trout, spiker, shad, spot1, spot2])
|
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
for index in range(12):
|
for index in range(12):
|
||||||
|
external_id = f"seed:pike:{index}"
|
||||||
|
if db.scalar(select(CatchReport.id).where(CatchReport.source_external_id == external_id)):
|
||||||
|
continue
|
||||||
db.add(CatchReport(
|
db.add(CatchReport(
|
||||||
fish=pike, spot=spot1, waterbody=vyunok, bait=spiker,
|
fish=pike, spot=spot1, waterbody=vyunok, bait=spiker,
|
||||||
weight_g=2_600 + index * 480, fishing_method="spinning",
|
weight_g=2_600 + index * 480, fishing_method="spinning",
|
||||||
@@ -43,9 +63,13 @@ def seed() -> None:
|
|||||||
caught_at=now - timedelta(minutes=25 + index * 47),
|
caught_at=now - timedelta(minutes=25 + index * 47),
|
||||||
reported_at=now - timedelta(minutes=20 + index * 47),
|
reported_at=now - timedelta(minutes=20 + index * 47),
|
||||||
player_name=f"DemoPlayer{index % 7 + 1}", source_type=SourceType.manual_import,
|
player_name=f"DemoPlayer{index % 7 + 1}", source_type=SourceType.manual_import,
|
||||||
|
source_external_id=external_id,
|
||||||
source_confidence=80 + index % 3 * 5, moderation_status=ModerationStatus.approved,
|
source_confidence=80 + index % 3 * 5, moderation_status=ModerationStatus.approved,
|
||||||
))
|
))
|
||||||
for index in range(5):
|
for index in range(5):
|
||||||
|
external_id = f"seed:trout:{index}"
|
||||||
|
if db.scalar(select(CatchReport.id).where(CatchReport.source_external_id == external_id)):
|
||||||
|
continue
|
||||||
db.add(CatchReport(
|
db.add(CatchReport(
|
||||||
fish=trout, spot=spot2, waterbody=kuori, bait=shad,
|
fish=trout, spot=spot2, waterbody=kuori, bait=shad,
|
||||||
weight_g=4_200 + index * 900, fishing_method="spinning",
|
weight_g=4_200 + index * 900, fishing_method="spinning",
|
||||||
@@ -53,6 +77,7 @@ def seed() -> None:
|
|||||||
caught_at=now - timedelta(hours=2 + index * 4),
|
caught_at=now - timedelta(hours=2 + index * 4),
|
||||||
reported_at=now - timedelta(hours=2 + index * 4),
|
reported_at=now - timedelta(hours=2 + index * 4),
|
||||||
player_name=f"DemoAngler{index + 1}", source_type=SourceType.manual_import,
|
player_name=f"DemoAngler{index + 1}", source_type=SourceType.manual_import,
|
||||||
|
source_external_id=external_id,
|
||||||
source_confidence=85, moderation_status=ModerationStatus.approved,
|
source_confidence=85, moderation_status=ModerationStatus.approved,
|
||||||
))
|
))
|
||||||
|
|
||||||
|
|||||||
@@ -187,10 +187,13 @@ def test_admin_can_start_and_list_official_import(monkeypatch) -> None:
|
|||||||
def test_pending_report_accepts_one_validated_screenshot(monkeypatch) -> None:
|
def test_pending_report_accepts_one_validated_screenshot(monkeypatch) -> None:
|
||||||
created = client.post("/api/v1/catch-reports", json={"fish_slug": "pike", "waterbody_slug": "test-lake", "x": 91, "y": 92, "weight_g": 4200}).json()
|
created = client.post("/api/v1/catch-reports", json={"fish_slug": "pike", "waterbody_slug": "test-lake", "x": 91, "y": 92, "weight_g": 4200}).json()
|
||||||
monkeypatch.setattr("app.main.upload_screenshot", lambda raw, **metadata: "reports/test.jpg" if raw == b"image-bytes" and metadata == {"filename": "catch.jpg", "content_type": "image/jpeg"} else "unexpected")
|
monkeypatch.setattr("app.main.upload_screenshot", lambda raw, **metadata: "reports/test.jpg" if raw == b"image-bytes" and metadata == {"filename": "catch.jpg", "content_type": "image/jpeg"} else "unexpected")
|
||||||
response = client.post(f"/api/v1/catch-reports/{created['id']}/screenshot", files={"screenshot": ("catch.jpg", b"image-bytes", "image/jpeg")})
|
upload_url = f"/api/v1/catch-reports/{created['id']}/screenshot"
|
||||||
|
assert client.post(upload_url, files={"screenshot": ("catch.jpg", b"image-bytes", "image/jpeg")}).status_code == 401
|
||||||
|
assert client.post(upload_url, headers={"X-Upload-Token": "wrong"}, files={"screenshot": ("catch.jpg", b"image-bytes", "image/jpeg")}).status_code == 401
|
||||||
|
response = client.post(upload_url, headers={"X-Upload-Token": created["screenshot_upload_token"]}, files={"screenshot": ("catch.jpg", b"image-bytes", "image/jpeg")})
|
||||||
assert response.status_code == 204
|
assert response.status_code == 204
|
||||||
duplicate = client.post(f"/api/v1/catch-reports/{created['id']}/screenshot", files={"screenshot": ("catch.jpg", b"image-bytes", "image/jpeg")})
|
reused = client.post(upload_url, headers={"X-Upload-Token": created["screenshot_upload_token"]}, files={"screenshot": ("catch.jpg", b"image-bytes", "image/jpeg")})
|
||||||
assert duplicate.status_code == 409
|
assert reused.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
def test_admin_delete_anonymizes_report_removes_screenshot_and_keeps_audit(monkeypatch) -> None:
|
def test_admin_delete_anonymizes_report_removes_screenshot_and_keeps_audit(monkeypatch) -> None:
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import pytest
|
||||||
|
from pydantic import ValidationError
|
||||||
|
|
||||||
|
from app.config import Settings
|
||||||
|
|
||||||
|
|
||||||
|
def production_settings(**changes) -> Settings:
|
||||||
|
values = {
|
||||||
|
"deployment_environment": "production",
|
||||||
|
"admin_token": "a" * 32,
|
||||||
|
"rate_limit_secret": "r" * 32,
|
||||||
|
"s3_access_key": "access-key-1234",
|
||||||
|
"s3_secret_key": "s" * 32,
|
||||||
|
"s3_public_endpoint_url": "https://files.rf4spotter.ru",
|
||||||
|
"cors_origins": ["https://rf4spotter.ru"],
|
||||||
|
"seed_demo_data": False,
|
||||||
|
}
|
||||||
|
return Settings(**(values | changes))
|
||||||
|
|
||||||
|
|
||||||
|
def test_production_settings_accept_real_domains_and_secrets() -> None:
|
||||||
|
settings = production_settings()
|
||||||
|
|
||||||
|
assert settings.cors_origins == ["https://rf4spotter.ru"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(("field", "value"), [
|
||||||
|
("admin_token", "change-me-in-production"),
|
||||||
|
("rate_limit_secret", "short"),
|
||||||
|
("s3_access_key", "rf4-local"),
|
||||||
|
("s3_secret_key", "rf4-local-secret"),
|
||||||
|
("cors_origins", ["http://rf4spotter.ru"]),
|
||||||
|
("s3_public_endpoint_url", "http://files.rf4spotter.ru"),
|
||||||
|
("seed_demo_data", True),
|
||||||
|
])
|
||||||
|
def test_production_settings_reject_insecure_values(field: str, value: object) -> None:
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
production_settings(**{field: value})
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
from sqlalchemy import create_engine, func, select
|
||||||
|
from sqlalchemy.orm import Session, sessionmaker
|
||||||
|
from sqlalchemy.pool import StaticPool
|
||||||
|
|
||||||
|
from app.database import Base
|
||||||
|
from app.models import CatchReport, Fish, Spot, Waterbody
|
||||||
|
from app import seed as seed_module
|
||||||
|
|
||||||
|
|
||||||
|
def test_seed_repairs_partial_database_and_is_idempotent(monkeypatch) -> None:
|
||||||
|
engine = create_engine("sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool)
|
||||||
|
Base.metadata.create_all(engine)
|
||||||
|
sessions = sessionmaker(bind=engine, expire_on_commit=False)
|
||||||
|
with Session(engine) as db:
|
||||||
|
db.add(Fish(slug="pike", name_ru="Щука", trophy_weight_g=10_000))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
monkeypatch.setattr(seed_module, "SessionLocal", sessions)
|
||||||
|
monkeypatch.setattr(seed_module.settings, "seed_demo_data", False)
|
||||||
|
seed_module.seed()
|
||||||
|
seed_module.seed()
|
||||||
|
|
||||||
|
with Session(engine) as db:
|
||||||
|
assert db.scalar(select(func.count()).select_from(Fish)) == 2
|
||||||
|
assert db.scalar(select(func.count()).select_from(Waterbody)) == 2
|
||||||
|
assert db.scalar(select(func.count()).select_from(Spot)) == 2
|
||||||
|
assert db.scalar(select(func.count()).select_from(CatchReport)) == 0
|
||||||
@@ -8,6 +8,7 @@
|
|||||||
"build": "astro check && astro build",
|
"build": "astro check && astro build",
|
||||||
"start": "node ./dist/server/entry.mjs",
|
"start": "node ./dist/server/entry.mjs",
|
||||||
"check": "astro check",
|
"check": "astro check",
|
||||||
|
"test:unit": "node --test tests/unit/*.test.ts",
|
||||||
"test:e2e": "playwright test"
|
"test:e2e": "playwright test"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { defineConfig } from "@playwright/test";
|
|||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
testDir: "./tests",
|
testDir: "./tests",
|
||||||
|
testIgnore: "**/unit/**",
|
||||||
use: { baseURL: process.env.WEB_URL || "http://127.0.0.1:4321" },
|
use: { baseURL: process.env.WEB_URL || "http://127.0.0.1:4321" },
|
||||||
retries: 0,
|
retries: 0,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
---
|
---
|
||||||
import type { Activity } from "../lib/api";
|
import type { Activity } from "../lib/api";
|
||||||
import { ago, kg } from "../lib/api";
|
import { activityLevel, ago, kg, plural } from "../lib/api";
|
||||||
|
import FishingIcon from "./FishingIcon.astro";
|
||||||
const { item } = Astro.props as { item: Activity };
|
const { item } = Astro.props as { item: Activity };
|
||||||
const level = item.activity_score >= 80 ? "Очень высокий" : item.activity_score >= 60 ? "Высокий" : item.activity_score >= 40 ? "Средний" : "Низкий";
|
const level = activityLevel(item.activity_score);
|
||||||
const limited = item.catches < 3;
|
const limited = item.catches < 3;
|
||||||
---
|
---
|
||||||
<a class="spot-card" data-testid={`spot-${item.x}-${item.y}`} href={`/spots/${item.spot_id}`}>
|
<a class="spot-card" data-testid={`spot-${item.x}-${item.y}`} href={`/spots/${item.spot_id}`}>
|
||||||
<span class="spot-rank">{String(item.activity_score).padStart(2,"0")}</span>
|
<span class="spot-rank">{String(item.activity_score).padStart(2,"0")}</span>
|
||||||
<div class="spot-main"><div class="spot-topline"><span>{item.waterbody}</span><span class="activity-pill"><i></i>{level}</span>{limited && <span class="data-quality">Данных мало</span>}</div><h3>{item.fish}</h3><div class="spot-meta"><span>⌖ {item.x}:{item.y}</span><span>◷ {ago(item.last_confirmed_at)}</span></div><p class="data-note">{item.explanation}</p><div class="bait-line"><span class="lure-dot"></span><div><span>Работает сейчас</span><strong>{item.best_bait ?? "не указана"}</strong></div></div></div>
|
<div class="spot-main"><div class="spot-topline"><span>{item.waterbody}</span><span class="activity-pill" data-activity-level={level.short}><i></i>{level.short}</span>{limited && <span class="data-quality">Данных мало</span>}</div><h3>{item.fish}</h3><div class="spot-meta"><span><FishingIcon name="pin" size={14}/> {item.x}:{item.y}</span><span><FishingIcon name="clock" size={14}/> {ago(item.last_confirmed_at)}</span></div><p class="data-note">{item.explanation}</p><div class="bait-line"><FishingIcon name="lure" size={25}/><div><span>Работает сейчас</span><strong>{item.best_bait ?? "не указана"}</strong></div></div></div>
|
||||||
<div class="spot-stats"><div><strong>{item.catches}</strong><span>уловов</span></div><div><strong>{item.unique_players}</strong><span>игроков</span></div><div><strong>{kg(item.average_weight_g)}</strong><span>средний вес</span></div><div><strong>{item.confidence_score}%</strong><span>уверенность</span></div></div><span class="card-arrow">›</span>
|
<div class="spot-stats"><div><strong>{item.catches}</strong><span>{plural(item.catches, ["улов", "улова", "уловов"])}</span></div><div><strong>{item.unique_players}</strong><span>{plural(item.unique_players, ["игрок", "игрока", "игроков"])}</span></div><div><strong>{kg(item.average_weight_g)}</strong><span>средний вес</span></div><div><strong>{item.confidence_score}%</strong><span>уверенность</span></div></div><span class="card-arrow"><FishingIcon name="arrow" size={22}/></span>
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
---
|
||||||
|
type IconName = "hook" | "float" | "trophy" | "plus" | "pin" | "clock" | "ripple" | "angler" | "scale" | "lure" | "arrow";
|
||||||
|
const { name, size = 18, label } = Astro.props as { name: IconName; size?: number; label?: string };
|
||||||
|
---
|
||||||
|
<svg class={`fish-icon fish-icon--${name}`} width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden={label ? undefined : "true"} role={label ? "img" : undefined} aria-label={label}>
|
||||||
|
{name === "hook" && <><path d="M15 3v11a6 6 0 1 1-6-6"/><path d="m6 9 3-1 1 3"/><path d="M15 3h3"/></>}
|
||||||
|
{name === "float" && <><path d="M12 2v3M12 19v3"/><path d="M12 5c2.2 0 3 2.8 3 6s-.8 8-3 8-3-4.8-3-8 .8-6 3-6Z"/><path d="M9.2 11h5.6"/></>}
|
||||||
|
{name === "trophy" && <><path d="M8 4h8v4a4 4 0 0 1-8 0V4Z"/><path d="M8 6H5c0 3 1.5 5 4.5 5M16 6h3c0 3-1.5 5-4.5 5M12 12v5M8 20h8M9 17h6"/></>}
|
||||||
|
{name === "plus" && <><path d="M12 5v14M5 12h14"/><circle cx="12" cy="12" r="9"/></>}
|
||||||
|
{name === "pin" && <><path d="M12 21s6-6.1 6-12a6 6 0 1 0-12 0c0 5.9 6 12 6 12Z"/><circle cx="12" cy="9" r="2"/></>}
|
||||||
|
{name === "clock" && <><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></>}
|
||||||
|
{name === "ripple" && <><circle cx="12" cy="12" r="2"/><path d="M6.6 8.4A6.5 6.5 0 0 0 5.5 12c0 1.3.4 2.5 1.1 3.6M17.4 8.4a6.5 6.5 0 0 1 1.1 3.6c0 1.3-.4 2.5-1.1 3.6M3.5 5.7A10 10 0 0 0 2 12c0 2.4.8 4.6 2.1 6.3M20.5 5.7A10 10 0 0 1 22 12c0 2.4-.8 4.6-2.1 6.3"/></>}
|
||||||
|
{name === "angler" && <><circle cx="9" cy="6" r="2"/><path d="m8 9-2 5 4 2 2 5M8 12l5 2M13 14l5-8M17 6c2 3 3 6 3 10"/></>}
|
||||||
|
{name === "scale" && <><path d="M12 3v18M7 6h10M6 6l-3 6h6L6 6ZM18 6l-3 6h6l-3-6ZM8 21h8"/></>}
|
||||||
|
{name === "lure" && <><path d="M9 4c4 1 7 5 6 10s-5 7-8 4 1-7 2-14Z"/><path d="m14 8 4-2M8 18c0 3 4 3 4 0"/></>}
|
||||||
|
{name === "arrow" && <><path d="M5 12h14M14 7l5 5-5 5"/></>}
|
||||||
|
</svg>
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
---
|
---
|
||||||
import "../styles/global.css";
|
import "../styles/global.css";
|
||||||
|
import FishingIcon from "../components/FishingIcon.astro";
|
||||||
const { title = "RF4 Spotter" } = Astro.props;
|
const { title = "RF4 Spotter" } = Astro.props;
|
||||||
const path = Astro.url.pathname;
|
const path = Astro.url.pathname;
|
||||||
---
|
---
|
||||||
@@ -8,11 +9,11 @@ const path = Astro.url.pathname;
|
|||||||
<head><meta charset="UTF-8" /><meta name="viewport" content="width=device-width" /><meta name="description" content="Свежие точки и статистика клёва Russian Fishing 4" /><link rel="icon" href="/favicon.svg" /><title>{title}</title></head>
|
<head><meta charset="UTF-8" /><meta name="viewport" content="width=device-width" /><meta name="description" content="Свежие точки и статистика клёва Russian Fishing 4" /><link rel="icon" href="/favicon.svg" /><title>{title}</title></head>
|
||||||
<body>
|
<body>
|
||||||
<header class="topbar">
|
<header class="topbar">
|
||||||
<a href="/" class="brand" aria-label="Ни хвоста, ни чешуи"><span class="brand-mark" aria-hidden="true">⌁</span><span class="brand-name"><span>Ни хвоста,</span><strong>ни чешуи</strong></span></a>
|
<a href="/" class="brand" aria-label="Ни хвоста, ни чешуи"><span class="brand-mark" aria-hidden="true"><FishingIcon name="hook" size={24}/></span><span class="brand-name"><span>Ни хвоста,</span><strong>ни чешуи</strong></span></a>
|
||||||
<nav aria-label="Разделы сайта"><a class:list={{active:path === "/"}} href="/">≈ <span>Сейчас клюёт</span></a><a class:list={{active:path.startsWith("/records")}} href="/records">♜ <span>Рекорды</span></a><a class:list={{active:path.startsWith("/report")}} href="/report">+ <span>Добавить улов</span></a></nav>
|
<nav aria-label="Разделы сайта"><a class:list={{active:path === "/"}} href="/"><FishingIcon name="float"/> <span>Сейчас клюёт</span></a><a class:list={{active:path.startsWith("/records")}} href="/records"><FishingIcon name="trophy"/> <span>Рекорды</span></a><a class:list={{active:path.startsWith("/report")}} href="/report"><FishingIcon name="plus"/> <span>Добавить улов</span></a></nav>
|
||||||
<p class="live-badge"><span></span> Свежие данные и честная оценка</p>
|
<p class="live-badge"><span></span> Свежие данные и честная оценка</p>
|
||||||
</header>
|
</header>
|
||||||
<main><slot /></main>
|
<main><slot /></main>
|
||||||
<footer><a href="/" class="brand"><span class="brand-mark">⌁</span><span class="brand-name"><span>Ни хвоста,</span><strong>ни чешуи</strong></span></a><p>Неофициальный проект для игроков Russian Fishing 4. Данные seed демонстрационные.</p><span>НХНЧ · 2026</span></footer>
|
<footer><a href="/" class="brand"><span class="brand-mark"><FishingIcon name="hook" size={24}/></span><span class="brand-name"><span>Ни хвоста,</span><strong>ни чешуи</strong></span></a><p>Неофициальный проект для игроков Russian Fishing 4. Данные seed демонстрационные.</p><span>НХНЧ · 2026</span></footer>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ export type DictionaryItem = { id: string; slug: string; name_ru: string };
|
|||||||
export type OfficialRecord = { id: string; fish: string; weight_g: number; waterbody: string; bait: string | null; player_name: string | null; record_date: string | null; category: string | null; region: string | null; source_url: string | null };
|
export type OfficialRecord = { id: string; fish: string; weight_g: number; waterbody: string; bait: string | null; player_name: string | null; record_date: string | null; category: string | null; region: string | null; source_url: string | null };
|
||||||
export type ImportRun = { id: string; started_at: string; finished_at: string | null; status: string; source_url: string; rows_seen: number; rows_created: number; rows_updated: number; error_summary: string | null };
|
export type ImportRun = { id: string; started_at: string; finished_at: string | null; status: string; source_url: string; rows_seen: number; rows_created: number; rows_updated: number; error_summary: string | null };
|
||||||
|
|
||||||
|
export { activityLevel, ago, kg, plural } from "./presentation";
|
||||||
|
|
||||||
const base = process.env.API_INTERNAL_URL || import.meta.env.API_INTERNAL_URL || "http://localhost:8000";
|
const base = process.env.API_INTERNAL_URL || import.meta.env.API_INTERNAL_URL || "http://localhost:8000";
|
||||||
|
|
||||||
export async function api<T>(path: string): Promise<T> {
|
export async function api<T>(path: string): Promise<T> {
|
||||||
@@ -19,9 +21,3 @@ export async function api<T>(path: string): Promise<T> {
|
|||||||
if (!response.ok) throw new Error(`API ${response.status}`);
|
if (!response.ok) throw new Error(`API ${response.status}`);
|
||||||
return response.json() as Promise<T>;
|
return response.json() as Promise<T>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function kg(grams: number) { return `${(grams / 1000).toFixed(2)} кг`; }
|
|
||||||
export function ago(value: string) {
|
|
||||||
const minutes = Math.max(0, Math.round((Date.now() - new Date(value).getTime()) / 60000));
|
|
||||||
return minutes < 60 ? `${minutes} мин назад` : `${Math.floor(minutes / 60)} ч назад`;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
export function kg(grams: number) { return `${(grams / 1000).toFixed(2)} кг`; }
|
||||||
|
|
||||||
|
export function plural(number: number, forms: readonly [string, string, string]) {
|
||||||
|
const absolute = Math.abs(number) % 100;
|
||||||
|
const last = absolute % 10;
|
||||||
|
if (absolute > 10 && absolute < 20) return forms[2];
|
||||||
|
if (last === 1) return forms[0];
|
||||||
|
if (last >= 2 && last <= 4) return forms[1];
|
||||||
|
return forms[2];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function activityLevel(score: number) {
|
||||||
|
if (score >= 80) return { short: "Очень высокий", description: "Очень высокая активность" };
|
||||||
|
if (score >= 60) return { short: "Высокий", description: "Высокая активность" };
|
||||||
|
if (score >= 40) return { short: "Средний", description: "Средняя активность" };
|
||||||
|
return { short: "Низкий", description: "Низкая активность" };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ago(value: string) {
|
||||||
|
const minutes = Math.max(0, Math.round((Date.now() - new Date(value).getTime()) / 60000));
|
||||||
|
return minutes < 60 ? `${minutes} мин назад` : `${Math.floor(minutes / 60)} ч назад`;
|
||||||
|
}
|
||||||
@@ -2,14 +2,17 @@ import type { APIRoute } from "astro";
|
|||||||
|
|
||||||
const base = process.env.API_INTERNAL_URL || import.meta.env.API_INTERNAL_URL || "http://localhost:8000";
|
const base = process.env.API_INTERNAL_URL || import.meta.env.API_INTERNAL_URL || "http://localhost:8000";
|
||||||
|
|
||||||
export const POST: APIRoute = async ({ request, redirect }) => {
|
export const POST: APIRoute = async ({ request, redirect, cookies }) => {
|
||||||
const form = await request.formData();
|
const form = await request.formData();
|
||||||
const reportId = String(form.get("report_id") || "");
|
const reportId = String(form.get("report_id") || "");
|
||||||
const screenshot = form.get("screenshot");
|
const screenshot = form.get("screenshot");
|
||||||
if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(reportId) || !(screenshot instanceof File) || screenshot.size === 0) return redirect(`/report?state=screenshot_error&report_id=${encodeURIComponent(reportId)}`, 303);
|
const cookieName = `rf4-upload-${reportId}`;
|
||||||
|
const uploadToken = cookies.get(cookieName)?.value;
|
||||||
|
if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(reportId) || !uploadToken || !(screenshot instanceof File) || screenshot.size === 0) return redirect(`/report?state=screenshot_error&report_id=${encodeURIComponent(reportId)}`, 303);
|
||||||
try {
|
try {
|
||||||
const upload = new FormData(); upload.set("screenshot", screenshot);
|
const upload = new FormData(); upload.set("screenshot", screenshot);
|
||||||
const response = await fetch(`${base}/api/v1/catch-reports/${reportId}/screenshot`, { method: "POST", body: upload });
|
const response = await fetch(`${base}/api/v1/catch-reports/${reportId}/screenshot`, { method: "POST", headers:{"X-Upload-Token":uploadToken}, body: upload });
|
||||||
|
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 { return redirect(`/report?state=screenshot_error&report_id=${encodeURIComponent(reportId)}`, 303); }
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,23 +1,32 @@
|
|||||||
import type { APIRoute } from "astro";
|
import type { APIRoute } from "astro";
|
||||||
const base = process.env.API_INTERNAL_URL || import.meta.env.API_INTERNAL_URL || "http://localhost:8000";
|
const base = process.env.API_INTERNAL_URL || import.meta.env.API_INTERNAL_URL || "http://localhost:8000";
|
||||||
export const POST: APIRoute = async ({ request, redirect }) => {
|
export const POST: APIRoute = async ({ request, redirect, cookies }) => {
|
||||||
const form = await request.formData();
|
const form = await request.formData();
|
||||||
const text = (name: string) => String(form.get(name) || "").trim() || null;
|
const text = (name: string) => String(form.get(name) || "").trim() || null;
|
||||||
const number = (name: string) => text(name) ? Number(text(name)) : null;
|
const number = (name: string) => text(name) ? Number(text(name)) : null;
|
||||||
const payload = { fish_slug: String(form.get("fish_slug") || ""), waterbody_slug: String(form.get("waterbody_slug") || ""), x: Number(form.get("x")), y: Number(form.get("y")), weight_g: Number(form.get("weight_g")), bait_name: text("bait_name"), fishing_method: text("fishing_method"), retrieve_method: text("retrieve_method"), retrieve_speed: number("retrieve_speed"), player_name: text("player_name"), comment: text("comment"), website: String(form.get("website") || "") };
|
const payload = { fish_slug: String(form.get("fish_slug") || ""), waterbody_slug: String(form.get("waterbody_slug") || ""), x: Number(form.get("x")), y: Number(form.get("y")), weight_g: Number(form.get("weight_g")), bait_name: text("bait_name"), fishing_method: text("fishing_method"), retrieve_method: text("retrieve_method"), retrieve_speed: number("retrieve_speed"), player_name: text("player_name"), comment: text("comment"), website: String(form.get("website") || "") };
|
||||||
let createdId: string | null = null;
|
let createdId: string | null = null;
|
||||||
|
let uploadToken: string | null = null;
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${base}/api/v1/catch-reports`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(payload) });
|
const response = await fetch(`${base}/api/v1/catch-reports`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(payload) });
|
||||||
if (!response.ok) return redirect("/report?state=create_error", 303);
|
if (!response.ok) return redirect("/report?state=create_error", 303);
|
||||||
const created = await response.json();
|
const created = await response.json() as { id?: unknown; screenshot_upload_token?: unknown };
|
||||||
|
if (typeof created.id !== "string" || typeof created.screenshot_upload_token !== "string") return redirect("/report?state=create_error", 303);
|
||||||
createdId = created.id;
|
createdId = created.id;
|
||||||
|
uploadToken = created.screenshot_upload_token;
|
||||||
const screenshot = form.get("screenshot");
|
const screenshot = form.get("screenshot");
|
||||||
if (screenshot instanceof File && screenshot.size > 0) {
|
if (screenshot instanceof File && screenshot.size > 0) {
|
||||||
const upload = new FormData(); upload.set("screenshot", screenshot);
|
const upload = new FormData(); upload.set("screenshot", screenshot);
|
||||||
const uploaded = await fetch(`${base}/api/v1/catch-reports/${created.id}/screenshot`, { method: "POST", body: upload });
|
const uploaded = await fetch(`${base}/api/v1/catch-reports/${created.id}/screenshot`, { method: "POST", headers: {"X-Upload-Token": created.screenshot_upload_token}, body: upload });
|
||||||
if (!uploaded.ok) return redirect(`/report?state=screenshot_error&report_id=${encodeURIComponent(created.id)}`, 303);
|
if (!uploaded.ok) {
|
||||||
|
cookies.set(`rf4-upload-${created.id}`, created.screenshot_upload_token, {httpOnly:true, sameSite:"strict", secure:import.meta.env.PROD, path:"/", maxAge:3600});
|
||||||
|
return redirect(`/report?state=screenshot_error&report_id=${encodeURIComponent(created.id)}`, 303);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return redirect("/report?state=sent", 303);
|
return redirect("/report?state=sent", 303);
|
||||||
}
|
}
|
||||||
catch { return redirect(createdId ? `/report?state=screenshot_error&report_id=${encodeURIComponent(createdId)}` : "/report?state=create_error", 303); }
|
catch {
|
||||||
|
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=create_error", 303);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
---
|
---
|
||||||
import Layout from "../layouts/Layout.astro";
|
import Layout from "../layouts/Layout.astro";
|
||||||
import ActivityCard from "../components/ActivityCard.astro";
|
import ActivityCard from "../components/ActivityCard.astro";
|
||||||
import { ago, api, kg, type Activity, type DictionaryItem } from "../lib/api";
|
import FishingIcon from "../components/FishingIcon.astro";
|
||||||
|
import { activityLevel, ago, api, kg, plural, type Activity, type DictionaryItem } from "../lib/api";
|
||||||
|
|
||||||
const params = Astro.url.searchParams;
|
const params = Astro.url.searchParams;
|
||||||
const hours = params.get("hours") ?? "24";
|
const hours = params.get("hours") ?? "24";
|
||||||
@@ -18,9 +19,10 @@ try {
|
|||||||
items = await api<Activity[]>(`/api/v1/activity?${query}`);
|
items = await api<Activity[]>(`/api/v1/activity?${query}`);
|
||||||
}
|
}
|
||||||
} catch { unavailable = true; }
|
} catch { unavailable = true; }
|
||||||
|
const leaderLevel = items[0] ? activityLevel(items[0].activity_score) : null;
|
||||||
---
|
---
|
||||||
<Layout title="Что клюёт сейчас — RF4 Spotter">
|
<Layout title="Что клюёт сейчас — RF4 Spotter">
|
||||||
<section class="intro content-grid"><div class="intro-copy"><span class="eyebrow"><b>RF4</b> Живая карта клёва</span><h1>Выбирай место,<br/><em>пока клюёт.</em></h1><p>Свежие точки, рабочие приманки и честная оценка данных от игроков.</p></div><div class="lake-card"><img src="/lake-dawn.png" alt="Туманное озеро на рассвете"/><div class="lake-overlay"><div><span class="overline">Пульс водоёмов</span><strong>{items.length} точки показывают активность</strong></div><div class="pulse-orb"><span></span></div></div></div></section>
|
<section class="intro content-grid"><div class="intro-copy"><span class="eyebrow"><b>RF4</b> Живая карта клёва</span><h1>Выбирай место,<br/><em>пока клюёт.</em></h1><p>Свежие точки, рабочие приманки и честная оценка данных от игроков.</p></div><div class="lake-card"><img src="/lake-dawn.png" alt="Туманное озеро на рассвете"/><div class="lake-overlay"><div><span class="overline">Пульс водоёмов</span><strong>{items.length} {plural(items.length, ["точка показывает", "точки показывают", "точек показывают"])} активность</strong></div><div class="pulse-orb"><span></span></div></div></div></section>
|
||||||
<section class="filters-wrap"><form class="filters content-grid" method="get">
|
<section class="filters-wrap"><form class="filters content-grid" method="get">
|
||||||
<label>Водоём<select name="waterbody"><option value="">Все водоёмы</option>{waterbodies.map(x => <option value={x.slug} selected={waterbody === x.slug}>{x.name_ru}</option>)}</select></label>
|
<label>Водоём<select name="waterbody"><option value="">Все водоёмы</option>{waterbodies.map(x => <option value={x.slug} selected={waterbody === x.slug}>{x.name_ru}</option>)}</select></label>
|
||||||
<label>Рыба<select name="fish"><option value="">Любая рыба</option>{fishes.map(x => <option value={x.slug} selected={fish === x.slug}>{x.name_ru}</option>)}</select></label>
|
<label>Рыба<select name="fish"><option value="">Любая рыба</option>{fishes.map(x => <option value={x.slug} selected={fish === x.slug}>{x.name_ru}</option>)}</select></label>
|
||||||
@@ -28,8 +30,8 @@ try {
|
|||||||
<label>Сначала<select name="sort"><option value="activity" selected={sort === "activity"}>Активные</option><option value="confidence" selected={sort === "confidence"}>Надёжные</option><option value="freshness" selected={sort === "freshness"}>Свежие</option></select></label>
|
<label>Сначала<select name="sort"><option value="activity" selected={sort === "activity"}>Активные</option><option value="confidence" selected={sort === "confidence"}>Надёжные</option><option value="freshness" selected={sort === "freshness"}>Свежие</option></select></label>
|
||||||
<button>⌕ Найти клёв</button>
|
<button>⌕ Найти клёв</button>
|
||||||
</form></section>
|
</form></section>
|
||||||
<section class="dashboard content-grid"><div class="results-column"><div class="section-heading"><div><span class="overline">За выбранный период</span><h2>Горячие точки</h2></div><span class="result-count">{items.length} точки</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> : <div class="state"><h2>Пока нет свежих данных</h2><p>Для выбранных фильтров нет одобренных наблюдений. Расширьте период или выберите другой водоём.</p></div>}</div>
|
<section class="dashboard content-grid"><div class="results-column"><div class="section-heading"><div><span class="overline">За выбранный период</span><h2>Горячие точки</h2></div><span class="result-count">{items.length} {plural(items.length, ["точка", "точки", "точек"])}</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> : <div class="state"><h2>Пока нет свежих данных</h2><p>Для выбранных фильтров нет одобренных наблюдений. Расширьте период или выберите другой водоём.</p></div>}</div>
|
||||||
{items[0] && <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="Открыть точку">↗</a></div><div class="detail-score"><div class="score-ring" style={`--score:${items[0].activity_score * 3.6}deg`}><div><strong>{items[0].activity_score}</strong><span>из 100</span></div></div><div><span>Индекс активности</span><strong>{items[0].activity_score >= 70 ? "Клёв отличный" : "Клёв хороший"}</strong><p>{items[0].explanation}</p></div></div><div class="metric-grid"><div><span>◎</span><small>Уверенность</small><strong>{items[0].confidence_score}%</strong></div><div><span>♟</span><small>Игроков</small><strong>{items[0].unique_players}</strong></div><div><span>◷</span><small>Последний</small><strong>{ago(items[0].last_confirmed_at)}</strong></div><div><span>⌁</span><small>Средний вес</small><strong>{kg(items[0].average_weight_g)}</strong></div></div><div class="best-lure"><span class="overline">Лучшая связка</span><div><i></i><strong>{items[0].best_bait ?? "Не указана"}</strong><span>{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="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>
|
||||||
<section class="how-it-works content-grid"><div><span class="overline">Как читать данные</span><h2>Не обещаем рыбу.<br/>Показываем факты.</h2></div><div class="principles"><article><span>01</span><h3>Свежесть</h3><p>Чем старше сообщение, тем меньше оно влияет на активность.</p></article><article><span>02</span><h3>Разные игроки</h3><p>Десять уловов одного человека не равны десяти подтверждениям.</p></article><article><span>03</span><h3>Уверенность</h3><p>Каждая оценка объясняет, сколько данных за ней стоит.</p></article></div></section>
|
<section class="how-it-works content-grid"><div><span class="overline">Как читать данные</span><h2>Не обещаем рыбу.<br/>Показываем факты.</h2></div><div class="principles"><article><span>01</span><h3>Свежесть</h3><p>Чем старше сообщение, тем меньше оно влияет на активность.</p></article><article><span>02</span><h3>Разные игроки</h3><p>Десять уловов одного человека не равны десяти подтверждениям.</p></article><article><span>03</span><h3>Уверенность</h3><p>Каждая оценка объясняет, сколько данных за ней стоит.</p></article></div></section>
|
||||||
</Layout>
|
</Layout>
|
||||||
|
|||||||
@@ -1,18 +1,19 @@
|
|||||||
---
|
---
|
||||||
import Layout from "../../layouts/Layout.astro";
|
import Layout from "../../layouts/Layout.astro";
|
||||||
import { api, kg, type Activity, type Catch, type Spot } from "../../lib/api";
|
import { activityLevel, api, kg, plural, type Activity, type Catch, type Spot } from "../../lib/api";
|
||||||
const { id } = Astro.params;
|
const { id } = Astro.params;
|
||||||
let spot: Spot | null = null, catches: Catch[] = [], activity: Activity | null = null, unavailable = false;
|
let spot: Spot | null = null, catches: Catch[] = [], activity: Activity | null = null, unavailable = false;
|
||||||
try {
|
try {
|
||||||
const [spotResult, catchResult, activityRows] = await Promise.all([api<Spot>(`/api/v1/spots/${id}`), api<Catch[]>(`/api/v1/spots/${id}/catches`), api<Activity[]>("/api/v1/activity?hours=24&limit=100")]);
|
const [spotResult, catchResult, activityRows] = await Promise.all([api<Spot>(`/api/v1/spots/${id}`), api<Catch[]>(`/api/v1/spots/${id}/catches`), api<Activity[]>("/api/v1/activity?hours=24&limit=100")]);
|
||||||
spot = spotResult; catches = catchResult; activity = activityRows.find(item => item.spot_id === id) ?? null;
|
spot = spotResult; catches = catchResult; activity = activityRows.find(item => item.spot_id === id) ?? null;
|
||||||
} catch { unavailable = true; }
|
} catch { unavailable = true; }
|
||||||
|
const level = activity ? activityLevel(activity.activity_score) : null;
|
||||||
---
|
---
|
||||||
<Layout title={spot ? `${spot.waterbody} ${spot.x}:${spot.y} — RF4 Spotter` : "Точка — RF4 Spotter"}>
|
<Layout title={spot ? `${spot.waterbody} ${spot.x}:${spot.y} — RF4 Spotter` : "Точка — RF4 Spotter"}>
|
||||||
<a class="back" href="/">← Все активные точки</a>
|
<a class="back" href="/">← Все активные точки</a>
|
||||||
{unavailable || !spot ? <div class="state"><h1>Точка недоступна</h1><p>API не ответил или такой точки нет.</p></div> : <>
|
{unavailable || !spot ? <div class="state"><h1>Точка недоступна</h1><p>API не ответил или такой точки нет.</p></div> : <>
|
||||||
<section class="spot-hero"><div><span class="eyebrow">{spot.waterbody}</span><h1>Точка {spot.x}:{spot.y}</h1><p>{spot.description}</p></div><div class="pin">{spot.x}<span>:</span>{spot.y}</div></section>
|
<section class="spot-hero"><div><span class="eyebrow">{spot.waterbody}</span><h1>Точка {spot.x}:{spot.y}</h1><p>{spot.description}</p></div><div class="pin">{spot.x}<span>:</span>{spot.y}</div></section>
|
||||||
<div class="periods"><div><strong>{spot.catches_24h}</strong><span>за 24 часа</span></div><div><strong>{spot.catches_3d}</strong><span>за 3 дня</span></div><div><strong>{spot.catches_7d}</strong><span>за 7 дней</span></div></div>
|
<div class="periods"><div><strong>{spot.catches_24h}</strong><span>за 24 часа</span></div><div><strong>{spot.catches_3d}</strong><span>за 3 дня</span></div><div><strong>{spot.catches_7d}</strong><span>за 7 дней</span></div></div>
|
||||||
<section class="detail-grid"><div><div class="section-heading"><h2>Последние уловы</h2></div>{catches.length ? <div class="catch-list">{catches.map(item => <article><div><strong>{item.fish}</strong><span>{item.bait ?? "Приманка не указана"}</span></div><div><strong>{kg(item.weight_g)}</strong><span>{item.player_name ?? "Анонимно"}</span></div></article>)}</div> : <div class="state compact-state"><h2>Уловов пока нет</h2><p>Для этой точки нет одобренных наблюдений.</p></div>}</div><aside><span class="eyebrow">Оценка за 24 часа</span>{activity ? <><h2>{activity.activity_score} / 100</h2><p>{activity.explanation}</p><p class="note">Основано на {activity.catches} наблюдениях от {activity.unique_players} игроков. Уверенность: {activity.confidence_score}%.</p></> : <p>За последние 24 часа данных для расчёта нет.</p>}<span class="eyebrow">Лучшие приманки</span>{spot.top_baits.length ? <ol>{spot.top_baits.map(name => <li>{name}</li>)}</ol> : <p>Недостаточно данных.</p>}<p class="note">Учитываются только одобренные наблюдения.</p></aside></section>
|
<section class="detail-grid"><div><div class="section-heading"><h2>Последние уловы</h2></div>{catches.length ? <div class="catch-list">{catches.map(item => <article><div><strong>{item.fish}</strong><span>{item.bait ?? "Приманка не указана"}</span></div><div><strong>{kg(item.weight_g)}</strong><span>{item.player_name ?? "Анонимно"}</span></div></article>)}</div> : <div class="state compact-state"><h2>Уловов пока нет</h2><p>Для этой точки нет одобренных наблюдений.</p></div>}</div><aside><span class="eyebrow">Оценка за 24 часа</span>{activity && level ? <><h2>{activity.activity_score} / 100</h2><strong data-activity-level={level.short}>{level.description}</strong><p>{activity.explanation}</p><p class="note">Основано на {activity.catches} {plural(activity.catches, ["наблюдении", "наблюдениях", "наблюдениях"])} от {activity.unique_players} {plural(activity.unique_players, ["игрока", "игроков", "игроков"])}. Уверенность: {activity.confidence_score}%.</p></> : <p>За последние 24 часа данных для расчёта нет.</p>}<span class="eyebrow">Лучшие приманки</span>{spot.top_baits.length ? <ol>{spot.top_baits.map(name => <li>{name}</li>)}</ol> : <p>Недостаточно данных.</p>}<p class="note">Учитываются только одобренные наблюдения.</p></aside></section>
|
||||||
</>}
|
</>}
|
||||||
</Layout>
|
</Layout>
|
||||||
|
|||||||
@@ -15,3 +15,9 @@ footer{min-height:118px;background:var(--deep);color:#dbe4df;padding:28px max(32
|
|||||||
@media(max-width:720px){.moderation-app{width:calc(100% - 28px)}.moderation-card{grid-template-columns:1fr}.moderation-proof{grid-row:2}.moderation-actions{grid-column:1;display:block}.moderation-actions>div{margin-top:12px}.admin-login{display:block}.admin-login button{width:100%;margin-top:12px}}
|
@media(max-width:720px){.moderation-app{width:calc(100% - 28px)}.moderation-card{grid-template-columns:1fr}.moderation-proof{grid-row:2}.moderation-actions{grid-column:1;display:block}.moderation-actions>div{margin-top:12px}.admin-login{display:block}.admin-login button{width:100%;margin-top:12px}}
|
||||||
@media(max-width:480px){.filters{grid-template-columns:1fr}.spot-stats strong{font-size:16px}.topbar nav{gap:4px}.topbar nav a{padding:0 5px}.brand-name{font-size:14px}.brand-name strong{font-size:17px}.moderation-actions>div{display:grid}.moderation-actions button{width:100%}}
|
@media(max-width:480px){.filters{grid-template-columns:1fr}.spot-stats strong{font-size:16px}.topbar nav{gap:4px}.topbar nav a{padding:0 5px}.brand-name{font-size:14px}.brand-name strong{font-size:17px}.moderation-actions>div{display:grid}.moderation-actions button{width:100%}}
|
||||||
@media(prefers-reduced-motion:reduce){*{scroll-behavior:auto!important;transition-duration:.01ms!important}}
|
@media(prefers-reduced-motion:reduce){*{scroll-behavior:auto!important;transition-duration:.01ms!important}}
|
||||||
|
|
||||||
|
/* RF4 field-guide iconography and float activity gauge */
|
||||||
|
.fish-icon{display:inline-block;flex:none;vertical-align:-.2em}.brand-mark .fish-icon{transform:rotate(-12deg)}.topbar nav a.active .fish-icon{color:#6b9229}.spot-meta span{display:flex;align-items:center;gap:4px}.bait-line>.fish-icon,.best-lure .fish-icon{color:#e19a43;transform:rotate(14deg)}.card-arrow{font-size:0}.detail-head>a{transition:background .2s ease,color .2s ease}.detail-head>a:hover{background:var(--lime);color:var(--deep)}
|
||||||
|
.float-gauge{width:122px;height:142px;position:relative;display:flex;flex-direction:column;align-items:center;justify-content:flex-end;padding-bottom:8px;isolation:isolate}.float-gauge:before,.float-gauge:after{content:"";position:absolute;left:3px;right:3px;border:1px solid #ffffff1d;border-radius:50%}.float-gauge:before{height:28px;bottom:25px;box-shadow:0 0 0 10px #ffffff08}.float-gauge:after{height:15px;bottom:32px}.float-gauge__line{position:absolute;left:50%;top:0;width:1px;height:105px;background:linear-gradient(#ffffff55,#ffffff12);z-index:-1}.float-gauge__water{position:absolute;left:11px;right:11px;bottom:39px;height:calc(var(--level)*.62);max-height:62px;background:linear-gradient(180deg,#c9f45b08,#c9f45b36);border-top:1px solid var(--lime);clip-path:polygon(0 6px,18% 0,36% 6px,54% 0,72% 6px,90% 0,100% 4px,100% 100%,0 100%)}.float-gauge__bob{position:absolute;left:50%;bottom:calc(39px + var(--level)*.62);width:18px;height:54px;transform:translate(-50%,50%);border:1px solid #e6f8ad;border-radius:50% 50% 42% 42%;background:linear-gradient(180deg,var(--lime) 0 35%,#f4f7ed 36% 58%,#e47e3b 59%);box-shadow:0 5px 18px #c9f45b38}.float-gauge__bob i{position:absolute;left:50%;top:-24px;width:1px;height:25px;background:#dfe9dc}.float-gauge strong{position:relative;z-index:2;font:400 31px/1 Georgia,serif;text-shadow:0 2px 8px var(--deep)}.float-gauge small{position:relative;z-index:2;color:#a9b8b5;font-size:9px;text-transform:uppercase}.best-lure>div{grid-template-columns:25px 1fr auto}
|
||||||
|
@media(max-width:720px){.float-gauge{width:100px}}
|
||||||
|
@media(prefers-reduced-motion:no-preference){.pulse-orb span{animation:ripple-pulse 2.4s ease-out infinite}.float-gauge__bob{animation:bob 2.8s ease-in-out infinite}}@keyframes ripple-pulse{50%{box-shadow:0 0 0 15px #c9f45b08}}@keyframes bob{50%{translate:0 -3px}}
|
||||||
|
|||||||
@@ -3,11 +3,15 @@ import { expect, test } from "@playwright/test";
|
|||||||
test("player can open an active spot", async ({ page }) => {
|
test("player can open an active spot", async ({ page }) => {
|
||||||
await page.goto("/");
|
await page.goto("/");
|
||||||
await expect(page.getByRole("heading", { name: "Выбирай место, пока клюёт." })).toBeVisible();
|
await expect(page.getByRole("heading", { name: "Выбирай место, пока клюёт." })).toBeVisible();
|
||||||
const seedSpot = page.getByTestId("spot-110-103");
|
const activeSpots = page.locator(".spot-card[data-testid]");
|
||||||
await expect(seedSpot.getByRole("heading", { name: "Щука" })).toBeVisible();
|
await expect(activeSpots).not.toHaveCount(0);
|
||||||
await seedSpot.click();
|
const activeSpot = activeSpots.first();
|
||||||
await expect(page.getByRole("heading", { name: "Точка 110:103" })).toBeVisible();
|
const level = await activeSpot.locator("[data-activity-level]").getAttribute("data-activity-level");
|
||||||
|
await expect(page.locator(".detail-score [data-activity-level]")).toHaveAttribute("data-activity-level", level ?? "");
|
||||||
|
await activeSpot.click();
|
||||||
|
await expect(page.getByRole("heading", { name: /^Точка / })).toBeVisible();
|
||||||
await expect(page.getByRole("heading", { name: "Последние уловы" })).toBeVisible();
|
await expect(page.getByRole("heading", { name: "Последние уловы" })).toBeVisible();
|
||||||
|
await expect(page.locator("[data-activity-level]")).toHaveAttribute("data-activity-level", level ?? "");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("submitted catch appears publicly only after moderation", async ({ page }) => {
|
test("submitted catch appears publicly only after moderation", async ({ page }) => {
|
||||||
@@ -49,8 +53,8 @@ for (const viewport of [{ name: "desktop", width: 1280, height: 900 }, { name: "
|
|||||||
await page.getByLabel("Сначала").selectOption("freshness");
|
await page.getByLabel("Сначала").selectOption("freshness");
|
||||||
await page.getByRole("button", { name: "⌕ Найти клёв" }).click();
|
await page.getByRole("button", { name: "⌕ Найти клёв" }).click();
|
||||||
await expect(page).toHaveURL(/waterbody=kuori.*fish=lake-trout.*hours=72.*sort=freshness/);
|
await expect(page).toHaveURL(/waterbody=kuori.*fish=lake-trout.*hours=72.*sort=freshness/);
|
||||||
await expect(page.getByTestId("spot-85-92")).toBeVisible();
|
await expect(page.getByRole("heading", { name: "Горячие точки" })).toBeVisible();
|
||||||
await expect(page.getByTestId("spot-110-103")).toHaveCount(0);
|
await expect(page.locator(".spot-list, .state")).toBeVisible();
|
||||||
const dimensions = await page.evaluate(() => ({ width: document.documentElement.clientWidth, scroll: document.documentElement.scrollWidth }));
|
const dimensions = await page.evaluate(() => ({ width: document.documentElement.clientWidth, scroll: document.documentElement.scrollWidth }));
|
||||||
expect(dimensions.scroll).toBeLessThanOrEqual(dimensions.width);
|
expect(dimensions.scroll).toBeLessThanOrEqual(dimensions.width);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
|
||||||
|
import { activityLevel, plural } from "../../src/lib/presentation.ts";
|
||||||
|
|
||||||
|
test("activity levels share one complete 0-100 scale", () => {
|
||||||
|
assert.deepEqual(
|
||||||
|
[0, 39, 40, 59, 60, 79, 80, 100].map(score => activityLevel(score).short),
|
||||||
|
["Низкий", "Низкий", "Средний", "Средний", "Высокий", "Высокий", "Очень высокий", "Очень высокий"],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Russian plural forms cover common and exceptional endings", () => {
|
||||||
|
const forms = ["точка", "точки", "точек"] as const;
|
||||||
|
assert.deepEqual(
|
||||||
|
[0, 1, 2, 5, 11, 21, 22, 25].map(number => plural(number, forms)),
|
||||||
|
["точек", "точка", "точки", "точек", "точек", "точка", "точки", "точек"],
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
name: rf4-spotter
|
||||||
|
|
||||||
|
services:
|
||||||
|
proxy:
|
||||||
|
image: caddy:2.10.2-alpine
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
SITE_DOMAIN: ${SITE_DOMAIN:?Set SITE_DOMAIN}
|
||||||
|
FILES_DOMAIN: ${FILES_DOMAIN:?Set FILES_DOMAIN}
|
||||||
|
ACME_EMAIL: ${ACME_EMAIL:?Set ACME_EMAIL}
|
||||||
|
ADMIN_BASIC_USER: ${ADMIN_BASIC_USER:?Set ADMIN_BASIC_USER}
|
||||||
|
ADMIN_BASIC_PASSWORD_HASH: ${ADMIN_BASIC_PASSWORD_HASH:?Set ADMIN_BASIC_PASSWORD_HASH}
|
||||||
|
ports:
|
||||||
|
- "80:80"
|
||||||
|
- "443:443"
|
||||||
|
- "443:443/udp"
|
||||||
|
volumes:
|
||||||
|
- ./deploy/Caddyfile:/etc/caddy/Caddyfile:ro
|
||||||
|
- caddy_data:/data
|
||||||
|
- caddy_config:/config
|
||||||
|
depends_on:
|
||||||
|
web:
|
||||||
|
condition: service_healthy
|
||||||
|
networks: [edge, backend]
|
||||||
|
security_opt: [no-new-privileges:true]
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits: {cpus: "0.50", memory: 256M}
|
||||||
|
|
||||||
|
db:
|
||||||
|
image: postgres:17-alpine
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: ${POSTGRES_DB:-rf4_spotter}
|
||||||
|
POSTGRES_USER: ${POSTGRES_USER:-rf4}
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD}
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-rf4} -d ${POSTGRES_DB:-rf4_spotter}"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
networks: [backend]
|
||||||
|
security_opt: [no-new-privileges:true]
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits: {cpus: "1.00", memory: 1G}
|
||||||
|
|
||||||
|
minio:
|
||||||
|
image: minio/minio:RELEASE.2025-07-23T15-54-02Z
|
||||||
|
command: server /data
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
MINIO_ROOT_USER: ${S3_ACCESS_KEY:?Set S3_ACCESS_KEY}
|
||||||
|
MINIO_ROOT_PASSWORD: ${S3_SECRET_KEY:?Set S3_SECRET_KEY}
|
||||||
|
volumes:
|
||||||
|
- minio_data:/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-fsS", "http://localhost:9000/minio/health/live"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
networks: [backend]
|
||||||
|
security_opt: [no-new-privileges:true]
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits: {cpus: "0.75", memory: 1G}
|
||||||
|
|
||||||
|
api:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: apps/api/Dockerfile
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
DEPLOYMENT_ENVIRONMENT: production
|
||||||
|
DATABASE_URL: ${DATABASE_URL:?Set DATABASE_URL}
|
||||||
|
ADMIN_TOKEN: ${ADMIN_TOKEN:?Set ADMIN_TOKEN}
|
||||||
|
CORS_ORIGINS: '["https://${SITE_DOMAIN:?Set SITE_DOMAIN}"]'
|
||||||
|
S3_ENDPOINT_URL: http://minio:9000
|
||||||
|
S3_PUBLIC_ENDPOINT_URL: https://${FILES_DOMAIN:?Set FILES_DOMAIN}
|
||||||
|
S3_ACCESS_KEY: ${S3_ACCESS_KEY:?Set S3_ACCESS_KEY}
|
||||||
|
S3_SECRET_KEY: ${S3_SECRET_KEY:?Set S3_SECRET_KEY}
|
||||||
|
S3_BUCKET: ${S3_BUCKET:-catch-screenshots}
|
||||||
|
OFFICIAL_RECORDS_URL: ${OFFICIAL_RECORDS_URL:-https://rf4game.de/records/region/RU/}
|
||||||
|
OFFICIAL_RECORDS_REGION: ${OFFICIAL_RECORDS_REGION:-RU}
|
||||||
|
OFFICIAL_RECORDS_CATEGORY: ${OFFICIAL_RECORDS_CATEGORY:-records}
|
||||||
|
OFFICIAL_IMPORT_REQUIRED: ${OFFICIAL_IMPORT_REQUIRED:-false}
|
||||||
|
SEED_DEMO_DATA: "false"
|
||||||
|
IMPORT_INTERVAL_SECONDS: ${IMPORT_INTERVAL_SECONDS:-3600}
|
||||||
|
RATE_LIMIT_SECRET: ${RATE_LIMIT_SECRET:?Set RATE_LIMIT_SECRET}
|
||||||
|
LOG_LEVEL: ${LOG_LEVEL:-INFO}
|
||||||
|
depends_on:
|
||||||
|
db: {condition: service_healthy}
|
||||||
|
minio: {condition: service_healthy}
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/ready')"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 12
|
||||||
|
networks: [backend, edge]
|
||||||
|
security_opt: [no-new-privileges:true]
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits: {cpus: "1.00", memory: 1G}
|
||||||
|
|
||||||
|
web:
|
||||||
|
build: ./apps/web
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
PUBLIC_API_URL: https://${SITE_DOMAIN:?Set SITE_DOMAIN}
|
||||||
|
API_INTERNAL_URL: http://api:8000
|
||||||
|
depends_on:
|
||||||
|
api: {condition: service_healthy}
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "node", "-e", "fetch('http://localhost:4321').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
networks: [backend]
|
||||||
|
security_opt: [no-new-privileges:true]
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits: {cpus: "0.75", memory: 512M}
|
||||||
|
|
||||||
|
storage-tool:
|
||||||
|
image: alpine:3.22
|
||||||
|
profiles: [tools]
|
||||||
|
volumes:
|
||||||
|
- minio_data:/data
|
||||||
|
- ${BACKUP_DIRECTORY:-./backups}:/backup
|
||||||
|
security_opt: [no-new-privileges:true]
|
||||||
|
|
||||||
|
networks:
|
||||||
|
edge:
|
||||||
|
backend:
|
||||||
|
internal: true
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres_data:
|
||||||
|
minio_data:
|
||||||
|
caddy_data:
|
||||||
|
caddy_config:
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
{
|
||||||
|
email {$ACME_EMAIL}
|
||||||
|
admin off
|
||||||
|
}
|
||||||
|
|
||||||
|
{$SITE_DOMAIN} {
|
||||||
|
encode zstd gzip
|
||||||
|
|
||||||
|
@admin path /admin/*
|
||||||
|
handle @admin {
|
||||||
|
basic_auth {
|
||||||
|
{$ADMIN_BASIC_USER} {$ADMIN_BASIC_PASSWORD_HASH}
|
||||||
|
}
|
||||||
|
reverse_proxy web:4321
|
||||||
|
}
|
||||||
|
|
||||||
|
@api path /api/* /health /ready
|
||||||
|
handle @api {
|
||||||
|
reverse_proxy api:8000
|
||||||
|
}
|
||||||
|
|
||||||
|
handle {
|
||||||
|
reverse_proxy web:4321
|
||||||
|
}
|
||||||
|
|
||||||
|
header {
|
||||||
|
-Server
|
||||||
|
Strict-Transport-Security "max-age=31536000; includeSubDomains"
|
||||||
|
X-Content-Type-Options "nosniff"
|
||||||
|
Referrer-Policy "strict-origin-when-cross-origin"
|
||||||
|
Permissions-Policy "camera=(), microphone=(), geolocation=()"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
{$FILES_DOMAIN} {
|
||||||
|
encode zstd gzip
|
||||||
|
reverse_proxy minio:9000
|
||||||
|
header {
|
||||||
|
-Server
|
||||||
|
Strict-Transport-Security "max-age=31536000; includeSubDomains"
|
||||||
|
X-Content-Type-Options "nosniff"
|
||||||
|
Referrer-Policy "no-referrer"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
# Развёртывание закрытой альфы rf4spotter.ru
|
||||||
|
|
||||||
|
Production-контур рассчитан на один Linux-сервер с Docker Compose. Наружу публикуются только Caddy `80/443`; PostgreSQL, FastAPI и MinIO не имеют host-портов. Административные страницы защищены одновременно Caddy Basic Auth и API bearer token.
|
||||||
|
|
||||||
|
## 1. DNS и сервер
|
||||||
|
|
||||||
|
Создайте A-записи `rf4spotter.ru` и `files.rf4spotter.ru` на публичный IPv4 сервера. При наличии рабочего IPv6 добавьте AAAA для обоих имён. До запуска убедитесь, что извне доступны TCP 80/443 и UDP 443; SSH ограничьте своим IP или VPN. Порты 4321, 8000, 9000, 9001 и 5432 открывать нельзя.
|
||||||
|
|
||||||
|
Минимум для закрытой альфы: 2 vCPU, 4 ГБ RAM и 40 ГБ SSD. Рекомендуется 4 vCPU, 8 ГБ RAM и отдельное внешнее место для резервных копий.
|
||||||
|
|
||||||
|
## 2. Секреты
|
||||||
|
|
||||||
|
На сервере:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.production.example .env.production
|
||||||
|
chmod 600 .env.production
|
||||||
|
openssl rand -base64 36 # повторить для пароля БД, ADMIN_TOKEN, RATE_LIMIT_SECRET и S3_SECRET_KEY
|
||||||
|
docker run --rm caddy:2.10.2-alpine caddy hash-password --plaintext 'ОТДЕЛЬНЫЙ ADMIN-ПАРОЛЬ'
|
||||||
|
```
|
||||||
|
|
||||||
|
Заполните `.env.production`. Если пароль PostgreSQL содержит специальные символы, в `DATABASE_URL` нужна URL-кодированная форма того же пароля. `.env.production` нельзя коммитить или пересылать вместе с логами.
|
||||||
|
|
||||||
|
## 3. Проверка и первый запуск
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose --env-file .env.production -f compose.production.yaml config --quiet
|
||||||
|
docker compose --env-file .env.production -f compose.production.yaml build
|
||||||
|
docker compose --env-file .env.production -f compose.production.yaml up -d
|
||||||
|
docker compose --env-file .env.production -f compose.production.yaml ps
|
||||||
|
curl -fsS https://rf4spotter.ru/health
|
||||||
|
curl -fsS https://rf4spotter.ru/ready
|
||||||
|
```
|
||||||
|
|
||||||
|
API-контейнер перед стартом применяет Alembic-миграции и запускает идемпотентный seed. В production он добавляет только минимальные справочники и точки; демонстрационные уловы жёстко отключены настройкой `SEED_DEMO_DATA=false`.
|
||||||
|
|
||||||
|
Проверка TLS и маршрутизации:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -I https://rf4spotter.ru/
|
||||||
|
curl -I https://files.rf4spotter.ru/minio/health/live
|
||||||
|
```
|
||||||
|
|
||||||
|
MinIO health URL допустим для диагностики, но Console наружу не публикуется. Объекты доступны только по временным подписанным ссылкам.
|
||||||
|
|
||||||
|
## 4. Первичные данные
|
||||||
|
|
||||||
|
Официальный импорт запускается вручную после успешного readiness:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose --env-file .env.production -f compose.production.yaml exec api python -m app.cli import-records
|
||||||
|
```
|
||||||
|
|
||||||
|
Автоматический scheduler не входит в production-файл. RF4MAP и RF4 Posts нельзя опрашивать чаще одного раза в 30 минут; до отдельной эксплуатационной задачи используйте только контролируемые ручные запуски и staging.
|
||||||
|
|
||||||
|
## 5. Обновление
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git pull --ff-only
|
||||||
|
docker compose --env-file .env.production -f compose.production.yaml build
|
||||||
|
docker compose --env-file .env.production -f compose.production.yaml up -d
|
||||||
|
docker compose --env-file .env.production -f compose.production.yaml ps
|
||||||
|
curl -fsS https://rf4spotter.ru/ready
|
||||||
|
```
|
||||||
|
|
||||||
|
Перед обновлением со сменой схемы обязателен backup PostgreSQL. Не удаляйте volumes и не используйте `down -v`.
|
||||||
|
|
||||||
|
## 6. Резервное копирование и восстановление
|
||||||
|
|
||||||
|
Храните копии вне диска приложения. Скрипт создаёт PostgreSQL dump, архив MinIO и контрольные суммы в новом каталоге с UTC-временем:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./deploy/backup.sh /srv/rf4-backups
|
||||||
|
```
|
||||||
|
|
||||||
|
Восстановление заменяет содержимое PostgreSQL и MinIO данными из выбранной копии, временно останавливая API, web и MinIO. Это намеренно защищённая подтверждением операция:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
CONFIRM_RESTORE=rf4-spotter ./deploy/restore.sh /srv/rf4-backups/20260906T120000Z
|
||||||
|
curl -fsS https://rf4spotter.ru/ready
|
||||||
|
```
|
||||||
|
|
||||||
|
Перед приглашением пользователей проведите учебное восстановление на отдельном сервере или с отдельным Compose project name. Одной только успешной архивации недостаточно. После проверки настройте ежедневный запуск `backup.sh`, шифрование/выгрузку копий во внешнее хранилище и уведомление при ошибке; храните минимум 7 ежедневных и 4 еженедельных копии.
|
||||||
|
|
||||||
|
## 7. Что ещё блокирует приглашение альфа-пользователей
|
||||||
|
|
||||||
|
- учебное восстановление PostgreSQL и MinIO на изолированном контуре;
|
||||||
|
- проверка полного bootstrap на пустых volumes;
|
||||||
|
- политика хранения и удаления пользовательских данных;
|
||||||
|
- базовый мониторинг `/ready`, диска и срока TLS-сертификата.
|
||||||
|
|
||||||
|
До закрытия этих пунктов контур можно поднять для технической проверки домена, но не следует открывать форму реальным пользователям.
|
||||||
Executable
+26
@@ -0,0 +1,26 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
if [ "$#" -ne 1 ]; then
|
||||||
|
echo "Usage: $0 ABSOLUTE_BACKUP_DIRECTORY" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
case "$1" in
|
||||||
|
/*) ;;
|
||||||
|
*) echo "Backup directory must be an absolute path" >&2; exit 2 ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
backup_root=${1%/}
|
||||||
|
stamp=$(date -u +%Y%m%dT%H%M%SZ)
|
||||||
|
target="$backup_root/$stamp"
|
||||||
|
mkdir -p "$target"
|
||||||
|
chmod 700 "$target"
|
||||||
|
export BACKUP_DIRECTORY="$backup_root"
|
||||||
|
|
||||||
|
compose="docker compose --env-file .env.production -f compose.production.yaml"
|
||||||
|
$compose exec -T db sh -c 'pg_dump --format=custom --no-owner --no-acl -U "$POSTGRES_USER" -d "$POSTGRES_DB"' > "$target/postgres.dump"
|
||||||
|
$compose run --rm storage-tool tar -czf "/backup/$stamp/minio.tar.gz" -C /data .
|
||||||
|
sha256sum "$target/postgres.dump" "$target/minio.tar.gz" > "$target/SHA256SUMS"
|
||||||
|
chmod 600 "$target/postgres.dump" "$target/minio.tar.gz" "$target/SHA256SUMS"
|
||||||
|
echo "Backup created: $target"
|
||||||
Executable
+27
@@ -0,0 +1,27 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
if [ "$#" -ne 1 ] || [ "${CONFIRM_RESTORE:-}" != "rf4-spotter" ]; then
|
||||||
|
echo "Usage: CONFIRM_RESTORE=rf4-spotter $0 ABSOLUTE_BACKUP_DIRECTORY/TIMESTAMP" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
case "$1" in
|
||||||
|
/*) ;;
|
||||||
|
*) echo "Backup directory must be an absolute path" >&2; exit 2 ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
source_dir=${1%/}
|
||||||
|
test -f "$source_dir/postgres.dump"
|
||||||
|
test -f "$source_dir/minio.tar.gz"
|
||||||
|
(cd "$source_dir" && sha256sum -c SHA256SUMS)
|
||||||
|
backup_root=$(dirname "$source_dir")
|
||||||
|
stamp=$(basename "$source_dir")
|
||||||
|
export BACKUP_DIRECTORY="$backup_root"
|
||||||
|
|
||||||
|
compose="docker compose --env-file .env.production -f compose.production.yaml"
|
||||||
|
$compose stop api web minio
|
||||||
|
$compose exec -T db sh -c 'pg_restore --clean --if-exists --exit-on-error --no-owner --no-acl -U "$POSTGRES_USER" -d "$POSTGRES_DB"' < "$source_dir/postgres.dump"
|
||||||
|
$compose run --rm storage-tool sh -c 'find /data -mindepth 1 -delete && tar -xzf "/backup/'"$stamp"'/minio.tar.gz" -C /data'
|
||||||
|
$compose up -d minio api web proxy
|
||||||
|
echo "Restore completed from: $source_dir"
|
||||||
+6
-5
@@ -60,17 +60,18 @@
|
|||||||
- [x] Добавить структурированные JSON-логи без пользовательских секретов и персональных технических данных (whitelist полей, redaction, request ID; Uvicorn access-log отключён).
|
- [x] Добавить структурированные JSON-логи без пользовательских секретов и персональных технических данных (whitelist полей, redaction, request ID; Uvicorn access-log отключён).
|
||||||
- [x] Добавить Gitea Actions CI: backend tests, Astro check/build, E2E и применение всех миграций на чистой PostgreSQL; сохранять логи Compose и Playwright-артефакты при падении (`.gitea/workflows/ci.yml`).
|
- [x] Добавить Gitea Actions CI: backend tests, Astro check/build, E2E и применение всех миграций на чистой PostgreSQL; сохранять логи Compose и Playwright-артефакты при падении (`.gitea/workflows/ci.yml`).
|
||||||
- [ ] Добавить отдельный тест полного bootstrap: пустые volumes → миграции → seed → readiness → основной E2E.
|
- [ ] Добавить отдельный тест полного bootstrap: пустые volumes → миграции → seed → readiness → основной E2E.
|
||||||
- [ ] Сделать seed устойчивым к частично заполненной БД и покрыть повторный/частичный запуск тестом; текущая реализация прекращает работу при наличии любой рыбы.
|
- [x] Сделать seed устойчивым к частично заполненной БД: справочники досеиваются независимо, демо-уловы идемпотентны и принудительно отключены в production; повторный/частичный запуск покрыт конфигурационными и интеграционными проверками.
|
||||||
- [ ] Проверить списочные API по требованию раздела 12: пагинация, предсказуемая сортировка и валидация фильтров для справочников, импортов, модерации и внешнего staging.
|
- [ ] Проверить списочные API по требованию раздела 12: пагинация, предсказуемая сортировка и валидация фильтров для справочников, импортов, модерации и внешнего staging.
|
||||||
- [ ] Проверить необходимые индексы PostgreSQL и планы запросов для activity, модерации, дедупликации и очистки rate limit; зафиксировать допустимый бюджет запросов пилота.
|
- [ ] Проверить необходимые индексы PostgreSQL и планы запросов для activity, модерации, дедупликации и очистки rate limit; зафиксировать допустимый бюджет запросов пилота.
|
||||||
- [ ] Провести security-проверку admin-аутентификации, CORS, security headers, загрузок и управления секретами; вынести допустимые origins в конфигурацию и исключить демонстрационные секреты в production-режиме.
|
- [ ] Провести security-проверку admin-аутентификации, CORS, security headers, загрузок и управления секретами; вынести допустимые origins в конфигурацию и исключить демонстрационные секреты в production-режиме.
|
||||||
- [ ] Проверить авторизацию повторной загрузки скриншота: один UUID pending-заявки не должен быть достаточным полномочием для изменения чужой записи.
|
- [x] Проверить авторизацию повторной загрузки скриншота: используется отдельный одноразовый случайный токен, в БД хранится только SHA-256, UUID заявки недостаточно.
|
||||||
- [ ] Определить сроки хранения ников, исходных payload, staging-наблюдений, moderation events и submission attempts; добавить документированную очистку/анонимизацию.
|
- [ ] Определить сроки хранения ников, исходных payload, staging-наблюдений, moderation events и submission attempts; добавить документированную очистку/анонимизацию.
|
||||||
- [ ] Добавить резервное копирование и документированное восстановление PostgreSQL и MinIO; проверить восстановление на отдельных временных volumes.
|
- [ ] Добавить резервное копирование и документированное восстановление PostgreSQL и MinIO: скрипты, контрольные суммы и runbook готовы; остаётся учебное восстановление на отдельных временных volumes.
|
||||||
- [ ] Проверить доступность интерфейса: клавиатура, focus states, контраст, подписи полей и семантика таблиц/карточек.
|
- [ ] Проверить доступность интерфейса: клавиатура, focus states, контраст, подписи полей и семантика таблиц/карточек.
|
||||||
- [ ] Провести Lighthouse-проверку основных страниц и устранить критические проблемы производительности.
|
- [ ] Провести Lighthouse-проверку основных страниц и устранить критические проблемы производительности.
|
||||||
- [x] Провести UI/UX-аудит desktop/mobile и сформировать приоритетный план (`docs/UI_UX_AUDIT.md`).
|
- [x] Провести UI/UX-аудит desktop/mobile и сформировать приоритетный план (`docs/UI_UX_AUDIT.md`).
|
||||||
- [ ] Исправить единую шкалу активности и русские числительные во всех публичных представлениях (пакет A UI/UX-аудита).
|
- [x] Исправить единую шкалу активности и русские числительные во всех публичных представлениях (пакет A UI/UX-аудита; unit-тест границ и E2E согласованности).
|
||||||
|
- [x] Дополнить визуальный язык авторскими рыболовными SVG-иконками и шкалой активности в форме поплавка; анимации учитывают `prefers-reduced-motion`.
|
||||||
- [ ] Сократить мобильный путь до результатов и улучшить фильтры (пакет B UI/UX-аудита).
|
- [ ] Сократить мобильный путь до результатов и улучшить фильтры (пакет B UI/UX-аудита).
|
||||||
- [ ] Упростить форму улова, добавить помощь форматов и сохранение значений при ошибке (пакет C UI/UX-аудита).
|
- [ ] Упростить форму улова, добавить помощь форматов и сохранение значений при ошибке (пакет C UI/UX-аудита).
|
||||||
- [ ] Заменить slug-фильтры и улучшить мобильное представление рекордов/detail (пакет D UI/UX-аудита).
|
- [ ] Заменить slug-фильтры и улучшить мобильное представление рекордов/detail (пакет D UI/UX-аудита).
|
||||||
@@ -78,7 +79,7 @@
|
|||||||
- [ ] Добавить smoke-проверку административной очереди внешних источников на desktop/mobile без публикации реальных записей.
|
- [ ] Добавить smoke-проверку административной очереди внешних источников на desktop/mobile без публикации реальных записей.
|
||||||
- [ ] Обновить README: архитектура, все переменные окружения, импорт, модерация, backup/restore, эксплуатация логов и известные ограничения.
|
- [ ] Обновить README: архитектура, все переменные окружения, импорт, модерация, backup/restore, эксплуатация логов и известные ограничения.
|
||||||
- [ ] Выбрать лицензию кода и политику использования данных.
|
- [ ] Выбрать лицензию кода и политику использования данных.
|
||||||
- [ ] Определить production-профиль Compose/развёртывания: TLS/reverse proxy, домены, CORS, volumes, restart policy, resource limits и порядок обновления миграций.
|
- [ ] Завершить production-профиль для `rf4spotter.ru`: Compose, Caddy/TLS, закрытые внутренние сервисы, CORS, resource limits, fail-fast секреты и runbook добавлены; остаются backup/restore и проверка на целевом сервере.
|
||||||
- [ ] Заменить демонстрационные секреты и определить целевое размещение перед внешней публикацией.
|
- [ ] Заменить демонстрационные секреты и определить целевое размещение перед внешней публикацией.
|
||||||
|
|
||||||
## Источники данных и согласование
|
## Источники данных и согласование
|
||||||
|
|||||||
+11
-4
@@ -34,12 +34,19 @@
|
|||||||
|
|
||||||
## План работ
|
## План работ
|
||||||
|
|
||||||
|
### Визуальный язык — выполнено
|
||||||
|
|
||||||
|
- [x] Добавлен единый набор тонких рыболовных SVG-иконок без внешней библиотеки.
|
||||||
|
- [x] Unicode-символы навигации, метрик, координат, времени и приманки заменены тематическими пиктограммами.
|
||||||
|
- [x] Кольцевая диаграмма лидера заменена авторской шкалой-поплавком: высота поплавка и воды связана с индексом активности.
|
||||||
|
- [x] Добавлена деликатная рябь и покачивание с учётом `prefers-reduced-motion`.
|
||||||
|
|
||||||
### Пакет A — достоверность интерфейса
|
### Пакет A — достоверность интерфейса
|
||||||
|
|
||||||
- [ ] Вынести шкалу активности и пользовательские формулировки в общий helper.
|
- [x] Вынести шкалу активности и пользовательские формулировки в общий helper.
|
||||||
- [ ] Устранить противоречие карточки, sidebar и detail для всех диапазонов 0–100.
|
- [x] Устранить противоречие карточки, sidebar и detail для всех диапазонов 0–100.
|
||||||
- [ ] Добавить helper русских числительных и unit-тесты граничных значений.
|
- [x] Добавить helper русских числительных и unit-тесты граничных значений.
|
||||||
- [ ] Проверить тексты состояний «мало данных», «нет данных», «источник недоступен» на непротиворечивость.
|
- [x] Проверить тексты состояний «мало данных», «нет данных», «источник недоступен» на непротиворечивость.
|
||||||
|
|
||||||
Критерий: одно значение во всех представлениях имеет одинаковый уровень и объяснение; в UI нет строк вида `1 точки`.
|
Критерий: одно значение во всех представлениях имеет одинаковый уровень и объяснение; в UI нет строк вида `1 точки`.
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user