Compare commits

..
3 Commits
Author SHA1 Message Date
ik c93c6adc7a security: restrict production content origins
CI / backend-and-migrations (push) Canceled after 0s
CI / astro-build (push) Canceled after 0s
CI / dependency-audit (push) Canceled after 0s
CI / compose-e2e (push) Canceled after 0s
2026-09-13 09:33:39 +07:00
ik aae5e0926a docs: define alpha observability baseline 2026-09-13 09:32:22 +07:00
ik 016d459861 feat: degrade home sections independently 2026-09-13 09:28:12 +07:00
6 changed files with 101 additions and 18 deletions
+6
View File
@@ -30,6 +30,8 @@ Web Docker-образ устанавливает зависимости чере
Функциональный MVP и локальный production-контур готовятся к открытой альфе: официальный импорт, пользовательские заявки, модерация, объяснимый индекс, staging внешних источников, адаптивный Astro UI, миграции, резервное копирование, retention, мониторинг и security/accessibility-проверки реализованы. На всех страницах подключён компактный баннер открытой альфы со ссылками на статус, правила и отправку улова. В production Compose включён community scheduler; локально он запускается отдельным профилем. Публичный запуск блокируют покупка и настройка сервера, DNS/TLS, реальные секреты, внешний backup, канал уведомлений; публичный адрес обратной связи ещё не задан. Функциональный MVP и локальный production-контур готовятся к открытой альфе: официальный импорт, пользовательские заявки, модерация, объяснимый индекс, staging внешних источников, адаптивный Astro UI, миграции, резервное копирование, retention, мониторинг и security/accessibility-проверки реализованы. На всех страницах подключён компактный баннер открытой альфы со ссылками на статус, правила и отправку улова. В production Compose включён community scheduler; локально он запускается отдельным профилем. Публичный запуск блокируют покупка и настройка сервера, DNS/TLS, реальные секреты, внешний backup, канал уведомлений; публичный адрес обратной связи ещё не задан.
Главная деградирует по секциям: activity, community signals и справочники загружаются независимо. Частичный отказ сохраняет доступные данные и возвращает HTTP 200 с `X-RF4-Partial` и запретом кэширования; общий 503 возникает только при отказе всех частей.
RF4DB/RF4-STAT/RF4MAP/RF4 Posts сначала принимаются в изолированный staging. Полные записи с ранее подтверждёнными алиасами источника публикуются автоматически; новые соответствия и неполные записи остаются на ручной проверке. Admin API предлагает точные ранее подтверждённые алиасы отдельно от mapping-действия и запрещает молча переназначать alias другой сущности. Для разрешённых community-источников действует интервал не менее 30 минут на сайт, общий для всех его endpoint. Открытая альфа не использует продуктовый allowlist: интерфейс показывает весь корректно загруженный разрешённый каталог, сохраняя требования полноты и модерации. RF4DB/RF4-STAT/RF4MAP/RF4 Posts сначала принимаются в изолированный staging. Полные записи с ранее подтверждёнными алиасами источника публикуются автоматически; новые соответствия и неполные записи остаются на ручной проверке. Admin API предлагает точные ранее подтверждённые алиасы отдельно от mapping-действия и запрещает молча переназначать alias другой сущности. Для разрешённых community-источников действует интервал не менее 30 минут на сайт, общий для всех его endpoint. Открытая альфа не использует продуктовый allowlist: интерфейс показывает весь корректно загруженный разрешённый каталог, сохраняя требования полноты и модерации.
На сайте у каждой записи отображается источник, а у агрегированной активности — все вошедшие в расчёт источники. Неполные community-наблюдения публикуются сразу в отдельной ленте «Полевые сигналы» с предупреждением и перечнем отсутствующих полей; до подтверждения полноты они не влияют на индекс клёва. Лента раскрывается серверной кнопкой «Показать ещё», сохраняет выбранные фильтры и ограничена 48 сигналами на страницу. Визуально объединяются только повторы одного ID источника; похожие записи разных площадок остаются самостоятельными наблюдениями. Sidebar лидера скрывается при единственном результате, чтобы не повторять ту же карточку. На сайте у каждой записи отображается источник, а у агрегированной активности — все вошедшие в расчёт источники. Неполные community-наблюдения публикуются сразу в отдельной ленте «Полевые сигналы» с предупреждением и перечнем отсутствующих полей; до подтверждения полноты они не влияют на индекс клёва. Лента раскрывается серверной кнопкой «Показать ещё», сохраняет выбранные фильтры и ограничена 48 сигналами на страницу. Визуально объединяются только повторы одного ID источника; похожие записи разных площадок остаются самостоятельными наблюдениями. Sidebar лидера скрывается при единственном результате, чтобы не повторять ту же карточку.
@@ -68,6 +70,8 @@ Production-контур для домена `rf4spotter.ru`, TLS, секреты
Host-side мониторинг контейнеров, readiness, диска, объёма PostgreSQL/MinIO, резервных копий и TLS описан в [`docs/production-monitoring.md`](docs/production-monitoring.md). Host-side мониторинг контейнеров, readiness, диска, объёма PostgreSQL/MinIO, резервных копий и TLS описан в [`docs/production-monitoring.md`](docs/production-monitoring.md).
Минимальные SLI открытой альфы, стартовые пороги и правила безопасной телеметрии собраны в [observability plan](docs/observability.md). До выбора сервера используются существующие JSON-логи, readiness, diagnostics и host monitor; отдельный metrics-стек заранее не добавляется.
Принятые границы стека, memory-cache, scheduler и хранилищ зафиксированы в [архитектурных решениях](docs/architecture-decisions.md). Порядок действий при заполнении диска, отказах PostgreSQL/MinIO, зависшем импорте, ошибке миграции и утечке секрета находится в [incident runbook](docs/incident-runbook.md). Принятые границы стека, memory-cache, scheduler и хранилищ зафиксированы в [архитектурных решениях](docs/architecture-decisions.md). Порядок действий при заполнении диска, отказах PostgreSQL/MinIO, зависшем импорте, ошибке миграции и утечке секрета находится в [incident runbook](docs/incident-runbook.md).
Ежедневный systemd timer создаёт проверяемую копию до retention-очистки, а production Compose ограничивает рост JSON-логов контейнеров. Ежедневный systemd timer создаёт проверяемую копию до retention-очистки, а production Compose ограничивает рост JSON-логов контейнеров.
@@ -87,6 +91,8 @@ FastAPI ─ PostgreSQL 17
Наружу production-профиль публикует только Caddy. PostgreSQL, API, Astro и MinIO находятся в Docker-сетях. Caddy завершает TLS и защищает административные страницы Basic Auth; административный API отдельно проверяет Bearer-токен в FastAPI. Basic не накладывается на API-запросы. Наружу production-профиль публикует только Caddy. PostgreSQL, API, Astro и MinIO находятся в Docker-сетях. Caddy завершает TLS и защищает административные страницы Basic Auth; административный API отдельно проверяет Bearer-токен в FastAPI. Basic не накладывается на API-запросы.
Production CSP ограничивает browser-запросы текущим доменом и отдельным files-доменом для изображений, запрещает plugins, frames, inline handlers, eval, wildcard и HTTP. Оставшиеся inline script/style зависимости и путь к nonce/hash перечислены в [CSP inventory](docs/csp-inventory.md).
Gitea Actions workflow `.gitea/workflows/ci.yml` на каждый push и pull request проверяет Python, миграции на чистой PostgreSQL, Astro build и полный Compose/Playwright-сценарий. При падении E2E сохраняются логи контейнеров и Playwright-артефакты. Gitea Actions workflow `.gitea/workflows/ci.yml` на каждый push и pull request проверяет Python, миграции на чистой PostgreSQL, Astro build и полный Compose/Playwright-сценарий. При падении E2E сохраняются логи контейнеров и Playwright-артефакты.
Актуальная инвентаризация источников и правила подключения адаптеров находятся в [`docs/data-source-audit.md`](docs/data-source-audit.md). Разрешённый технический пилот RF4DB/RF4-STAT описан в [`docs/community-source-pilot.md`](docs/community-source-pilot.md), а статус разрешений и лимитов — в [`docs/data-permissions.md`](docs/data-permissions.md). Все наблюдения сначала попадают в staging. Полные записи с уже подтверждёнными алиасами могут публиковаться автоматически; новые соответствия требуют модерации, а неполные записи показываются отдельно и не влияют на индекс. Актуальная инвентаризация источников и правила подключения адаптеров находятся в [`docs/data-source-audit.md`](docs/data-source-audit.md). Разрешённый технический пилот RF4DB/RF4-STAT описан в [`docs/community-source-pilot.md`](docs/community-source-pilot.md), а статус разрешений и лимитов — в [`docs/data-permissions.md`](docs/data-permissions.md). Все наблюдения сначала попадают в staging. Полные записи с уже подтверждёнными алиасами могут публиковаться автоматически; новые соответствия требуют модерации, а неполные записи показываются отдельно и не влияют на индекс.
+30 -14
View File
@@ -5,6 +5,7 @@ import FishingIcon from "../components/FishingIcon.astro";
import SourceBadge from "../components/SourceBadge.astro"; import SourceBadge from "../components/SourceBadge.astro";
import TackleGlyph from "../components/TackleGlyph.astro"; import TackleGlyph from "../components/TackleGlyph.astro";
import SignalFeed from "../components/SignalFeed.astro"; import SignalFeed from "../components/SignalFeed.astro";
import StatePanel from "../components/StatePanel.astro";
import { activityLevel, ago, api, kg, plural, type Activity, type DictionaryItem, type PaginatedActivity, type PublicObservation } from "../lib/api"; import { activityLevel, ago, api, kg, plural, type Activity, type DictionaryItem, type PaginatedActivity, type PublicObservation } from "../lib/api";
const params = Astro.url.searchParams; const params = Astro.url.searchParams;
@@ -19,27 +20,40 @@ const signalLimit = Number.isInteger(requestedSignalLimit) ? Math.min(48, Math.m
let items: Activity[] = [], signals: PublicObservation[] = [], fishes: DictionaryItem[] = [], waterbodies: DictionaryItem[] = []; let items: Activity[] = [], signals: PublicObservation[] = [], fishes: DictionaryItem[] = [], waterbodies: DictionaryItem[] = [];
let hasMoreSignals = false, totalItems = 0; let hasMoreSignals = false, totalItems = 0;
const filterError = !["6", "12", "24", "72"].includes(hours) || !["activity", "confidence", "freshness"].includes(sort); const filterError = !["6", "12", "24", "72"].includes(hours) || !["activity", "confidence", "freshness"].includes(sort);
let unavailable = false, showNoIndex = false; let activityUnavailable = false, signalsUnavailable = false, fishCatalogUnavailable = false, waterCatalogUnavailable = false, showNoIndex = false;
if (filterError) { showNoIndex = true; Astro.response.status = 422; } if (filterError) { showNoIndex = true; Astro.response.status = 422; }
try { let signalRows: PublicObservation[] = [];
let signalRows: PublicObservation[] = []; const signalParams = new URLSearchParams({ limit: String(signalLimit + 1) });
const signalParams = new URLSearchParams({ limit: String(signalLimit + 1) }); if (waterbody) signalParams.set("waterbody", waterbody);
if (waterbody) signalParams.set("waterbody", waterbody); if (fish) signalParams.set("fish", fish);
if (fish) signalParams.set("fish", fish); const [fishResult, waterResult, signalResult] = await Promise.allSettled([
[fishes, waterbodies, signalRows] = await Promise.all([api<DictionaryItem[]>("/api/v1/fishes"), api<DictionaryItem[]>("/api/v1/waterbodies"), api<PublicObservation[]>(`/api/v1/community-observations?${signalParams}`)]); api<DictionaryItem[]>("/api/v1/fishes"), api<DictionaryItem[]>("/api/v1/waterbodies"),
api<PublicObservation[]>(`/api/v1/community-observations?${signalParams}`),
]);
if (fishResult.status === "fulfilled") fishes = fishResult.value; else fishCatalogUnavailable = true;
if (waterResult.status === "fulfilled") waterbodies = waterResult.value; else waterCatalogUnavailable = true;
if (signalResult.status === "fulfilled") signalRows = signalResult.value; else signalsUnavailable = true;
if (!signalsUnavailable) {
hasMoreSignals = signalRows.length > signalLimit; hasMoreSignals = signalRows.length > signalLimit;
signals = signalRows.slice(0, signalLimit); signals = signalRows.slice(0, signalLimit);
if (!filterError) { }
const query = new URLSearchParams({ hours, waterbody, fish, sort, limit: "20", offset: String(offset) }); if (!filterError) {
const query = new URLSearchParams({ hours, waterbody, fish, sort, limit: "20", offset: String(offset) });
try {
const paginated = await api<PaginatedActivity>(`/api/v1/activity?${query}`); const paginated = await api<PaginatedActivity>(`/api/v1/activity?${query}`);
items = offset > 0 ? [...items, ...paginated.items] : paginated.items; items = offset > 0 ? [...items, ...paginated.items] : paginated.items;
totalItems = paginated.total; totalItems = paginated.total;
} } catch { activityUnavailable = true; }
} catch { }
unavailable = true; const catalogUnavailable = fishCatalogUnavailable || waterCatalogUnavailable;
const allUnavailable = activityUnavailable && signalsUnavailable && fishCatalogUnavailable && waterCatalogUnavailable;
if (!filterError && allUnavailable) {
showNoIndex = true; showNoIndex = true;
Astro.response.status = 503; Astro.response.status = 503;
Astro.response.headers.set("Retry-After", "60"); Astro.response.headers.set("Retry-After", "60");
} else if (activityUnavailable || signalsUnavailable || catalogUnavailable) {
Astro.response.headers.set("X-RF4-Partial", [activityUnavailable && "activity", signalsUnavailable && "signals", catalogUnavailable && "catalog"].filter(Boolean).join(","));
Astro.response.headers.set("Cache-Control", "no-store");
} }
const leaderLevel = items.length > 1 && items[0] ? activityLevel(items[0].activity_score) : null; const leaderLevel = items.length > 1 && items[0] ? activityLevel(items[0].activity_score) : null;
const selectedWaterbody = waterbodies.find(item => item.slug === waterbody)?.name_ru ?? "Все водоёмы"; const selectedWaterbody = waterbodies.find(item => item.slug === waterbody)?.name_ru ?? "Все водоёмы";
@@ -58,8 +72,9 @@ const datasetJsonLd = {
creator: { "@type": "Organization", name: "RF4 Spotter" }, isAccessibleForFree: true, creator: { "@type": "Organization", name: "RF4 Spotter" }, isAccessibleForFree: true,
}; };
--- ---
<Layout title="Что клюёт сейчас в Russian Fishing 4 — RF4 Spotter" description="Свежие точки клёва RF4, рабочие приманки, вес уловов и прозрачная оценка данных с указанием каждого источника." structuredData={datasetJsonLd} noindex={showNoIndex} errorPage={filterError || unavailable}> <Layout title="Что клюёт сейчас в Russian Fishing 4 — RF4 Spotter" description="Свежие точки клёва RF4, рабочие приманки, вес уловов и прозрачная оценка данных с указанием каждого источника." structuredData={datasetJsonLd} noindex={showNoIndex} errorPage={filterError || allUnavailable}>
<section class="intro content-grid"><div class="intro-copy"><span class="eyebrow"><b>RF4</b> Живая карта клёва</span><h1>Выбирай место,<br/><em>пока клюёт.</em></h1><p>Свежие точки, рабочие приманки и честная оценка данных от игроков.</p></div><div class="lake-card"><img src="/lake-dawn.webp" alt="Туманное озеро на рассвете" width="1774" height="887" fetchpriority="high"/><div class="lake-overlay"><div><span class="overline">Пульс водоёмов</span><strong>{items.length} {plural(items.length, ["точка показывает", "точки показывают", "точек показывают"])} активность</strong></div><div class="pulse-orb"><span></span></div></div></div></section> <section class="intro content-grid"><div class="intro-copy"><span class="eyebrow"><b>RF4</b> Живая карта клёва</span><h1>Выбирай место,<br/><em>пока клюёт.</em></h1><p>Свежие точки, рабочие приманки и честная оценка данных от игроков.</p></div><div class="lake-card"><img src="/lake-dawn.webp" alt="Туманное озеро на рассвете" width="1774" height="887" fetchpriority="high"/><div class="lake-overlay"><div><span class="overline">Пульс водоёмов</span><strong>{items.length} {plural(items.length, ["точка показывает", "точки показывают", "точек показывают"])} активность</strong></div><div class="pulse-orb"><span></span></div></div></div></section>
{catalogUnavailable && <div class="content-grid"><StatePanel tone="unavailable" title="Часть справочников недоступна" description="Фильтры показаны без непроверенных значений; остальные разделы загружаются независимо." /></div>}
<section class="filters-wrap"><form class="filters content-grid" method="get" action="/#results"> <section class="filters-wrap"><form class="filters content-grid" method="get" action="/#results">
<label>Водоём<select name="waterbody"><option value="">Все водоёмы</option>{waterbodies.map(x => <option value={x.slug} selected={waterbody === x.slug}>{x.name_ru}</option>)}</select></label> <label>Водоём<select name="waterbody"><option value="">Все водоёмы</option>{waterbodies.map(x => <option value={x.slug} selected={waterbody === x.slug}>{x.name_ru}</option>)}</select></label>
<label>Рыба<select name="fish"><option value="">Любая рыба</option>{fishes.map(x => <option value={x.slug} selected={fish === x.slug}>{x.name_ru}</option>)}</select></label> <label>Рыба<select name="fish"><option value="">Любая рыба</option>{fishes.map(x => <option value={x.slug} selected={fish === x.slug}>{x.name_ru}</option>)}</select></label>
@@ -70,10 +85,11 @@ const datasetJsonLd = {
<button data-action="primary">⌕ Найти клёв</button> <button data-action="primary">⌕ Найти клёв</button>
</form></section> </form></section>
<div class="active-filters content-grid" aria-label="Применённые фильтры"><span>{selectedWaterbody}</span><span>{selectedFish}</span><span>{periodLabel}</span><span>{sortLabel}</span>{filtersChanged && <a href="/#results">Сбросить</a>}</div> <div class="active-filters content-grid" aria-label="Применённые фильтры"><span>{selectedWaterbody}</span><span>{selectedFish}</span><span>{periodLabel}</span><span>{sortLabel}</span>{filtersChanged && <a href="/#results">Сбросить</a>}</div>
<section class="dashboard content-grid" id="results"><div class="results-column"><div class="section-heading"><div><span class="overline">За выбранный период</span><h2>Горячие точки</h2></div><span class="result-count">{items.length} из {totalItems} {plural(totalItems, ["точка", "точки", "точек"])}</span></div>{filterError ? <div class="state error-state"><h2>Некорректные фильтры</h2><p>Выберите период и сортировку из предложенных значений.</p><a data-action="secondary" href="/">Сбросить фильтры</a></div> : unavailable ? <div class="state"><h2>Источник временно недоступен</h2><p>Не показываем устаревшие догадки. Попробуйте позже.</p></div> : items.length ? <><div class="spot-list">{items.map(item => <ActivityCard item={item} />)}</div>{offset + items.length < totalItems && <a class="load-more" data-action="secondary" href={`/?${(() => { const p = new URLSearchParams(params); p.delete("offset"); p.set("offset", String(offset + items.length)); return p.toString(); })()}#results`}>Показать ещё <span>{offset + items.length} из {totalItems}</span> ↓</a>}</> : <div class="state"><h2>Пока нет свежих данных</h2><p>Для выбранных фильтров нет одобренных наблюдений. Расширьте период или выберите другой водоём.</p></div>}</div> <section class="dashboard content-grid" id="results"><div class="results-column"><div class="section-heading"><div><span class="overline">За выбранный период</span><h2>Горячие точки</h2></div><span class="result-count">{items.length} из {totalItems} {plural(totalItems, ["точка", "точки", "точек"])}</span></div>{filterError ? <div class="state error-state"><h2>Некорректные фильтры</h2><p>Выберите период и сортировку из предложенных значений.</p><a data-action="secondary" href="/">Сбросить фильтры</a></div> : activityUnavailable ? <StatePanel contained={false} tone="unavailable" title="Горячие точки временно недоступны" description="Полевые сигналы и справочники продолжают работать независимо." /> : items.length ? <><div class="spot-list">{items.map(item => <ActivityCard item={item} />)}</div>{offset + items.length < totalItems && <a class="load-more" data-action="secondary" href={`/?${(() => { const p = new URLSearchParams(params); p.delete("offset"); p.set("offset", String(offset + items.length)); return p.toString(); })()}#results`}>Показать ещё <span>{offset + items.length} из {totalItems}</span> ↓</a>}</> : <div class="state"><h2>Пока нет свежих данных</h2><p>Для выбранных фильтров нет одобренных наблюдений. Расширьте период или выберите другой водоём.</p></div>}</div>
{items[0] && leaderLevel && <aside class="detail-card"><div class="detail-head"><div><span class="overline">Лидер активности</span><h2>{items[0].waterbody} <em>{items[0].x}:{items[0].y}</em></h2></div><a href={`/spots/${items[0].spot_id}`} aria-label="Открыть точку"><FishingIcon name="arrow"/></a></div><div class="source-strip">{items[0].sources.map(source => <SourceBadge source={source}/>)}</div><div class="detail-score"><div class="float-gauge" style={`--level:${items[0].activity_score}%`} aria-label={`Индекс активности: ${items[0].activity_score} из 100`}><span class="float-gauge__line"></span><span class="float-gauge__water"></span><span class="float-gauge__bob"><i></i></span><strong>{items[0].activity_score}</strong><small>из 100</small></div><div><span>Индекс активности</span><strong data-activity-level={leaderLevel.short}>{leaderLevel.description}</strong><p>{items[0].explanation}</p></div></div><div class="metric-grid"><div><span><FishingIcon name="ripple"/></span><small>Уверенность</small><strong>{items[0].confidence_score}%</strong></div><div><span><FishingIcon name="angler"/></span><small>{plural(items[0].unique_players, ["Игрок", "Игрока", "Игроков"])}</small><strong>{items[0].unique_players}</strong></div><div><span><FishingIcon name="clock"/></span><small>Последний</small><strong>{ago(items[0].last_confirmed_at)}</strong></div><div><span><FishingIcon name="scale"/></span><small>Средний вес</small><strong>{kg(items[0].average_weight_g)}</strong></div></div><div class="best-lure"><span class="overline">Лучшая связка</span><div><TackleGlyph name={items[0].best_bait}/><strong>{items[0].best_bait ?? "Не указана"}</strong><span>{items[0].catches} {plural(items[0].catches, ["улов", "улова", "уловов"])}</span></div></div><p class="confidence-note"><span>✓</span><span><strong>Оценка объяснима.</strong> Один игрок не может искусственно поднять уверенность.</span></p></aside>} {items[0] && leaderLevel && <aside class="detail-card"><div class="detail-head"><div><span class="overline">Лидер активности</span><h2>{items[0].waterbody} <em>{items[0].x}:{items[0].y}</em></h2></div><a href={`/spots/${items[0].spot_id}`} aria-label="Открыть точку"><FishingIcon name="arrow"/></a></div><div class="source-strip">{items[0].sources.map(source => <SourceBadge source={source}/>)}</div><div class="detail-score"><div class="float-gauge" style={`--level:${items[0].activity_score}%`} aria-label={`Индекс активности: ${items[0].activity_score} из 100`}><span class="float-gauge__line"></span><span class="float-gauge__water"></span><span class="float-gauge__bob"><i></i></span><strong>{items[0].activity_score}</strong><small>из 100</small></div><div><span>Индекс активности</span><strong data-activity-level={leaderLevel.short}>{leaderLevel.description}</strong><p>{items[0].explanation}</p></div></div><div class="metric-grid"><div><span><FishingIcon name="ripple"/></span><small>Уверенность</small><strong>{items[0].confidence_score}%</strong></div><div><span><FishingIcon name="angler"/></span><small>{plural(items[0].unique_players, ["Игрок", "Игрока", "Игроков"])}</small><strong>{items[0].unique_players}</strong></div><div><span><FishingIcon name="clock"/></span><small>Последний</small><strong>{ago(items[0].last_confirmed_at)}</strong></div><div><span><FishingIcon name="scale"/></span><small>Средний вес</small><strong>{kg(items[0].average_weight_g)}</strong></div></div><div class="best-lure"><span class="overline">Лучшая связка</span><div><TackleGlyph name={items[0].best_bait}/><strong>{items[0].best_bait ?? "Не указана"}</strong><span>{items[0].catches} {plural(items[0].catches, ["улов", "улова", "уловов"])}</span></div></div><p class="confidence-note"><span>✓</span><span><strong>Оценка объяснима.</strong> Один игрок не может искусственно поднять уверенность.</span></p></aside>}
</section> </section>
{signals.length > 0 && <SignalFeed signals={signals}/>} {signals.length > 0 && <SignalFeed signals={signals}/>}
{signalsUnavailable && <div class="content-grid"><StatePanel tone="unavailable" title="Полевые сигналы временно недоступны" description="Горячие точки и справочники продолжают работать независимо." /></div>}
{hasMoreSignals && signalLimit < 48 && <a class="signal-more" data-action="secondary" href={moreSignalsHref}>Показать ещё <span>{signalLimit} из доступных</span> ↓</a>} {hasMoreSignals && signalLimit < 48 && <a class="signal-more" data-action="secondary" href={moreSignalsHref}>Показать ещё <span>{signalLimit} из доступных</span> ↓</a>}
<section class="how-it-works content-grid"><div><span class="overline">Как читать данные</span><h2>Не обещаем рыбу.<br/>Показываем факты.</h2></div><div class="principles"><article><span>01</span><h3>Свежесть</h3><p>Чем старше сообщение, тем меньше оно влияет на активность.</p></article><article><span>02</span><h3>Разные игроки</h3><p>Десять уловов одного человека не равны десяти подтверждениям.</p></article><article><span>03</span><h3>Уверенность</h3><p>Каждая оценка объясняет, сколько данных за ней стоит.</p></article></div></section> <section class="how-it-works content-grid"><div><span class="overline">Как читать данные</span><h2>Не обещаем рыбу.<br/>Показываем факты.</h2></div><div class="principles"><article><span>01</span><h3>Свежесть</h3><p>Чем старше сообщение, тем меньше оно влияет на активность.</p></article><article><span>02</span><h3>Разные игроки</h3><p>Десять уловов одного человека не равны десяти подтверждениям.</p></article><article><span>03</span><h3>Уверенность</h3><p>Каждая оценка объясняет, сколько данных за ней стоит.</p></article></div></section>
<script> <script>
+1 -1
View File
@@ -52,7 +52,7 @@
Permissions-Policy "camera=(), microphone=(), geolocation=()" Permissions-Policy "camera=(), microphone=(), geolocation=()"
X-Frame-Options "DENY" X-Frame-Options "DENY"
Cross-Origin-Opener-Policy "same-origin" Cross-Origin-Opener-Policy "same-origin"
Content-Security-Policy "frame-ancestors 'none'; base-uri 'self'; object-src 'none'" Content-Security-Policy "default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'none'; form-action 'self'; connect-src 'self'; img-src 'self' data: https://{$FILES_DOMAIN}; font-src 'self'; media-src 'self'; manifest-src 'self'; script-src 'self' 'unsafe-inline'; script-src-attr 'none'; style-src 'self' 'unsafe-inline'; style-src-attr 'unsafe-inline'; upgrade-insecure-requests"
} }
} }
+3 -3
View File
@@ -54,11 +54,11 @@
- [ ] **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. - [ ] **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 — origin-policy внедрена.** Production ограничивает default/connect/form/font/media/manifest текущим доменом, изображения — self/data/`FILES_DOMAIN`, запрещает inline event handlers, eval, wildcard и HTTP. Инвентаризация зафиксировала динамический JSON-LD, page scripts, scoped styles и CSS variables; из-за них `unsafe-inline` временно остаётся только для script/style элементов и style attributes. Далее вынести page scripts, решить nonce/hash JSON-LD и убрать исключения поэтапно с bootstrap-проверкой report/admin/OG/screenshots.
- [ ] **Q15 · Частичная деградация главной.** Разделить получение activity, community signals и справочников так, чтобы отказ одного источника не превращал всю главную в общий 503. Для SSR не добавлять искусственный client-side loading/optimistic UI; показывать независимые `StatePanel` и корректный HTTP/cache статус. - [x] **Q15 · Частичная деградация главной.** SSR независимо получает activity, community signals и оба справочника через settled-результаты. Отказ секции показывает собственный `StatePanel`, сохраняет остальные данные и HTTP 200 с `X-RF4-Partial`/`Cache-Control: no-store`; только отказ всех четырёх частей возвращает 503, `Retry-After` и noindex. Client-side loading и optimistic UI не добавлялись.
- [x] **Q16 · Контракт OpenAPI.** `apps/api/openapi.json` детерминированно генерируется из FastAPI; CI проверяет его актуальность после backend suite. Изменение artifact обязательно рассматривается вместе с реализацией, а ручное редактирование не используется. - [x] **Q16 · Контракт OpenAPI.** `apps/api/openapi.json` детерминированно генерируется из FastAPI; CI проверяет его актуальность после backend suite. Изменение artifact обязательно рассматривается вместе с реализацией, а ручное редактирование не используется.
- [x] **Q17 · Эксплуатационные документы.** Зафиксированы ADR по Astro/FastAPI/PostgreSQL, локальному cache, scheduler/cooldown и разделению PostgreSQL/MinIO. Incident runbook покрывает заполнение диска, отказ PostgreSQL/MinIO, зависшие импорты, ошибки миграций, компрометацию секретов и критерии закрытия без опасных reset/recreate операций. - [x] **Q17 · Эксплуатационные документы.** Зафиксированы ADR по Astro/FastAPI/PostgreSQL, локальному cache, scheduler/cooldown и разделению PostgreSQL/MinIO. Incident runbook покрывает заполнение диска, отказ PostgreSQL/MinIO, зависшие импорты, ошибки миграций, компрометацию секретов и критерии закрытия без опасных reset/recreate операций.
- [ ] **Q18 · Минимальная observability.** До открытой альфы определить дешёвые метрики request count/latency/error rate, глубины moderation/staging и возраста последнего успешного импорта. Формат и exporter выбрать после выбора мониторинга сервера; полноценный tracing не внедрять без подтверждённой потребности. - [x] **Q18 · Минимальная observability.** Зафиксированы дешёвые SLI, стартовые пороги и источники для request count/latency/5xx, moderation/staging depth, возраста импортов и инфраструктуры. Начальный контур использует JSON-логи, readiness, diagnostics и host monitor; exporter выбирается после покупки сервера, tracing и Prometheus не вводятся без измеренной потребности. Перед альфой остаётся подключить реальный канал и проверить critical/recovery alert.
### Административная панель ### Административная панель
+21
View File
@@ -0,0 +1,21 @@
# CSP inventory
Проверено 13 сентября 2026 года. Production не требует произвольных внешних origin: browser API-запросы идут на текущий домен, изображения скриншотов — только на `FILES_DOMAIN`, шрифты и сборка Astro — локальные.
## Временные inline-зависимости
- динамический JSON-LD в `Layout.astro`;
- inline bootstrap в `report.astro` и `spots/[id].astro`;
- page scripts главной и трёх admin-экранов;
- scoped `<style>` Astro-компонентов;
- вычисляемые CSS custom properties для шкалы активности и timeline.
Текущая CSP поэтому временно допускает `unsafe-inline` отдельно для script/style elements и style attributes. Inline event handlers запрещены `script-src-attr 'none'`; `unsafe-eval`, wildcard, HTTP и сторонние connect/script origin отсутствуют.
## Путь к nonce/hash
1. Перенести page scripts в импортируемые клиентские модули, конфигурацию передавать через безопасные `data-*`.
2. JSON-LD либо хэшировать на уровне SSR-заголовка, либо выдавать nonce из web-runtime; статический nonce запрещён.
3. Оставить scoped styles в скомпилированных `_astro` assets; вычисляемые значения заменить классами/`data-*` или nonce style.
4. Удалить `unsafe-inline` из `script-src`, затем из `style-src`; `style-src-attr` убрать последним.
5. Проверить report, spots, admin, JSON-LD и signed screenshots в production bootstrap. Не добавлять `blob:`, `*` или произвольные CDN для устранения ошибок.
+40
View File
@@ -0,0 +1,40 @@
# Минимальная observability открытой альфы
До выбора сервера RF4 Spotter не добавляет Prometheus, Grafana, tracing или отдельную БД метрик. Начальный контур использует уже существующие структурированные JSON-логи API/scheduler, `/health`, `/ready`, защищённый diagnostics и `deploy/monitor.sh`. Это снижает эксплуатационную стоимость и не создаёт ложной уверенности от непроверенного exporter.
## Обязательные сигналы
| Сигнал | Источник | Начальный порог | Реакция |
|---|---|---|---|
| HTTP request count | JSON-событие `request completed`, по method/path/status | отсутствие запросов само по себе не авария | сравнить с доступностью proxy и DNS |
| HTTP error rate | те же события, доля 5xx за 5 минут | warning > 2%, critical > 5% при ≥20 запросах | проверить request ID, API/DB/MinIO |
| HTTP latency | `duration_ms`, p50/p95 за 5 минут | warning p95 > 750 мс, critical > 2 с при ≥20 запросах | отделить DB-запросы от внешних зависимостей |
| Moderation queue | diagnostics: `catch_reports.pending` | warning > 50 или старейшая запись > 24 ч | увеличить окно модерации, проверить уведомления |
| Staging queue | diagnostics: `external_observations` staged/mapped/ready | warning > 500 или устойчивый рост 24 ч | проверить aliases, полноту и импорт |
| Возраст импорта | `/ready` community sources и `/api/v1/source-status` | warning > 2 интервалов; critical согласно required source | проверить cooldown/backoff и DOM-контракт |
| Контейнеры/диск/DB/MinIO/backup/TLS | `deploy/monitor.sh` | пороги `.env.production` | действовать по incident runbook |
Порог является стартовым и меняется только после недели полевых данных. Низкий трафик не должен превращать единичный 5xx в ложную аварию, поэтому rate/latency оцениваются только при минимальном числе запросов.
## Безопасность телеметрии
- не собирать IP, Authorization, cookies, query string, имена игроков и parser payload;
- связывать ошибку только по случайному `request_id`;
- admin diagnostics не передавать внешнему сервису и получать только через оба защитных барьера;
- хранить подробные application logs 14 дней, агрегаты — до 90 дней; доступ ограничить владельцем сервера;
- URL источника и свободный текст причины модерации не использовать как label метрики.
## Выбор exporter после покупки сервера
1. Если провайдер уже даёт сбор journald/container logs и uptime checks — использовать его.
2. Иначе подключить лёгкий host-agent, который читает stdout контейнеров и результат systemd monitor; не выдавать внутренний `/metrics` наружу.
3. Полноценный Prometheus вводить только при необходимости исторических p95 и нескольких хостах. Tracing — только после доказанного межсервисного bottleneck.
4. До приглашения игроков проверить доставку тестового critical-сигнала, recovery-сигнала и отсутствие секретов в уведомлении.
## Альфа-приёмка
- внешний probe проверяет `/health`, а отдельный — `/ready`;
- failed systemd unit доставляет результат `deploy/monitor.sh` в реальный канал;
- dashboard показывает request rate, 5xx rate, p95, две глубины очередей и возраст каждого импорта;
- уведомления группируются минимум на 10 минут и имеют recovery-событие;
- ссылка из alert ведёт в этот документ и [incident runbook](incident-runbook.md).