Compare commits

..
5 Commits
Author SHA1 Message Date
ik 5221442aeb docs: close admin media review milestone
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-16 20:29:10 +07:00
ik 85d81aa996 feat: add admin media upgrade decisions 2026-09-16 20:28:45 +07:00
ik 53e7b0e4ae docs: refresh media catalog snapshot 2026-09-16 20:26:30 +07:00
ik d63eedf41b test: gate admin cache headers in bootstrap 2026-09-16 20:23:37 +07:00
ik 28fffb3acb docs: record media visual review 2026-09-16 20:22:32 +07:00
8 changed files with 82 additions and 7 deletions
+3 -1
View File
@@ -46,9 +46,11 @@ RF4DB/RF4-STAT/RF4MAP/RF4 Posts сначала принимаются в изо
Медиасборщик индексирует разрешённые изображения отдельно от публичного каталога: manifest хранит исходную страницу, URL, предполагаемый тип сущности и время обнаружения, а оригиналы сохраняются по SHA-256 без hotlink. После явного разрешения владельца от 14 сентября все скачанные и целостные материалы опубликованы в `/media`; публичный API отдаёт Git-копии по content-addressed URL, а каждая карточка показывает плашку и прямую ссылку на источник. Будущие загрузки по-прежнему не одобряются автоматически. Локальный `media_cli --audit` без сетевых запросов проверяет хэши, файлы, MIME, размеры, approved-сопоставления и отсутствие бесхозных оригиналов. Медиасборщик индексирует разрешённые изображения отдельно от публичного каталога: manifest хранит исходную страницу, URL, предполагаемый тип сущности и время обнаружения, а оригиналы сохраняются по SHA-256 без hotlink. После явного разрешения владельца от 14 сентября все скачанные и целостные материалы опубликованы в `/media`; публичный API отдаёт Git-копии по content-addressed URL, а каждая карточка показывает плашку и прямую ссылку на источник. Будущие загрузки по-прежнему не одобряются автоматически. Локальный `media_cli --audit` без сетевых запросов проверяет хэши, файлы, MIME, размеры, approved-сопоставления и отсутствие бесхозных оригиналов.
Актуальный offline-срез от 16.09.2026: 704 записи manifest, 466 `approved`, 226 `superseded`, 1 `duplicate` и 11 `invalid`; audit проходит без ошибок и orphan-файлов. У 252 из 253 рыбных изображений есть 1024×1024 WebP, один 48×48 fallback сохранён из-за отсутствия проверенной альтернативы. Производные WebP/AVIF и provenance хранятся в Git; визуальная browser-приёмка остаётся отдельным пунктом B25.
`python -m rf4_research.media_cli --coverage` сравнивает manifest с датированным `data/media/catalog-baseline.json`: отдельно считает уникальные нормализованные подписи и кандидатов без подписи, поэтому альтернативные URL не завышают покрытие. На 14.09.2026 manifest содержит 704 записи: 456 approved, 227 duplicate, 10 queued и 11 invalid. Опубликованы 243 уникально подписанные рыбы из 252, все 149 найденных изображений снастей/приманок и 64 справочных материала; 10 оставшихся RF4DB URL будут загружены после cooldown. Карты 19 водоёмов ещё предстоит проиндексировать, а общий target снастей остаётся `null` до проверяемого полного счётчика. `python -m rf4_research.media_cli --coverage` сравнивает manifest с датированным `data/media/catalog-baseline.json`: отдельно считает уникальные нормализованные подписи и кандидатов без подписи, поэтому альтернативные URL не завышают покрытие. На 14.09.2026 manifest содержит 704 записи: 456 approved, 227 duplicate, 10 queued и 11 invalid. Опубликованы 243 уникально подписанные рыбы из 252, все 149 найденных изображений снастей/приманок и 64 справочных материала; 10 оставшихся RF4DB URL будут загружены после cooldown. Карты 19 водоёмов ещё предстоит проиндексировать, а общий target снастей остаётся `null` до проверяемого полного счётчика.
`python -m rf4_research.media_cli --quality-report` выполняет offline-проверку разрешения опубликованных рыб. Текущий отчёт выявляет 227 PNG RF4MAP размером 48×48, которые увеличиваются в карточках до 180 px, и 16 WebP 1024×1024; для всех низких версий уже известны альтернативные RF4DB URL. План загрузки, сравнения, безопасного переключения и генерации производных размеров ведётся в B20B25 ROADMAP. `python -m rf4_research.media_cli --quality-report` выполняет offline-проверку разрешения опубликованных рыб. Текущий отчёт выявляет 227 PNG RF4MAP размером 48×48, которые увеличиваются в карточках до 180 px, и 16 WebP 1024×1024; для всех низких версий уже известны альтернативные RF4DB URL. `--queue-quality-upgrades` переводит только прямые альтернативы низкоразрешённых published-файлов в безопасную очередь, не снимая текущую версию с публикации. На 15.09 первая партия из 40 замен уже сохранена в карантине для сравнения и review; дальнейшие этапы ведутся в B21B25 ROADMAP.
`python -m rf4_research.media_cli --queue-plan` без сетевых запросов объединяет manifest с общим cooldown-state: показывает queued-состав каждого домена, оставшееся время и наиболее полезный следующий asset с приоритетом водоёмов и рыб. Разрешённое media-окно загружается командой `--download-batch --batch-limit 40`: до 40 assets на домен под одной резервацией, затем 30 минут до нового batch. Блокировка/rate limit/сетевая ошибка останавливает домен сразу, три последовательных невалидных ответа — досрочно. Все файлы остаются в карантине до ручного review. Точный поимённый список отсутствующих сущностей появится только после получения канонического перечня; разница между двумя несогласованными каталогами не выдаётся за доказанный gap. `python -m rf4_research.media_cli --queue-plan` без сетевых запросов объединяет manifest с общим cooldown-state: показывает queued-состав каждого домена, оставшееся время и наиболее полезный следующий asset с приоритетом водоёмов и рыб. Разрешённое media-окно загружается командой `--download-batch --batch-limit 40`: до 40 assets на домен под одной резервацией, затем 30 минут до нового batch. Блокировка/rate limit/сетевая ошибка останавливает домен сразу, три последовательных невалидных ответа — досрочно. Все файлы остаются в карантине до ручного review. Точный поимённый список отсутствующих сущностей появится только после получения канонического перечня; разница между двумя несогласованными каталогами не выдаётся за доказанный gap.
+2
View File
@@ -81,9 +81,11 @@ def review_assets(entity_type: str | None = None, status: str | None = None) ->
"height": item.get("height"), "height": item.get("height"),
"content_type": item.get("content_type"), "content_type": item.get("content_type"),
"image_url": f"/api/v1/admin/media/assets/{digest}", "image_url": f"/api/v1/admin/media/assets/{digest}",
"asset_url": item.get("asset_url", ""),
"source_system": source, "source_system": source,
"source_url": source_page, "source_url": source_page,
"duplicate_of": item.get("duplicate_of"), "duplicate_of": item.get("duplicate_of"),
"supersedes": item.get("supersedes"),
"derivatives": [{ "derivatives": [{
"role": variant.get("role"), "format": variant.get("format"), "role": variant.get("role"), "format": variant.get("format"),
"width": variant.get("width"), "height": variant.get("height"), "width": variant.get("width"), "height": variant.get("height"),
+30 -2
View File
@@ -15,10 +15,12 @@ from ..community_review import ExternalReviewError, map_observation, publish_obs
from ..config import settings from ..config import settings
from ..dependencies import Db from ..dependencies import Db
from ..importer import ImportAlreadyRunning, ImportSourceError, import_records from ..importer import ImportAlreadyRunning, ImportSourceError, import_records
from ..media_catalog import review_assets, review_file from rf4_research.media_assets import publish_quality_upgrades, rollback_quality_upgrade
from ..media_catalog import MEDIA_ROOT, review_assets, review_file
from ..models import CatchReport, CommunityImportRun, DataSource, ExternalObservation, Fish, ModerationEvent, ModerationStatus, OfficialRecordImport, SourceType, Waterbody from ..models import CatchReport, CommunityImportRun, DataSource, ExternalObservation, Fish, ModerationEvent, ModerationStatus, OfficialRecordImport, SourceType, Waterbody
from ..public_cache import public_cache from ..public_cache import public_cache
from ..schemas import AdminCatchReportOut, AdminMediaReviewOut, AdminModerationHistoryOut, AdminSourceStatusOut, CatchReportCreated, ExternalAliasSuggestionOut, ExternalObservationAction, ExternalObservationDecision, ExternalObservationMapping, ExternalObservationOut, ExternalObservationPublished, ImportRunOut, ModerationUpdate from ..schemas import AdminCatchReportOut, AdminMediaDecision, AdminMediaReviewOut, AdminMediaRollback, AdminModerationHistoryOut, AdminSourceStatusOut, CatchReportCreated, ExternalAliasSuggestionOut, ExternalObservationAction, ExternalObservationDecision, ExternalObservationMapping, ExternalObservationOut, ExternalObservationPublished, ImportRunOut, ModerationUpdate
from ..storage import delete_screenshot, signed_screenshot_url from ..storage import delete_screenshot, signed_screenshot_url
from ..time_utils import aware from ..time_utils import aware
@@ -50,6 +52,32 @@ def admin_media_asset(digest: str, _: Annotated[str, Depends(_admin)]) -> FileRe
return FileResponse(path, media_type=media_type, headers={"Cache-Control": "private, no-store"}) return FileResponse(path, media_type=media_type, headers={"Cache-Control": "private, no-store"})
@router.post("/api/v1/admin/media/upgrades/publish")
def admin_publish_media_upgrades(
payload: AdminMediaDecision,
_: Annotated[str, Depends(_admin)],
) -> dict[str, int]:
"""Atomically publish all stored quality upgrades after an explicit decision."""
try:
return publish_quality_upgrades(MEDIA_ROOT / "manifest.json", note=payload.note)
except ValueError as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
@router.post("/api/v1/admin/media/upgrades/rollback")
def admin_rollback_media_upgrade(
payload: AdminMediaRollback,
_: Annotated[str, Depends(_admin)],
) -> dict[str, str]:
"""Restore one superseded fallback while retaining the reviewed candidate."""
try:
return rollback_quality_upgrade(
MEDIA_ROOT / "manifest.json", asset_url=payload.asset_url, note=payload.note,
)
except ValueError as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
@router.get("/api/v1/admin/diagnostics") @router.get("/api/v1/admin/diagnostics")
def admin_diagnostics(db: Db, _: Annotated[str, Depends(_admin)]) -> JSONResponse: def admin_diagnostics(db: Db, _: Annotated[str, Depends(_admin)]) -> JSONResponse:
report_counts = {status.value: count for status, count in db.execute( report_counts = {status.value: count for status, count in db.execute(
+10
View File
@@ -318,7 +318,17 @@ class AdminMediaReviewOut(BaseModel):
height: int | None height: int | None
content_type: str | None content_type: str | None
image_url: str image_url: str
asset_url: str
source_system: str source_system: str
source_url: str source_url: str
duplicate_of: str | None duplicate_of: str | None
supersedes: str | None
derivatives: list[AdminMediaDerivativeOut] derivatives: list[AdminMediaDerivativeOut]
class AdminMediaDecision(BaseModel):
note: str = Field(min_length=1, max_length=1000)
class AdminMediaRollback(AdminMediaDecision):
asset_url: str = Field(min_length=1, max_length=2000)
+29
View File
@@ -13,6 +13,7 @@ from app.community_importer import stage_observations
from app.importer import ImportAlreadyRunning from app.importer import ImportAlreadyRunning
from app.main import app from app.main import app
from app.models import Bait, BaitKind, CatchReport, DataSource, ExternalEntityAlias, ExternalObservation, Fish, ImportStatus, ModerationEvent, ModerationStatus, OfficialRecordImport, SourceType, Spot, SubmissionAttempt, Waterbody from app.models import Bait, BaitKind, CatchReport, DataSource, ExternalEntityAlias, ExternalObservation, Fish, ImportStatus, ModerationEvent, ModerationStatus, OfficialRecordImport, SourceType, Spot, SubmissionAttempt, Waterbody
from app.routers import admin as admin_router
engine = create_engine("sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool) engine = create_engine("sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool)
@@ -182,6 +183,34 @@ def test_admin_media_review_requires_auth() -> None:
assert all({"role", "format", "width", "height"} <= set(derivative) for derivative in response.json()[0]["derivatives"]) assert all({"role", "format", "width", "height"} <= set(derivative) for derivative in response.json()[0]["derivatives"])
def test_admin_media_decisions_require_auth_and_note(monkeypatch) -> None:
assert client.post("/api/v1/admin/media/upgrades/publish", json={"note": "publish"}).status_code == 401
assert client.post("/api/v1/admin/media/upgrades/rollback", json={"asset_url": "https://example.test/a", "note": "rollback"}).status_code == 401
monkeypatch.setattr(admin_router, "publish_quality_upgrades", lambda path, note: {"published": 2, "retained_fallbacks": 2})
publish = client.post(
"/api/v1/admin/media/upgrades/publish",
json={"note": "visual review complete"},
headers={"Authorization": "Bearer change-me-in-production"},
)
assert publish.status_code == 200
assert publish.json() == {"published": 2, "retained_fallbacks": 2}
monkeypatch.setattr(admin_router, "rollback_quality_upgrade", lambda path, asset_url, note: {"rolled_back": asset_url, "restored": "https://example.test/fallback"})
rollback = client.post(
"/api/v1/admin/media/upgrades/rollback",
json={"asset_url": "https://example.test/a", "note": "fallback is preferred"},
headers={"Authorization": "Bearer change-me-in-production"},
)
assert rollback.status_code == 200
assert rollback.json()["rolled_back"] == "https://example.test/a"
assert client.post(
"/api/v1/admin/media/upgrades/publish",
json={"note": ""},
headers={"Authorization": "Bearer change-me-in-production"},
).status_code == 422
def test_liveness_does_not_probe_dependencies() -> None: def test_liveness_does_not_probe_dependencies() -> None:
response = client.get("/health?token=must-not-be-logged") response = client.get("/health?token=must-not-be-logged")
assert response.json() == {"status": "ok"} assert response.json() == {"status": "ok"}
+4 -2
View File
@@ -8,7 +8,7 @@ const apiUrl = import.meta.env.PUBLIC_API_URL || "http://localhost:8000";
<section class="moderation-app" data-api-url={apiUrl}> <section class="moderation-app" data-api-url={apiUrl}>
<AdminNav /> <AdminNav />
<form class="admin-login" autocomplete="off"><label>Административный токен<input name="token" type="password" required autocomplete="off" /></label><button data-action="primary" type="submit">Открыть медиатеку</button></form> <form class="admin-login" autocomplete="off"><label>Административный токен<input name="token" type="password" required autocomplete="off" /></label><button data-action="primary" type="submit">Открыть медиатеку</button></form>
<p class="privacy">Публичные файлы не переключаются из этого экрана. Токен хранится только в памяти страницы.</p> <p class="privacy">Публикация требует явной причины и атомарно сохраняет fallback для rollback. Токен хранится только в памяти страницы.</p>
<div class="admin-session-bar" hidden><span>Административная сессия активна</span><button data-action="secondary" type="button" data-admin-logout>Выйти</button></div> <div class="admin-session-bar" hidden><span>Административная сессия активна</span><button data-action="secondary" type="button" data-admin-logout>Выйти</button></div>
<form class="admin-queue-filters" hidden><label>Тип<select name="entity_type"><option value="">Все типы</option><option value="fish">Рыбы</option><option value="waterbody">Водоёмы</option><option value="tackle">Снасти</option><option value="reference">Справка</option></select></label><label>Состояние<select name="status"><option value="">Все состояния</option><option value="approved">Approved</option><option value="upgrade_queued">Upgrade queued</option><option value="upgrade_stored">Upgrade stored</option></select></label><button data-action="primary" type="submit">Применить</button></form> <form class="admin-queue-filters" hidden><label>Тип<select name="entity_type"><option value="">Все типы</option><option value="fish">Рыбы</option><option value="waterbody">Водоёмы</option><option value="tackle">Снасти</option><option value="reference">Справка</option></select></label><label>Состояние<select name="status"><option value="">Все состояния</option><option value="approved">Approved</option><option value="upgrade_queued">Upgrade queued</option><option value="upgrade_stored">Upgrade stored</option></select></label><button data-action="primary" type="submit">Применить</button></form>
<div class="notice error" data-admin-error role="alert" hidden></div><div class="notice success" data-admin-status role="status" hidden></div><section class="media-library__grid" data-media-list aria-live="polite"></section> <div class="notice error" data-admin-error role="alert" hidden></div><div class="notice success" data-admin-status role="status" hidden></div><section class="media-library__grid" data-media-list aria-live="polite"></section>
@@ -47,7 +47,9 @@ const apiUrl = import.meta.env.PUBLIC_API_URL || "http://localhost:8000";
if (!rows.length && offset > 0) { offset = 0; return load(); } if (!rows.length && offset > 0) { offset = 0; return load(); }
const assets = rows.slice(0, 50); if (pages) pages.hidden = !assets.length; if (previous) previous.disabled = offset === 0; if (next) next.disabled = rows.length <= 50; if (pageNumber) pageNumber.textContent = `Страница ${offset / 50 + 1}`; const assets = rows.slice(0, 50); if (pages) pages.hidden = !assets.length; if (previous) previous.disabled = offset === 0; if (next) next.disabled = rows.length <= 50; if (pageNumber) pageNumber.textContent = `Страница ${offset / 50 + 1}`;
if (!assets.length) { list.innerHTML = '<div class="state"><h2>Кандидатов нет</h2><p>Для выбранных фильтров нет approved или upgrade_queued файлов.</p></div>'; return; } if (!assets.length) { list.innerHTML = '<div class="state"><h2>Кандидатов нет</h2><p>Для выбранных фильтров нет approved или upgrade_queued файлов.</p></div>'; return; }
list.innerHTML = assets.map(asset => { const image = url(asset.image_url); const source = url(asset.source_url); const variants = (asset.derivatives as Record<string, unknown>[] ?? []).map(item => `${esc(item.format)} ${esc(item.width)}×${esc(item.height)}`).join(", "); return `<article class="media-library__card"><a href="${image}" target="_blank" rel="noreferrer">${image ? `<img src="${image}" alt="${esc(asset.label)}" loading="lazy" />` : ""}</a><h2>${esc(asset.label)}</h2><span>${esc(asset.status)} · ${esc(asset.entity_type)} · ${esc(asset.width)}×${esc(asset.height)}</span><p>${esc(asset.source_system)}${asset.duplicate_of ? ` · duplicate_of ${esc(asset.duplicate_of)}` : ""}</p>${variants ? `<small>Производные: ${variants}</small>` : "<small>Производных нет</small>"}${source ? `<a href="${source}" target="_blank" rel="noreferrer">Первоисточник →</a>` : ""}</article>`; }).join(""); list.innerHTML = assets.map(asset => { const image = url(asset.image_url); const source = url(asset.source_url); const variants = (asset.derivatives as Record<string, unknown>[] ?? []).map(item => `${esc(item.format)} ${esc(item.width)}×${esc(item.height)}`).join(", "); const rollback = asset.status === "approved" && asset.supersedes ? `<button data-media-rollback="${esc(asset.asset_url)}" type="button" data-action="secondary">Откатить замену</button>` : ""; return `<article class="media-library__card"><a href="${image}" target="_blank" rel="noreferrer">${image ? `<img src="${image}" alt="${esc(asset.label)}" loading="lazy" />` : ""}</a><h2>${esc(asset.label)}</h2><span>${esc(asset.status)} · ${esc(asset.entity_type)} · ${esc(asset.width)}×${esc(asset.height)}</span><p>${esc(asset.source_system)}${asset.duplicate_of ? ` · duplicate_of ${esc(asset.duplicate_of)}` : ""}</p>${variants ? `<small>Производные: ${variants}</small>` : "<small>Производных нет</small>"}${source ? `<a href="${source}" target="_blank" rel="noreferrer">Первоисточник →</a>` : ""}${rollback}</article>`; }).join("");
list.querySelectorAll<HTMLButtonElement>("[data-media-rollback]").forEach(button => button.addEventListener("click", async () => { const note = window.prompt("Причина отката:"); if (!note?.trim()) return; button.disabled = true; try { const response = await fetch(`${root?.dataset.apiUrl}/api/v1/admin/media/upgrades/rollback`, {method:"POST", headers:{Authorization:`Bearer ${token}`, "Content-Type":"application/json"}, body:JSON.stringify({asset_url:button.dataset.mediaRollback, note:note.trim()})}); if (!response.ok) throw new Error(adminErrorMessage(response.status, "Не удалось выполнить откат.")); succeed("Fallback восстановлен."); await load(); } catch (cause) { fail(cause instanceof Error ? cause.message : "Не удалось выполнить откат."); } finally { button.disabled = false; } }));
if (assets.some(asset => asset.status === "upgrade_stored")) { const publish = document.createElement("button"); publish.type = "button"; publish.dataset.action = "primary"; publish.textContent = "Опубликовать сохранённые замены"; publish.addEventListener("click", async () => { const note = window.prompt("Причина публикации замен:"); if (!note?.trim()) return; publish.disabled = true; try { const response = await fetch(`${root?.dataset.apiUrl}/api/v1/admin/media/upgrades/publish`, {method:"POST", headers:{Authorization:`Bearer ${token}`, "Content-Type":"application/json"}, body:JSON.stringify({note:note.trim()})}); if (!response.ok) throw new Error(adminErrorMessage(response.status, "Не удалось опубликовать замены.")); succeed("Замены опубликованы атомарно, fallback сохранены."); await load(); } catch (cause) { fail(cause instanceof Error ? cause.message : "Не удалось опубликовать замены."); } finally { publish.disabled = false; } }); list.prepend(publish); }
} }
login?.addEventListener("submit", async event => { event.preventDefault(); token = String(new FormData(login).get("token") || ""); offset = 0; try { await load(); login.hidden = true; if (sessionBar) sessionBar.hidden = false; if (filters) filters.hidden = false; } catch (cause) { list && (list.innerHTML = ""); fail(cause instanceof Error ? cause.message : "Ошибка загрузки."); } }); login?.addEventListener("submit", async event => { event.preventDefault(); token = String(new FormData(login).get("token") || ""); offset = 0; try { await load(); login.hidden = true; if (sessionBar) sessionBar.hidden = false; if (filters) filters.hidden = false; } catch (cause) { list && (list.innerHTML = ""); fail(cause instanceof Error ? cause.message : "Ошибка загрузки."); } });
filters?.addEventListener("submit", async event => { event.preventDefault(); offset = 0; try { await load(); } catch (cause) { fail(cause instanceof Error ? cause.message : "Ошибка фильтрации."); } }); filters?.addEventListener("submit", async event => { event.preventDefault(); offset = 0; try { await load(); } catch (cause) { fail(cause instanceof Error ? cause.message : "Ошибка фильтрации."); } });
+2
View File
@@ -84,4 +84,6 @@ test "$($compose exec -T db psql -At -U rf4 -d rf4_spotter -c 'select count(*) f
test "$($compose exec -T db psql -At -U rf4 -d rf4_spotter -c 'select count(*) from catch_report')" = "0" 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 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
curl -fsS -D - -o /dev/null -H 'Authorization: Bearer replace-with-at-least-32-random-characters' "http://127.0.0.1:$BOOTSTRAP_API_PORT/api/v1/admin/catch-reports" | grep -qi '^cache-control: no-store' curl -fsS -D - -o /dev/null -H 'Authorization: Bearer replace-with-at-least-32-random-characters' "http://127.0.0.1:$BOOTSTRAP_API_PORT/api/v1/admin/catch-reports" | grep -qi '^cache-control: no-store'
curl -fsS -D - -o /dev/null -H 'Authorization: Bearer replace-with-at-least-32-random-characters' "http://127.0.0.1:$BOOTSTRAP_API_PORT/api/v1/admin/media/catalog" | grep -qi '^cache-control: no-store'
curl -fsS -D - -o /dev/null -H 'Authorization: Bearer replace-with-at-least-32-random-characters' "http://127.0.0.1:$BOOTSTRAP_API_PORT/api/v1/admin/source-status" | grep -qi '^cache-control: no-store'
echo "Production bootstrap passed from empty volumes" echo "Production bootstrap passed from empty volumes"
+2 -2
View File
@@ -44,7 +44,7 @@
- [ ] **B22 · Сравнение вариантов разных источников.** Offline-команда `--compare-quality-upgrades` сопоставляет сохранённые кандидаты с опубликованными fallback по `duplicate_of` и проверяет dimensions, размер файла, MIME, прозрачность и aspect ratio. Первая партия: 40/40 кандидатов — прозрачные WebP 1024×1024, все прошли порог 256 px и сохранили пропорции; технических ошибок нет. До переключения остаётся визуально подтвердить соответствие подписи. Маленький файл сохраняется как fallback; источник RF4DB/RF4MAP/официальный RF4 не получает автоматического приоритета. - [ ] **B22 · Сравнение вариантов разных источников.** Offline-команда `--compare-quality-upgrades` сопоставляет сохранённые кандидаты с опубликованными fallback по `duplicate_of` и проверяет dimensions, размер файла, MIME, прозрачность и aspect ratio. Первая партия: 40/40 кандидатов — прозрачные WebP 1024×1024, все прошли порог 256 px и сохранили пропорции; технических ошибок нет. До переключения остаётся визуально подтвердить соответствие подписи. Маленький файл сохраняется как fallback; источник RF4DB/RF4MAP/официальный RF4 не получает автоматического приоритета.
- [x] **B23 · Безопасное продвижение и provenance.** Связи `supersedes`/`replaced_by`, отдельное решение review, атомарное переключение публичного `entity_key` и явный CLI rollback сохраняют обе исходные ссылки и возможность отката. Публичная карточка показывает источник выбранного изображения, а manifest сохраняет проверенные варианты. Старый Git-файл не удаляется при включении нового. - [x] **B23 · Безопасное продвижение и provenance.** Связи `supersedes`/`replaced_by`, отдельное решение review, атомарное переключение публичного `entity_key` и явный CLI rollback сохраняют обе исходные ссылки и возможность отката. Публичная карточка показывает источник выбранного изображения, а manifest сохраняет проверенные варианты. Старый Git-файл не удаляется при включении нового.
- [x] **B24 · Производные размеры.** После выбора оригиналов генерируются детерминированные WebP/AVIF thumbnails для каталога и отдельный крупный вариант для detail, хэши производных фиксируются в manifest и отдаются через каталог. Маленькие карточки больше не обязаны загружать 1024×1024 оригинал. - [x] **B24 · Производные размеры.** После выбора оригиналов генерируются детерминированные WebP/AVIF thumbnails для каталога и отдельный крупный вариант для detail, хэши производных фиксируются в manifest и отдаются через каталог. Маленькие карточки больше не обязаны загружать 1024×1024 оригинал.
- [ ] **B25 · Визуальная приёмка media.** Собирать контактный лист «старое / кандидат / выбранное» с названием и источником; вручную проверить минимум все замены и репрезентативные desktop/mobile страницы в light/dark. Gate: нет битых файлов, искажённых пропорций, ложных соответствий, обрезанного объекта и изображений ниже 256 px без явной пометки «низкое разрешение». - [ ] **B25 · Визуальная приёмка media.** Contact sheets для всех 226 замен собраны и просмотрены offline: явных ложных соответствий, обрезки и искажённых пропорций не обнаружено; одно отличие подписи (`Ерш-носарь`/`Ёрш-носарь`) орфографическое. Осталась browser-проверка репрезентативных desktop/mobile страниц в light/dark; sandbox Chromium пока не позволяет её выполнить. Gate: нет битых файлов, искажённых пропорций, ложных соответствий, обрезанного объекта и изображений ниже 256 px без явной пометки «низкое разрешение».
### Каталог водоёмов, карты и координаты ### Каталог водоёмов, карты и координаты
@@ -127,7 +127,7 @@
- [x] **A01 · Защита маршрутов и границы сессии.** Покрыть точный `/admin` и `/admin/*` единым Caddy Basic Auth, выставлять `noindex` и `no-store` для всех административных ответов, проверить отсутствие обхода через API и корректные `401/429`. Критерий: автоматический proxy-smoke для `/admin`, страниц и `/api/v1/admin/*` с отсутствующим, неверным и валидным доступом. - [x] **A01 · Защита маршрутов и границы сессии.** Покрыть точный `/admin` и `/admin/*` единым Caddy Basic Auth, выставлять `noindex` и `no-store` для всех административных ответов, проверить отсутствие обхода через API и корректные `401/429`. Критерий: автоматический proxy-smoke для `/admin`, страниц и `/api/v1/admin/*` с отсутствующим, неверным и валидным доступом.
- [x] **A02 · Единая auth/error UX.** Привести dashboard, moderation и external sources к одинаковому поведению при `401`, `409`, `429`, `5xx`, loading/empty-состояниях: понятное сообщение, блокировка повторной отправки, возврат к входу только при истёкшей авторизации. Критерий: regression-тесты на каждый ответ и сохранение введённой причины. - [x] **A02 · Единая auth/error UX.** Привести dashboard, moderation и external sources к одинаковому поведению при `401`, `409`, `429`, `5xx`, loading/empty-состояниях: понятное сообщение, блокировка повторной отправки, возврат к входу только при истёкшей авторизации. Критерий: regression-тесты на каждый ответ и сохранение введённой причины.
- [x] **A03 · Полный single-owner workflow.** Добавить пагинацию очереди уловов, кнопку запуска официального импорта и отображение результата/истории, безопасные статусы источников с возрастом данных, cooldown/backoff и ручное обновление очередей. Критерий: владелец может пройти путь «импорт → проверка → решение → история» без API/CLI; старые данные сохраняются при сбое импорта. - [x] **A03 · Полный single-owner workflow.** Добавить пагинацию очереди уловов, кнопку запуска официального импорта и отображение результата/истории, безопасные статусы источников с возрастом данных, cooldown/backoff и ручное обновление очередей. Критерий: владелец может пройти путь «импорт → проверка → решение → история» без API/CLI; старые данные сохраняются при сбое импорта.
- [ ] **A04 · Контур медиа-проверки.** Сделать admin-экран для просмотра approved/upgrade_queued медиа, исходника, размеров, производных и provenance; добавить approve/rollback только через существующие безопасные состояния. Критерий: ни одна публичная замена не происходит без явного решения и проверяемого manifest-а. - [x] **A04 · Контур медиа-проверки.** Admin-экран показывает approved/upgrade_queued/upgrade_stored медиа, источник, размеры, производные и provenance. Защищённые действия требуют непустую причину: publish атомарно продвигает только проверенные `upgrade_stored` и сохраняет fallback, rollback принимает только связанную пару `approved`/`superseded`; небезопасные состояния возвращают `409`. API-тесты проверяют auth, валидацию причины и вызов безопасных операций; browser acceptance остаётся в A06.
- [ ] **A05 · Многопользовательский доступ.** После пилота заменить общий Bearer-токен персональными аккаунтами и короткими серверными сессиями с отзывом, ролями read-only/moderator/importer/owner, operator ID в аудите и журналом входов. Критерий: минимальные права реально ограничивают действия, logout/revoke инвалидируют сессию на сервере. - [ ] **A05 · Многопользовательский доступ.** После пилота заменить общий Bearer-токен персональными аккаунтами и короткими серверными сессиями с отзывом, ролями read-only/moderator/importer/owner, operator ID в аудите и журналом входов. Критерий: минимальные права реально ограничивают действия, logout/revoke инвалидируют сессию на сервере.
- [ ] **A06 · Browser/accessibility acceptance.** Проверить `/admin`, moderation и external sources в 320/390/768/1280 px: клавиатура, focus order, screen reader labels, reduced motion, forced colors, темы, ошибки/пустые очереди и конфликт `409`. Критерий: Playwright + axe без блокирующих дефектов и ручная визуальная проверка. - [ ] **A06 · Browser/accessibility acceptance.** Проверить `/admin`, moderation и external sources в 320/390/768/1280 px: клавиатура, focus order, screen reader labels, reduced motion, forced colors, темы, ошибки/пустые очереди и конфликт `409`. Критерий: Playwright + axe без блокирующих дефектов и ручная визуальная проверка.
- [ ] **A07 · Production gate.** Выполнить preflight с реальными секретами, проверить Caddy Basic + API Bearer, закрытые внутренние порты, backup/restore PostgreSQL и MinIO, readiness, no-store и внешний smoke после деплоя. Критерий: acceptance-runbook пройден, rollback и процедура отзыва доступа документированы. - [ ] **A07 · Production gate.** Выполнить preflight с реальными секретами, проверить Caddy Basic + API Bearer, закрытые внутренние порты, backup/restore PostgreSQL и MinIO, readiness, no-store и внешний smoke после деплоя. Критерий: acceptance-runbook пройден, rollback и процедура отзыва доступа документированы.