Compare commits
5
Commits
870d9cc7f9
...
6e60988ac9
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6e60988ac9 | ||
|
|
e6653d4c77 | ||
|
|
2200336524 | ||
|
|
0f075469c8 | ||
|
|
66fb1625da |
@@ -24,4 +24,18 @@ OFFICIAL_RECORDS_REGION=RU
|
||||
OFFICIAL_RECORDS_CATEGORY=records
|
||||
OFFICIAL_IMPORT_REQUIRED=false
|
||||
IMPORT_INTERVAL_SECONDS=3600
|
||||
|
||||
# Data retention for the closed alpha; see docs/data-retention.md.
|
||||
RETENTION_SUBMISSION_DAYS=1
|
||||
RETENTION_UNREVIEWED_DAYS=30
|
||||
RETENTION_APPROVED_PERSONAL_DAYS=180
|
||||
RETENTION_STAGING_DAYS=90
|
||||
RETENTION_AUDIT_DAYS=365
|
||||
RETENTION_PUBLISHED_PAYLOAD_DAYS=365
|
||||
|
||||
# Host-side monitoring. BACKUP_ROOT must match the backup.sh destination.
|
||||
BACKUP_ROOT=/srv/rf4-backups
|
||||
MONITOR_DISK_MAX_PERCENT=85
|
||||
MONITOR_TLS_MIN_DAYS=14
|
||||
MONITOR_BACKUP_MAX_HOURS=26
|
||||
LOG_LEVEL=INFO
|
||||
|
||||
@@ -14,7 +14,13 @@ RF4 Spotter — неофициальный сервис свежих точек
|
||||
|
||||
Подробный план и актуальные чекбоксы находятся в [`docs/ROADMAP.md`](docs/ROADMAP.md). Результаты проверки интерфейса и пять приоритетных UX-пакетов описаны в [`docs/UI_UX_AUDIT.md`](docs/UI_UX_AUDIT.md).
|
||||
|
||||
Production-контур для домена `rf4spotter.ru`, TLS, секреты, backup/restore и команды первого запуска описаны в [`deploy/README.md`](deploy/README.md). Он использует отдельный `compose.production.yaml`; локальный `compose.yaml` остаётся средой разработки. Production seed добавляет только справочники — демонстрационные уловы отключены.
|
||||
Production-контур для домена `rf4spotter.ru`, TLS, секреты, backup/restore и команды первого запуска описаны в [`deploy/README.md`](deploy/README.md). Он использует отдельный `compose.production.yaml`; локальный `compose.yaml` остаётся средой разработки. Production seed добавляет только справочники — демонстрационные уловы отключены. Изолированные проверки `deploy/test-production-bootstrap.sh` и `deploy/test-backup-restore.sh` подтверждают старт с пустых volumes и восстановление данных.
|
||||
|
||||
Политика минимизации данных и ежедневная dry-run-first очистка описаны в [`docs/data-retention.md`](docs/data-retention.md).
|
||||
|
||||
Host-side мониторинг контейнеров, readiness, диска, резервных копий и TLS описан в [`docs/production-monitoring.md`](docs/production-monitoring.md).
|
||||
|
||||
Фактическое состояние DNS/TLS домена и серверный чек-лист ведутся в [`docs/deployment-status.md`](docs/deployment-status.md).
|
||||
|
||||
Gitea Actions workflow `.gitea/workflows/ci.yml` на каждый push и pull request проверяет Python, миграции на чистой PostgreSQL, Astro build и полный Compose/Playwright-сценарий. При падении E2E сохраняются логи контейнеров и Playwright-артефакты.
|
||||
|
||||
@@ -79,7 +85,7 @@ docker compose up --build
|
||||
## Что реализовано
|
||||
|
||||
- FastAPI и SQLAlchemy 2;
|
||||
- PostgreSQL 17 и миграции Alembic до `0009`;
|
||||
- PostgreSQL 17 и миграции Alembic до `0010`;
|
||||
- идемпотентный seed с двумя точками и свежими демо-уловами;
|
||||
- `GET /api/v1/activity` с фильтрами периода, водоёма, рыбы, способа и сортировки;
|
||||
- `GET /api/v1/spots/{id}` и `/catches`;
|
||||
@@ -91,7 +97,7 @@ docker compose up --build
|
||||
- идемпотентный импорт официальных записей с журналом запусков;
|
||||
- публичная страница `/records` с источником и временем последнего импорта;
|
||||
- форма `/report`, защищённые admin API и журнал модерации;
|
||||
- отдельные состояния ошибки создания заявки и загрузки скриншота; неудачный скриншот можно добавить повторно по ID уже сохранённой заявки;
|
||||
- отдельные состояния ошибки создания заявки и загрузки скриншота; неудачный скриншот можно добавить повторно по ID и одноразовому секрету уже сохранённой заявки;
|
||||
- honeypot и постоянный rate limit в PostgreSQL с HMAC-отпечатками вместо исходных IP;
|
||||
- скриншоты уловов в MinIO/S3 с проверкой MIME, расширения, размера и фактического содержимого, повторным кодированием и очисткой метаданных;
|
||||
- административная очередь `/admin/moderation` с одобрением, отклонением и обезличенным удалением записи с аудитом.
|
||||
@@ -159,7 +165,7 @@ curl -H "Authorization: Bearer change-me-in-production" \
|
||||
|
||||
Перед внешним развёртыванием обязательно замените демонстрационные `ADMIN_TOKEN`, `RATE_LIMIT_SECRET`, `S3_ACCESS_KEY` и `S3_SECRET_KEY`. Форма принимает JPEG, PNG и WebP до 8 МБ; API сверяет MIME и расширение с фактическим форматом, повторно кодирует изображение и удаляет EXIF перед сохранением в MinIO. Модератор получает временную подписанную ссылку через admin API.
|
||||
|
||||
Если создание записи прошло успешно, а загрузка скриншота завершилась ошибкой, форма сохраняет ID заявки и предлагает повторить только загрузку изображения. Повторно отправлять сам улов не требуется.
|
||||
Если создание записи прошло успешно, а загрузка скриншота завершилась ошибкой, форма сохраняет на один час ID заявки и одноразовый секрет в защищённой `HttpOnly` cookie и предлагает повторить только загрузку изображения. Повторно отправлять сам улов не требуется; один UUID заявки не даёт права изменить чужую запись.
|
||||
|
||||
Очередь модерации доступна по адресу <http://localhost:4321/admin/moderation>. Администратор вводит `ADMIN_TOKEN`; интерфейс держит его только в памяти открытой страницы и не сохраняет в URL или браузерном хранилище.
|
||||
|
||||
|
||||
+18
-1
@@ -3,10 +3,14 @@ from __future__ import annotations
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from dataclasses import asdict
|
||||
|
||||
from .config import settings
|
||||
from .database import SessionLocal
|
||||
from .importer import import_records
|
||||
from .community_importer import stage_observations
|
||||
from .retention import RetentionPolicy, apply_retention
|
||||
from .storage import delete_screenshot
|
||||
|
||||
|
||||
def main() -> int:
|
||||
@@ -19,12 +23,14 @@ def main() -> int:
|
||||
community = sub.add_parser("stage-community-json")
|
||||
community.add_argument("--input", default="-", help="JSON array path or - for stdin")
|
||||
community.add_argument("--limit", type=int, default=500)
|
||||
cleanup = sub.add_parser("cleanup-retention")
|
||||
cleanup.add_argument("--apply", action="store_true", help="apply changes; default is dry-run")
|
||||
args = parser.parse_args()
|
||||
with SessionLocal() as session:
|
||||
if args.command == "import-records":
|
||||
run = import_records(session, url=args.url, region=args.region, category=args.category)
|
||||
print(f"import {run.status.value}: seen={run.rows_seen} created={run.rows_created} updated={run.rows_updated}")
|
||||
else:
|
||||
elif args.command == "stage-community-json":
|
||||
if not 1 <= args.limit <= 5000:
|
||||
parser.error("--limit must be between 1 and 5000")
|
||||
stream = sys.stdin if args.input == "-" else open(args.input, encoding="utf-8")
|
||||
@@ -37,6 +43,17 @@ def main() -> int:
|
||||
parser.error("input must be a JSON array")
|
||||
created, updated = stage_observations(session, payload[:args.limit])
|
||||
print(f"staged: created={created} updated={updated}")
|
||||
else:
|
||||
policy = RetentionPolicy(
|
||||
submission_days=settings.retention_submission_days,
|
||||
unreviewed_days=settings.retention_unreviewed_days,
|
||||
approved_personal_days=settings.retention_approved_personal_days,
|
||||
staging_days=settings.retention_staging_days,
|
||||
audit_days=settings.retention_audit_days,
|
||||
published_payload_days=settings.retention_published_payload_days,
|
||||
)
|
||||
counts = apply_retention(session, policy=policy, dry_run=not args.apply, delete_object=delete_screenshot)
|
||||
print(json.dumps({"mode": "apply" if args.apply else "dry-run", "policy": asdict(policy), "counts": counts}, ensure_ascii=False))
|
||||
return 0
|
||||
|
||||
|
||||
|
||||
@@ -17,6 +17,12 @@ class Settings(BaseSettings):
|
||||
official_records_category: str = "records"
|
||||
official_import_required: bool = False
|
||||
seed_demo_data: bool = True
|
||||
retention_submission_days: int = Field(default=1, ge=1)
|
||||
retention_unreviewed_days: int = Field(default=30, ge=7)
|
||||
retention_approved_personal_days: int = Field(default=180, ge=30)
|
||||
retention_staging_days: int = Field(default=90, ge=30)
|
||||
retention_audit_days: int = Field(default=365, ge=90)
|
||||
retention_published_payload_days: int = Field(default=365, ge=90)
|
||||
import_interval_seconds: int = Field(default=3600, ge=3600)
|
||||
rate_limit_secret: str = "change-rate-limit-secret"
|
||||
log_level: str = "INFO"
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Callable
|
||||
|
||||
from sqlalchemy import delete, or_, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .models import CatchReport, ExternalObservation, ModerationEvent, ModerationStatus, SourceType, SubmissionAttempt
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RetentionPolicy:
|
||||
submission_days: int = 1
|
||||
unreviewed_days: int = 30
|
||||
approved_personal_days: int = 180
|
||||
staging_days: int = 90
|
||||
audit_days: int = 365
|
||||
published_payload_days: int = 365
|
||||
|
||||
|
||||
def apply_retention(
|
||||
session: Session, *, policy: RetentionPolicy = RetentionPolicy(),
|
||||
now: datetime | None = None, dry_run: bool = True,
|
||||
delete_object: Callable[[str], None] | None = None,
|
||||
) -> dict[str, int]:
|
||||
current = now or datetime.now(timezone.utc)
|
||||
counts = {
|
||||
"submission_attempts": 0,
|
||||
"user_reports_anonymized": 0,
|
||||
"pending_reports_expired": 0,
|
||||
"screenshots_deleted": 0,
|
||||
"staging_observations_deleted": 0,
|
||||
"published_payloads_cleared": 0,
|
||||
"moderation_events_deleted": 0,
|
||||
}
|
||||
|
||||
attempts = list(session.scalars(select(SubmissionAttempt.id).where(
|
||||
SubmissionAttempt.created_at < current - timedelta(days=policy.submission_days),
|
||||
)))
|
||||
counts["submission_attempts"] = len(attempts)
|
||||
|
||||
candidate_cutoff = current - timedelta(days=min(policy.unreviewed_days, policy.approved_personal_days))
|
||||
candidates = list(session.scalars(select(CatchReport).where(
|
||||
CatchReport.source_type == SourceType.user,
|
||||
CatchReport.reported_at < candidate_cutoff,
|
||||
or_(
|
||||
CatchReport.player_name.is_not(None), CatchReport.source_url.is_not(None),
|
||||
CatchReport.raw_payload.is_not(None), CatchReport.screenshot_key.is_not(None),
|
||||
CatchReport.screenshot_upload_token_hash.is_not(None),
|
||||
),
|
||||
)))
|
||||
reports = [item for item in candidates if item.reported_at.replace(tzinfo=item.reported_at.tzinfo or timezone.utc) < current - timedelta(
|
||||
days=policy.approved_personal_days if item.moderation_status == ModerationStatus.approved else policy.unreviewed_days,
|
||||
)]
|
||||
counts["user_reports_anonymized"] = len(reports)
|
||||
counts["screenshots_deleted"] = sum(bool(item.screenshot_key) for item in reports)
|
||||
counts["pending_reports_expired"] = sum(item.moderation_status == ModerationStatus.pending for item in reports)
|
||||
|
||||
stale = list(session.scalars(select(ExternalObservation).where(
|
||||
ExternalObservation.catch_report_id.is_(None),
|
||||
ExternalObservation.status != "published",
|
||||
ExternalObservation.last_seen_at < current - timedelta(days=policy.staging_days),
|
||||
)))
|
||||
counts["staging_observations_deleted"] = len(stale)
|
||||
published = list(session.scalars(select(ExternalObservation).where(
|
||||
ExternalObservation.status == "published",
|
||||
ExternalObservation.last_seen_at < current - timedelta(days=policy.published_payload_days),
|
||||
)))
|
||||
published = [item for item in published if item.payload]
|
||||
counts["published_payloads_cleared"] = len(published)
|
||||
events = list(session.scalars(select(ModerationEvent.id).where(
|
||||
ModerationEvent.created_at < current - timedelta(days=policy.audit_days),
|
||||
)))
|
||||
counts["moderation_events_deleted"] = len(events)
|
||||
|
||||
if dry_run:
|
||||
return counts
|
||||
|
||||
if counts["screenshots_deleted"] and delete_object is None:
|
||||
raise ValueError("delete_object is required when retained screenshots must be deleted")
|
||||
|
||||
for report in reports:
|
||||
if report.screenshot_key and delete_object:
|
||||
delete_object(report.screenshot_key)
|
||||
if report.moderation_status == ModerationStatus.pending:
|
||||
session.add(ModerationEvent(
|
||||
catch_report=report, created_at=current,
|
||||
previous_status=ModerationStatus.pending, new_status=ModerationStatus.rejected,
|
||||
moderator="retention-policy", reason="pending report retention period expired",
|
||||
))
|
||||
report.moderation_status = ModerationStatus.rejected
|
||||
report.player_name = None
|
||||
report.source_url = None
|
||||
report.raw_payload = None
|
||||
report.screenshot_key = None
|
||||
report.screenshot_upload_token_hash = None
|
||||
for observation in stale:
|
||||
session.delete(observation)
|
||||
for observation in published:
|
||||
observation.payload = {}
|
||||
if attempts:
|
||||
session.execute(delete(SubmissionAttempt).where(SubmissionAttempt.id.in_(attempts)))
|
||||
if events:
|
||||
session.execute(delete(ModerationEvent).where(ModerationEvent.id.in_(events)))
|
||||
session.commit()
|
||||
return counts
|
||||
@@ -0,0 +1,59 @@
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from sqlalchemy import create_engine, func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.database import Base
|
||||
from app.models import CatchReport, DataSource, ExternalObservation, Fish, ModerationEvent, ModerationStatus, SourceType, SubmissionAttempt, Waterbody
|
||||
from app.retention import apply_retention
|
||||
|
||||
|
||||
NOW = datetime(2026, 9, 6, tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def test_retention_dry_run_then_apply() -> None:
|
||||
engine = create_engine("sqlite://")
|
||||
Base.metadata.create_all(engine)
|
||||
with Session(engine) as db:
|
||||
fish = Fish(slug="pike", name_ru="Щука", trophy_weight_g=10_000)
|
||||
waterbody = Waterbody(slug="lake", name_ru="Озеро", unlock_level=1)
|
||||
source = DataSource(key="rf4db", name="RF4DB", base_url="https://rf4db.com", default_confidence=60, enabled=False)
|
||||
pending = CatchReport(
|
||||
fish=fish, waterbody=waterbody, weight_g=1000, reported_at=NOW - timedelta(days=31),
|
||||
player_name="Private", source_type=SourceType.user, source_confidence=60,
|
||||
moderation_status=ModerationStatus.pending, raw_payload={"comment": "private"},
|
||||
screenshot_key="reports/old.jpg", screenshot_upload_token_hash="a" * 64,
|
||||
)
|
||||
approved = CatchReport(
|
||||
fish=fish, waterbody=waterbody, weight_g=2000, reported_at=NOW - timedelta(days=181),
|
||||
player_name="Old winner", source_type=SourceType.user, source_confidence=60,
|
||||
moderation_status=ModerationStatus.approved, raw_payload={"comment": "old"},
|
||||
)
|
||||
fresh = CatchReport(
|
||||
fish=fish, waterbody=waterbody, weight_g=3000, reported_at=NOW - timedelta(days=10),
|
||||
player_name="Fresh", source_type=SourceType.user, source_confidence=60,
|
||||
moderation_status=ModerationStatus.pending, raw_payload={"comment": "fresh"},
|
||||
)
|
||||
db.add_all([source, pending, approved, fresh])
|
||||
db.flush()
|
||||
db.add(SubmissionAttempt(client_hash="x" * 64, created_at=NOW - timedelta(days=2)))
|
||||
db.add(ModerationEvent(catch_report=approved, created_at=NOW - timedelta(days=366), previous_status=ModerationStatus.pending, new_status=ModerationStatus.approved, moderator="admin"))
|
||||
db.add(ExternalObservation(source=source, source_external_id="stale", source_url="https://rf4db.com/1", fish_name="Щука", waterbody_name="Озеро", first_seen_at=NOW - timedelta(days=100), last_seen_at=NOW - timedelta(days=100), status="staged", payload={"raw": True}))
|
||||
db.add(ExternalObservation(source=source, source_external_id="published", source_url="https://rf4db.com/2", fish_name="Щука", waterbody_name="Озеро", first_seen_at=NOW - timedelta(days=400), last_seen_at=NOW - timedelta(days=400), status="published", payload={"raw": True}, catch_report=approved))
|
||||
db.commit()
|
||||
|
||||
preview = apply_retention(db, now=NOW)
|
||||
assert preview == {"submission_attempts": 1, "user_reports_anonymized": 2, "pending_reports_expired": 1, "screenshots_deleted": 1, "staging_observations_deleted": 1, "published_payloads_cleared": 1, "moderation_events_deleted": 1}
|
||||
assert db.get(CatchReport, pending.id).player_name == "Private"
|
||||
|
||||
deleted: list[str] = []
|
||||
assert apply_retention(db, now=NOW, dry_run=False, delete_object=deleted.append) == preview
|
||||
assert deleted == ["reports/old.jpg"]
|
||||
assert db.get(CatchReport, pending.id).moderation_status == ModerationStatus.rejected
|
||||
assert db.get(CatchReport, pending.id).player_name is None
|
||||
assert db.get(CatchReport, approved.id).raw_payload is None
|
||||
assert db.get(CatchReport, fresh.id).player_name == "Fresh"
|
||||
assert db.scalar(select(func.count()).select_from(SubmissionAttempt)) == 0
|
||||
assert db.scalar(select(func.count()).select_from(ExternalObservation)) == 1
|
||||
assert db.scalar(select(ExternalObservation)).payload == {}
|
||||
assert db.scalar(select(func.count()).select_from(ModerationEvent)) == 1
|
||||
@@ -1,5 +1,7 @@
|
||||
FROM node:22-alpine AS build
|
||||
WORKDIR /app
|
||||
ARG PUBLIC_API_URL=http://localhost:8000
|
||||
ENV PUBLIC_API_URL=$PUBLIC_API_URL
|
||||
COPY package*.json ./
|
||||
RUN npm install
|
||||
COPY . .
|
||||
|
||||
@@ -9,7 +9,8 @@
|
||||
"start": "node ./dist/server/entry.mjs",
|
||||
"check": "astro check",
|
||||
"test:unit": "node --test tests/unit/*.test.ts",
|
||||
"test:e2e": "playwright test"
|
||||
"test:e2e": "playwright test",
|
||||
"test:bootstrap": "playwright test tests/production-bootstrap.spec.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/check": "^0.9.10",
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test("production bootstrap supports submission and moderation", async ({ page, request }) => {
|
||||
const player = `Bootstrap Player ${Date.now()}`;
|
||||
const x = 7000 + Date.now() % 1000;
|
||||
const y = x + 1;
|
||||
|
||||
await page.goto("/");
|
||||
await expect(page.getByRole("heading", { name: "Выбирай место, пока клюёт." })).toBeVisible();
|
||||
await page.goto("/report");
|
||||
await page.getByLabel("Координата X *").fill(String(x));
|
||||
await page.getByLabel("Координата Y *").fill(String(y));
|
||||
await page.getByLabel("Вес, граммы *").fill("4321");
|
||||
await page.getByLabel("Ник игрока").fill(player);
|
||||
await page.getByRole("button", { name: "Отправить на проверку" }).click();
|
||||
await expect(page.getByText("Улов отправлен на модерацию. Спасибо!")).toBeVisible();
|
||||
|
||||
const response = await request.get(`${process.env.BOOTSTRAP_API_URL}/api/v1/admin/catch-reports?status=pending`, {
|
||||
headers: { Authorization: `Bearer ${process.env.BOOTSTRAP_ADMIN_TOKEN}` },
|
||||
});
|
||||
expect(response.ok()).toBeTruthy();
|
||||
expect(await response.text()).toContain(player);
|
||||
});
|
||||
+16
-3
@@ -89,6 +89,12 @@ services:
|
||||
SEED_DEMO_DATA: "false"
|
||||
IMPORT_INTERVAL_SECONDS: ${IMPORT_INTERVAL_SECONDS:-3600}
|
||||
RATE_LIMIT_SECRET: ${RATE_LIMIT_SECRET:?Set RATE_LIMIT_SECRET}
|
||||
RETENTION_SUBMISSION_DAYS: ${RETENTION_SUBMISSION_DAYS:-1}
|
||||
RETENTION_UNREVIEWED_DAYS: ${RETENTION_UNREVIEWED_DAYS:-30}
|
||||
RETENTION_APPROVED_PERSONAL_DAYS: ${RETENTION_APPROVED_PERSONAL_DAYS:-180}
|
||||
RETENTION_STAGING_DAYS: ${RETENTION_STAGING_DAYS:-90}
|
||||
RETENTION_AUDIT_DAYS: ${RETENTION_AUDIT_DAYS:-365}
|
||||
RETENTION_PUBLISHED_PAYLOAD_DAYS: ${RETENTION_PUBLISHED_PAYLOAD_DAYS:-365}
|
||||
LOG_LEVEL: ${LOG_LEVEL:-INFO}
|
||||
depends_on:
|
||||
db: {condition: service_healthy}
|
||||
@@ -105,7 +111,10 @@ services:
|
||||
limits: {cpus: "1.00", memory: 1G}
|
||||
|
||||
web:
|
||||
build: ./apps/web
|
||||
build:
|
||||
context: ./apps/web
|
||||
args:
|
||||
PUBLIC_API_URL: https://${SITE_DOMAIN:?Set SITE_DOMAIN}
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
PUBLIC_API_URL: https://${SITE_DOMAIN:?Set SITE_DOMAIN}
|
||||
@@ -124,11 +133,15 @@ services:
|
||||
limits: {cpus: "0.75", memory: 512M}
|
||||
|
||||
storage-tool:
|
||||
image: alpine:3.22
|
||||
image: minio/mc:RELEASE.2025-07-21T05-28-08Z
|
||||
profiles: [tools]
|
||||
environment:
|
||||
S3_ACCESS_KEY: ${S3_ACCESS_KEY:?Set S3_ACCESS_KEY}
|
||||
S3_SECRET_KEY: ${S3_SECRET_KEY:?Set S3_SECRET_KEY}
|
||||
S3_BUCKET: ${S3_BUCKET:-catch-screenshots}
|
||||
volumes:
|
||||
- minio_data:/data
|
||||
- ${BACKUP_DIRECTORY:-./backups}:/backup
|
||||
networks: [backend]
|
||||
security_opt: [no-new-privileges:true]
|
||||
|
||||
networks:
|
||||
|
||||
+43
-7
@@ -2,6 +2,8 @@
|
||||
|
||||
Production-контур рассчитан на один Linux-сервер с Docker Compose. Наружу публикуются только Caddy `80/443`; PostgreSQL, FastAPI и MinIO не имеют host-портов. Административные страницы защищены одновременно Caddy Basic Auth и API bearer token.
|
||||
|
||||
Текущее состояние публичных DNS/TLS и незакрытые инфраструктурные действия ведутся в [`docs/deployment-status.md`](../docs/deployment-status.md).
|
||||
|
||||
## 1. DNS и сервер
|
||||
|
||||
Создайте A-записи `rf4spotter.ru` и `files.rf4spotter.ru` на публичный IPv4 сервера. При наличии рабочего IPv6 добавьте AAAA для обоих имён. До запуска убедитесь, что извне доступны TCP 80/443 и UDP 443; SSH ограничьте своим IP или VPN. Порты 4321, 8000, 9000, 9001 и 5432 открывать нельзя.
|
||||
@@ -32,6 +34,14 @@ curl -fsS https://rf4spotter.ru/health
|
||||
curl -fsS https://rf4spotter.ru/ready
|
||||
```
|
||||
|
||||
До запуска на сервере можно воспроизвести полный production bootstrap на пустых изолированных volumes. Скрипт собирает образы, применяет миграции, проверяет отсутствие демо-уловов, readiness и отправку заявки:
|
||||
|
||||
```bash
|
||||
./deploy/test-production-bootstrap.sh
|
||||
```
|
||||
|
||||
По умолчанию временно используются только loopback-порты `14321` и `18000`; PostgreSQL и MinIO наружу не публикуются. Контур и volumes удаляются после проверки. Drill успешно пройден 6 сентября 2026 года.
|
||||
|
||||
API-контейнер перед стартом применяет Alembic-миграции и запускает идемпотентный seed. В production он добавляет только минимальные справочники и точки; демонстрационные уловы жёстко отключены настройкой `SEED_DEMO_DATA=false`.
|
||||
|
||||
Проверка TLS и маршрутизации:
|
||||
@@ -73,20 +83,46 @@ curl -fsS https://rf4spotter.ru/ready
|
||||
./deploy/backup.sh /srv/rf4-backups
|
||||
```
|
||||
|
||||
Восстановление заменяет содержимое PostgreSQL и MinIO данными из выбранной копии, временно останавливая API, web и MinIO. Это намеренно защищённая подтверждением операция:
|
||||
Восстановление заменяет содержимое PostgreSQL и MinIO данными из выбранной копии, временно останавливая API и web. Это намеренно защищённая подтверждением операция:
|
||||
|
||||
```bash
|
||||
CONFIRM_RESTORE=rf4-spotter ./deploy/restore.sh /srv/rf4-backups/20260906T120000Z
|
||||
curl -fsS https://rf4spotter.ru/ready
|
||||
```
|
||||
|
||||
Перед приглашением пользователей проведите учебное восстановление на отдельном сервере или с отдельным Compose project name. Одной только успешной архивации недостаточно. После проверки настройте ежедневный запуск `backup.sh`, шифрование/выгрузку копий во внешнее хранилище и уведомление при ошибке; храните минимум 7 ежедневных и 4 еженедельных копии.
|
||||
Репозиторий содержит изолированный drill, который создаёт отдельные Compose volumes, портит тестовые данные, восстанавливает их и сравнивает PostgreSQL и MinIO:
|
||||
|
||||
## 7. Что ещё блокирует приглашение альфа-пользователей
|
||||
```bash
|
||||
./deploy/test-backup-restore.sh
|
||||
```
|
||||
|
||||
- учебное восстановление PostgreSQL и MinIO на изолированном контуре;
|
||||
- проверка полного bootstrap на пустых volumes;
|
||||
- политика хранения и удаления пользовательских данных;
|
||||
- базовый мониторинг `/ready`, диска и срока TLS-сертификата.
|
||||
Drill успешно пройден 6 сентября 2026 года. На целевом сервере всё равно проведите учебное восстановление с реальной зашифрованной копией перед приглашением пользователей. Затем настройте ежедневный запуск `backup.sh`, выгрузку копий во внешнее хранилище и уведомление при ошибке; храните минимум 7 ежедневных и 4 еженедельных копии.
|
||||
|
||||
## 7. Ежедневное обслуживание
|
||||
|
||||
После успешного backup сначала проверьте план очистки, затем примените его:
|
||||
|
||||
```bash
|
||||
docker compose --env-file .env.production -f compose.production.yaml exec -T api python -m app.cli cleanup-retention
|
||||
docker compose --env-file .env.production -f compose.production.yaml exec -T api python -m app.cli cleanup-retention --apply
|
||||
```
|
||||
|
||||
Сроки и состав данных описаны в [`docs/data-retention.md`](../docs/data-retention.md). Автоматизацию включайте только после проверки dry-run на рабочем наборе.
|
||||
|
||||
## 8. Мониторинг
|
||||
|
||||
После настройки DNS, TLS и первого backup выполните:
|
||||
|
||||
```bash
|
||||
./deploy/monitor.sh
|
||||
```
|
||||
|
||||
Проверка контролирует контейнеры, `/ready`, диск, свежесть backup и срок TLS. Установка systemd timer, пороги и порядок реакции описаны в [`docs/production-monitoring.md`](../docs/production-monitoring.md). До подключения реального канала уведомлений одного журнала systemd недостаточно.
|
||||
|
||||
## 9. Что ещё блокирует приглашение альфа-пользователей
|
||||
|
||||
- подключение уведомлений о сбоях мониторинга;
|
||||
- проверка DNS/TLS и полного запуска на целевом сервере;
|
||||
- внешнее зашифрованное хранилище резервных копий.
|
||||
|
||||
До закрытия этих пунктов контур можно поднять для технической проверки домена, но не следует открывать форму реальным пользователям.
|
||||
|
||||
+10
-3
@@ -14,13 +14,20 @@ esac
|
||||
backup_root=${1%/}
|
||||
stamp=$(date -u +%Y%m%dT%H%M%SZ)
|
||||
target="$backup_root/$stamp"
|
||||
if [ -e "$target" ]; then
|
||||
echo "Backup target already exists: $target" >&2
|
||||
exit 1
|
||||
fi
|
||||
mkdir -p "$target"
|
||||
chmod 700 "$target"
|
||||
export BACKUP_DIRECTORY="$backup_root"
|
||||
|
||||
compose="docker compose --env-file .env.production -f compose.production.yaml"
|
||||
env_file=${COMPOSE_ENV_FILE:-.env.production}
|
||||
compose="docker compose --env-file $env_file -f compose.production.yaml"
|
||||
$compose exec -T db sh -c 'pg_dump --format=custom --no-owner --no-acl -U "$POSTGRES_USER" -d "$POSTGRES_DB"' > "$target/postgres.dump"
|
||||
$compose run --rm storage-tool tar -czf "/backup/$stamp/minio.tar.gz" -C /data .
|
||||
sha256sum "$target/postgres.dump" "$target/minio.tar.gz" > "$target/SHA256SUMS"
|
||||
$compose run --rm --entrypoint /bin/sh storage-tool -c 'mc alias set local http://minio:9000 "$S3_ACCESS_KEY" "$S3_SECRET_KEY" >/dev/null && mc mb --ignore-existing "local/$S3_BUCKET" >/dev/null && mc mirror --overwrite "local/$S3_BUCKET" "/backup/'"$stamp"'/minio" && chmod -R a+rwX "/backup/'"$stamp"'/minio"'
|
||||
tar -czf "$target/minio.tar.gz" -C "$target" minio
|
||||
rm -rf "$target/minio"
|
||||
(cd "$target" && sha256sum postgres.dump minio.tar.gz > SHA256SUMS)
|
||||
chmod 600 "$target/postgres.dump" "$target/minio.tar.gz" "$target/SHA256SUMS"
|
||||
echo "Backup created: $target"
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
services:
|
||||
api:
|
||||
ports:
|
||||
- "127.0.0.1:${BOOTSTRAP_API_PORT:-18000}:8000"
|
||||
web:
|
||||
networks: [backend, edge]
|
||||
ports:
|
||||
- "127.0.0.1:${BOOTSTRAP_WEB_PORT:-14321}:4321"
|
||||
Executable
+80
@@ -0,0 +1,80 @@
|
||||
#!/bin/sh
|
||||
set -u
|
||||
|
||||
repo=$(CDPATH= cd -- "$(dirname "$0")/.." && pwd)
|
||||
cd "$repo" || exit 2
|
||||
env_file=${COMPOSE_ENV_FILE:-.env.production}
|
||||
if [ ! -r "$env_file" ]; then
|
||||
printf 'CRITICAL rf4spotter failures="env-file-unreadable"\n'
|
||||
exit 2
|
||||
fi
|
||||
|
||||
read_env() {
|
||||
key=$1
|
||||
value=$(sed -n "s/^${key}=//p" "$env_file" | tail -n 1)
|
||||
printf '%s' "$value"
|
||||
}
|
||||
|
||||
site_domain=${SITE_DOMAIN:-$(read_env SITE_DOMAIN)}
|
||||
backup_root=${BACKUP_ROOT:-$(read_env BACKUP_ROOT)}
|
||||
disk_max=${MONITOR_DISK_MAX_PERCENT:-$(read_env MONITOR_DISK_MAX_PERCENT)}
|
||||
tls_days=${MONITOR_TLS_MIN_DAYS:-$(read_env MONITOR_TLS_MIN_DAYS)}
|
||||
backup_hours=${MONITOR_BACKUP_MAX_HOURS:-$(read_env MONITOR_BACKUP_MAX_HOURS)}
|
||||
disk_max=${disk_max:-85}
|
||||
tls_days=${tls_days:-14}
|
||||
backup_hours=${backup_hours:-26}
|
||||
for value in "$disk_max" "$tls_days" "$backup_hours"; do
|
||||
case "$value" in ''|*[!0-9]*) printf 'CRITICAL rf4spotter failures="monitor-threshold-invalid"\n'; exit 2 ;; esac
|
||||
done
|
||||
base_url=${MONITOR_BASE_URL:-https://$site_domain}
|
||||
compose="docker compose --env-file $env_file -f compose.production.yaml"
|
||||
failures=""
|
||||
|
||||
fail() {
|
||||
failures="${failures}${failures:+; }$1"
|
||||
}
|
||||
|
||||
running=$($compose ps --status running --services 2>/dev/null) || running=""
|
||||
for service in proxy db minio api web; do
|
||||
printf '%s\n' "$running" | grep -qx "$service" || fail "service:$service"
|
||||
done
|
||||
|
||||
ready_payload=$(curl -fsS --max-time 10 "$base_url/ready" 2>/dev/null) || ready_payload=""
|
||||
printf '%s' "$ready_payload" | grep -Eq '"status"[[:space:]]*:[[:space:]]*"ready"' || fail "readiness"
|
||||
|
||||
disk_used=$(df -Pk "$repo" | awk 'NR==2 {gsub(/%/, "", $5); print $5}')
|
||||
case "$disk_used" in
|
||||
''|*[!0-9]*) fail "disk:unknown" ;;
|
||||
*) [ "$disk_used" -lt "$disk_max" ] || fail "disk:${disk_used}%" ;;
|
||||
esac
|
||||
|
||||
if [ -z "$backup_root" ] || [ ! -d "$backup_root" ]; then
|
||||
fail "backup:directory"
|
||||
else
|
||||
recent_backup=$(find "$backup_root" -mindepth 2 -maxdepth 2 -name SHA256SUMS -mmin "-$((backup_hours * 60))" -print -quit 2>/dev/null)
|
||||
if [ -z "$recent_backup" ]; then
|
||||
fail "backup:stale"
|
||||
else
|
||||
(cd "$(dirname "$recent_backup")" && sha256sum -c --quiet SHA256SUMS) >/dev/null 2>&1 || fail "backup:checksum"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -z "$site_domain" ]; then
|
||||
fail "tls:domain"
|
||||
else
|
||||
tls_seconds=$((tls_days * 86400))
|
||||
certificate=$(openssl s_client -servername "$site_domain" -connect "$site_domain:443" </dev/null 2>/dev/null | openssl x509 -outform PEM 2>/dev/null) || certificate=""
|
||||
if [ -z "$certificate" ]; then
|
||||
fail "tls:unavailable"
|
||||
else
|
||||
printf '%s\n' "$certificate" | openssl x509 -checkend "$tls_seconds" -noout >/dev/null 2>&1 || fail "tls:expires-soon"
|
||||
fi
|
||||
fi
|
||||
|
||||
timestamp=$(date -u +%Y-%m-%dT%H:%M:%SZ)
|
||||
if [ -n "$failures" ]; then
|
||||
printf 'CRITICAL rf4spotter timestamp=%s failures="%s"\n' "$timestamp" "$failures"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
printf 'OK rf4spotter timestamp=%s disk_used=%s%% backup_max_age=%sh tls_min=%sd\n' "$timestamp" "$disk_used" "$backup_hours" "$tls_days"
|
||||
+9
-5
@@ -16,12 +16,16 @@ test -f "$source_dir/postgres.dump"
|
||||
test -f "$source_dir/minio.tar.gz"
|
||||
(cd "$source_dir" && sha256sum -c SHA256SUMS)
|
||||
backup_root=$(dirname "$source_dir")
|
||||
stamp=$(basename "$source_dir")
|
||||
export BACKUP_DIRECTORY="$backup_root"
|
||||
restore_tmp=$(mktemp -d "$backup_root/.rf4-restore.XXXXXX")
|
||||
trap 'rm -rf "$restore_tmp"' EXIT INT TERM
|
||||
tar -xzf "$source_dir/minio.tar.gz" -C "$restore_tmp"
|
||||
restore_name=$(basename "$restore_tmp")
|
||||
|
||||
compose="docker compose --env-file .env.production -f compose.production.yaml"
|
||||
$compose stop api web minio
|
||||
env_file=${COMPOSE_ENV_FILE:-.env.production}
|
||||
compose="docker compose --env-file $env_file -f compose.production.yaml"
|
||||
$compose stop api web
|
||||
$compose exec -T db sh -c 'pg_restore --clean --if-exists --exit-on-error --no-owner --no-acl -U "$POSTGRES_USER" -d "$POSTGRES_DB"' < "$source_dir/postgres.dump"
|
||||
$compose run --rm storage-tool sh -c 'find /data -mindepth 1 -delete && tar -xzf "/backup/'"$stamp"'/minio.tar.gz" -C /data'
|
||||
$compose up -d minio api web proxy
|
||||
$compose run --rm --entrypoint /bin/sh storage-tool -c 'mc alias set local http://minio:9000 "$S3_ACCESS_KEY" "$S3_SECRET_KEY" >/dev/null && mc rm --recursive --force "local/$S3_BUCKET" >/dev/null 2>&1 || true; mc mb --ignore-existing "local/$S3_BUCKET" >/dev/null && mc mirror --overwrite "/backup/'"$restore_name"'/minio" "local/$S3_BUCKET"'
|
||||
$compose up -d ${RESTORE_START_SERVICES:-minio api web proxy}
|
||||
echo "Restore completed from: $source_dir"
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
[Unit]
|
||||
Description=RF4 Spotter production health check
|
||||
After=docker.service network-online.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
User=rf4spotter
|
||||
WorkingDirectory=/opt/rf4-spotter
|
||||
ExecStart=/opt/rf4-spotter/deploy/monitor.sh
|
||||
@@ -0,0 +1,11 @@
|
||||
[Unit]
|
||||
Description=Run RF4 Spotter health check every five minutes
|
||||
|
||||
[Timer]
|
||||
OnBootSec=2min
|
||||
OnUnitActiveSec=5min
|
||||
RandomizedDelaySec=30s
|
||||
Persistent=true
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
Executable
+33
@@ -0,0 +1,33 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
repo=$(CDPATH= cd -- "$(dirname "$0")/.." && pwd)
|
||||
cd "$repo"
|
||||
drill_root=$(mktemp -d /tmp/rf4-restore-drill.XXXXXX)
|
||||
project="rf4-restore-drill-$$"
|
||||
export COMPOSE_PROJECT_NAME="$project"
|
||||
export COMPOSE_ENV_FILE=.env.production.example
|
||||
export BACKUP_DIRECTORY="$drill_root"
|
||||
compose="docker compose --env-file $COMPOSE_ENV_FILE -f compose.production.yaml"
|
||||
|
||||
cleanup() {
|
||||
$compose down --volumes --remove-orphans >/dev/null 2>&1 || true
|
||||
rm -rf "$drill_root"
|
||||
}
|
||||
trap cleanup EXIT INT TERM
|
||||
|
||||
$compose up -d --wait db minio
|
||||
$compose exec -T db sh -c 'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB" -c "create table restore_drill (value text primary key); insert into restore_drill values ('"'"'database-ok'"'"');"' >/dev/null
|
||||
$compose run --rm --entrypoint /bin/sh storage-tool -c 'mc alias set local http://minio:9000 "$S3_ACCESS_KEY" "$S3_SECRET_KEY" >/dev/null; mc mb --ignore-existing "local/$S3_BUCKET" >/dev/null; printf object-ok | mc pipe "local/$S3_BUCKET/restore-drill.txt"' >/dev/null
|
||||
|
||||
./deploy/backup.sh "$drill_root"
|
||||
backup=$(find "$drill_root" -mindepth 1 -maxdepth 1 -type d ! -name '.rf4-restore.*' | head -n 1)
|
||||
$compose exec -T db sh -c 'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB" -c "drop table restore_drill;"' >/dev/null
|
||||
$compose run --rm --entrypoint /bin/sh storage-tool -c 'mc alias set local http://minio:9000 "$S3_ACCESS_KEY" "$S3_SECRET_KEY" >/dev/null; mc rm --force "local/$S3_BUCKET/restore-drill.txt"' >/dev/null
|
||||
|
||||
CONFIRM_RESTORE=rf4-spotter RESTORE_START_SERVICES=minio ./deploy/restore.sh "$backup"
|
||||
db_value=$($compose exec -T db sh -c 'psql -At -U "$POSTGRES_USER" -d "$POSTGRES_DB" -c "select value from restore_drill;"')
|
||||
object_value=$($compose run --rm --entrypoint /bin/sh storage-tool -c 'mc alias set local http://minio:9000 "$S3_ACCESS_KEY" "$S3_SECRET_KEY" >/dev/null; mc cat "local/$S3_BUCKET/restore-drill.txt"')
|
||||
test "$db_value" = "database-ok"
|
||||
test "$object_value" = "object-ok"
|
||||
echo "Backup/restore drill passed for PostgreSQL and MinIO"
|
||||
Executable
+32
@@ -0,0 +1,32 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
repo=$(CDPATH= cd -- "$(dirname "$0")/.." && pwd)
|
||||
cd "$repo"
|
||||
project="rf4-bootstrap-$$"
|
||||
export COMPOSE_PROJECT_NAME="$project"
|
||||
export BOOTSTRAP_API_PORT=${BOOTSTRAP_API_PORT:-18000}
|
||||
export BOOTSTRAP_WEB_PORT=${BOOTSTRAP_WEB_PORT:-14321}
|
||||
export POSTGRES_PASSWORD=bootstrap-postgres-password
|
||||
export DATABASE_URL=postgresql+psycopg://rf4:bootstrap-postgres-password@db:5432/rf4_spotter
|
||||
compose="docker compose --env-file .env.production.example -f compose.production.yaml -f deploy/compose.bootstrap.yaml"
|
||||
|
||||
cleanup() {
|
||||
status=$?
|
||||
if [ "$status" -ne 0 ]; then
|
||||
$compose ps >&2 || true
|
||||
$compose logs --no-color api web db minio >&2 || true
|
||||
fi
|
||||
$compose down --volumes --remove-orphans >/dev/null 2>&1 || true
|
||||
}
|
||||
trap cleanup EXIT INT TERM
|
||||
|
||||
$compose up --build -d --wait db minio api web
|
||||
curl -fsS "http://127.0.0.1:$BOOTSTRAP_API_PORT/ready" >/dev/null
|
||||
curl -fsS "http://127.0.0.1:$BOOTSTRAP_WEB_PORT/" >/dev/null
|
||||
test "$($compose exec -T db psql -At -U rf4 -d rf4_spotter -c 'select version_num from alembic_version')" = "0010"
|
||||
test "$($compose exec -T db psql -At -U rf4 -d rf4_spotter -c 'select count(*) from fish')" = "2"
|
||||
test "$($compose exec -T db psql -At -U rf4 -d rf4_spotter -c 'select count(*) from waterbody')" = "2"
|
||||
test "$($compose exec -T db psql -At -U rf4 -d rf4_spotter -c 'select count(*) from catch_report')" = "0"
|
||||
WEB_URL="http://127.0.0.1:$BOOTSTRAP_WEB_PORT" BOOTSTRAP_API_URL="http://127.0.0.1:$BOOTSTRAP_API_PORT" BOOTSTRAP_ADMIN_TOKEN=replace-with-at-least-32-random-characters npm --prefix apps/web run test:bootstrap
|
||||
echo "Production bootstrap passed from empty volumes"
|
||||
+12
-14
@@ -2,7 +2,7 @@
|
||||
|
||||
Этот файл — рабочий источник правды по развитию проекта. После завершения задачи её чекбокс меняется с `[ ]` на `[x]`, рядом добавляется ссылка на коммит или короткое подтверждение проверки. Новые задачи добавляются в соответствующий этап, а не хранятся только в переписке.
|
||||
|
||||
Последняя сверка плана со спецификацией, кодом и UI/UX-аудитом: 5 сентября 2026 года.
|
||||
Последняя сверка плана со спецификацией, кодом и UI/UX-аудитом: 6 сентября 2026 года.
|
||||
|
||||
Обозначения:
|
||||
|
||||
@@ -57,16 +57,17 @@
|
||||
## Подготовка MVP к пилоту
|
||||
|
||||
- [x] Добавить health/readiness-проверки PostgreSQL, MinIO, API и импорта; отразить их в Compose (`/health` без зависимостей, `/ready` с компонентами и режимом обязательного импорта).
|
||||
- [x] Добавить host-side production monitor контейнеров, `/ready`, диска, свежести/целостности backup и срока TLS; подготовлены systemd timer и runbook, реальный канал уведомлений подключается на сервере.
|
||||
- [x] Добавить структурированные JSON-логи без пользовательских секретов и персональных технических данных (whitelist полей, redaction, request ID; Uvicorn access-log отключён).
|
||||
- [x] Добавить Gitea Actions CI: backend tests, Astro check/build, E2E и применение всех миграций на чистой PostgreSQL; сохранять логи Compose и Playwright-артефакты при падении (`.gitea/workflows/ci.yml`).
|
||||
- [ ] Добавить отдельный тест полного bootstrap: пустые volumes → миграции → seed → readiness → основной E2E.
|
||||
- [x] Добавить отдельный тест полного bootstrap: пустые production volumes → миграции `0010` → seed без демо-уловов → readiness → браузерная отправка и проверка moderation API (`deploy/test-production-bootstrap.sh`, 6 сентября 2026).
|
||||
- [x] Сделать seed устойчивым к частично заполненной БД: справочники досеиваются независимо, демо-уловы идемпотентны и принудительно отключены в production; повторный/частичный запуск покрыт конфигурационными и интеграционными проверками.
|
||||
- [ ] Проверить списочные API по требованию раздела 12: пагинация, предсказуемая сортировка и валидация фильтров для справочников, импортов, модерации и внешнего staging.
|
||||
- [ ] Проверить необходимые индексы PostgreSQL и планы запросов для activity, модерации, дедупликации и очистки rate limit; зафиксировать допустимый бюджет запросов пилота.
|
||||
- [ ] Провести security-проверку admin-аутентификации, CORS, security headers, загрузок и управления секретами; вынести допустимые origins в конфигурацию и исключить демонстрационные секреты в production-режиме.
|
||||
- [x] Проверить авторизацию повторной загрузки скриншота: используется отдельный одноразовый случайный токен, в БД хранится только SHA-256, UUID заявки недостаточно.
|
||||
- [ ] Определить сроки хранения ников, исходных payload, staging-наблюдений, moderation events и submission attempts; добавить документированную очистку/анонимизацию.
|
||||
- [ ] Добавить резервное копирование и документированное восстановление PostgreSQL и MinIO: скрипты, контрольные суммы и runbook готовы; остаётся учебное восстановление на отдельных временных volumes.
|
||||
- [x] Определить сроки хранения ников, исходных payload, staging-наблюдений, moderation events и submission attempts; добавлены настраиваемая dry-run-first очистка, тест и `docs/data-retention.md`.
|
||||
- [x] Добавить резервное копирование и документированное восстановление PostgreSQL и MinIO: консистентные `pg_dump` и MinIO API mirror, контрольные суммы, runbook и успешный изолированный drill с намеренным удалением данных (6 сентября 2026).
|
||||
- [ ] Проверить доступность интерфейса: клавиатура, focus states, контраст, подписи полей и семантика таблиц/карточек.
|
||||
- [ ] Провести Lighthouse-проверку основных страниц и устранить критические проблемы производительности.
|
||||
- [x] Провести UI/UX-аудит desktop/mobile и сформировать приоритетный план (`docs/UI_UX_AUDIT.md`).
|
||||
@@ -79,7 +80,7 @@
|
||||
- [ ] Добавить smoke-проверку административной очереди внешних источников на desktop/mobile без публикации реальных записей.
|
||||
- [ ] Обновить README: архитектура, все переменные окружения, импорт, модерация, backup/restore, эксплуатация логов и известные ограничения.
|
||||
- [ ] Выбрать лицензию кода и политику использования данных.
|
||||
- [ ] Завершить production-профиль для `rf4spotter.ru`: Compose, Caddy/TLS, закрытые внутренние сервисы, CORS, resource limits, fail-fast секреты и runbook добавлены; остаются backup/restore и проверка на целевом сервере.
|
||||
- [ ] Завершить production-профиль для `rf4spotter.ru`: приложение готово; внешняя проверка 6 сентября выявила неверный сертификат `*.timeweb.ru` на корневом домене и отсутствие DNS для `files.rf4spotter.ru`; требуются IP целевого сервера, DNS, запуск и канал уведомлений (`docs/deployment-status.md`).
|
||||
- [ ] Заменить демонстрационные секреты и определить целевое размещение перед внешней публикацией.
|
||||
|
||||
## Источники данных и согласование
|
||||
@@ -115,17 +116,14 @@
|
||||
|
||||
## Ближайший рабочий пакет
|
||||
|
||||
Технические health/readiness и безопасные логи готовы. Следующие пункты выполняются строго по одному:
|
||||
Технический production-контур, health/readiness, backup/restore и безопасные логи готовы. Следующие пункты выполняются строго по одному:
|
||||
|
||||
1. защита официального импорта от конкурентных запусков;
|
||||
2. полный bootstrap-тест и исправление seed для частично заполненной БД;
|
||||
3. security-аудит admin/CORS/headers/secrets и повторной загрузки скриншота;
|
||||
4. backup/restore PostgreSQL и MinIO с реальной проверкой восстановления;
|
||||
5. пагинация/сортировка списочных API и индексы PostgreSQL;
|
||||
6. UI/UX-пакет A: единая шкала активности и числительные;
|
||||
7. UI/UX-пакеты B–D: мобильная главная, форма и рекорды;
|
||||
8. accessibility/admin safety и Lighthouse;
|
||||
9. production-профиль и финальное обновление README.
|
||||
2. пагинация/сортировка списочных API и индексы PostgreSQL;
|
||||
3. UI/UX-пакеты B–D: мобильная главная, форма и рекорды;
|
||||
4. accessibility/admin safety и Lighthouse;
|
||||
5. мониторинг, DNS/TLS и проверка production-профиля на целевом сервере;
|
||||
6. финальное обновление README, лицензия кода и политика данных.
|
||||
|
||||
После каждого пункта необходимо:
|
||||
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
# Политика хранения данных закрытой альфы
|
||||
|
||||
Политика минимизирует персональные и диагностические данные, не разрушая обезличенную статистику клёва. Сроки считаются от `reported_at`, `created_at` или `last_seen_at` соответствующей записи.
|
||||
|
||||
| Данные | Срок | Действие |
|
||||
|---|---:|---|
|
||||
| HMAC-отпечатки IP для rate limit | 1 день | удалить запись |
|
||||
| Pending/rejected пользовательские уловы | 30 дней | стереть ник, комментарий, source URL, токен и скриншот; просроченный pending перевести в rejected с системным событием |
|
||||
| Ник, комментарий и скриншот одобренного пользовательского улова | 180 дней | анонимизировать, сохранив рыбу, точку, вес, снасть и время для агрегатов |
|
||||
| Неопубликованные/rejected staging-наблюдения внешних источников | 90 дней без обновления | удалить запись целиком |
|
||||
| Raw payload опубликованного внешнего наблюдения | 365 дней без обновления | очистить payload, сохранив URL, внешний ID и каноническую связь |
|
||||
| События модерации | 365 дней | удалить событие; сама обезличенная запись улова остаётся |
|
||||
| Официальные рекорды и их публичные поля | пока запись актуальна | сохранять для сверки; удаление регулируется процедурой обновления источника |
|
||||
|
||||
Сроки задаются переменными `RETENTION_*_DAYS` из `.env.production`. Минимальные значения ограничены конфигурацией, чтобы ошибочное значение не вызвало немедленную массовую очистку.
|
||||
|
||||
## Запуск
|
||||
|
||||
Сначала обязателен просмотр плана без изменений:
|
||||
|
||||
```bash
|
||||
docker compose --env-file .env.production -f compose.production.yaml exec -T api python -m app.cli cleanup-retention
|
||||
```
|
||||
|
||||
После проверки счётчиков и свежего backup:
|
||||
|
||||
```bash
|
||||
docker compose --env-file .env.production -f compose.production.yaml exec -T api python -m app.cli cleanup-retention --apply
|
||||
```
|
||||
|
||||
Команда выводит JSON с режимом, применёнными сроками и числом затронутых сущностей. Для альфы её следует запускать ежедневно после успешного backup. Ошибка удаления объекта MinIO прерывает обработку базы; повторный запуск безопасен.
|
||||
|
||||
Администратор может вручную удалить пользовательскую заявку раньше срока через очередь модерации. В таком случае ник, исходный payload и скриншот удаляются немедленно, а обезличенный tombstone и событие аудита остаются до штатной очистки.
|
||||
@@ -0,0 +1,29 @@
|
||||
# Статус развёртывания rf4spotter.ru
|
||||
|
||||
Последняя внешняя read-only проверка: 6 сентября 2026 года.
|
||||
|
||||
## Что видно публично сейчас
|
||||
|
||||
| Проверка | Результат | Статус |
|
||||
|---|---|---|
|
||||
| `A rf4spotter.ru` | `176.57.210.144` | требует подтверждения, что это IP целевого сервера |
|
||||
| HTTPS `rf4spotter.ru` | сервер отвечает, но сертификат имеет SAN только `*.timeweb.ru` и `timeweb.ru` | не готово |
|
||||
| `A files.rf4spotter.ru` | имя не резолвится | не готово |
|
||||
| HTTPS `files.rf4spotter.ru` | невозможен без DNS | не готово |
|
||||
|
||||
Текущий сертификат выпущен GlobalSign для `*.timeweb.ru`, действует с 17 июня 2026 до 2 января 2027 года и не подтверждает `rf4spotter.ru`. Это похоже на ответ парковочной/хостинговой площадки, но назначение IP необходимо подтвердить в панели DNS или на целевом сервере.
|
||||
|
||||
## Следующие действия на инфраструктуре
|
||||
|
||||
- [ ] Определить публичный IPv4 целевого Linux-сервера и подтвердить доступ по SSH.
|
||||
- [ ] Установить `A` для `rf4spotter.ru` на этот IPv4.
|
||||
- [ ] Установить `A` для `files.rf4spotter.ru` на тот же IPv4.
|
||||
- [ ] Добавлять `AAAA` только при реально настроенном публичном IPv6; иначе удалить ошибочную AAAA.
|
||||
- [ ] Разрешить входящие TCP `80/443` и UDP `443`, не публикуя `4321`, `8000`, `9000`, `9001`, `5432`.
|
||||
- [ ] Дождаться, пока оба имени одинаково резолвятся через несколько публичных резолверов.
|
||||
- [ ] Разместить проект, создать `.env.production`, заменить все заглушки и выполнить production bootstrap из runbook.
|
||||
- [ ] Убедиться, что Caddy получил сертификаты с SAN `rf4spotter.ru` и `files.rf4spotter.ru`.
|
||||
- [ ] Выполнить `curl` для `/`, `/health`, `/ready` и MinIO health через оба домена.
|
||||
- [ ] Создать первую внешнюю резервную копию, запустить `deploy/monitor.sh` и подключить уведомления.
|
||||
|
||||
Не следует отключать TLS verification или сохранять текущий чужой сертификат в исключения: после правильных DNS и доступных портов Caddy должен получить корректный сертификат автоматически.
|
||||
@@ -0,0 +1,45 @@
|
||||
# Мониторинг закрытой альфы
|
||||
|
||||
`deploy/monitor.sh` — host-side проверка production-контура. Она не изменяет данные и не печатает секреты. Успех возвращает exit code `0` и одну строку `OK`; любая проблема возвращает `1`/`2` и строку `CRITICAL`.
|
||||
|
||||
Проверяются:
|
||||
|
||||
- контейнеры `proxy`, `db`, `minio`, `api`, `web` находятся в running;
|
||||
- публичный `https://rf4spotter.ru/ready` сообщает `ready`;
|
||||
- файловая система проекта заполнена менее чем на 85%;
|
||||
- последняя полная резервная копия не старше 26 часов и проходит проверку `SHA256SUMS`;
|
||||
- TLS-сертификат домена действует ещё минимум 14 дней.
|
||||
|
||||
Пороги задаются в `.env.production`: `BACKUP_ROOT`, `MONITOR_DISK_MAX_PERCENT`, `MONITOR_BACKUP_MAX_HOURS`, `MONITOR_TLS_MIN_DAYS`. Проверка TLS использует SNI и поэтому обнаруживает как истечение, так и выдачу сертификата для неверного домена.
|
||||
|
||||
## Ручная проверка
|
||||
|
||||
```bash
|
||||
./deploy/monitor.sh
|
||||
echo $?
|
||||
```
|
||||
|
||||
Первый успешный запуск следует сделать только после появления DNS, TLS и первой резервной копии. До этого `CRITICAL` для этих компонентов ожидаем.
|
||||
|
||||
## systemd timer
|
||||
|
||||
Шаблоны рассчитаны на пользователя `rf4spotter` и каталог `/opt/rf4-spotter`. При другом размещении измените оба пути в service-файле.
|
||||
|
||||
```bash
|
||||
sudo cp deploy/systemd/rf4spotter-monitor.service /etc/systemd/system/
|
||||
sudo cp deploy/systemd/rf4spotter-monitor.timer /etc/systemd/system/
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable --now rf4spotter-monitor.timer
|
||||
systemctl list-timers rf4spotter-monitor.timer
|
||||
journalctl -u rf4spotter-monitor.service -n 20 --no-pager
|
||||
```
|
||||
|
||||
Сам timer записывает результат в journal. Для реального оповещения подключите failed unit к существующему серверному мониторингу либо настройте внешний HTTPS-monitor на `/ready`; уведомления должны приходить минимум по состояниям readiness, disk, backup и TLS. Не передавайте `.env.production` или вывод `docker compose config` внешнему сервису.
|
||||
|
||||
## Реакция
|
||||
|
||||
1. `service:*` — посмотреть `docker compose ... ps` и логи конкретного контейнера; не выполнять `down -v`.
|
||||
2. `readiness` — проверить `/ready`, PostgreSQL и MinIO; на время сбоя остановить приём пользовательских заявок.
|
||||
3. `disk:*` — сначала перенести старые backup во внешнее хранилище; не удалять Docker volumes вслепую.
|
||||
4. `backup:*` — запустить `deploy/backup.sh`, проверить `SHA256SUMS` и устранить причину пропуска расписания.
|
||||
5. `tls:*` — проверить DNS, доступность портов 80/443 и логи Caddy; не отключать проверку сертификата.
|
||||
Reference in New Issue
Block a user