diff --git a/README.md b/README.md index 46208a4..e2b12ab 100644 --- a/README.md +++ b/README.md @@ -91,7 +91,7 @@ FastAPI ─ PostgreSQL 17 Наружу 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. Page scripts и scoped styles принудительно выпускаются отдельными same-origin `_astro`-ассетами, поэтому `style-src` больше не допускает inline style elements; оставшиеся исключения и путь к nonce/hash перечислены в [CSP inventory](docs/csp-inventory.md). +Production CSP ограничивает browser-запросы текущим доменом и отдельным files-доменом для изображений, запрещает plugins, frames, inline handlers и attributes, eval, wildcard и HTTP. Page scripts и scoped styles принудительно выпускаются отдельными same-origin `_astro`-ассетами; единственное временное inline-исключение остаётся для динамического JSON-LD и описано в [CSP inventory](docs/csp-inventory.md). Gitea Actions workflow `.gitea/workflows/ci.yml` на каждый push и pull request проверяет Python, миграции на чистой PostgreSQL, Astro build и полный Compose/Playwright-сценарий. При падении E2E сохраняются логи контейнеров и Playwright-артефакты. diff --git a/apps/web/src/components/ActivityTimeline.astro b/apps/web/src/components/ActivityTimeline.astro index 72ef457..2115f44 100644 --- a/apps/web/src/components/ActivityTimeline.astro +++ b/apps/web/src/components/ActivityTimeline.astro @@ -1,10 +1,11 @@ --- const { buckets } = Astro.props as { buckets: { start: string; end: string; count: number }[] }; const max = Math.max(1, ...buckets.map(bucket => bucket.count)); +const meterLevel = (count: number) => Math.round((count / max * 100) / 5) * 5; ---
Последние 72 часа

Леска активности

Все одобренные записи · по времени поступления · шаг 12 часов

- {buckets.map((bucket, index) =>
{bucket.count}{`${(6-index)*12}–${(5-index)*12} ч назад`}
)} + {buckets.map((bucket, index) =>
{bucket.count}{`${(6-index)*12}–${(5-index)*12} ч назад`}
)}
diff --git a/apps/web/src/pages/index.astro b/apps/web/src/pages/index.astro index da8d124..fe82f0d 100644 --- a/apps/web/src/pages/index.astro +++ b/apps/web/src/pages/index.astro @@ -56,6 +56,7 @@ if (!filterError && allUnavailable) { Astro.response.headers.set("Cache-Control", "no-store"); } const leaderLevel = items.length > 1 && items[0] ? activityLevel(items[0].activity_score) : null; +const leaderMeter = items[0] ? Math.round(Math.min(100, Math.max(0, items[0].activity_score)) / 5) * 5 : 0; const selectedWaterbody = waterbodies.find(item => item.slug === waterbody)?.name_ru ?? "Все водоёмы"; const selectedFish = fishes.find(item => item.slug === fish)?.name_ru ?? "Любая рыба"; const periodLabel = { "6": "6 часов", "12": "12 часов", "24": "24 часа", "72": "72 часа" }[hours] ?? hours; @@ -86,7 +87,7 @@ const datasetJsonLd = {
{selectedWaterbody}{selectedFish}{periodLabel}{sortLabel}{filtersChanged && Сбросить}
За выбранный период

Горячие точки

{items.length} из {totalItems} {plural(totalItems, ["точка", "точки", "точек"])}
{filterError ?

Некорректные фильтры

Выберите период и сортировку из предложенных значений.

Сбросить фильтры
: activityUnavailable ? : items.length ? <>
{items.map(item => )}
{offset + items.length < totalItems && { const p = new URLSearchParams(params); p.delete("offset"); p.set("offset", String(offset + items.length)); return p.toString(); })()}#results`}>Показать ещё {offset + items.length} из {totalItems}} :

Пока нет свежих данных

Для выбранных фильтров нет одобренных наблюдений. Расширьте период или выберите другой водоём.

}
- {items[0] && leaderLevel && } + {items[0] && leaderLevel && }
{signals.length > 0 && } {signalsUnavailable &&
} diff --git a/apps/web/src/styles/activity-timeline.css b/apps/web/src/styles/activity-timeline.css index 2b4aaa9..b5bbd19 100644 --- a/apps/web/src/styles/activity-timeline.css +++ b/apps/web/src/styles/activity-timeline.css @@ -1,3 +1,3 @@ .activity-timeline{width:min(1180px,calc(100% - 64px));margin:20px auto 0;padding:25px 28px;border-radius:14px;background:#fff;border:1px solid #d9e1da}.activity-timeline header{display:flex;align-items:end;justify-content:space-between;gap:20px}.activity-timeline h2{font:400 29px Georgia,serif;margin:5px 0 0}.activity-timeline header p{margin:0;color:#71817e;font-size:10px} -.timeline-chart{height:145px;display:grid;grid-template-columns:repeat(6,1fr);gap:8px;margin-top:18px;padding-top:8px;border-bottom:1px solid #a9bbb1;background:repeating-linear-gradient(to top,transparent 0,transparent 35px,#dce5de 36px)}.timeline-slot{position:relative;display:grid;grid-template-rows:1fr auto;place-items:center}.timeline-line{position:relative;align-self:end;width:2px;height:var(--height);min-height:10px;background:#3f7779}.timeline-line i{position:absolute;left:0;top:0;width:12px;height:18px;margin:-4px 0 0 -5px;border-radius:7px 7px 9px 9px;background:linear-gradient(#ff785a 0 35%,var(--lime) 35%);box-shadow:0 0 0 4px #c9f45b18}.timeline-slot strong{position:absolute;top:2px;font:400 14px Georgia,serif;color:#365c5e}.timeline-slot small{position:absolute;bottom:-20px;color:#71817e;font-size:8px;white-space:nowrap}.activity-timeline+*{margin-top:42px} +.timeline-chart{height:145px;display:grid;grid-template-columns:repeat(6,1fr);gap:8px;margin-top:18px;padding-top:8px;border-bottom:1px solid #a9bbb1;background:repeating-linear-gradient(to top,transparent 0,transparent 35px,#dce5de 36px)}.timeline-slot{position:relative;display:grid;grid-template-rows:1fr auto;place-items:center}.timeline-line{position:relative;align-self:end;width:2px;height:var(--meter-level);min-height:10px;background:#3f7779}.timeline-line i{position:absolute;left:0;top:0;width:12px;height:18px;margin:-4px 0 0 -5px;border-radius:7px 7px 9px 9px;background:linear-gradient(#ff785a 0 35%,var(--lime) 35%);box-shadow:0 0 0 4px #c9f45b18}.timeline-slot strong{position:absolute;top:2px;font:400 14px Georgia,serif;color:#365c5e}.timeline-slot small{position:absolute;bottom:-20px;color:#71817e;font-size:8px;white-space:nowrap}.activity-timeline+*{margin-top:42px} @media(max-width:720px){.activity-timeline{width:calc(100% - 28px);padding:20px 15px}.activity-timeline header{display:block}.activity-timeline header p{margin-top:6px}.timeline-slot small{font-size:7px}} diff --git a/apps/web/src/styles/global.css b/apps/web/src/styles/global.css index c060384..35e4f61 100644 --- a/apps/web/src/styles/global.css +++ b/apps/web/src/styles/global.css @@ -31,7 +31,8 @@ footer{min-height:118px;background:var(--deep);color:#dbe4df;padding:28px max(32 /* RF4 field-guide iconography and float activity gauge */ .fish-icon{display:inline-block;flex:none;vertical-align:-.2em}.brand-mark .fish-icon{transform:rotate(-12deg)}.topbar nav a.active .fish-icon{color:#6b9229}.spot-meta span{display:flex;align-items:center;gap:4px}.bait-line>.fish-icon,.best-lure .fish-icon{color:#e19a43;transform:rotate(14deg)}.card-arrow{font-size:0}.detail-head>a{transition:background .2s ease,color .2s ease}.detail-head>a:hover{background:var(--lime);color:var(--deep)} -.float-gauge{width:122px;height:142px;position:relative;display:flex;flex-direction:column;align-items:center;justify-content:flex-end;padding-bottom:8px;isolation:isolate}.float-gauge:before,.float-gauge:after{content:"";position:absolute;left:3px;right:3px;border:1px solid #ffffff1d;border-radius:50%}.float-gauge:before{height:28px;bottom:25px;box-shadow:0 0 0 10px #ffffff08}.float-gauge:after{height:15px;bottom:32px}.float-gauge__line{position:absolute;left:50%;top:0;width:1px;height:105px;background:linear-gradient(#ffffff55,#ffffff12);z-index:-1}.float-gauge__water{position:absolute;left:11px;right:11px;bottom:39px;height:calc(var(--level)*.62);max-height:62px;background:linear-gradient(180deg,#c9f45b08,#c9f45b36);border-top:1px solid var(--lime);clip-path:polygon(0 6px,18% 0,36% 6px,54% 0,72% 6px,90% 0,100% 4px,100% 100%,0 100%)}.float-gauge__bob{position:absolute;left:50%;bottom:calc(39px + var(--level)*.62);width:18px;height:54px;transform:translate(-50%,50%);border:1px solid #e6f8ad;border-radius:50% 50% 42% 42%;background:linear-gradient(180deg,var(--lime) 0 35%,#f4f7ed 36% 58%,#e47e3b 59%);box-shadow:0 5px 18px #c9f45b38}.float-gauge__bob i{position:absolute;left:50%;top:-24px;width:1px;height:25px;background:#dfe9dc}.float-gauge strong{position:relative;z-index:2;font:400 31px/1 Georgia,serif;text-shadow:0 2px 8px var(--deep)}.float-gauge small{position:relative;z-index:2;color:#a9b8b5;font-size:9px;text-transform:uppercase}.best-lure>div{grid-template-columns:25px 1fr auto} +.float-gauge{width:122px;height:142px;position:relative;display:flex;flex-direction:column;align-items:center;justify-content:flex-end;padding-bottom:8px;isolation:isolate}.float-gauge:before,.float-gauge:after{content:"";position:absolute;left:3px;right:3px;border:1px solid #ffffff1d;border-radius:50%}.float-gauge:before{height:28px;bottom:25px;box-shadow:0 0 0 10px #ffffff08}.float-gauge:after{height:15px;bottom:32px}.float-gauge__line{position:absolute;left:50%;top:0;width:1px;height:105px;background:linear-gradient(#ffffff55,#ffffff12);z-index:-1}.float-gauge__water{position:absolute;left:11px;right:11px;bottom:39px;height:calc(var(--meter-level)*.62);max-height:62px;background:linear-gradient(180deg,#c9f45b08,#c9f45b36);border-top:1px solid var(--lime);clip-path:polygon(0 6px,18% 0,36% 6px,54% 0,72% 6px,90% 0,100% 4px,100% 100%,0 100%)}.float-gauge__bob{position:absolute;left:50%;bottom:calc(39px + var(--meter-level)*.62);width:18px;height:54px;transform:translate(-50%,50%);border:1px solid #e6f8ad;border-radius:50% 50% 42% 42%;background:linear-gradient(180deg,var(--lime) 0 35%,#f4f7ed 36% 58%,#e47e3b 59%);box-shadow:0 5px 18px #c9f45b38}.float-gauge__bob i{position:absolute;left:50%;top:-24px;width:1px;height:25px;background:#dfe9dc}.float-gauge strong{position:relative;z-index:2;font:400 31px/1 Georgia,serif;text-shadow:0 2px 8px var(--deep)}.float-gauge small{position:relative;z-index:2;color:#a9b8b5;font-size:9px;text-transform:uppercase}.best-lure>div{grid-template-columns:25px 1fr auto} +.meter-level-0{--meter-level:0%}.meter-level-5{--meter-level:5%}.meter-level-10{--meter-level:10%}.meter-level-15{--meter-level:15%}.meter-level-20{--meter-level:20%}.meter-level-25{--meter-level:25%}.meter-level-30{--meter-level:30%}.meter-level-35{--meter-level:35%}.meter-level-40{--meter-level:40%}.meter-level-45{--meter-level:45%}.meter-level-50{--meter-level:50%}.meter-level-55{--meter-level:55%}.meter-level-60{--meter-level:60%}.meter-level-65{--meter-level:65%}.meter-level-70{--meter-level:70%}.meter-level-75{--meter-level:75%}.meter-level-80{--meter-level:80%}.meter-level-85{--meter-level:85%}.meter-level-90{--meter-level:90%}.meter-level-95{--meter-level:95%}.meter-level-100{--meter-level:100%} @media(max-width:720px){.float-gauge{width:100px}} @media(prefers-reduced-motion:no-preference){.pulse-orb span{animation:ripple-pulse var(--motion-signal) ease-out infinite}.float-gauge__bob{animation:bob calc(var(--motion-signal) + 400ms) ease-in-out infinite}}@keyframes ripple-pulse{50%{box-shadow:0 0 0 15px #c9f45b08}}@keyframes bob{50%{translate:0 -3px}} .report-form fieldset{border:0;padding:0;margin:0}.report-form legend{width:100%;margin-bottom:18px;font:400 24px Georgia,serif}.report-form legend span,.optional-fields summary span{float:right;font:11px Inter,sans-serif;text-transform:uppercase;letter-spacing:.08em;color:#7c8c89}.field-help{color:#657572;font-size:12px}.optional-fields{margin:26px 0;border-block:1px solid #d6dfd7;padding:18px 0}.optional-fields summary{cursor:pointer;font:400 20px Georgia,serif}.optional-fields[open] summary{margin-bottom:20px}.report-form input:user-invalid,.report-form select:user-invalid{border-color:#b84c3c} diff --git a/deploy/Caddyfile b/deploy/Caddyfile index 52cc169..47aef19 100644 --- a/deploy/Caddyfile +++ b/deploy/Caddyfile @@ -52,7 +52,7 @@ Permissions-Policy "camera=(), microphone=(), geolocation=()" X-Frame-Options "DENY" Cross-Origin-Opener-Policy "same-origin" - 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'; style-src-attr 'unsafe-inline'; upgrade-insecure-requests" + 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'; style-src-attr 'none'; upgrade-insecure-requests" } } diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index bd654ab..e9a8875 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -54,7 +54,7 @@ - [x] **Q11 · Декомпозиция API.** Catalog, activity/spots, records/community/status/import-history, submissions и весь admin API вынесены в отдельные `APIRouter`. `main.py` оставляет composition root, middleware, health/readiness и временные совместимые экспорты rate-limit для тестового контракта; URL и OpenAPI сохранены. - [ ] **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. -- [ ] **Q14 · Полная CSP — в работе.** Production ограничивает default/connect/form/font/media/manifest текущим доменом, изображения — self/data/`FILES_DOMAIN`, запрещает inline event handlers, eval, wildcard и HTTP. Page scripts переведены в Astro client modules, конфигурация формы передаётся через `data-*`, а `assetsInlineLimit: 0` гарантирует внешние `_astro`-ассеты для малых scripts/scoped styles. `unsafe-inline` удалён из `style-src`; остаются динамический JSON-LD в `script-src` и два вычисляемых CSS variable в `style-src-attr`. Далее внедрить nonce/hash и убрать оба исключения с bootstrap-проверкой report/admin/OG/screenshots. +- [ ] **Q14 · Полная CSP — в работе.** Production ограничивает default/connect/form/font/media/manifest текущим доменом, изображения — self/data/`FILES_DOMAIN`, запрещает inline handlers, eval, wildcard и HTTP. Page scripts и scoped styles гарантированно выпускаются внешними `_astro`-ассетами; динамические шкалы переведены на CSS-классы. `style-src` теперь только `'self'`, `style-src-attr` и `script-src-attr` — `'none'`. Остаётся внедрить nonce/hash для динамического JSON-LD, убрать последнее `unsafe-inline` из `script-src` и выполнить bootstrap-проверку report/admin/OG/screenshots. - [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] **Q17 · Эксплуатационные документы.** Зафиксированы ADR по Astro/FastAPI/PostgreSQL, локальному cache, scheduler/cooldown и разделению PostgreSQL/MinIO. Incident runbook покрывает заполнение диска, отказ PostgreSQL/MinIO, зависшие импорты, ошибки миграций, компрометацию секретов и критерии закрытия без опасных reset/recreate операций. diff --git a/docs/csp-inventory.md b/docs/csp-inventory.md index 49ea2ee..951c6d3 100644 --- a/docs/csp-inventory.md +++ b/docs/csp-inventory.md @@ -5,14 +5,13 @@ ## Временные inline-зависимости - динамический JSON-LD в `Layout.astro` остаётся единственным намеренно inline script; -- вычисляемые CSS custom properties для шкалы активности и timeline. -Vite собирает даже малые page scripts и scoped Astro styles в same-origin `_astro` assets (`assetsInlineLimit: 0`). Поэтому `style-src` уже ограничен `'self'`; временный `unsafe-inline` остаётся только для динамического JSON-LD в `script-src` и вычисляемых style attributes. Inline event handlers запрещены `script-src-attr 'none'`; `unsafe-eval`, wildcard, HTTP и сторонние connect/script origin отсутствуют. +Vite собирает даже малые page scripts и scoped Astro styles в same-origin `_astro` assets (`assetsInlineLimit: 0`). Динамические шкалы используют дискретные CSS-классы вместо style attributes, поэтому `style-src` ограничен `'self'`, а `style-src-attr` — `'none'`. Временный `unsafe-inline` остаётся только для динамического JSON-LD в `script-src`. Inline event handlers запрещены `script-src-attr 'none'`; `unsafe-eval`, wildcard, HTTP и сторонние connect/script origin отсутствуют. ## Путь к nonce/hash 1. Page scripts перенесены в собираемые клиентские модули; конфигурация формы передаётся через безопасный `data-state`. Нулевой inline limit запрещает Vite встраивать малые модули обратно в HTML. 2. JSON-LD либо хэшировать на уровне SSR-заголовка, либо выдавать nonce из web-runtime; статический nonce запрещён. -3. Scoped styles уже находятся в скомпилированных `_astro` assets, и `unsafe-inline` удалён из `style-src`; вычисляемые значения заменить классами/`data-*`. -4. Удалить `unsafe-inline` из `script-src` после решения JSON-LD; `style-src-attr` убрать последним. +3. Scoped styles находятся в скомпилированных `_astro` assets, вычисляемые шкалы используют классы с шагом 5%; inline-разрешения для styles и attributes удалены. +4. Удалить последнее `unsafe-inline` из `script-src` после решения JSON-LD. 5. Проверить report, spots, admin, JSON-LD и signed screenshots в production bootstrap. Не добавлять `blob:`, `*` или произвольные CDN для устранения ошибок.