security: forbid inline style attributes
This commit is contained in:
@@ -91,7 +91,7 @@ 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. 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-артефакты.
|
Gitea Actions workflow `.gitea/workflows/ci.yml` на каждый push и pull request проверяет Python, миграции на чистой PostgreSQL, Astro build и полный Compose/Playwright-сценарий. При падении E2E сохраняются логи контейнеров и Playwright-артефакты.
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
---
|
---
|
||||||
const { buckets } = Astro.props as { buckets: { start: string; end: string; count: number }[] };
|
const { buckets } = Astro.props as { buckets: { start: string; end: string; count: number }[] };
|
||||||
const max = Math.max(1, ...buckets.map(bucket => bucket.count));
|
const max = Math.max(1, ...buckets.map(bucket => bucket.count));
|
||||||
|
const meterLevel = (count: number) => Math.round((count / max * 100) / 5) * 5;
|
||||||
---
|
---
|
||||||
<section class="activity-timeline" aria-labelledby="timeline-title">
|
<section class="activity-timeline" aria-labelledby="timeline-title">
|
||||||
<header><div><span class="overline">Последние 72 часа</span><h2 id="timeline-title">Леска активности</h2></div><p>Все одобренные записи · по времени поступления · шаг 12 часов</p></header>
|
<header><div><span class="overline">Последние 72 часа</span><h2 id="timeline-title">Леска активности</h2></div><p>Все одобренные записи · по времени поступления · шаг 12 часов</p></header>
|
||||||
<div class="timeline-chart">
|
<div class="timeline-chart">
|
||||||
{buckets.map((bucket, index) => <div class="timeline-slot"><span aria-hidden="true" class="timeline-line" style={`--height:${bucket.count / max * 100}%`}>{bucket.count > 0 && <i></i>}</span><strong>{bucket.count}</strong><small>{`${(6-index)*12}–${(5-index)*12} ч назад`}</small></div>)}
|
{buckets.map((bucket, index) => <div class="timeline-slot"><span aria-hidden="true" class:list={["timeline-line", `meter-level-${meterLevel(bucket.count)}`]}>{bucket.count > 0 && <i></i>}</span><strong>{bucket.count}</strong><small>{`${(6-index)*12}–${(5-index)*12} ч назад`}</small></div>)}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ if (!filterError && allUnavailable) {
|
|||||||
Astro.response.headers.set("Cache-Control", "no-store");
|
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 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 selectedWaterbody = waterbodies.find(item => item.slug === waterbody)?.name_ru ?? "Все водоёмы";
|
||||||
const selectedFish = fishes.find(item => item.slug === fish)?.name_ru ?? "Любая рыба";
|
const selectedFish = fishes.find(item => item.slug === fish)?.name_ru ?? "Любая рыба";
|
||||||
const periodLabel = { "6": "6 часов", "12": "12 часов", "24": "24 часа", "72": "72 часа" }[hours] ?? hours;
|
const periodLabel = { "6": "6 часов", "12": "12 часов", "24": "24 часа", "72": "72 часа" }[hours] ?? hours;
|
||||||
@@ -86,7 +87,7 @@ const datasetJsonLd = {
|
|||||||
</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> : 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>
|
<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:list={["float-gauge", `meter-level-${leaderMeter}`]} 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>}
|
{signalsUnavailable && <div class="content-grid"><StatePanel tone="unavailable" title="Полевые сигналы временно недоступны" description="Горячие точки и справочники продолжают работать независимо." /></div>}
|
||||||
|
|||||||
@@ -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}
|
.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}}
|
@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}}
|
||||||
|
|||||||
@@ -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 */
|
/* 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)}
|
.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(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}}
|
@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}
|
.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}
|
||||||
|
|||||||
+1
-1
@@ -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 "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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -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 сохранены.
|
- [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.
|
- [ ] **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 — в работе.** 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] **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 операций.
|
||||||
|
|||||||
@@ -5,14 +5,13 @@
|
|||||||
## Временные inline-зависимости
|
## Временные inline-зависимости
|
||||||
|
|
||||||
- динамический JSON-LD в `Layout.astro` остаётся единственным намеренно inline script;
|
- динамический 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
|
## Путь к nonce/hash
|
||||||
|
|
||||||
1. Page scripts перенесены в собираемые клиентские модули; конфигурация формы передаётся через безопасный `data-state`. Нулевой inline limit запрещает Vite встраивать малые модули обратно в HTML.
|
1. Page scripts перенесены в собираемые клиентские модули; конфигурация формы передаётся через безопасный `data-state`. Нулевой inline limit запрещает Vite встраивать малые модули обратно в HTML.
|
||||||
2. JSON-LD либо хэшировать на уровне SSR-заголовка, либо выдавать nonce из web-runtime; статический nonce запрещён.
|
2. JSON-LD либо хэшировать на уровне SSR-заголовка, либо выдавать nonce из web-runtime; статический nonce запрещён.
|
||||||
3. Scoped styles уже находятся в скомпилированных `_astro` assets, и `unsafe-inline` удалён из `style-src`; вычисляемые значения заменить классами/`data-*`.
|
3. Scoped styles находятся в скомпилированных `_astro` assets, вычисляемые шкалы используют классы с шагом 5%; inline-разрешения для styles и attributes удалены.
|
||||||
4. Удалить `unsafe-inline` из `script-src` после решения JSON-LD; `style-src-attr` убрать последним.
|
4. Удалить последнее `unsafe-inline` из `script-src` после решения JSON-LD.
|
||||||
5. Проверить report, spots, admin, JSON-LD и signed screenshots в production bootstrap. Не добавлять `blob:`, `*` или произвольные CDN для устранения ошибок.
|
5. Проверить report, spots, admin, JSON-LD и signed screenshots в production bootstrap. Не добавлять `blob:`, `*` или произвольные CDN для устранения ошибок.
|
||||||
|
|||||||
Reference in New Issue
Block a user