diff --git a/README.md b/README.md index e8e97f2..38cd5d4 100644 --- a/README.md +++ b/README.md @@ -244,6 +244,8 @@ curl -H "Authorization: Bearer change-me-in-production" \ Очередь модерации доступна по адресу . Администратор вводит `ADMIN_TOKEN`; интерфейс держит его только в памяти открытой страницы и не сохраняет в URL или браузерном хранилище. +В production HTML административных страниц дополнительно закрыт Caddy Basic Auth, а API независимо проверяет Bearer-токен. Интерфейс открывает только ссылки со схемой `http` или `https`; данные источника не могут подставить исполняемую URL-схему в ссылку или превью. + Администратор может одобрить, отклонить или удалить сообщение. Удаление очищает ник, комментарий, исходную ссылку и объект скриншота, исключает запись из статистики, но сохраняет обезличенный факт действия в журнале аудита. ## Эксплуатация production diff --git a/apps/web/src/pages/admin/external-sources.astro b/apps/web/src/pages/admin/external-sources.astro index fcecd95..dfd807e 100644 --- a/apps/web/src/pages/admin/external-sources.astro +++ b/apps/web/src/pages/admin/external-sources.astro @@ -28,6 +28,7 @@ const apiUrl = import.meta.env.PUBLIC_API_URL || "http://localhost:8000"; const loadingCards = () => `Загружаем внешние наблюдения`; const setLoading = (loading: boolean) => list?.setAttribute("aria-busy", String(loading)); 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 options = (items: Record[], selected?: unknown) => items.map(item => ``).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(); } @@ -44,7 +45,7 @@ const apiUrl = import.meta.env.PUBLIC_API_URL || "http://localhost:8000"; if (next) next.disabled = rows.length <= 50; if (pageNumber) pageNumber.textContent = `Страница ${offset / 50 + 1}`; if (!pending.length) { list.innerHTML = '

Очередь пуста

Все внешние записи обработаны.

'; return; } - list.innerHTML = pending.map((row: Record) => { const complete = row.x != null && row.y != null && row.weight_g != null; return `
${esc(row.source_system)} · ${esc(row.status)}

${esc(row.fish_name)}

${esc(row.waterbody_name)}

Координаты
${row.x == null || row.y == null ? "нет" : `${esc(row.x)}:${esc(row.y)}`}
Вес
${row.weight_g == null ? "нет" : `${esc(row.weight_g)} г`}
ID источника
${esc(row.source_external_id)}
Состояние
${complete ? "полная запись" : "неполная"}
Открыть первоисточник

`; }).join(""); + list.innerHTML = pending.map((row: Record) => { const complete = row.x != null && row.y != null && row.weight_g != null; const sourceUrl = safeHttpUrl(row.source_url); return `
${esc(row.source_system)} · ${esc(row.status)}

${esc(row.fish_name)}

${esc(row.waterbody_name)}

Координаты
${row.x == null || row.y == null ? "нет" : `${esc(row.x)}:${esc(row.y)}`}
Вес
${row.weight_g == null ? "нет" : `${esc(row.weight_g)} г`}
ID источника
${esc(row.source_external_id)}
Состояние
${complete ? "полная запись" : "неполная"}
${sourceUrl ? `Открыть первоисточник` : 'Ссылка источника имеет небезопасный формат'}

`; }).join(""); } async function changePage(delta: number) { const oldOffset = offset; diff --git a/apps/web/src/pages/admin/moderation.astro b/apps/web/src/pages/admin/moderation.astro index 928e7a8..733c2d2 100644 --- a/apps/web/src/pages/admin/moderation.astro +++ b/apps/web/src/pages/admin/moderation.astro @@ -18,6 +18,7 @@ const apiUrl = import.meta.env.PUBLIC_API_URL || "http://localhost:8000"; const loadingCards = () => `Загружаем очередь модерации`; const setLoading = (loading: boolean) => list?.setAttribute("aria-busy", String(loading)); 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; } }; async function loadQueue() { if (!root || !list) return; @@ -27,7 +28,7 @@ const apiUrl = import.meta.env.PUBLIC_API_URL || "http://localhost:8000"; const reports: Record[] = await response.json(); setLoading(false); if (!reports.length) { list.innerHTML = '

Очередь пуста

Новых уловов для проверки нет.

'; return; } - list.innerHTML = reports.map(report => `
На проверке

${esc(report.fish)}

${esc(report.waterbody)} · ${esc(report.coordinates)}

Вес
${esc(report.weight_g)} г
Приманка
${esc(report.bait)}
Игрок
${esc(report.player_name)}
Отправлено
${esc(new Date(String(report.reported_at)).toLocaleString("ru-RU"))}
${report.comment ? `
${esc(report.comment)}
` : ""}
${report.screenshot_url ? `Скриншот улова ${esc(report.fish)}` : '
Скриншот не приложен
'}
`).join(""); + list.innerHTML = reports.map(report => { const screenshotUrl = safeHttpUrl(report.screenshot_url); return `
На проверке

${esc(report.fish)}

${esc(report.waterbody)} · ${esc(report.coordinates)}

Вес
${esc(report.weight_g)} г
Приманка
${esc(report.bait)}
Игрок
${esc(report.player_name)}
Отправлено
${esc(new Date(String(report.reported_at)).toLocaleString("ru-RU"))}
${report.comment ? `
${esc(report.comment)}
` : ""}
${screenshotUrl ? `Скриншот улова ${esc(report.fish)}` : '
Скриншот не приложен
'}
`; }).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 : "Ошибка загрузки."); } }); list?.addEventListener("click", async event => { diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 87f1c1d..b9b0bc3 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -60,6 +60,15 @@ - [ ] **Q17 · Эксплуатационные документы.** Добавить короткие ADR по Astro/FastAPI/PostgreSQL, локальному cache и стратегии scheduler, а также incident runbook для заполнения диска/PostgreSQL, отказа MinIO, зависших импортов, ошибок миграции и компрометации секретов. - [ ] **Q18 · Минимальная observability.** До открытой альфы определить дешёвые метрики request count/latency/error rate, глубины moderation/staging и возраста последнего успешного импорта. Формат и exporter выбрать после выбора мониторинга сервера; полноценный tracing не внедрять без подтверждённой потребности. +### Административная панель + +- [ ] **M01 · Усиление административного входа — в работе.** Сохранить двойной барьер Caddy Basic Auth + API Bearer, добавить ограничение частоты неуспешной API-авторизации, таймаут сессии и явный выход. До перехода к аккаунтам не сохранять Bearer-токен в URL, cookie или browser storage. Внешние ссылки разрешать только с `http/https` схемой. +- [ ] **M02 · Единый dashboard.** Добавить `/admin` со счётчиками pending-уловов, staging-наблюдений, ошибками и свежестью источников, последними импортами и быстрыми переходами в очереди. Не выводить секреты, внутренние URL и полные тексты исключений. +- [ ] **M03 · Эффективность очередей.** Добавить фильтры по источнику, рыбе, водоёму, полноте и возрасту, сортировку по риску, понятное подтверждение результата, восстановление черновика причины после сетевой ошибки, переход к следующей карточке и доступные горячие клавиши. +- [ ] **M04 · Полный provenance и история решений.** Перед публикацией показывать исходные поля, источник, время наблюдения, missing fields и результат сопоставления. Добавить read-only журнал модерации и экспорт без персональных данных. +- [ ] **M05 · Защита от параллельных решений.** Ввести version/updated-at precondition для optimistic locking и возвращать понятный `409`, если запись уже изменена другим модератором. +- [ ] **M06 · Персональные роли — после пилота.** Если модераторов станет больше одного, заменить общий токен индивидуальными аккаунтами, короткими сессиями, отзывом доступа и ролями; писать идентификатор оператора в аудит. Для одного владельца альфы не добавлять отдельный auth-сервис заранее. + ## Готовность открытой альфы — требуется сервер или внешний сервис - [ ] Купить/подготовить Linux-сервер и подтвердить его публичный IPv4/IPv6.