Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8e419c1832 | ||
|
|
6e18e8e565 | ||
|
|
7c28a0c36a | ||
|
|
ff8ea40d40 | ||
|
|
ca22042d18 | ||
|
|
9158ae3d8a | ||
|
|
1776283a95 | ||
|
|
31bbc15535 | ||
|
|
e48b9ef876 |
@@ -50,7 +50,7 @@ Production release отделяет Alembic от runtime: одноразовый
|
|||||||
|
|
||||||
Тяжёлый production bootstrap вынесен в отдельный ручной/еженедельный CI workflow с 30-минутным timeout и сохраняемыми diagnostics; обычный push по-прежнему использует быстрый Compose E2E.
|
Тяжёлый production bootstrap вынесен в отдельный ручной/еженедельный CI workflow с 30-минутным timeout и сохраняемыми diagnostics; обычный push по-прежнему использует быстрый Compose E2E.
|
||||||
|
|
||||||
Публичный API зафиксирован генерируемым [OpenAPI-контрактом](docs/api-contract.md): CI сравнивает `apps/api/openapi.json` с фактической схемой FastAPI, поэтому рефакторинг routers не может незаметно изменить URL, параметры или response models. Декомпозиция выполняется инкрементально: общий `Db` вынесен в dependencies; каталог рыб/водоёмов/приманок, public spot pages, activity и чтение точек уже обслуживают отдельные `APIRouter`.
|
Публичный API зафиксирован генерируемым [OpenAPI-контрактом](docs/api-contract.md): CI сравнивает `apps/api/openapi.json` с фактической схемой FastAPI, поэтому рефакторинг routers не может незаметно изменить URL, параметры или response models. Декомпозиция выполняется инкрементально: catalog, activity/spots, public data и submissions принадлежат отдельным `APIRouter`; проверка доверенных proxy и persistent rate limit отправки улова изолированы в `submission_security`.
|
||||||
|
|
||||||
После повторных ошибок scheduler увеличивает паузу экспоненциально до 24 часов и возвращается к 30 минутам после успеха. Публичная страница `/status` показывает свежесть и состояние источников без URL запросов, внутренних ошибок и другой диагностической информации.
|
После повторных ошибок scheduler увеличивает паузу экспоненциально до 24 часов и возвращается к 30 минутам после успеха. Публичная страница `/status` показывает свежесть и состояние источников без URL запросов, внутренних ошибок и другой диагностической информации.
|
||||||
|
|
||||||
@@ -242,9 +242,11 @@ curl -H "Authorization: Bearer change-me-in-production" \
|
|||||||
|
|
||||||
Если создание записи прошло успешно, а загрузка скриншота завершилась ошибкой, форма сохраняет на один час ID заявки и одноразовый секрет в защищённой `HttpOnly` cookie и предлагает повторить только загрузку изображения. Повторно отправлять сам улов не требуется; один UUID заявки не даёт права изменить чужую запись.
|
Если создание записи прошло успешно, а загрузка скриншота завершилась ошибкой, форма сохраняет на один час ID заявки и одноразовый секрет в защищённой `HttpOnly` cookie и предлагает повторить только загрузку изображения. Повторно отправлять сам улов не требуется; один UUID заявки не даёт права изменить чужую запись.
|
||||||
|
|
||||||
Очередь модерации доступна по адресу <http://localhost:4321/admin/moderation>. Администратор вводит `ADMIN_TOKEN`; интерфейс держит его только в памяти открытой страницы и не сохраняет в URL или браузерном хранилище.
|
Единый dashboard доступен по адресу <http://localhost:4321/admin>, очереди — `/admin/moderation` и `/admin/external-sources`. Dashboard показывает объём очередей, состояние источников и последние импорты без внутренних URL и текстов ошибок. Администратор вводит `ADMIN_TOKEN`; интерфейс держит его только в памяти открытой страницы и не сохраняет в URL или браузерном хранилище. После 15 минут бездействия или ответа API `401` сессия очищается; также доступен явный выход.
|
||||||
|
|
||||||
Администратор может одобрить, отклонить или удалить сообщение. Удаление очищает ник, комментарий, исходную ссылку и объект скриншота, исключает запись из статистики, но сохраняет обезличенный факт действия в журнале аудита.
|
В production HTML административных страниц дополнительно закрыт Caddy Basic Auth, а API независимо проверяет Bearer-токен. Неуспешные попытки API-входа считаются в БД по HMAC-идентификатору адреса и временно блокируются после десяти ошибок за десять минут; успешная авторизация очищает ошибки клиента. Интерфейс открывает только ссылки со схемой `http` или `https`; данные источника не могут подставить исполняемую URL-схему в ссылку или превью.
|
||||||
|
|
||||||
|
Администратор может одобрить, отклонить или удалить сообщение. Очередь внешних наблюдений фильтруется на сервере по источнику и полноте, ищет рыбу/водоём и сортируется по свежести до применения пагинации. Удаление очищает ник, комментарий, исходную ссылку и объект скриншота, исключает запись из статистики, но сохраняет обезличенный факт действия в журнале аудита.
|
||||||
|
|
||||||
## Эксплуатация production
|
## Эксплуатация production
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
"""Add persistent administrative authentication rate limit."""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision = "0014"
|
||||||
|
down_revision = "20260910_recovery"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"admin_auth_attempt",
|
||||||
|
sa.Column("id", sa.Uuid(), nullable=False),
|
||||||
|
sa.Column("client_hash", sa.String(length=64), nullable=False),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_admin_auth_attempt_client_hash", "admin_auth_attempt", ["client_hash"])
|
||||||
|
op.create_index("ix_admin_auth_attempt_created_at", "admin_auth_attempt", ["created_at"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("ix_admin_auth_attempt_created_at", table_name="admin_auth_attempt")
|
||||||
|
op.drop_index("ix_admin_auth_attempt_client_hash", table_name="admin_auth_attempt")
|
||||||
|
op.drop_table("admin_auth_attempt")
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
|
||||||
|
from fastapi import HTTPException, Request
|
||||||
|
from sqlalchemy import delete, func, select, text
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from .models import AdminAuthAttempt
|
||||||
|
from .submission_security import RateLimitConfig, client_address
|
||||||
|
|
||||||
|
|
||||||
|
class AdminAuthConfig(RateLimitConfig):
|
||||||
|
admin_token: str
|
||||||
|
admin_auth_attempt_limit: int
|
||||||
|
admin_auth_window_seconds: int
|
||||||
|
|
||||||
|
|
||||||
|
def verify_admin(
|
||||||
|
request: Request,
|
||||||
|
db: Session,
|
||||||
|
authorization: str | None,
|
||||||
|
config: AdminAuthConfig,
|
||||||
|
) -> str:
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
cutoff = now - timedelta(seconds=config.admin_auth_window_seconds)
|
||||||
|
client = client_address(request, config.trusted_proxy_cidrs)
|
||||||
|
client_hash = hmac.new(
|
||||||
|
config.rate_limit_secret.encode(), f"admin:{client}".encode(), hashlib.sha256
|
||||||
|
).hexdigest()
|
||||||
|
if db.get_bind().dialect.name == "postgresql":
|
||||||
|
lock_key = int(client_hash[:16], 16) & 0x7FFF_FFFF_FFFF_FFFF
|
||||||
|
db.execute(text("SELECT pg_advisory_xact_lock(:lock_key)"), {"lock_key": lock_key})
|
||||||
|
db.execute(delete(AdminAuthAttempt).where(AdminAuthAttempt.created_at < now - timedelta(days=1)))
|
||||||
|
failures = db.scalar(
|
||||||
|
select(func.count()).select_from(AdminAuthAttempt).where(
|
||||||
|
AdminAuthAttempt.client_hash == client_hash,
|
||||||
|
AdminAuthAttempt.created_at >= cutoff,
|
||||||
|
)
|
||||||
|
) or 0
|
||||||
|
if failures >= config.admin_auth_attempt_limit:
|
||||||
|
db.commit()
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=429,
|
||||||
|
detail="too many admin authentication attempts",
|
||||||
|
headers={"Retry-After": str(config.admin_auth_window_seconds)},
|
||||||
|
)
|
||||||
|
expected = f"Bearer {config.admin_token}"
|
||||||
|
if not authorization or not hmac.compare_digest(authorization, expected):
|
||||||
|
db.add(AdminAuthAttempt(client_hash=client_hash, created_at=now))
|
||||||
|
db.commit()
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=401,
|
||||||
|
detail="invalid admin token",
|
||||||
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
|
)
|
||||||
|
db.execute(delete(AdminAuthAttempt).where(AdminAuthAttempt.client_hash == client_hash))
|
||||||
|
db.commit()
|
||||||
|
return "admin"
|
||||||
@@ -31,6 +31,8 @@ class Settings(BaseSettings):
|
|||||||
rf4map_point_url: str = "https://rf4map.ru/points/275"
|
rf4map_point_url: str = "https://rf4map.ru/points/275"
|
||||||
rf4posts_spot_url: str = "https://rf4-posts.com/ru/spots/d0c6d9c6-4ebf-49a7-98a8-9a562553a8ee"
|
rf4posts_spot_url: str = "https://rf4-posts.com/ru/spots/d0c6d9c6-4ebf-49a7-98a8-9a562553a8ee"
|
||||||
rate_limit_secret: str = "change-rate-limit-secret"
|
rate_limit_secret: str = "change-rate-limit-secret"
|
||||||
|
admin_auth_attempt_limit: int = Field(default=10, ge=3, le=100)
|
||||||
|
admin_auth_window_seconds: int = Field(default=600, ge=60, le=3600)
|
||||||
log_level: str = "INFO"
|
log_level: str = "INFO"
|
||||||
cors_origins: list[str] = Field(default_factory=lambda: ["http://localhost:4321", "http://127.0.0.1:4321"])
|
cors_origins: list[str] = Field(default_factory=lambda: ["http://localhost:4321", "http://127.0.0.1:4321"])
|
||||||
trusted_proxy_cidrs: list[str] = Field(default_factory=lambda: ["127.0.0.1/32", "::1/128"])
|
trusted_proxy_cidrs: list[str] = Field(default_factory=lambda: ["127.0.0.1/32", "::1/128"])
|
||||||
|
|||||||
+39
-131
@@ -1,7 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from ipaddress import IPv4Address, IPv6Address, IPv4Network, IPv6Network
|
|
||||||
import hashlib
|
import hashlib
|
||||||
import hmac
|
import hmac
|
||||||
import json
|
import json
|
||||||
@@ -16,10 +15,11 @@ import httpx
|
|||||||
from fastapi import Depends, FastAPI, File, Header, HTTPException, Query, Request, Response, UploadFile
|
from fastapi import Depends, FastAPI, File, Header, HTTPException, Query, Request, Response, UploadFile
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
from sqlalchemy import delete, func, select, text
|
from sqlalchemy import func, or_, select
|
||||||
from sqlalchemy.exc import IntegrityError
|
from sqlalchemy.exc import IntegrityError
|
||||||
from sqlalchemy.orm import Session, joinedload
|
from sqlalchemy.orm import Session, joinedload
|
||||||
|
|
||||||
|
from .admin_security import verify_admin
|
||||||
from .config import settings
|
from .config import settings
|
||||||
from .dependencies import Db
|
from .dependencies import Db
|
||||||
from .community_review import ExternalReviewError, map_observation, publish_observation, reject_observation, suggest_aliases
|
from .community_review import ExternalReviewError, map_observation, publish_observation, reject_observation, suggest_aliases
|
||||||
@@ -29,10 +29,13 @@ from .models import Bait, BaitKind, CatchReport, CommunityImportRun, DataSource,
|
|||||||
from .readiness import readiness_report
|
from .readiness import readiness_report
|
||||||
from .routers.activity import router as activity_router
|
from .routers.activity import router as activity_router
|
||||||
from .routers.catalog import router as catalog_router
|
from .routers.catalog import router as catalog_router
|
||||||
from .time_utils import aware
|
from .routers.public_data import router as public_data_router
|
||||||
|
from .routers.submissions import router as submissions_router
|
||||||
from .public_cache import public_cache
|
from .public_cache import public_cache
|
||||||
from .schemas import ActivityOut, AdminCatchReportOut, CatchReportAccepted, CatchReportCreate, CatchReportCreated, ExternalAliasSuggestionOut, ExternalObservationDecision, ExternalObservationMapping, ExternalObservationOut, ExternalObservationPublished, ImportRunOut, ImportRunPublicOut, ModerationUpdate, OfficialRecordOut, PaginatedOfficialRecordOut, PublicObservationOut, SourceStatusOut
|
from .schemas import ActivityOut, AdminCatchReportOut, CatchReportAccepted, CatchReportCreate, CatchReportCreated, ExternalAliasSuggestionOut, ExternalObservationDecision, ExternalObservationMapping, ExternalObservationOut, ExternalObservationPublished, ImportRunOut, ModerationUpdate
|
||||||
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
|
||||||
|
from .submission_security import check_rate_limit
|
||||||
|
from .submission_security import is_trusted_proxy as _is_trusted_proxy
|
||||||
|
|
||||||
|
|
||||||
configure_logging(settings.log_level)
|
configure_logging(settings.log_level)
|
||||||
@@ -100,91 +103,11 @@ def ready(db: Db) -> JSONResponse:
|
|||||||
|
|
||||||
app.include_router(catalog_router)
|
app.include_router(catalog_router)
|
||||||
app.include_router(activity_router)
|
app.include_router(activity_router)
|
||||||
|
app.include_router(public_data_router)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/v1/community-observations", response_model=list[PublicObservationOut])
|
def _admin(request: Request, db: Db, authorization: Annotated[str | None, Header()] = None) -> str:
|
||||||
def community_observations(
|
return verify_admin(request, db, authorization, settings)
|
||||||
db: Db, limit: int = Query(12, ge=1, le=50), offset: int = Query(0, ge=0),
|
|
||||||
waterbody: str | None = None, fish: str | None = None,
|
|
||||||
) -> list[PublicObservationOut]:
|
|
||||||
query = select(ExternalObservation).join(ExternalObservation.source).options(joinedload(ExternalObservation.source)).where(
|
|
||||||
ExternalObservation.catch_report_id.is_(None),
|
|
||||||
ExternalObservation.status != "rejected",
|
|
||||||
DataSource.enabled.is_(True),
|
|
||||||
)
|
|
||||||
if waterbody:
|
|
||||||
query = query.join(ExternalObservation.waterbody).where(Waterbody.slug == waterbody)
|
|
||||||
if fish:
|
|
||||||
query = query.join(ExternalObservation.fish).where(Fish.slug == fish)
|
|
||||||
items = list(db.scalars(query.order_by(ExternalObservation.last_seen_at.desc(), ExternalObservation.id.desc()).offset(offset).limit(limit)))
|
|
||||||
result: list[PublicObservationOut] = []
|
|
||||||
for item in items:
|
|
||||||
missing = []
|
|
||||||
if item.x is None or item.y is None:
|
|
||||||
missing.append("координаты")
|
|
||||||
if item.weight_g is None:
|
|
||||||
missing.append("вес")
|
|
||||||
result.append(PublicObservationOut(
|
|
||||||
id=item.id, source_system=item.source_system, source_name=item.source.name,
|
|
||||||
source_url=item.source_url, fish_name=item.fish_name, waterbody_name=item.waterbody_name,
|
|
||||||
x=item.x, y=item.y, weight_g=item.weight_g, last_seen_at=item.last_seen_at,
|
|
||||||
missing_fields=missing, quality="incomplete" if missing else "unverified",
|
|
||||||
))
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/v1/source-status", response_model=list[SourceStatusOut])
|
|
||||||
def source_status(db: Db) -> list[SourceStatusOut]:
|
|
||||||
now = datetime.now(timezone.utc)
|
|
||||||
result = []
|
|
||||||
for source in db.scalars(select(DataSource).order_by(DataSource.name)):
|
|
||||||
runs = list(db.scalars(select(CommunityImportRun).where(CommunityImportRun.source_system == source.key).order_by(CommunityImportRun.started_at.desc()).limit(20)))
|
|
||||||
latest = runs[0] if runs else None
|
|
||||||
success = next((run for run in runs if run.status == "success"), None)
|
|
||||||
if not source.enabled:
|
|
||||||
state = "disabled"
|
|
||||||
elif latest is None:
|
|
||||||
state = "waiting"
|
|
||||||
elif latest.status == "failed":
|
|
||||||
state = "source_changed" if "CommunityParseError" in (latest.error_summary or "") else "temporarily_limited"
|
|
||||||
elif aware(latest.started_at) < now - timedelta(seconds=settings.community_import_interval_seconds * 2):
|
|
||||||
state = "stale"
|
|
||||||
else:
|
|
||||||
state = "healthy"
|
|
||||||
result.append(SourceStatusOut(source_system=source.key, name=source.name, status=state,
|
|
||||||
last_started_at=latest.started_at if latest else None, last_success_at=success.started_at if success else None,
|
|
||||||
observations=db.scalar(select(func.count()).select_from(ExternalObservation).where(ExternalObservation.source_system == source.key)) or 0))
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/v1/records", response_model=PaginatedOfficialRecordOut)
|
|
||||||
def records(
|
|
||||||
db: Db, fish: str | None = None, waterbody: str | None = None,
|
|
||||||
category: str | None = None, limit: int = Query(50, ge=1, le=100),
|
|
||||||
offset: int = Query(0, ge=0),
|
|
||||||
) -> PaginatedOfficialRecordOut:
|
|
||||||
query = select(CatchReport).options(joinedload(CatchReport.fish), joinedload(CatchReport.waterbody), joinedload(CatchReport.bait)).where(CatchReport.source_type == SourceType.official_record)
|
|
||||||
if fish:
|
|
||||||
query = query.join(CatchReport.fish).where(Fish.slug == fish)
|
|
||||||
if waterbody:
|
|
||||||
query = query.join(CatchReport.waterbody).where(Waterbody.slug == waterbody)
|
|
||||||
if category:
|
|
||||||
query = query.where(CatchReport.raw_payload["category"].as_string() == category)
|
|
||||||
# Count from the exact same filtered query (before pagination).
|
|
||||||
count_query = query.with_only_columns(func.count(CatchReport.id), maintain_column_froms=True).order_by(None)
|
|
||||||
total = db.scalar(count_query) or 0
|
|
||||||
items = list(db.scalars(query.order_by(CatchReport.caught_at.desc(), CatchReport.weight_g.desc(), CatchReport.id.desc()).offset(offset).limit(limit)))
|
|
||||||
return PaginatedOfficialRecordOut(
|
|
||||||
items=[OfficialRecordOut(id=r.id, fish=r.fish.name_ru, weight_g=r.weight_g, waterbody=r.waterbody.name_ru, bait=r.bait.name if r.bait else None, player_name=r.player_name, record_date=r.caught_at, category=(r.raw_payload or {}).get("category"), region=(r.raw_payload or {}).get("region"), source_url=r.source_url) for r in items],
|
|
||||||
total=total, limit=limit, offset=offset,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _admin(authorization: Annotated[str | None, Header()] = None) -> str:
|
|
||||||
expected = f"Bearer {settings.admin_token}"
|
|
||||||
if not authorization or not hmac.compare_digest(authorization, expected):
|
|
||||||
raise HTTPException(status_code=401, detail="invalid admin token", headers={"WWW-Authenticate": "Bearer"})
|
|
||||||
return "admin"
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/v1/admin/diagnostics")
|
@app.get("/api/v1/admin/diagnostics")
|
||||||
@@ -210,11 +133,6 @@ def admin_diagnostics(db: Db, _: Annotated[str, Depends(_admin)]) -> JSONRespons
|
|||||||
return JSONResponse(payload, headers={"Content-Disposition": "attachment; filename=rf4spotter-diagnostics.json"})
|
return JSONResponse(payload, headers={"Content-Disposition": "attachment; filename=rf4spotter-diagnostics.json"})
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/v1/imports", response_model=list[ImportRunPublicOut])
|
|
||||||
def imports(db: Db, limit: int = Query(20, ge=1, le=100), offset: int = Query(0, ge=0)) -> list[OfficialRecordImport]:
|
|
||||||
return list(db.scalars(select(OfficialRecordImport).order_by(OfficialRecordImport.started_at.desc(), OfficialRecordImport.id.desc()).offset(offset).limit(limit)))
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/v1/admin/imports", response_model=list[ImportRunOut])
|
@app.get("/api/v1/admin/imports", response_model=list[ImportRunOut])
|
||||||
def admin_imports(
|
def admin_imports(
|
||||||
db: Db,
|
db: Db,
|
||||||
@@ -260,7 +178,11 @@ def _external_out(item: ExternalObservation) -> ExternalObservationOut:
|
|||||||
def admin_external_observations(
|
def admin_external_observations(
|
||||||
db: Db, _: Annotated[str, Depends(_admin)],
|
db: Db, _: Annotated[str, Depends(_admin)],
|
||||||
status: Literal["staged", "mapped", "ready", "published", "rejected", "review"] | None = None,
|
status: Literal["staged", "mapped", "ready", "published", "rejected", "review"] | None = None,
|
||||||
source_system: str | None = None, limit: int = Query(50, ge=1, le=200), offset: int = Query(0, ge=0),
|
source_system: str | None = None,
|
||||||
|
completeness: Literal["all", "complete", "incomplete"] = "all",
|
||||||
|
order: Literal["newest", "oldest"] = "newest",
|
||||||
|
q: str | None = Query(None, max_length=100),
|
||||||
|
limit: int = Query(50, ge=1, le=200), offset: int = Query(0, ge=0),
|
||||||
) -> list[ExternalObservationOut]:
|
) -> list[ExternalObservationOut]:
|
||||||
query = select(ExternalObservation).options(
|
query = select(ExternalObservation).options(
|
||||||
joinedload(ExternalObservation.fish), joinedload(ExternalObservation.waterbody),
|
joinedload(ExternalObservation.fish), joinedload(ExternalObservation.waterbody),
|
||||||
@@ -271,7 +193,24 @@ def admin_external_observations(
|
|||||||
query = query.where(ExternalObservation.status == status)
|
query = query.where(ExternalObservation.status == status)
|
||||||
if source_system:
|
if source_system:
|
||||||
query = query.where(ExternalObservation.source_system == source_system)
|
query = query.where(ExternalObservation.source_system == source_system)
|
||||||
items = db.scalars(query.order_by(ExternalObservation.last_seen_at.desc(), ExternalObservation.id.desc()).offset(offset).limit(limit))
|
if completeness == "complete":
|
||||||
|
query = query.where(
|
||||||
|
ExternalObservation.x.is_not(None), ExternalObservation.y.is_not(None),
|
||||||
|
ExternalObservation.weight_g.is_not(None),
|
||||||
|
)
|
||||||
|
elif completeness == "incomplete":
|
||||||
|
query = query.where(or_(
|
||||||
|
ExternalObservation.x.is_(None), ExternalObservation.y.is_(None),
|
||||||
|
ExternalObservation.weight_g.is_(None),
|
||||||
|
))
|
||||||
|
if q and q.strip():
|
||||||
|
term = q.strip()
|
||||||
|
query = query.where(or_(
|
||||||
|
ExternalObservation.fish_name.icontains(term, autoescape=True),
|
||||||
|
ExternalObservation.waterbody_name.icontains(term, autoescape=True),
|
||||||
|
))
|
||||||
|
direction = ExternalObservation.last_seen_at.asc() if order == "oldest" else ExternalObservation.last_seen_at.desc()
|
||||||
|
items = db.scalars(query.order_by(direction, ExternalObservation.id.desc()).offset(offset).limit(limit))
|
||||||
return [_external_out(item) for item in items]
|
return [_external_out(item) for item in items]
|
||||||
|
|
||||||
|
|
||||||
@@ -336,7 +275,7 @@ 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=CatchReportAccepted, status_code=201)
|
@submissions_router.post("/api/v1/catch-reports", response_model=CatchReportAccepted, status_code=201)
|
||||||
def create_catch_report(
|
def create_catch_report(
|
||||||
payload: CatchReportCreate, request: Request, db: Db,
|
payload: CatchReportCreate, request: Request, db: Db,
|
||||||
idempotency_key: Annotated[str | None, Header()] = None,
|
idempotency_key: Annotated[str | None, Header()] = None,
|
||||||
@@ -412,7 +351,7 @@ def create_catch_report(
|
|||||||
return CatchReportAccepted(id=report.id, moderation_status=report.moderation_status.value, screenshot_upload_token=upload_token, idempotent=False)
|
return CatchReportAccepted(id=report.id, moderation_status=report.moderation_status.value, screenshot_upload_token=upload_token, idempotent=False)
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/v1/catch-reports/{report_id}/screenshot", status_code=204, response_class=Response)
|
@submissions_router.post("/api/v1/catch-reports/{report_id}/screenshot", status_code=204, response_class=Response)
|
||||||
def add_screenshot(
|
def add_screenshot(
|
||||||
report_id: UUID, db: Db, screenshot: UploadFile = File(),
|
report_id: UUID, db: Db, screenshot: UploadFile = File(),
|
||||||
upload_token: Annotated[str | None, Header(alias="X-Upload-Token")] = None,
|
upload_token: Annotated[str | None, Header(alias="X-Upload-Token")] = None,
|
||||||
@@ -435,6 +374,9 @@ def add_screenshot(
|
|||||||
return Response(status_code=204)
|
return Response(status_code=204)
|
||||||
|
|
||||||
|
|
||||||
|
app.include_router(submissions_router)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/v1/admin/catch-reports", response_model=list[AdminCatchReportOut])
|
@app.get("/api/v1/admin/catch-reports", response_model=list[AdminCatchReportOut])
|
||||||
def admin_reports(db: Db, _: Annotated[str, Depends(_admin)], status: ModerationStatus = ModerationStatus.pending, limit: int = Query(50, ge=1, le=100), offset: int = Query(0, ge=0)) -> list[AdminCatchReportOut]:
|
def admin_reports(db: Db, _: Annotated[str, Depends(_admin)], status: ModerationStatus = ModerationStatus.pending, limit: int = Query(50, ge=1, le=100), offset: int = Query(0, ge=0)) -> list[AdminCatchReportOut]:
|
||||||
reports = list(db.scalars(select(CatchReport).options(joinedload(CatchReport.fish), joinedload(CatchReport.waterbody), joinedload(CatchReport.spot), joinedload(CatchReport.bait)).where(CatchReport.source_type == SourceType.user, CatchReport.moderation_status == status, CatchReport.deleted_at.is_(None)).order_by(CatchReport.reported_at, CatchReport.id).offset(offset).limit(limit)))
|
reports = list(db.scalars(select(CatchReport).options(joinedload(CatchReport.fish), joinedload(CatchReport.waterbody), joinedload(CatchReport.spot), joinedload(CatchReport.bait)).where(CatchReport.source_type == SourceType.user, CatchReport.moderation_status == status, CatchReport.deleted_at.is_(None)).order_by(CatchReport.reported_at, CatchReport.id).offset(offset).limit(limit)))
|
||||||
@@ -477,39 +419,5 @@ def delete_report(report_id: UUID, db: Db, moderator: Annotated[str, Depends(_ad
|
|||||||
return Response(status_code=204)
|
return Response(status_code=204)
|
||||||
|
|
||||||
|
|
||||||
def _is_trusted_proxy(address: str, trusted_cidrs: list[str]) -> bool:
|
|
||||||
"""Check if address is in trusted proxy CIDRs."""
|
|
||||||
try:
|
|
||||||
addr = IPv4Address(address) if ":" not in address else IPv6Address(address)
|
|
||||||
except ValueError:
|
|
||||||
return False
|
|
||||||
for cidr in trusted_cidrs:
|
|
||||||
try:
|
|
||||||
network = IPv4Network(cidr) if ":" not in cidr else IPv6Network(cidr)
|
|
||||||
if addr in network:
|
|
||||||
return True
|
|
||||||
except ValueError:
|
|
||||||
continue
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def _check_rate_limit(request: Request, db: Session) -> None:
|
def _check_rate_limit(request: Request, db: Session) -> None:
|
||||||
now = datetime.now(timezone.utc)
|
check_rate_limit(request, db, settings)
|
||||||
cutoff = now - timedelta(minutes=10)
|
|
||||||
# Extract real client IP from forwarded headers
|
|
||||||
client = request.client.host if request.client else "unknown"
|
|
||||||
forwarded = request.headers.get("x-forwarded-for")
|
|
||||||
# Only trust X-Forwarded-For if connection came from a trusted proxy
|
|
||||||
if forwarded and request.client and _is_trusted_proxy(request.client.host, settings.trusted_proxy_cidrs):
|
|
||||||
client = forwarded.split(",")[0].strip()
|
|
||||||
client_hash = hmac.new(settings.rate_limit_secret.encode(), client.encode(), hashlib.sha256).hexdigest()
|
|
||||||
if db.get_bind().dialect.name == "postgresql":
|
|
||||||
lock_key = int(client_hash[:16], 16) & 0x7FFF_FFFF_FFFF_FFFF
|
|
||||||
db.execute(text("SELECT pg_advisory_xact_lock(:lock_key)"), {"lock_key": lock_key})
|
|
||||||
db.execute(delete(SubmissionAttempt).where(SubmissionAttempt.created_at < now - timedelta(days=1)))
|
|
||||||
recent = db.scalar(select(func.count()).select_from(SubmissionAttempt).where(SubmissionAttempt.client_hash == client_hash, SubmissionAttempt.created_at >= cutoff)) or 0
|
|
||||||
if recent >= 5:
|
|
||||||
db.commit()
|
|
||||||
raise HTTPException(status_code=429, detail="too many submissions")
|
|
||||||
db.add(SubmissionAttempt(client_hash=client_hash, created_at=now))
|
|
||||||
db.commit()
|
|
||||||
|
|||||||
@@ -143,6 +143,13 @@ class SubmissionAttempt(Base):
|
|||||||
catch_report: Mapped[CatchReport | None] = relationship()
|
catch_report: Mapped[CatchReport | None] = relationship()
|
||||||
|
|
||||||
|
|
||||||
|
class AdminAuthAttempt(Base):
|
||||||
|
__tablename__ = "admin_auth_attempt"
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
|
||||||
|
client_hash: Mapped[str] = mapped_column(String(64), index=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), index=True)
|
||||||
|
|
||||||
|
|
||||||
class DataSource(Base):
|
class DataSource(Base):
|
||||||
__tablename__ = "data_source"
|
__tablename__ = "data_source"
|
||||||
key: Mapped[str] = mapped_column(String(50), primary_key=True)
|
key: Mapped[str] = mapped_column(String(50), primary_key=True)
|
||||||
|
|||||||
@@ -0,0 +1,170 @@
|
|||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Query
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
from sqlalchemy.orm import joinedload
|
||||||
|
|
||||||
|
from ..config import settings
|
||||||
|
from ..dependencies import Db
|
||||||
|
from ..models import (
|
||||||
|
CatchReport,
|
||||||
|
CommunityImportRun,
|
||||||
|
DataSource,
|
||||||
|
ExternalObservation,
|
||||||
|
Fish,
|
||||||
|
OfficialRecordImport,
|
||||||
|
SourceType,
|
||||||
|
Waterbody,
|
||||||
|
)
|
||||||
|
from ..schemas import (
|
||||||
|
ImportRunPublicOut,
|
||||||
|
OfficialRecordOut,
|
||||||
|
PaginatedOfficialRecordOut,
|
||||||
|
PublicObservationOut,
|
||||||
|
SourceStatusOut,
|
||||||
|
)
|
||||||
|
from ..time_utils import aware
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/v1/community-observations", response_model=list[PublicObservationOut])
|
||||||
|
def community_observations(
|
||||||
|
db: Db,
|
||||||
|
limit: int = Query(12, ge=1, le=50),
|
||||||
|
offset: int = Query(0, ge=0),
|
||||||
|
waterbody: str | None = None,
|
||||||
|
fish: str | None = None,
|
||||||
|
) -> list[PublicObservationOut]:
|
||||||
|
query = select(ExternalObservation).join(ExternalObservation.source).options(
|
||||||
|
joinedload(ExternalObservation.source)
|
||||||
|
).where(
|
||||||
|
ExternalObservation.catch_report_id.is_(None),
|
||||||
|
ExternalObservation.status != "rejected",
|
||||||
|
DataSource.enabled.is_(True),
|
||||||
|
)
|
||||||
|
if waterbody:
|
||||||
|
query = query.join(ExternalObservation.waterbody).where(Waterbody.slug == waterbody)
|
||||||
|
if fish:
|
||||||
|
query = query.join(ExternalObservation.fish).where(Fish.slug == fish)
|
||||||
|
items = list(db.scalars(
|
||||||
|
query.order_by(ExternalObservation.last_seen_at.desc(), ExternalObservation.id.desc())
|
||||||
|
.offset(offset).limit(limit)
|
||||||
|
))
|
||||||
|
result: list[PublicObservationOut] = []
|
||||||
|
for item in items:
|
||||||
|
missing = []
|
||||||
|
if item.x is None or item.y is None:
|
||||||
|
missing.append("координаты")
|
||||||
|
if item.weight_g is None:
|
||||||
|
missing.append("вес")
|
||||||
|
result.append(PublicObservationOut(
|
||||||
|
id=item.id,
|
||||||
|
source_system=item.source_system,
|
||||||
|
source_name=item.source.name,
|
||||||
|
source_url=item.source_url,
|
||||||
|
fish_name=item.fish_name,
|
||||||
|
waterbody_name=item.waterbody_name,
|
||||||
|
x=item.x,
|
||||||
|
y=item.y,
|
||||||
|
weight_g=item.weight_g,
|
||||||
|
last_seen_at=item.last_seen_at,
|
||||||
|
missing_fields=missing,
|
||||||
|
quality="incomplete" if missing else "unverified",
|
||||||
|
))
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/v1/source-status", response_model=list[SourceStatusOut])
|
||||||
|
def source_status(db: Db) -> list[SourceStatusOut]:
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
result = []
|
||||||
|
for source in db.scalars(select(DataSource).order_by(DataSource.name)):
|
||||||
|
runs = list(db.scalars(
|
||||||
|
select(CommunityImportRun)
|
||||||
|
.where(CommunityImportRun.source_system == source.key)
|
||||||
|
.order_by(CommunityImportRun.started_at.desc()).limit(20)
|
||||||
|
))
|
||||||
|
latest = runs[0] if runs else None
|
||||||
|
success = next((run for run in runs if run.status == "success"), None)
|
||||||
|
if not source.enabled:
|
||||||
|
state = "disabled"
|
||||||
|
elif latest is None:
|
||||||
|
state = "waiting"
|
||||||
|
elif latest.status == "failed":
|
||||||
|
state = "source_changed" if "CommunityParseError" in (latest.error_summary or "") else "temporarily_limited"
|
||||||
|
elif aware(latest.started_at) < now - timedelta(seconds=settings.community_import_interval_seconds * 2):
|
||||||
|
state = "stale"
|
||||||
|
else:
|
||||||
|
state = "healthy"
|
||||||
|
result.append(SourceStatusOut(
|
||||||
|
source_system=source.key,
|
||||||
|
name=source.name,
|
||||||
|
status=state,
|
||||||
|
last_started_at=latest.started_at if latest else None,
|
||||||
|
last_success_at=success.started_at if success else None,
|
||||||
|
observations=db.scalar(
|
||||||
|
select(func.count()).select_from(ExternalObservation)
|
||||||
|
.where(ExternalObservation.source_system == source.key)
|
||||||
|
) or 0,
|
||||||
|
))
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/v1/records", response_model=PaginatedOfficialRecordOut)
|
||||||
|
def records(
|
||||||
|
db: Db,
|
||||||
|
fish: str | None = None,
|
||||||
|
waterbody: str | None = None,
|
||||||
|
category: str | None = None,
|
||||||
|
limit: int = Query(50, ge=1, le=100),
|
||||||
|
offset: int = Query(0, ge=0),
|
||||||
|
) -> PaginatedOfficialRecordOut:
|
||||||
|
query = select(CatchReport).options(
|
||||||
|
joinedload(CatchReport.fish),
|
||||||
|
joinedload(CatchReport.waterbody),
|
||||||
|
joinedload(CatchReport.bait),
|
||||||
|
).where(CatchReport.source_type == SourceType.official_record)
|
||||||
|
if fish:
|
||||||
|
query = query.join(CatchReport.fish).where(Fish.slug == fish)
|
||||||
|
if waterbody:
|
||||||
|
query = query.join(CatchReport.waterbody).where(Waterbody.slug == waterbody)
|
||||||
|
if category:
|
||||||
|
query = query.where(CatchReport.raw_payload["category"].as_string() == category)
|
||||||
|
total = db.scalar(
|
||||||
|
query.with_only_columns(func.count(CatchReport.id), maintain_column_froms=True).order_by(None)
|
||||||
|
) or 0
|
||||||
|
items = list(db.scalars(
|
||||||
|
query.order_by(CatchReport.caught_at.desc(), CatchReport.weight_g.desc(), CatchReport.id.desc())
|
||||||
|
.offset(offset).limit(limit)
|
||||||
|
))
|
||||||
|
return PaginatedOfficialRecordOut(
|
||||||
|
items=[OfficialRecordOut(
|
||||||
|
id=item.id,
|
||||||
|
fish=item.fish.name_ru,
|
||||||
|
weight_g=item.weight_g,
|
||||||
|
waterbody=item.waterbody.name_ru,
|
||||||
|
bait=item.bait.name if item.bait else None,
|
||||||
|
player_name=item.player_name,
|
||||||
|
record_date=item.caught_at,
|
||||||
|
category=(item.raw_payload or {}).get("category"),
|
||||||
|
region=(item.raw_payload or {}).get("region"),
|
||||||
|
source_url=item.source_url,
|
||||||
|
) for item in items],
|
||||||
|
total=total,
|
||||||
|
limit=limit,
|
||||||
|
offset=offset,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/v1/imports", response_model=list[ImportRunPublicOut])
|
||||||
|
def imports(
|
||||||
|
db: Db,
|
||||||
|
limit: int = Query(20, ge=1, le=100),
|
||||||
|
offset: int = Query(0, ge=0),
|
||||||
|
) -> list[OfficialRecordImport]:
|
||||||
|
return list(db.scalars(
|
||||||
|
select(OfficialRecordImport)
|
||||||
|
.order_by(OfficialRecordImport.started_at.desc(), OfficialRecordImport.id.desc())
|
||||||
|
.offset(offset).limit(limit)
|
||||||
|
))
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
from fastapi import APIRouter
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
from ipaddress import IPv4Address, IPv4Network, IPv6Address, IPv6Network, ip_address
|
||||||
|
from typing import Protocol
|
||||||
|
|
||||||
|
from fastapi import HTTPException, Request
|
||||||
|
from sqlalchemy import delete, func, select, text
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from .models import SubmissionAttempt
|
||||||
|
|
||||||
|
|
||||||
|
class RateLimitConfig(Protocol):
|
||||||
|
rate_limit_secret: str
|
||||||
|
trusted_proxy_cidrs: list[str]
|
||||||
|
|
||||||
|
|
||||||
|
def is_trusted_proxy(address: str, trusted_cidrs: list[str]) -> bool:
|
||||||
|
try:
|
||||||
|
addr = IPv4Address(address) if ":" not in address else IPv6Address(address)
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
for cidr in trusted_cidrs:
|
||||||
|
try:
|
||||||
|
network = IPv4Network(cidr) if ":" not in cidr else IPv6Network(cidr)
|
||||||
|
if addr in network:
|
||||||
|
return True
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def client_address(request: Request, trusted_cidrs: list[str]) -> str:
|
||||||
|
client = request.client.host if request.client else "unknown"
|
||||||
|
forwarded = request.headers.get("x-forwarded-for")
|
||||||
|
if forwarded and request.client and is_trusted_proxy(request.client.host, trusted_cidrs):
|
||||||
|
candidate = forwarded.split(",")[0].strip()
|
||||||
|
try:
|
||||||
|
return str(ip_address(candidate))
|
||||||
|
except ValueError:
|
||||||
|
return client
|
||||||
|
return client
|
||||||
|
|
||||||
|
|
||||||
|
def check_rate_limit(request: Request, db: Session, config: RateLimitConfig) -> None:
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
cutoff = now - timedelta(minutes=10)
|
||||||
|
client = client_address(request, config.trusted_proxy_cidrs)
|
||||||
|
client_hash = hmac.new(config.rate_limit_secret.encode(), client.encode(), hashlib.sha256).hexdigest()
|
||||||
|
if db.get_bind().dialect.name == "postgresql":
|
||||||
|
lock_key = int(client_hash[:16], 16) & 0x7FFF_FFFF_FFFF_FFFF
|
||||||
|
db.execute(text("SELECT pg_advisory_xact_lock(:lock_key)"), {"lock_key": lock_key})
|
||||||
|
db.execute(delete(SubmissionAttempt).where(SubmissionAttempt.created_at < now - timedelta(days=1)))
|
||||||
|
recent = db.scalar(
|
||||||
|
select(func.count()).select_from(SubmissionAttempt).where(
|
||||||
|
SubmissionAttempt.client_hash == client_hash,
|
||||||
|
SubmissionAttempt.created_at >= cutoff,
|
||||||
|
)
|
||||||
|
) or 0
|
||||||
|
if recent >= 5:
|
||||||
|
db.commit()
|
||||||
|
raise HTTPException(status_code=429, detail="too many submissions")
|
||||||
|
db.add(SubmissionAttempt(client_hash=client_hash, created_at=now))
|
||||||
|
db.commit()
|
||||||
@@ -1979,6 +1979,52 @@
|
|||||||
"title": "Source System"
|
"title": "Source System"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"in": "query",
|
||||||
|
"name": "completeness",
|
||||||
|
"required": false,
|
||||||
|
"schema": {
|
||||||
|
"default": "all",
|
||||||
|
"enum": [
|
||||||
|
"all",
|
||||||
|
"complete",
|
||||||
|
"incomplete"
|
||||||
|
],
|
||||||
|
"title": "Completeness",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"in": "query",
|
||||||
|
"name": "order",
|
||||||
|
"required": false,
|
||||||
|
"schema": {
|
||||||
|
"default": "newest",
|
||||||
|
"enum": [
|
||||||
|
"newest",
|
||||||
|
"oldest"
|
||||||
|
],
|
||||||
|
"title": "Order",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"in": "query",
|
||||||
|
"name": "q",
|
||||||
|
"required": false,
|
||||||
|
"schema": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"maxLength": 100,
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Q"
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"in": "query",
|
"in": "query",
|
||||||
"name": "limit",
|
"name": "limit",
|
||||||
|
|||||||
@@ -339,8 +339,12 @@ def test_incomplete_external_observation_is_publicly_labelled_but_not_counted()
|
|||||||
assert signal["source_system"] == "rf4db"
|
assert signal["source_system"] == "rf4db"
|
||||||
assert signal["quality"] == "incomplete"
|
assert signal["quality"] == "incomplete"
|
||||||
assert signal["missing_fields"] == ["вес"]
|
assert signal["missing_fields"] == ["вес"]
|
||||||
assert all(item["x"] != 32 or item["y"] != 42 for item in client.get("/api/v1/activity").json()["items"])
|
|
||||||
headers = {"Authorization": "Bearer change-me-in-production"}
|
headers = {"Authorization": "Bearer change-me-in-production"}
|
||||||
|
incomplete = client.get("/api/v1/admin/external-observations?status=review&completeness=incomplete&q=Pike", headers=headers)
|
||||||
|
assert any(item["id"] == str(observation_id) for item in incomplete.json())
|
||||||
|
complete = client.get("/api/v1/admin/external-observations?status=review&completeness=complete&q=Pike", headers=headers)
|
||||||
|
assert all(item["id"] != str(observation_id) for item in complete.json())
|
||||||
|
assert all(item["x"] != 32 or item["y"] != 42 for item in client.get("/api/v1/activity").json()["items"])
|
||||||
mapped = client.patch(
|
mapped = client.patch(
|
||||||
f"/api/v1/admin/external-observations/{observation_id}/mapping", headers=headers,
|
f"/api/v1/admin/external-observations/{observation_id}/mapping", headers=headers,
|
||||||
json={"fish_slug": "pike", "waterbody_slug": "test-lake"},
|
json={"fish_slug": "pike", "waterbody_slug": "test-lake"},
|
||||||
|
|||||||
@@ -9,8 +9,51 @@ from sqlalchemy import create_engine, select
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.database import Base
|
from app.database import Base
|
||||||
|
from app.admin_security import verify_admin
|
||||||
from app.main import _check_rate_limit, _is_trusted_proxy
|
from app.main import _check_rate_limit, _is_trusted_proxy
|
||||||
from app.models import SubmissionAttempt
|
from app.models import AdminAuthAttempt, SubmissionAttempt
|
||||||
|
|
||||||
|
|
||||||
|
def _admin_config() -> MagicMock:
|
||||||
|
config = MagicMock()
|
||||||
|
config.admin_token = "correct-token"
|
||||||
|
config.admin_auth_attempt_limit = 3
|
||||||
|
config.admin_auth_window_seconds = 600
|
||||||
|
config.rate_limit_secret = "test-secret-for-testing"
|
||||||
|
config.trusted_proxy_cidrs = ["127.0.0.1/32"]
|
||||||
|
return config
|
||||||
|
|
||||||
|
|
||||||
|
def test_admin_auth_failures_are_hashed_and_limited() -> None:
|
||||||
|
engine = create_engine("sqlite://")
|
||||||
|
Base.metadata.create_all(engine)
|
||||||
|
request = MagicMock()
|
||||||
|
request.client.host = "203.0.113.42"
|
||||||
|
request.headers.get.return_value = None
|
||||||
|
with Session(engine) as db:
|
||||||
|
for _ in range(3):
|
||||||
|
with pytest.raises(HTTPException) as denied:
|
||||||
|
verify_admin(request, db, "Bearer wrong", _admin_config())
|
||||||
|
assert denied.value.status_code == 401
|
||||||
|
with pytest.raises(HTTPException) as limited:
|
||||||
|
verify_admin(request, db, "Bearer correct-token", _admin_config())
|
||||||
|
assert limited.value.status_code == 429
|
||||||
|
attempts = list(db.scalars(select(AdminAuthAttempt)))
|
||||||
|
assert len(attempts) == 3
|
||||||
|
assert all(item.client_hash != "203.0.113.42" and len(item.client_hash) == 64 for item in attempts)
|
||||||
|
|
||||||
|
|
||||||
|
def test_successful_admin_auth_clears_previous_failures() -> None:
|
||||||
|
engine = create_engine("sqlite://")
|
||||||
|
Base.metadata.create_all(engine)
|
||||||
|
request = MagicMock()
|
||||||
|
request.client.host = "203.0.113.43"
|
||||||
|
request.headers.get.return_value = None
|
||||||
|
with Session(engine) as db:
|
||||||
|
with pytest.raises(HTTPException):
|
||||||
|
verify_admin(request, db, "Bearer wrong", _admin_config())
|
||||||
|
assert verify_admin(request, db, "Bearer correct-token", _admin_config()) == "admin"
|
||||||
|
assert list(db.scalars(select(AdminAuthAttempt))) == []
|
||||||
|
|
||||||
|
|
||||||
def test_rate_limit_is_persistent_and_does_not_store_raw_client() -> None:
|
def test_rate_limit_is_persistent_and_does_not_store_raw_client() -> None:
|
||||||
|
|||||||
@@ -7,6 +7,14 @@ const apiUrl = import.meta.env.PUBLIC_API_URL || "http://localhost:8000";
|
|||||||
<section class="moderation-app" data-api-url={apiUrl}>
|
<section class="moderation-app" data-api-url={apiUrl}>
|
||||||
<form class="admin-login" autocomplete="off"><label>Административный токен<input name="token" type="password" required autocomplete="off" /></label><button data-action="primary" type="submit">Открыть очередь</button></form>
|
<form class="admin-login" autocomplete="off"><label>Административный токен<input name="token" type="password" required autocomplete="off" /></label><button data-action="primary" type="submit">Открыть очередь</button></form>
|
||||||
<p class="privacy">Токен остаётся только в памяти страницы. Исходная ссылка и происхождение сохраняются при публикации.</p>
|
<p class="privacy">Токен остаётся только в памяти страницы. Исходная ссылка и происхождение сохраняются при публикации.</p>
|
||||||
|
<div class="admin-session-bar" hidden><span>Административная сессия активна</span><button data-action="secondary" type="button" data-admin-logout>Выйти</button></div>
|
||||||
|
<form class="admin-queue-filters" hidden>
|
||||||
|
<label>Поиск<input name="q" type="search" maxlength="100" placeholder="Рыба или водоём" /></label>
|
||||||
|
<label>Источник<select name="source"><option value="">Все источники</option></select></label>
|
||||||
|
<label>Полнота<select name="completeness"><option value="all">Все записи</option><option value="complete">Только полные</option><option value="incomplete">Только неполные</option></select></label>
|
||||||
|
<label>Порядок<select name="order"><option value="newest">Сначала новые</option><option value="oldest">Сначала старые</option></select></label>
|
||||||
|
<button data-action="primary" type="submit">Применить</button>
|
||||||
|
</form>
|
||||||
<div class="notice error" data-admin-error role="alert" hidden></div><div class="moderation-list" data-external-list aria-live="polite"></div>
|
<div class="notice error" data-admin-error role="alert" hidden></div><div class="moderation-list" data-external-list aria-live="polite"></div>
|
||||||
<nav data-pages aria-label="Страницы очереди" hidden>
|
<nav data-pages aria-label="Страницы очереди" hidden>
|
||||||
<button data-action="secondary" type="button" data-previous>Предыдущая</button>
|
<button data-action="secondary" type="button" data-previous>Предыдущая</button>
|
||||||
@@ -23,19 +31,27 @@ const apiUrl = import.meta.env.PUBLIC_API_URL || "http://localhost:8000";
|
|||||||
const previous = document.querySelector<HTMLButtonElement>("[data-previous]");
|
const previous = document.querySelector<HTMLButtonElement>("[data-previous]");
|
||||||
const next = document.querySelector<HTMLButtonElement>("[data-next]");
|
const next = document.querySelector<HTMLButtonElement>("[data-next]");
|
||||||
const pageNumber = document.querySelector<HTMLElement>("[data-page-number]");
|
const pageNumber = document.querySelector<HTMLElement>("[data-page-number]");
|
||||||
|
const sessionBar = document.querySelector<HTMLElement>(".admin-session-bar");
|
||||||
|
const logout = document.querySelector<HTMLButtonElement>("[data-admin-logout]");
|
||||||
|
const filters = document.querySelector<HTMLFormElement>(".admin-queue-filters");
|
||||||
let offset = 0;
|
let offset = 0;
|
||||||
let token = ""; let fishes: Record<string, string>[] = []; let waters: Record<string, string>[] = [];
|
let token = ""; let fishes: Record<string, string>[] = []; let waters: Record<string, string>[] = [];
|
||||||
|
let sessionTimer: ReturnType<typeof setTimeout> | undefined;
|
||||||
const loadingCards = () => `<div class="loading-grid" aria-hidden="true">${Array.from({length:2}, () => '<div class="loading-card"><span class="loading-line loading-line--label"></span><span class="loading-line loading-line--title"></span><span class="loading-line"></span><span class="loading-line loading-line--short"></span></div>').join("")}</div><span class="sr-only">Загружаем внешние наблюдения</span>`;
|
const loadingCards = () => `<div class="loading-grid" aria-hidden="true">${Array.from({length:2}, () => '<div class="loading-card"><span class="loading-line loading-line--label"></span><span class="loading-line loading-line--title"></span><span class="loading-line"></span><span class="loading-line loading-line--short"></span></div>').join("")}</div><span class="sr-only">Загружаем внешние наблюдения</span>`;
|
||||||
const setLoading = (loading: boolean) => list?.setAttribute("aria-busy", String(loading));
|
const setLoading = (loading: boolean) => list?.setAttribute("aria-busy", String(loading));
|
||||||
const esc = (value: unknown) => String(value ?? "—").replace(/[&<>'"]/g, char => ({"&":"&","<":"<",">":">","'":"'",'"':"""}[char] ?? char));
|
const esc = (value: unknown) => String(value ?? "—").replace(/[&<>'"]/g, char => ({"&":"&","<":"<",">":">","'":"'",'"':"""}[char] ?? char));
|
||||||
|
const safeHttpUrl = (value: unknown) => { try { const url = new URL(String(value)); return url.protocol === "http:" || url.protocol === "https:" ? esc(url.href) : ""; } catch { return ""; } };
|
||||||
const fail = (message: string) => { if (error) { error.textContent = message; error.hidden = false; } };
|
const fail = (message: string) => { if (error) { error.textContent = message; error.hidden = false; } };
|
||||||
|
const endSession = (message?: string) => { token = ""; if (sessionTimer) clearTimeout(sessionTimer); sessionTimer = undefined; if (login) { login.hidden = false; login.reset(); } if (sessionBar) sessionBar.hidden = true; if (filters) filters.hidden = true; if (list) list.innerHTML = ""; pages?.setAttribute("hidden", ""); if (message) fail(message); };
|
||||||
|
const keepSession = () => { if (sessionTimer) clearTimeout(sessionTimer); sessionTimer = setTimeout(() => endSession("Сессия завершена после 15 минут бездействия. Введите токен снова."), 15 * 60 * 1000); };
|
||||||
const options = (items: Record<string, string>[], selected?: unknown) => items.map(item => `<option value="${esc(item.slug)}" ${item.slug === selected ? "selected" : ""}>${esc(item.name_ru)}</option>`).join("");
|
const options = (items: Record<string, string>[], selected?: unknown) => items.map(item => `<option value="${esc(item.slug)}" ${item.slug === selected ? "selected" : ""}>${esc(item.name_ru)}</option>`).join("");
|
||||||
async function json(url: string, init: RequestInit = {}) { const response = await fetch(url, init); if (!response.ok) throw new Error(response.status === 401 ? "Неверный административный токен." : `Запрос завершился ошибкой ${response.status}.`); return response.json(); }
|
async function json(url: string, init: RequestInit = {}) { const response = await fetch(url, init); if (response.status === 401) { endSession(); throw new Error("Неверный или истёкший административный токен."); } if (!response.ok) throw new Error(`Запрос завершился ошибкой ${response.status}.`); if ((init.headers as Record<string, string> | undefined)?.Authorization) keepSession(); return response.json(); }
|
||||||
async function loadQueue() {
|
async function loadQueue() {
|
||||||
if (!root || !list) return; error?.setAttribute("hidden", ""); setLoading(true); list.innerHTML = loadingCards();
|
if (!root || !list) return; error?.setAttribute("hidden", ""); setLoading(true); list.innerHTML = loadingCards();
|
||||||
[fishes, waters] = await Promise.all([json(`${root.dataset.apiUrl}/api/v1/fishes`), json(`${root.dataset.apiUrl}/api/v1/waterbodies`)]);
|
if (!fishes.length || !waters.length) { const [fishRows, waterRows, sources] = await Promise.all([json(`${root.dataset.apiUrl}/api/v1/fishes`), json(`${root.dataset.apiUrl}/api/v1/waterbodies`), json(`${root.dataset.apiUrl}/api/v1/source-status`)]); fishes = fishRows; waters = waterRows; const sourceSelect = filters?.querySelector<HTMLSelectElement>('[name="source"]'); if (sourceSelect) sourceSelect.innerHTML = '<option value="">Все источники</option>' + (sources as Record<string, unknown>[]).map(source => `<option value="${esc(source.source_system)}">${esc(source.name)}</option>`).join(""); }
|
||||||
pages?.setAttribute("hidden", "");
|
pages?.setAttribute("hidden", "");
|
||||||
const rows = await json(`${root.dataset.apiUrl}/api/v1/admin/external-observations?status=review&limit=51&offset=${offset}`, {headers:{Authorization:`Bearer ${token}`}});
|
const values = new FormData(filters ?? undefined); const params = new URLSearchParams({status:"review",limit:"51",offset:String(offset),completeness:String(values.get("completeness") || "all"),order:String(values.get("order") || "newest")}); const source = String(values.get("source") || ""); const query = String(values.get("q") || "").trim(); if (source) params.set("source_system", source); if (query) params.set("q", query);
|
||||||
|
const rows = await json(`${root.dataset.apiUrl}/api/v1/admin/external-observations?${params}`, {headers:{Authorization:`Bearer ${token}`}});
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
if (!rows.length && offset > 0) { offset = 0; return loadQueue(); }
|
if (!rows.length && offset > 0) { offset = 0; return loadQueue(); }
|
||||||
const pending = rows.slice(0, 50);
|
const pending = rows.slice(0, 50);
|
||||||
@@ -44,7 +60,7 @@ const apiUrl = import.meta.env.PUBLIC_API_URL || "http://localhost:8000";
|
|||||||
if (next) next.disabled = rows.length <= 50;
|
if (next) next.disabled = rows.length <= 50;
|
||||||
if (pageNumber) pageNumber.textContent = `Страница ${offset / 50 + 1}`;
|
if (pageNumber) pageNumber.textContent = `Страница ${offset / 50 + 1}`;
|
||||||
if (!pending.length) { list.innerHTML = '<div class="state"><h2>Очередь пуста</h2><p>Все внешние записи обработаны.</p></div>'; return; }
|
if (!pending.length) { list.innerHTML = '<div class="state"><h2>Очередь пуста</h2><p>Все внешние записи обработаны.</p></div>'; return; }
|
||||||
list.innerHTML = pending.map((row: Record<string, unknown>) => { const complete = row.x != null && row.y != null && row.weight_g != null; return `<article class="moderation-card" data-observation-id="${esc(row.id)}"><div class="moderation-summary"><span class="activity-pill"><i></i>${esc(row.source_system)} · ${esc(row.status)}</span><h2>${esc(row.fish_name)}</h2><p>${esc(row.waterbody_name)}</p><dl><div><dt>Координаты</dt><dd>${row.x == null || row.y == null ? "нет" : `${esc(row.x)}:${esc(row.y)}`}</dd></div><div><dt>Вес</dt><dd>${row.weight_g == null ? "нет" : `${esc(row.weight_g)} г`}</dd></div><div><dt>ID источника</dt><dd>${esc(row.source_external_id)}</dd></div><div><dt>Состояние</dt><dd>${complete ? "полная запись" : "неполная"}</dd></div></dl><a href="${esc(row.source_url)}" target="_blank" rel="noreferrer">Открыть первоисточник</a></div><div class="moderation-actions"><label>Каноническая рыба<select name="fish" required><option value="">Выберите…</option>${options(fishes, row.fish_slug)}</select></label><label>Канонический водоём<select name="waterbody" required><option value="">Выберите…</option>${options(waters, row.waterbody_slug)}</select></label><label>Примечание<textarea name="note" rows="2" maxlength="1000">${esc(row.review_note ?? "")}</textarea></label><p data-alias-message role="status"></p><div><button data-action="secondary" type="button" data-suggest>Подсказать соответствия</button><button data-action="secondary" type="button" data-map>Сопоставить</button><button data-action="primary" type="button" data-publish ${complete && row.status === "ready" ? "" : "disabled"}>Опубликовать</button><button data-action="danger" type="button" data-reject>Отклонить</button></div></div></article>`; }).join("");
|
list.innerHTML = pending.map((row: Record<string, unknown>) => { const complete = row.x != null && row.y != null && row.weight_g != null; const sourceUrl = safeHttpUrl(row.source_url); return `<article class="moderation-card" data-observation-id="${esc(row.id)}"><div class="moderation-summary"><span class="activity-pill"><i></i>${esc(row.source_system)} · ${esc(row.status)}</span><h2>${esc(row.fish_name)}</h2><p>${esc(row.waterbody_name)}</p><dl><div><dt>Координаты</dt><dd>${row.x == null || row.y == null ? "нет" : `${esc(row.x)}:${esc(row.y)}`}</dd></div><div><dt>Вес</dt><dd>${row.weight_g == null ? "нет" : `${esc(row.weight_g)} г`}</dd></div><div><dt>ID источника</dt><dd>${esc(row.source_external_id)}</dd></div><div><dt>Состояние</dt><dd>${complete ? "полная запись" : "неполная"}</dd></div></dl>${sourceUrl ? `<a href="${sourceUrl}" target="_blank" rel="noreferrer">Открыть первоисточник</a>` : '<span class="privacy">Ссылка источника имеет небезопасный формат</span>'}</div><div class="moderation-actions"><label>Каноническая рыба<select name="fish" required><option value="">Выберите…</option>${options(fishes, row.fish_slug)}</select></label><label>Канонический водоём<select name="waterbody" required><option value="">Выберите…</option>${options(waters, row.waterbody_slug)}</select></label><label>Примечание<textarea name="note" rows="2" maxlength="1000">${esc(row.review_note ?? "")}</textarea></label><p data-alias-message role="status"></p><div><button data-action="secondary" type="button" data-suggest>Подсказать соответствия</button><button data-action="secondary" type="button" data-map>Сопоставить</button><button data-action="primary" type="button" data-publish ${complete && row.status === "ready" ? "" : "disabled"}>Опубликовать</button><button data-action="danger" type="button" data-reject>Отклонить</button></div></div></article>`; }).join("");
|
||||||
}
|
}
|
||||||
async function changePage(delta: number) {
|
async function changePage(delta: number) {
|
||||||
const oldOffset = offset;
|
const oldOffset = offset;
|
||||||
@@ -56,7 +72,9 @@ const apiUrl = import.meta.env.PUBLIC_API_URL || "http://localhost:8000";
|
|||||||
}
|
}
|
||||||
previous?.addEventListener("click", () => changePage(-50));
|
previous?.addEventListener("click", () => changePage(-50));
|
||||||
next?.addEventListener("click", () => changePage(50));
|
next?.addEventListener("click", () => changePage(50));
|
||||||
login?.addEventListener("submit", async event => { event.preventDefault(); offset = 0; token = String(new FormData(login).get("token") || ""); try { await loadQueue(); login.hidden = true; } catch (cause) { setLoading(false); if (list) list.innerHTML = ""; fail(cause instanceof Error ? cause.message : "Ошибка загрузки."); } });
|
login?.addEventListener("submit", async event => { event.preventDefault(); offset = 0; token = String(new FormData(login).get("token") || ""); try { await loadQueue(); login.hidden = true; if (sessionBar) sessionBar.hidden = false; if (filters) filters.hidden = false; } catch (cause) { setLoading(false); if (list) list.innerHTML = ""; fail(cause instanceof Error ? cause.message : "Ошибка загрузки."); } });
|
||||||
|
filters?.addEventListener("submit", async event => { event.preventDefault(); offset = 0; try { await loadQueue(); } catch (cause) { setLoading(false); fail(cause instanceof Error ? cause.message : "Ошибка фильтрации."); } });
|
||||||
|
logout?.addEventListener("click", () => endSession("Вы вышли из административной панели."));
|
||||||
list?.addEventListener("click", async event => { const button = (event.target as HTMLElement).closest<HTMLButtonElement>("button"); const card = button?.closest<HTMLElement>("[data-observation-id]"); if (!button || !card || !root) return; button.disabled = true; const base = `${root.dataset.apiUrl}/api/v1/admin/external-observations/${card.dataset.observationId}`; const headers = {Authorization:`Bearer ${token}`,"Content-Type":"application/json"}; try {
|
list?.addEventListener("click", async event => { const button = (event.target as HTMLElement).closest<HTMLButtonElement>("button"); const card = button?.closest<HTMLElement>("[data-observation-id]"); if (!button || !card || !root) return; button.disabled = true; const base = `${root.dataset.apiUrl}/api/v1/admin/external-observations/${card.dataset.observationId}`; const headers = {Authorization:`Bearer ${token}`,"Content-Type":"application/json"}; try {
|
||||||
if (button.hasAttribute("data-suggest")) {
|
if (button.hasAttribute("data-suggest")) {
|
||||||
const suggestion = await json(`${base}/alias-suggestions`, {headers});
|
const suggestion = await json(`${base}/alias-suggestions`, {headers});
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
---
|
||||||
|
import Layout from "../../layouts/Layout.astro";
|
||||||
|
const apiUrl = import.meta.env.PUBLIC_API_URL || "http://localhost:8000";
|
||||||
|
---
|
||||||
|
<Layout title="Административная панель — RF4 Spotter">
|
||||||
|
<section class="form-hero"><div><span class="eyebrow"><b>ADMIN</b> Центр управления</span><h1>Панель<br/><em>модератора</em></h1></div><p>Очереди, состояние источников и последние импорты в одном безопасном обзоре.</p></section>
|
||||||
|
<main class="admin-dashboard" data-api-url={apiUrl}>
|
||||||
|
<form class="admin-login" autocomplete="off"><label>Административный токен<input name="token" type="password" required autocomplete="off" /></label><button data-action="primary" type="submit">Открыть панель</button></form>
|
||||||
|
<p class="privacy">Токен существует только в памяти вкладки. Сессия завершится после 15 минут бездействия.</p>
|
||||||
|
<div class="admin-session-bar" hidden><span>Административная сессия активна</span><button data-action="secondary" type="button" data-admin-logout>Выйти</button></div>
|
||||||
|
<div class="notice error" data-admin-error role="alert" hidden></div>
|
||||||
|
<section class="admin-dashboard-content" aria-live="polite"></section>
|
||||||
|
</main>
|
||||||
|
<script>
|
||||||
|
const root = document.querySelector<HTMLElement>(".admin-dashboard");
|
||||||
|
const login = document.querySelector<HTMLFormElement>(".admin-login");
|
||||||
|
const content = document.querySelector<HTMLElement>(".admin-dashboard-content");
|
||||||
|
const error = document.querySelector<HTMLElement>("[data-admin-error]");
|
||||||
|
const sessionBar = document.querySelector<HTMLElement>(".admin-session-bar");
|
||||||
|
const logout = document.querySelector<HTMLButtonElement>("[data-admin-logout]");
|
||||||
|
let token = "";
|
||||||
|
let sessionTimer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
const esc = (value: unknown) => String(value ?? "—").replace(/[&<>'"]/g, char => ({"&":"&","<":"<",">":">","'":"'",'"':"""}[char] ?? char));
|
||||||
|
const fail = (message: string) => { if (error) { error.textContent = message; error.hidden = false; } };
|
||||||
|
const endSession = (message?: string) => { token = ""; if (sessionTimer) clearTimeout(sessionTimer); sessionTimer = undefined; if (login) { login.hidden = false; login.reset(); } if (sessionBar) sessionBar.hidden = true; if (content) content.innerHTML = ""; if (message) fail(message); };
|
||||||
|
const keepSession = () => { if (sessionTimer) clearTimeout(sessionTimer); sessionTimer = setTimeout(() => endSession("Сессия завершена после 15 минут бездействия. Введите токен снова."), 15 * 60 * 1000); };
|
||||||
|
async function authorizedJson(path: string) { const response = await fetch(`${root?.dataset.apiUrl}${path}`, {headers:{Authorization:`Bearer ${token}`}}); if (response.status === 401 || response.status === 429) { endSession(); throw new Error(response.status === 429 ? "Слишком много попыток входа. Повторите позже." : "Неверный или истёкший административный токен."); } if (!response.ok) throw new Error("Не удалось загрузить административные данные."); keepSession(); return response.json(); }
|
||||||
|
async function publicJson(path: string) { const response = await fetch(`${root?.dataset.apiUrl}${path}`); return response.ok ? response.json() : []; }
|
||||||
|
async function loadDashboard() {
|
||||||
|
if (!content) return;
|
||||||
|
error?.setAttribute("hidden", ""); content.setAttribute("aria-busy", "true"); content.innerHTML = '<div class="loading-grid" aria-hidden="true"><div class="loading-card"></div><div class="loading-card"></div></div>';
|
||||||
|
const diagnostics = await authorizedJson("/api/v1/admin/diagnostics");
|
||||||
|
const [imports, sources] = await Promise.all([authorizedJson("/api/v1/admin/imports?limit=5"), publicJson("/api/v1/source-status")]);
|
||||||
|
const reports = diagnostics.counts?.catch_reports ?? {}; const observations = diagnostics.counts?.external_observations ?? {};
|
||||||
|
const sourceLabels: Record<string, string> = {healthy:"Работает",waiting:"Ожидает",stale:"Устарел",disabled:"Отключён",source_changed:"Изменился",temporarily_limited:"Временно недоступен"};
|
||||||
|
const sourceRows = (sources as Record<string, unknown>[]).map(source => { const state = String(source.status); const safeState = Object.hasOwn(sourceLabels, state) ? state : "waiting"; return `<li><span><i class="status-dot ${safeState}"></i>${esc(source.name)}</span><strong>${sourceLabels[safeState]}</strong></li>`; }).join("");
|
||||||
|
const importRows = (imports as Record<string, unknown>[]).map(run => `<li><span>${esc(run.status)}</span><time>${esc(new Date(String(run.started_at)).toLocaleString("ru-RU"))}</time></li>`).join("");
|
||||||
|
content.removeAttribute("aria-busy"); content.innerHTML = `<div class="admin-kpis"><a href="/admin/moderation"><span>Уловы на проверке</span><strong>${esc(reports.pending ?? 0)}</strong><small>Открыть очередь →</small></a><a href="/admin/external-sources"><span>Наблюдения в staging</span><strong>${esc((observations.staged ?? 0) + (observations.mapped ?? 0) + (observations.ready ?? 0))}</strong><small>Проверить источники →</small></a><article><span>Одобрено уловов</span><strong>${esc(reports.approved ?? 0)}</strong><small>Участвуют в статистике</small></article><article><span>Источников включено</span><strong>${esc(diagnostics.counts?.enabled_data_sources ?? 0)}</strong><small>из ${esc(diagnostics.counts?.data_sources ?? 0)}</small></article></div><div class="admin-dashboard-grid"><section><h2>Состояние источников</h2><ul>${sourceRows || "<li>Нет данных</li>"}</ul><a href="/status">Публичная страница состояния →</a></section><section><h2>Последние импорты</h2><ul>${importRows || "<li>Запусков пока нет</li>"}</ul></section></div>`;
|
||||||
|
}
|
||||||
|
login?.addEventListener("submit", async event => { event.preventDefault(); token = String(new FormData(login).get("token") || ""); try { await loadDashboard(); login.hidden = true; if (sessionBar) sessionBar.hidden = false; } catch (cause) { if (content) { content.removeAttribute("aria-busy"); content.innerHTML = ""; } fail(cause instanceof Error ? cause.message : "Ошибка загрузки."); } });
|
||||||
|
logout?.addEventListener("click", () => endSession("Вы вышли из административной панели."));
|
||||||
|
</script>
|
||||||
|
</Layout>
|
||||||
@@ -7,6 +7,7 @@ const apiUrl = import.meta.env.PUBLIC_API_URL || "http://localhost:8000";
|
|||||||
<section class="moderation-app" data-api-url={apiUrl}>
|
<section class="moderation-app" data-api-url={apiUrl}>
|
||||||
<form class="admin-login" autocomplete="off"><label>Административный токен<input name="token" type="password" required autocomplete="off" /></label><button data-action="primary" type="submit">Открыть очередь</button></form>
|
<form class="admin-login" autocomplete="off"><label>Административный токен<input name="token" type="password" required autocomplete="off" /></label><button data-action="primary" type="submit">Открыть очередь</button></form>
|
||||||
<p class="privacy">Токен хранится только в памяти страницы и не записывается в URL или localStorage.</p>
|
<p class="privacy">Токен хранится только в памяти страницы и не записывается в URL или localStorage.</p>
|
||||||
|
<div class="admin-session-bar" hidden><span>Административная сессия активна</span><button data-action="secondary" type="button" data-admin-logout>Выйти</button></div>
|
||||||
<div class="notice error" data-admin-error role="alert" hidden></div><div class="moderation-list" data-moderation-list aria-live="polite"></div>
|
<div class="notice error" data-admin-error role="alert" hidden></div><div class="moderation-list" data-moderation-list aria-live="polite"></div>
|
||||||
</section>
|
</section>
|
||||||
<script>
|
<script>
|
||||||
@@ -14,29 +15,38 @@ const apiUrl = import.meta.env.PUBLIC_API_URL || "http://localhost:8000";
|
|||||||
const login = document.querySelector<HTMLFormElement>(".admin-login");
|
const login = document.querySelector<HTMLFormElement>(".admin-login");
|
||||||
const list = document.querySelector<HTMLElement>("[data-moderation-list]");
|
const list = document.querySelector<HTMLElement>("[data-moderation-list]");
|
||||||
const error = document.querySelector<HTMLElement>("[data-admin-error]");
|
const error = document.querySelector<HTMLElement>("[data-admin-error]");
|
||||||
|
const sessionBar = document.querySelector<HTMLElement>(".admin-session-bar");
|
||||||
|
const logout = document.querySelector<HTMLButtonElement>("[data-admin-logout]");
|
||||||
let token = "";
|
let token = "";
|
||||||
|
let sessionTimer: ReturnType<typeof setTimeout> | undefined;
|
||||||
const loadingCards = () => `<div class="loading-grid" aria-hidden="true">${Array.from({length:2}, () => '<div class="loading-card"><span class="loading-line loading-line--label"></span><span class="loading-line loading-line--title"></span><span class="loading-line"></span><span class="loading-line loading-line--short"></span></div>').join("")}</div><span class="sr-only">Загружаем очередь модерации</span>`;
|
const loadingCards = () => `<div class="loading-grid" aria-hidden="true">${Array.from({length:2}, () => '<div class="loading-card"><span class="loading-line loading-line--label"></span><span class="loading-line loading-line--title"></span><span class="loading-line"></span><span class="loading-line loading-line--short"></span></div>').join("")}</div><span class="sr-only">Загружаем очередь модерации</span>`;
|
||||||
const setLoading = (loading: boolean) => list?.setAttribute("aria-busy", String(loading));
|
const setLoading = (loading: boolean) => list?.setAttribute("aria-busy", String(loading));
|
||||||
const esc = (value: unknown) => String(value ?? "—").replace(/[&<>'"]/g, char => ({"&":"&","<":"<",">":">","'":"'",'"':"""}[char] ?? char));
|
const esc = (value: unknown) => String(value ?? "—").replace(/[&<>'"]/g, char => ({"&":"&","<":"<",">":">","'":"'",'"':"""}[char] ?? char));
|
||||||
|
const safeHttpUrl = (value: unknown) => { try { const url = new URL(String(value)); return url.protocol === "http:" || url.protocol === "https:" ? esc(url.href) : ""; } catch { return ""; } };
|
||||||
const fail = (message: string) => { if (error) { error.textContent = message; error.hidden = false; } };
|
const fail = (message: string) => { if (error) { error.textContent = message; error.hidden = false; } };
|
||||||
|
const endSession = (message?: string) => { token = ""; if (sessionTimer) clearTimeout(sessionTimer); sessionTimer = undefined; if (login) { login.hidden = false; login.reset(); } if (sessionBar) sessionBar.hidden = true; if (list) list.innerHTML = ""; if (message) fail(message); };
|
||||||
|
const keepSession = () => { if (sessionTimer) clearTimeout(sessionTimer); sessionTimer = setTimeout(() => endSession("Сессия завершена после 15 минут бездействия. Введите токен снова."), 15 * 60 * 1000); };
|
||||||
async function loadQueue() {
|
async function loadQueue() {
|
||||||
if (!root || !list) return;
|
if (!root || !list) return;
|
||||||
error?.setAttribute("hidden", ""); setLoading(true); list.innerHTML = loadingCards();
|
error?.setAttribute("hidden", ""); setLoading(true); list.innerHTML = loadingCards();
|
||||||
const response = await fetch(`${root.dataset.apiUrl}/api/v1/admin/catch-reports?status=pending`, {headers:{Authorization:`Bearer ${token}`}});
|
const response = await fetch(`${root.dataset.apiUrl}/api/v1/admin/catch-reports?status=pending`, {headers:{Authorization:`Bearer ${token}`}});
|
||||||
if (!response.ok) throw new Error(response.status === 401 ? "Неверный административный токен." : "Не удалось загрузить очередь.");
|
if (response.status === 401) { endSession(); throw new Error("Неверный или истёкший административный токен."); }
|
||||||
|
if (!response.ok) throw new Error("Не удалось загрузить очередь.");
|
||||||
const reports: Record<string, unknown>[] = await response.json();
|
const reports: Record<string, unknown>[] = await response.json();
|
||||||
|
keepSession();
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
if (!reports.length) { list.innerHTML = '<div class="state"><h2>Очередь пуста</h2><p>Новых уловов для проверки нет.</p></div>'; return; }
|
if (!reports.length) { list.innerHTML = '<div class="state"><h2>Очередь пуста</h2><p>Новых уловов для проверки нет.</p></div>'; return; }
|
||||||
list.innerHTML = reports.map(report => `<article class="moderation-card" data-report-id="${esc(report.id)}"><div class="moderation-summary"><span class="activity-pill"><i></i>На проверке</span><h2>${esc(report.fish)}</h2><p>${esc(report.waterbody)} · ${esc(report.coordinates)}</p><dl><div><dt>Вес</dt><dd>${esc(report.weight_g)} г</dd></div><div><dt>Приманка</dt><dd>${esc(report.bait)}</dd></div><div><dt>Игрок</dt><dd>${esc(report.player_name)}</dd></div><div><dt>Отправлено</dt><dd>${esc(new Date(String(report.reported_at)).toLocaleString("ru-RU"))}</dd></div></dl>${report.comment ? `<blockquote>${esc(report.comment)}</blockquote>` : ""}</div><div class="moderation-proof">${report.screenshot_url ? `<a href="${esc(report.screenshot_url)}" target="_blank" rel="noreferrer"><img src="${esc(report.screenshot_url)}" alt="Скриншот улова ${esc(report.fish)}" /></a>` : '<div class="no-proof">Скриншот не приложен</div>'}</div><div class="moderation-actions"><label>Причина решения<textarea rows="2" maxlength="1000"></textarea></label><div><button data-action="primary" type="button" data-decision="approved">Одобрить</button><button data-action="danger" type="button" data-decision="rejected">Отклонить</button><button data-action="quiet-danger" type="button" data-delete>Удалить</button></div></div></article>`).join("");
|
list.innerHTML = reports.map(report => { const screenshotUrl = safeHttpUrl(report.screenshot_url); return `<article class="moderation-card" data-report-id="${esc(report.id)}"><div class="moderation-summary"><span class="activity-pill"><i></i>На проверке</span><h2>${esc(report.fish)}</h2><p>${esc(report.waterbody)} · ${esc(report.coordinates)}</p><dl><div><dt>Вес</dt><dd>${esc(report.weight_g)} г</dd></div><div><dt>Приманка</dt><dd>${esc(report.bait)}</dd></div><div><dt>Игрок</dt><dd>${esc(report.player_name)}</dd></div><div><dt>Отправлено</dt><dd>${esc(new Date(String(report.reported_at)).toLocaleString("ru-RU"))}</dd></div></dl>${report.comment ? `<blockquote>${esc(report.comment)}</blockquote>` : ""}</div><div class="moderation-proof">${screenshotUrl ? `<a href="${screenshotUrl}" target="_blank" rel="noreferrer"><img src="${screenshotUrl}" alt="Скриншот улова ${esc(report.fish)}" /></a>` : '<div class="no-proof">Скриншот не приложен</div>'}</div><div class="moderation-actions"><label>Причина решения<textarea rows="2" maxlength="1000"></textarea></label><div><button data-action="primary" type="button" data-decision="approved">Одобрить</button><button data-action="danger" type="button" data-decision="rejected">Отклонить</button><button data-action="quiet-danger" type="button" data-delete>Удалить</button></div></div></article>`; }).join("");
|
||||||
}
|
}
|
||||||
login?.addEventListener("submit", async event => { event.preventDefault(); token = String(new FormData(login).get("token") || ""); try { await loadQueue(); login.hidden = true; } catch (cause) { setLoading(false); if (list) list.innerHTML = ""; fail(cause instanceof Error ? cause.message : "Ошибка загрузки."); } });
|
login?.addEventListener("submit", async event => { event.preventDefault(); token = String(new FormData(login).get("token") || ""); try { await loadQueue(); login.hidden = true; if (sessionBar) sessionBar.hidden = false; } catch (cause) { setLoading(false); if (list) list.innerHTML = ""; fail(cause instanceof Error ? cause.message : "Ошибка загрузки."); } });
|
||||||
|
logout?.addEventListener("click", () => endSession("Вы вышли из административной панели."));
|
||||||
list?.addEventListener("click", async event => {
|
list?.addEventListener("click", async event => {
|
||||||
const button = (event.target as HTMLElement).closest<HTMLButtonElement>("button[data-decision],button[data-delete]"); const card = button?.closest<HTMLElement>("[data-report-id]"); if (!button || !card || !root) return;
|
const button = (event.target as HTMLElement).closest<HTMLButtonElement>("button[data-decision],button[data-delete]"); const card = button?.closest<HTMLElement>("[data-report-id]"); if (!button || !card || !root) return;
|
||||||
const reason = card.querySelector<HTMLTextAreaElement>("textarea")?.value.trim() || null;
|
const reason = card.querySelector<HTMLTextAreaElement>("textarea")?.value.trim() || null;
|
||||||
if (button.dataset.decision === "rejected" && !reason) { fail("Укажите причину отклонения."); card.querySelector("textarea")?.focus(); return; }
|
if (button.dataset.decision === "rejected" && !reason) { fail("Укажите причину отклонения."); card.querySelector("textarea")?.focus(); return; }
|
||||||
if (button.hasAttribute("data-delete") && !window.confirm("Удалить и обезличить эту заявку? Действие нельзя отменить.")) return;
|
if (button.hasAttribute("data-delete") && !window.confirm("Удалить и обезличить эту заявку? Действие нельзя отменить.")) return;
|
||||||
button.disabled = true;
|
button.disabled = true;
|
||||||
try { const deleting = button.hasAttribute("data-delete"); const response = await fetch(`${root.dataset.apiUrl}/api/v1/admin/catch-reports/${card.dataset.reportId}`, {method:deleting ? "DELETE" : "PATCH",headers:{Authorization:`Bearer ${token}`,"Content-Type":"application/json"},body:deleting ? undefined : JSON.stringify({status:button.dataset.decision,reason})}); if (!response.ok) throw new Error("Не удалось сохранить решение."); card.remove(); if (list && !list.children.length) list.innerHTML = '<div class="state"><h2>Очередь пуста</h2><p>Все записи обработаны.</p></div>'; } catch (cause) { button.disabled = false; fail(cause instanceof Error ? cause.message : "Ошибка сохранения."); }
|
try { const deleting = button.hasAttribute("data-delete"); const response = await fetch(`${root.dataset.apiUrl}/api/v1/admin/catch-reports/${card.dataset.reportId}`, {method:deleting ? "DELETE" : "PATCH",headers:{Authorization:`Bearer ${token}`,"Content-Type":"application/json"},body:deleting ? undefined : JSON.stringify({status:button.dataset.decision,reason})}); if (response.status === 401) { endSession(); throw new Error("Сессия истекла. Введите токен снова."); } if (!response.ok) throw new Error("Не удалось сохранить решение."); keepSession(); card.remove(); if (list && !list.children.length) list.innerHTML = '<div class="state"><h2>Очередь пуста</h2><p>Все записи обработаны.</p></div>'; } catch (cause) { button.disabled = false; fail(cause instanceof Error ? cause.message : "Ошибка сохранения."); }
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
</Layout>
|
</Layout>
|
||||||
|
|||||||
@@ -13,6 +13,11 @@
|
|||||||
.records-hero,.form-hero{width:min(1360px,calc(100% - 64px));margin:auto;padding:76px 0 48px;display:flex;align-items:end;justify-content:space-between;gap:70px}.records-hero h1,.form-hero h1{font-size:clamp(58px,7vw,108px);margin-bottom:0}.records-hero>div:last-child,.form-hero>p{max-width:500px;color:#627370;line-height:1.65}.source-status{display:grid;grid-template-columns:auto 1fr;gap:4px 9px}.source-status small{grid-column:2}.status-dot{width:9px;height:9px;border-radius:50%;background:#82928f}.status-dot.success{background:#83b83a}.record-filters,.report-form{width:min(1360px,calc(100% - 64px));margin:0 auto 55px;padding:25px;background:#fff;border:1px solid #d4ddd6;border-radius:18px;box-shadow:0 30px 80px #16383c12}.record-filters{display:flex;gap:14px;background:var(--deep)}.record-filters label{flex:1}.record-filters input,.report-form input,.report-form textarea,.report-form select{display:block;width:100%;margin-top:7px;padding:13px 14px;border:1px solid #d6dfd7;border-radius:9px;background:#f3f6f1;color:var(--deep)}.record-table{width:min(1360px,calc(100% - 64px));margin:auto;background:#fff;border-radius:16px;overflow:hidden}.record-row{display:grid;grid-template-columns:1.2fr .65fr 1.1fr 1.5fr 1fr .75fr;gap:14px;padding:20px;border-top:1px solid #e3e9e3}.record-head{background:#e4ebe3;border:0}.record-head span{font-size:10px;text-transform:uppercase;letter-spacing:.08em;color:#647572}.record-row>span,.record-row>time{font-size:13px;color:#60716e}.official-note{width:min(1360px,calc(100% - 64px));margin:20px auto 100px;color:#647572;font-size:12px}.form-hero{align-items:center}.form-hero>div{}.form-hero>p{font-size:17px}.notice{width:min(1360px,calc(100% - 64px));margin:0 auto 18px;padding:16px;border-radius:10px}.notice.success{background:#dff0c3;color:#426315}.notice.error{background:#f5d8d2;color:#8a3025}.report-form{max-width:900px;padding:34px}.form-grid{display:grid;grid-template-columns:1fr 1fr;gap:20px 16px}.report-form label{display:block;color:#60716e}.report-form .wide{display:block;margin-top:20px}.report-form input:focus,.report-form textarea:focus,.report-form select:focus{outline:none;border-color:#839d4b;box-shadow:0 0 0 3px #c9f45b45}.privacy{font-size:12px;color:#647572}.honeypot{position:absolute;left:-9999px}.back{display:inline-block;margin:40px max(32px,calc((100% - 1360px)/2)) 10px;color:#60716e}.spot-hero,.periods,.detail-grid{width:min(1180px,calc(100% - 64px));margin-inline:auto}.spot-hero{padding:50px;background:var(--deep);color:#fff;border-radius:18px;display:flex;justify-content:space-between}.spot-hero h1{font-size:70px}.spot-hero p{color:#a9b8b5}.pin{width:160px;height:160px;border:1px solid #ffffff34;border-radius:50%;display:grid;place-items:center;color:var(--lime);font:400 35px Georgia}.periods{display:grid;grid-template-columns:repeat(3,1fr);margin-top:20px;background:#fff;border-radius:14px;overflow:hidden}.periods>div{text-align:center;padding:25px;border-right:1px solid #e2e8e2}.periods strong{display:block;font:400 32px Georgia;color:#537014}.periods span{font-size:12px;color:#647572}.detail-grid{display:grid;grid-template-columns:2fr 1fr;gap:24px;padding-bottom:80px}.catch-list article{display:flex;justify-content:space-between;padding:16px 0;border-bottom:1px solid #d5ded7}.catch-list article>div{display:flex;flex-direction:column}.catch-list article>div:last-child{text-align:right}.catch-list span{font-size:12px;color:#647572}.detail-grid aside{margin-top:50px;padding:25px;background:#fff;border-radius:14px}.detail-grid aside li{padding:10px 0;border-bottom:1px solid #e2e8e2}.note{font-size:12px;color:#647572}
|
.records-hero,.form-hero{width:min(1360px,calc(100% - 64px));margin:auto;padding:76px 0 48px;display:flex;align-items:end;justify-content:space-between;gap:70px}.records-hero h1,.form-hero h1{font-size:clamp(58px,7vw,108px);margin-bottom:0}.records-hero>div:last-child,.form-hero>p{max-width:500px;color:#627370;line-height:1.65}.source-status{display:grid;grid-template-columns:auto 1fr;gap:4px 9px}.source-status small{grid-column:2}.status-dot{width:9px;height:9px;border-radius:50%;background:#82928f}.status-dot.success{background:#83b83a}.record-filters,.report-form{width:min(1360px,calc(100% - 64px));margin:0 auto 55px;padding:25px;background:#fff;border:1px solid #d4ddd6;border-radius:18px;box-shadow:0 30px 80px #16383c12}.record-filters{display:flex;gap:14px;background:var(--deep)}.record-filters label{flex:1}.record-filters input,.report-form input,.report-form textarea,.report-form select{display:block;width:100%;margin-top:7px;padding:13px 14px;border:1px solid #d6dfd7;border-radius:9px;background:#f3f6f1;color:var(--deep)}.record-table{width:min(1360px,calc(100% - 64px));margin:auto;background:#fff;border-radius:16px;overflow:hidden}.record-row{display:grid;grid-template-columns:1.2fr .65fr 1.1fr 1.5fr 1fr .75fr;gap:14px;padding:20px;border-top:1px solid #e3e9e3}.record-head{background:#e4ebe3;border:0}.record-head span{font-size:10px;text-transform:uppercase;letter-spacing:.08em;color:#647572}.record-row>span,.record-row>time{font-size:13px;color:#60716e}.official-note{width:min(1360px,calc(100% - 64px));margin:20px auto 100px;color:#647572;font-size:12px}.form-hero{align-items:center}.form-hero>div{}.form-hero>p{font-size:17px}.notice{width:min(1360px,calc(100% - 64px));margin:0 auto 18px;padding:16px;border-radius:10px}.notice.success{background:#dff0c3;color:#426315}.notice.error{background:#f5d8d2;color:#8a3025}.report-form{max-width:900px;padding:34px}.form-grid{display:grid;grid-template-columns:1fr 1fr;gap:20px 16px}.report-form label{display:block;color:#60716e}.report-form .wide{display:block;margin-top:20px}.report-form input:focus,.report-form textarea:focus,.report-form select:focus{outline:none;border-color:#839d4b;box-shadow:0 0 0 3px #c9f45b45}.privacy{font-size:12px;color:#647572}.honeypot{position:absolute;left:-9999px}.back{display:inline-block;margin:40px max(32px,calc((100% - 1360px)/2)) 10px;color:#60716e}.spot-hero,.periods,.detail-grid{width:min(1180px,calc(100% - 64px));margin-inline:auto}.spot-hero{padding:50px;background:var(--deep);color:#fff;border-radius:18px;display:flex;justify-content:space-between}.spot-hero h1{font-size:70px}.spot-hero p{color:#a9b8b5}.pin{width:160px;height:160px;border:1px solid #ffffff34;border-radius:50%;display:grid;place-items:center;color:var(--lime);font:400 35px Georgia}.periods{display:grid;grid-template-columns:repeat(3,1fr);margin-top:20px;background:#fff;border-radius:14px;overflow:hidden}.periods>div{text-align:center;padding:25px;border-right:1px solid #e2e8e2}.periods strong{display:block;font:400 32px Georgia;color:#537014}.periods span{font-size:12px;color:#647572}.detail-grid{display:grid;grid-template-columns:2fr 1fr;gap:24px;padding-bottom:80px}.catch-list article{display:flex;justify-content:space-between;padding:16px 0;border-bottom:1px solid #d5ded7}.catch-list article>div{display:flex;flex-direction:column}.catch-list article>div:last-child{text-align:right}.catch-list span{font-size:12px;color:#647572}.detail-grid aside{margin-top:50px;padding:25px;background:#fff;border-radius:14px}.detail-grid aside li{padding:10px 0;border-bottom:1px solid #e2e8e2}.note{font-size:12px;color:#647572}
|
||||||
.notice.warning{background:#fff0c7;color:#73550c}.screenshot-retry{width:min(900px,calc(100% - 64px));margin:0 auto 24px;padding:22px;background:#fff;border:1px solid #e2c975;border-radius:12px}.screenshot-retry label{color:#60716e}.screenshot-retry input{display:block;width:100%;margin:8px 0 14px}.screenshot-retry button{border:0;border-radius:9px;padding:13px 20px;background:var(--deep);color:#fff;font-weight:750}
|
.notice.warning{background:#fff0c7;color:#73550c}.screenshot-retry{width:min(900px,calc(100% - 64px));margin:0 auto 24px;padding:22px;background:#fff;border:1px solid #e2c975;border-radius:12px}.screenshot-retry label{color:#60716e}.screenshot-retry input{display:block;width:100%;margin:8px 0 14px}.screenshot-retry button{border:0;border-radius:9px;padding:13px 20px;background:var(--deep);color:#fff;font-weight:750}
|
||||||
.moderation-app{width:min(1120px,calc(100% - 64px));margin:0 auto 100px}.admin-login{display:flex;gap:14px;align-items:end;padding:25px;background:var(--deep);border-radius:16px}.admin-login label{flex:1;color:#a8b7b4;font-size:12px;text-transform:uppercase;letter-spacing:.1em;font-weight:700}.admin-login input{display:block;width:100%;margin-top:7px;padding:13px 14px;border:1px solid #ffffff29;border-radius:9px;background:#ffffff0c;color:#fff}.admin-login button,.moderation-actions button{height:48px;padding:0 24px;border:0;border-radius:9px;background:var(--lime);color:var(--deep);font-weight:750}.moderation-app>.privacy{margin:10px 4px 25px}.moderation-app>.notice{width:100%}.moderation-list{display:grid;gap:18px}.moderation-card{display:grid;grid-template-columns:1fr 300px;gap:25px;padding:25px;background:#fff;border:1px solid #d4ddd6;border-radius:16px}.moderation-summary h2{font:400 32px Georgia,serif;margin:12px 0 4px}.moderation-summary>p{color:#647572;margin:0 0 20px}.moderation-summary dl{display:grid;grid-template-columns:1fr 1fr;gap:14px;margin:0}.moderation-summary dl div{border-top:1px solid #e1e8e2;padding-top:10px}.moderation-summary dt{font-size:10px;text-transform:uppercase;color:#7a8a87}.moderation-summary dd{margin:3px 0 0}.moderation-summary blockquote{margin:18px 0 0;padding:13px;border-left:3px solid var(--lime);background:#f2f6ef;color:#566865}.moderation-proof img,.no-proof{width:100%;height:210px;object-fit:cover;border-radius:11px}.no-proof{display:grid;place-items:center;background:#edf2ec;color:#71817f}.moderation-actions{grid-column:1/-1;display:flex;align-items:end;gap:14px;border-top:1px solid #e1e8e2;padding-top:18px}.moderation-actions label{flex:1;color:#60716e;font-size:12px;text-transform:uppercase;letter-spacing:.08em}.moderation-actions textarea{display:block;width:100%;margin-top:6px;padding:10px;border:1px solid #d4ddd6;border-radius:8px;resize:vertical}.moderation-actions>div{display:flex;gap:8px}.moderation-actions .reject{background:#f0d4ce;color:#842f25}.moderation-actions .delete{background:transparent;color:#842f25;border:1px solid #d8aaa1}.moderation-actions button:disabled{opacity:.55}
|
.moderation-app{width:min(1120px,calc(100% - 64px));margin:0 auto 100px}.admin-login{display:flex;gap:14px;align-items:end;padding:25px;background:var(--deep);border-radius:16px}.admin-login label{flex:1;color:#a8b7b4;font-size:12px;text-transform:uppercase;letter-spacing:.1em;font-weight:700}.admin-login input{display:block;width:100%;margin-top:7px;padding:13px 14px;border:1px solid #ffffff29;border-radius:9px;background:#ffffff0c;color:#fff}.admin-login button,.moderation-actions button{height:48px;padding:0 24px;border:0;border-radius:9px;background:var(--lime);color:var(--deep);font-weight:750}.moderation-app>.privacy{margin:10px 4px 25px}.moderation-app>.notice{width:100%}.moderation-list{display:grid;gap:18px}.moderation-card{display:grid;grid-template-columns:1fr 300px;gap:25px;padding:25px;background:#fff;border:1px solid #d4ddd6;border-radius:16px}.moderation-summary h2{font:400 32px Georgia,serif;margin:12px 0 4px}.moderation-summary>p{color:#647572;margin:0 0 20px}.moderation-summary dl{display:grid;grid-template-columns:1fr 1fr;gap:14px;margin:0}.moderation-summary dl div{border-top:1px solid #e1e8e2;padding-top:10px}.moderation-summary dt{font-size:10px;text-transform:uppercase;color:#7a8a87}.moderation-summary dd{margin:3px 0 0}.moderation-summary blockquote{margin:18px 0 0;padding:13px;border-left:3px solid var(--lime);background:#f2f6ef;color:#566865}.moderation-proof img,.no-proof{width:100%;height:210px;object-fit:cover;border-radius:11px}.no-proof{display:grid;place-items:center;background:#edf2ec;color:#71817f}.moderation-actions{grid-column:1/-1;display:flex;align-items:end;gap:14px;border-top:1px solid #e1e8e2;padding-top:18px}.moderation-actions label{flex:1;color:#60716e;font-size:12px;text-transform:uppercase;letter-spacing:.08em}.moderation-actions textarea{display:block;width:100%;margin-top:6px;padding:10px;border:1px solid #d4ddd6;border-radius:8px;resize:vertical}.moderation-actions>div{display:flex;gap:8px}.moderation-actions .reject{background:#f0d4ce;color:#842f25}.moderation-actions .delete{background:transparent;color:#842f25;border:1px solid #d8aaa1}.moderation-actions button:disabled{opacity:.55}
|
||||||
|
.admin-session-bar{display:flex;align-items:center;justify-content:space-between;gap:16px;margin:0 0 18px;padding:12px 14px;border:1px solid var(--line);border-radius:10px;background:var(--surface);color:var(--muted);font-size:13px}.admin-session-bar[hidden]{display:none}.admin-session-bar button{min-height:38px}
|
||||||
|
.admin-queue-filters{display:grid;grid-template-columns:1.3fr repeat(3,1fr) auto;gap:10px;align-items:end;margin:0 0 18px;padding:16px;border-radius:12px;background:var(--deep)}.admin-queue-filters[hidden]{display:none}.admin-queue-filters label{color:#b8c5c2;font-size:11px;text-transform:uppercase;letter-spacing:.08em}.admin-queue-filters input,.admin-queue-filters select{display:block;width:100%;height:42px;margin-top:6px;padding:0 10px;border:1px solid #ffffff29;border-radius:8px;background:#ffffff0c;color:#fff}.admin-queue-filters option{color:var(--ink)}.admin-queue-filters button{height:42px}
|
||||||
|
@media(max-width:900px){.admin-queue-filters{grid-template-columns:1fr 1fr}}@media(max-width:520px){.admin-queue-filters{grid-template-columns:1fr}}
|
||||||
|
.admin-dashboard{width:min(1120px,calc(100% - 64px));margin:0 auto 100px}.admin-dashboard>.privacy{margin:10px 4px 25px}.admin-kpis{display:grid;grid-template-columns:repeat(4,1fr);gap:14px}.admin-kpis>a,.admin-kpis>article,.admin-dashboard-grid>section{padding:22px;border:1px solid var(--line);border-radius:14px;background:var(--surface)}.admin-kpis>a{color:inherit;text-decoration:none}.admin-kpis span,.admin-kpis small{display:block;color:var(--text-muted)}.admin-kpis strong{display:block;margin:10px 0;font:400 42px Georgia,serif;color:var(--deep)}.admin-kpis>a:hover{border-color:var(--focus);transform:translateY(-2px)}.admin-dashboard-grid{display:grid;grid-template-columns:1fr 1fr;gap:18px;margin-top:18px}.admin-dashboard-grid h2{margin:0 0 16px;font:400 25px Georgia,serif}.admin-dashboard-grid ul{list-style:none;margin:0 0 16px;padding:0}.admin-dashboard-grid li{display:flex;justify-content:space-between;gap:14px;padding:10px 0;border-bottom:1px solid var(--border-soft);font-size:13px}.admin-dashboard-grid li span{display:flex;align-items:center;gap:8px}.admin-dashboard-grid time{color:var(--text-muted)}
|
||||||
|
@media(max-width:900px){.admin-kpis{grid-template-columns:1fr 1fr}}@media(max-width:720px){.admin-dashboard{width:calc(100% - 28px)}.admin-kpis,.admin-dashboard-grid{grid-template-columns:1fr}}
|
||||||
footer{min-height:118px;background:var(--deep);color:#dbe4df;padding:28px max(32px,calc((100vw - 1360px)/2));display:grid;grid-template-columns:1fr 1fr auto;align-items:center;gap:28px}footer .brand-mark{border-color:#ffffff32}footer .brand-name span{color:#9dafaa}footer p{font-size:12px;color:#92a4a0}footer>span{font:italic 16px Georgia;color:var(--lime)}
|
footer{min-height:118px;background:var(--deep);color:#dbe4df;padding:28px max(32px,calc((100vw - 1360px)/2));display:grid;grid-template-columns:1fr 1fr auto;align-items:center;gap:28px}footer .brand-mark{border-color:#ffffff32}footer .brand-name span{color:#9dafaa}footer p{font-size:12px;color:#92a4a0}footer>span{font:italic 16px Georgia;color:var(--lime)}
|
||||||
@media(max-width:1040px){.content-grid{width:min(100% - 36px,900px)}.topbar{width:calc(100% - 36px);grid-template-columns:1fr auto}.live-badge{display:none}.intro{grid-template-columns:1fr;gap:34px;padding-top:54px}.lake-card{height:280px}.dashboard{grid-template-columns:1fr}.detail-card{position:relative;top:auto}.how-it-works{grid-template-columns:1fr}.records-hero,.form-hero{display:block}.records-hero>div:last-child,.form-hero>p{margin-top:25px}}
|
@media(max-width:1040px){.content-grid{width:min(100% - 36px,900px)}.topbar{width:calc(100% - 36px);grid-template-columns:1fr auto}.live-badge{display:none}.intro{grid-template-columns:1fr;gap:34px;padding-top:54px}.lake-card{height:280px}.dashboard{grid-template-columns:1fr}.detail-card{position:relative;top:auto}.how-it-works{grid-template-columns:1fr}.records-hero,.form-hero{display:block}.records-hero>div:last-child,.form-hero>p{margin-top:25px}}
|
||||||
@media(max-width:720px){.content-grid,.records-hero,.form-hero,.record-filters,.record-table,.official-note,.report-form,.spot-hero,.periods,.detail-grid{width:calc(100% - 28px)}.topbar{width:100%;padding:13px 14px 0;display:flex;flex-wrap:wrap;height:auto}.topbar .brand{flex:1}.topbar nav{order:2;width:100%;height:46px;overflow-x:auto}.topbar nav a{flex:0 0 auto;font-size:13px}.intro h1,.records-hero h1,.form-hero h1{font-size:55px}.intro{padding:30px 0 25px}.intro h1{font-size:47px;margin:13px 0 12px}.intro-copy>p{font-size:15px;margin:0}.lake-card{display:none}.filters-wrap{position:relative;padding:17px 0}.filters{grid-template-columns:1fr 1fr}.filter-advanced-field{display:none}.filter-compact-hidden{display:none!important}.filter-advanced-fallback{display:block;grid-column:1/-1;border-top:1px solid #ffffff1d;padding-top:10px}.filter-advanced-fallback summary{display:flex;justify-content:space-between;color:#d5dfdc;font-size:12px;cursor:pointer;list-style:none}.filter-advanced-fallback summary::-webkit-details-marker{display:none}.filter-advanced-fallback summary:before{content:"+";margin-right:7px;color:var(--lime)}.filter-advanced-fallback[open] summary:before{content:"−"}.filter-advanced-fallback summary span{margin-left:auto;color:#9fb0ad}.filter-advanced-fallback .advanced-fields{display:grid!important;grid-template-columns:1fr 1fr;gap:12px;padding-top:12px}.filter-advanced-fallback:not([open]) .advanced-fields{display:none!important}.filters button{grid-column:1/-1}.active-filters{min-height:0;overflow-x:auto;padding:10px 14px 0;width:100%;scrollbar-width:none}.active-filters span{flex:0 0 auto}.active-filters a{position:sticky;right:0;padding:6px 10px;background:var(--paper)}.dashboard{padding:28px 0 74px;scroll-margin-top:10px}.spot-card{grid-template-columns:34px 1fr;padding:18px 18px 18px 14px;gap:10px}.spot-stats{grid-column:2;border:0;border-top:1px solid #e2e8e2;padding:13px 0 0;grid-template-columns:repeat(4,1fr)}.card-arrow{display:none}.detail-score{grid-template-columns:105px 1fr}.score-ring{width:100px;height:100px}.principles{grid-template-columns:1fr}.record-filters{display:grid}.record-row{grid-template-columns:1fr 1fr}.record-head{display:none}.record-row>*:nth-child(even){text-align:right}.form-grid,.detail-grid{grid-template-columns:1fr}.spot-hero{padding:28px}.spot-hero h1{font-size:48px}.pin{display:none}footer{grid-template-columns:1fr auto;padding:30px 20px}footer p{grid-column:1/-1;order:3}}
|
@media(max-width:720px){.content-grid,.records-hero,.form-hero,.record-filters,.record-table,.official-note,.report-form,.spot-hero,.periods,.detail-grid{width:calc(100% - 28px)}.topbar{width:100%;padding:13px 14px 0;display:flex;flex-wrap:wrap;height:auto}.topbar .brand{flex:1}.topbar nav{order:2;width:100%;height:46px;overflow-x:auto}.topbar nav a{flex:0 0 auto;font-size:13px}.intro h1,.records-hero h1,.form-hero h1{font-size:55px}.intro{padding:30px 0 25px}.intro h1{font-size:47px;margin:13px 0 12px}.intro-copy>p{font-size:15px;margin:0}.lake-card{display:none}.filters-wrap{position:relative;padding:17px 0}.filters{grid-template-columns:1fr 1fr}.filter-advanced-field{display:none}.filter-compact-hidden{display:none!important}.filter-advanced-fallback{display:block;grid-column:1/-1;border-top:1px solid #ffffff1d;padding-top:10px}.filter-advanced-fallback summary{display:flex;justify-content:space-between;color:#d5dfdc;font-size:12px;cursor:pointer;list-style:none}.filter-advanced-fallback summary::-webkit-details-marker{display:none}.filter-advanced-fallback summary:before{content:"+";margin-right:7px;color:var(--lime)}.filter-advanced-fallback[open] summary:before{content:"−"}.filter-advanced-fallback summary span{margin-left:auto;color:#9fb0ad}.filter-advanced-fallback .advanced-fields{display:grid!important;grid-template-columns:1fr 1fr;gap:12px;padding-top:12px}.filter-advanced-fallback:not([open]) .advanced-fields{display:none!important}.filters button{grid-column:1/-1}.active-filters{min-height:0;overflow-x:auto;padding:10px 14px 0;width:100%;scrollbar-width:none}.active-filters span{flex:0 0 auto}.active-filters a{position:sticky;right:0;padding:6px 10px;background:var(--paper)}.dashboard{padding:28px 0 74px;scroll-margin-top:10px}.spot-card{grid-template-columns:34px 1fr;padding:18px 18px 18px 14px;gap:10px}.spot-stats{grid-column:2;border:0;border-top:1px solid #e2e8e2;padding:13px 0 0;grid-template-columns:repeat(4,1fr)}.card-arrow{display:none}.detail-score{grid-template-columns:105px 1fr}.score-ring{width:100px;height:100px}.principles{grid-template-columns:1fr}.record-filters{display:grid}.record-row{grid-template-columns:1fr 1fr}.record-head{display:none}.record-row>*:nth-child(even){text-align:right}.form-grid,.detail-grid{grid-template-columns:1fr}.spot-hero{padding:28px}.spot-hero h1{font-size:48px}.pin{display:none}footer{grid-template-columns:1fr auto;padding:30px 20px}footer p{grid-column:1/-1;order:3}}
|
||||||
|
|||||||
@@ -8,9 +8,16 @@
|
|||||||
"entity_type": "reference",
|
"entity_type": "reference",
|
||||||
"label": "Zig-Rig Guide Unterschiedliche Wetterbedingungen führen zu Veränderungen der Wassertemperatur. Bei niedrigen Temperaturen können Karpfen in die sonnenerwärmten Oberflächenschichten des Gewässers aufsteigen. In solchen Fällen können Zig-Rig Montagen die besten Ergebnisse erzielen. Beim Zig-Rig wird a",
|
"label": "Zig-Rig Guide Unterschiedliche Wetterbedingungen führen zu Veränderungen der Wassertemperatur. Bei niedrigen Temperaturen können Karpfen in die sonnenerwärmten Oberflächenschichten des Gewässers aufsteigen. In solchen Fällen können Zig-Rig Montagen die besten Ergebnisse erzielen. Beim Zig-Rig wird a",
|
||||||
"external_id": null,
|
"external_id": null,
|
||||||
"status": "queued",
|
"status": "stored",
|
||||||
"first_seen_at": "2026-09-12T08:50:41.276898+00:00",
|
"first_seen_at": "2026-09-12T08:50:41.276898+00:00",
|
||||||
"last_seen_at": "2026-09-12T08:50:41.276898+00:00"
|
"last_seen_at": "2026-09-12T08:50:41.276898+00:00",
|
||||||
|
"sha256": "48b4e4f2cdca2d65bc17feee61a36a9eaf979ea6a83756f3805750d99dd6388e",
|
||||||
|
"local_path": "files/48/48b4e4f2cdca2d65bc17feee61a36a9eaf979ea6a83756f3805750d99dd6388e.jpg",
|
||||||
|
"content_type": "image/jpeg",
|
||||||
|
"bytes": 53123,
|
||||||
|
"width": 602,
|
||||||
|
"height": 208,
|
||||||
|
"fetched_at": "2026-09-12T14:24:59.865233+00:00"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"source_page": "https://rf4game.de/userguide/",
|
"source_page": "https://rf4game.de/userguide/",
|
||||||
|
|||||||
+10
-1
@@ -51,7 +51,7 @@
|
|||||||
|
|
||||||
Аудит выполнен на старой базе `13e04e6`; рекомендации ниже повторно проверены по текущей ветке. Уже реализованные или неприменимые предложения не возвращаются в backlog.
|
Аудит выполнен на старой базе `13e04e6`; рекомендации ниже повторно проверены по текущей ветке. Уже реализованные или неприменимые предложения не возвращаются в backlog.
|
||||||
|
|
||||||
- [ ] **Q11 · Декомпозиция API — в работе.** После фиксации OpenAPI catalog routes (рыбы, водоёмы, приманки, public spot pages) и контур activity/spots вынесены в отдельные `APIRouter`, общий `Db` — в dependencies, нормализация времени — в `time_utils`. URL, response models и generated contract сохранены. Далее: records/community status, submissions и admin отдельными пакетами.
|
- [ ] **Q11 · Декомпозиция API — в работе.** После фиксации OpenAPI публичные catalog, activity/spots и records/community/status/import-history вынесены в отдельные `APIRouter`; submission endpoints уже принадлежат собственному router, а их security-слой изолирован в `submission_security`. URL, response models и generated contract сохранены. Далее: физически вынести submission handlers из `main.py`, затем admin.
|
||||||
- [ ] **Q12 · Query-plan gate.** В рамках Q07 снять `EXPLAIN (ANALYZE, BUFFERS)` для activity, records, spot detail и public spot pages на реалистичном наборе данных. Существующие индексы миграции `0011_query_indexes` не дублировать; индекс с `fish_id`, SQL-агрегацию или materialized view добавлять только по измеренному плану и p95.
|
- [ ] **Q12 · Query-plan gate.** В рамках Q07 снять `EXPLAIN (ANALYZE, BUFFERS)` для activity, records, spot detail и public spot pages на реалистичном наборе данных. Существующие индексы миграции `0011_query_indexes` не дублировать; индекс с `fish_id`, SQL-агрегацию или materialized view добавлять только по измеренному плану и p95.
|
||||||
- [x] **Q13 · Production bootstrap в CI.** Отдельный workflow запускает `deploy/test-production-bootstrap.sh` вручную или раз в неделю, а не на каждом push. Вывод bootstrap всегда сохраняется 14 дней; при падении добавляются Compose status и Playwright diagnostics.
|
- [x] **Q13 · Production bootstrap в CI.** Отдельный workflow запускает `deploy/test-production-bootstrap.sh` вручную или раз в неделю, а не на каждом push. Вывод bootstrap всегда сохраняется 14 дней; при падении добавляются Compose status и Playwright diagnostics.
|
||||||
- [ ] **Q14 · Полная CSP.** Расширить текущую CSP (`frame-ancestors`, `base-uri`, `object-src`) до `default-src`, `script-src`, `style-src`, `img-src`, `connect-src` и `form-action`. Сначала инвентаризировать inline scripts/styles Astro, затем внедрить nonce/hash или безопасное вынесение; проверить report/admin/OG без ослабления до произвольных внешних origin.
|
- [ ] **Q14 · Полная CSP.** Расширить текущую CSP (`frame-ancestors`, `base-uri`, `object-src`) до `default-src`, `script-src`, `style-src`, `img-src`, `connect-src` и `form-action`. Сначала инвентаризировать inline scripts/styles Astro, затем внедрить nonce/hash или безопасное вынесение; проверить report/admin/OG без ослабления до произвольных внешних origin.
|
||||||
@@ -60,6 +60,15 @@
|
|||||||
- [ ] **Q17 · Эксплуатационные документы.** Добавить короткие ADR по Astro/FastAPI/PostgreSQL, локальному cache и стратегии scheduler, а также incident runbook для заполнения диска/PostgreSQL, отказа MinIO, зависших импортов, ошибок миграции и компрометации секретов.
|
- [ ] **Q17 · Эксплуатационные документы.** Добавить короткие ADR по Astro/FastAPI/PostgreSQL, локальному cache и стратегии scheduler, а также incident runbook для заполнения диска/PostgreSQL, отказа MinIO, зависших импортов, ошибок миграции и компрометации секретов.
|
||||||
- [ ] **Q18 · Минимальная observability.** До открытой альфы определить дешёвые метрики request count/latency/error rate, глубины moderation/staging и возраста последнего успешного импорта. Формат и exporter выбрать после выбора мониторинга сервера; полноценный tracing не внедрять без подтверждённой потребности.
|
- [ ] **Q18 · Минимальная observability.** До открытой альфы определить дешёвые метрики request count/latency/error rate, глубины moderation/staging и возраста последнего успешного импорта. Формат и exporter выбрать после выбора мониторинга сервера; полноценный tracing не внедрять без подтверждённой потребности.
|
||||||
|
|
||||||
|
### Административная панель
|
||||||
|
|
||||||
|
- [x] **M01 · Усиление административного входа.** Сохранён двойной барьер Caddy Basic Auth + API Bearer; внешние ссылки ограничены `http/https`, UI завершает сессию после 15 минут бездействия, предоставляет явный выход и возвращает вход после `401`. Неуспешная API-авторизация ограничена постоянным счётчиком по HMAC-идентификатору клиента с учётом доверенного proxy; успешный вход очищает ошибки клиента. Bearer-токен не сохраняется в URL, cookie или browser storage.
|
||||||
|
- [x] **M02 · Единый dashboard.** `/admin` показывает счётчики pending-уловов и staging-наблюдений, число активных источников, их безопасные публичные статусы, последние импорты и быстрые переходы в очереди. Dashboard использует тот же memory-only токен и 15-минутную сессию, не выводит секреты, внутренние URL и полные тексты исключений.
|
||||||
|
- [ ] **M03 · Эффективность очередей — в работе.** Очередь внешних наблюдений получила серверные фильтры по источнику и полноте, безопасный поиск по рыбе/водоёму и сортировку от новых или старых; параметры работают до пагинации. Далее: приоритет риска, понятное подтверждение результата, восстановление черновика причины, переход к следующей карточке и доступные горячие клавиши.
|
||||||
|
- [ ] **M04 · Полный provenance и история решений.** Перед публикацией показывать исходные поля, источник, время наблюдения, missing fields и результат сопоставления. Добавить read-only журнал модерации и экспорт без персональных данных.
|
||||||
|
- [ ] **M05 · Защита от параллельных решений.** Ввести version/updated-at precondition для optimistic locking и возвращать понятный `409`, если запись уже изменена другим модератором.
|
||||||
|
- [ ] **M06 · Персональные роли — после пилота.** Если модераторов станет больше одного, заменить общий токен индивидуальными аккаунтами, короткими сессиями, отзывом доступа и ролями; писать идентификатор оператора в аудит. Для одного владельца альфы не добавлять отдельный auth-сервис заранее.
|
||||||
|
|
||||||
## Готовность открытой альфы — требуется сервер или внешний сервис
|
## Готовность открытой альфы — требуется сервер или внешний сервис
|
||||||
|
|
||||||
- [ ] Купить/подготовить Linux-сервер и подтвердить его публичный IPv4/IPv6.
|
- [ ] Купить/подготовить Linux-сервер и подтвердить его публичный IPv4/IPv6.
|
||||||
|
|||||||
Reference in New Issue
Block a user