diff --git a/apps/web/src/components/SourceBadge.astro b/apps/web/src/components/SourceBadge.astro index 44e8e9d..882cf40 100644 --- a/apps/web/src/components/SourceBadge.astro +++ b/apps/web/src/components/SourceBadge.astro @@ -1,4 +1,5 @@ --- +import { safeHttpUrl } from "../lib/urls"; const { source, href, tone = "source" } = Astro.props as { source: string; href?: string | null; tone?: "source" | "incomplete" | "verified" }; const labels: Record = { "rf4-official": "RF4 · официальный", @@ -12,5 +13,6 @@ const labels: Record = { }; const label = labels[source] ?? source; const mark = source === "players" ? "♟" : source === "rf4-official" ? "★" : "↗"; +const safeHref = safeHttpUrl(href); --- -{href ? {mark}{label} : {mark}{label}} +{safeHref ? {mark}{label} : {mark}{label}} diff --git a/apps/web/src/lib/urls.ts b/apps/web/src/lib/urls.ts new file mode 100644 index 0000000..ab65de4 --- /dev/null +++ b/apps/web/src/lib/urls.ts @@ -0,0 +1,10 @@ +export function safeHttpUrl(value: string | null | undefined): string | null { + if (!value) return null; + try { + const url = new URL(value); + if ((url.protocol !== "http:" && url.protocol !== "https:") || url.username || url.password || !url.hostname) return null; + return url.href; + } catch { + return null; + } +} diff --git a/apps/web/src/pages/tackle/items/[id].astro b/apps/web/src/pages/tackle/items/[id].astro index 0ce3941..052dd01 100644 --- a/apps/web/src/pages/tackle/items/[id].astro +++ b/apps/web/src/pages/tackle/items/[id].astro @@ -6,6 +6,7 @@ import PageHero from "../../../components/PageHero.astro"; import StatePanel from "../../../components/StatePanel.astro"; import TackleGlyph from "../../../components/TackleGlyph.astro"; import { ApiError, api, type TackleItem } from "../../../lib/api"; +import { safeHttpUrl } from "../../../lib/urls"; const { id } = Astro.params; let item: TackleItem | undefined; @@ -25,6 +26,7 @@ if (unavailable) { const categoryLabels: Record = { bait: "Наживка", lure: "Приманка", rod: "Удилище", reel: "Катушка", line: "Леска", hook: "Крючок", rig: "Монтаж", float: "Поплавок", sinker: "Груз", other: "Другое" }; const missingLabels: Record = { subcategory: "подкатегория", brand: "бренд", family: "семейство", unlock_level: "уровень открытия", source_url: "ссылка на источник", source_checked_at: "дата проверки" }; const missing = item?.missing_fields.map((field) => missingLabels[field] ?? field) ?? []; +const sourceHref = safeHttpUrl(item?.source_url); --- @@ -32,7 +34,7 @@ const missing = item?.missing_fields.map((field) => missingLabels[field] ?? fiel {unavailable ? : !item ? : <>
{categoryLabels[item.category] ?? item.category}

{item.name}

{[item.brand, item.family].filter(Boolean).join(" · ") || "Бренд и семейство не указаны."}

-
Подкатегория
{item.subcategory ?? "Не указана"}
Уровень открытия
{item.unlock_level ?? "Не указан"}
Источник
{item.source_system ?? "Не указан"}
{missing.length > 0 &&

Не хватает: {missing.join(", ")}.

}{item.source_url && Открыть первоисточник}
+
Подкатегория
{item.subcategory ?? "Не указана"}
Уровень открытия
{item.unlock_level ?? "Не указан"}
Источник
{item.source_system ?? "Не указан"}
{missing.length > 0 &&

Не хватает: {missing.join(", ")}.

}{sourceHref && Открыть первоисточник}
} diff --git a/apps/web/src/pages/waterbodies/[slug].astro b/apps/web/src/pages/waterbodies/[slug].astro index 413f261..bafa80f 100644 --- a/apps/web/src/pages/waterbodies/[slug].astro +++ b/apps/web/src/pages/waterbodies/[slug].astro @@ -9,6 +9,7 @@ import DataPassport from "../../components/DataPassport.astro"; import Layout from "../../layouts/Layout.astro"; import { api, plural, type Activity, type DictionaryItem, type MediaAsset, type PaginatedActivity } from "../../lib/api"; import { findMediaByLabel } from "../../lib/media"; +import { safeHttpUrl } from "../../lib/urls"; const { slug } = Astro.params; let water: DictionaryItem | undefined, items: Activity[] = [], media: MediaAsset[] = [], unavailable = false; try { @@ -25,13 +26,14 @@ if (unavailable) { if (!water && !unavailable) Astro.response.status = 404; const fishes = [...new Map(items.map(item => [item.fish_slug, item.fish])).entries()]; const image = water ? findMediaByLabel(media, water.name_ru, ["waterbody_cover", "waterbody_map", "waterbody_depth_map"]) : undefined; +const sourceHref = safeHttpUrl(water?.source_url); const schema = water ? { "@context":"https://schema.org", "@type":"CollectionPage", name:`Что ловить на ${water.name_ru} в RF4`, url:`https://rf4spotter.ru/waterbodies/${water.slug}` } : null; --- {image &&
Карта и образ водоёма

{water!.name_ru}

Материал показан с прямой атрибуцией. Координаты активных точек ниже относятся к данным наблюдений, а не к геометрии изображения.

} - {water && (water.description || water.unlock_level !== undefined || water.source_url || water.source_fish_species) &&
Канонические сведения

{water.name_ru}

{water.description &&

{water.description}

}{water.unlock_level !== undefined && water.unlock_level !== null &&

Открывается с уровня: {water.unlock_level}

}{water.source_fish_species?.length &&

В карточке источника указано видов рыб: {water.source_fish_species.length}.

}{water.source_aliases?.length &&

Алиасы источника: {water.source_aliases.join(", ")}.

}
Источник{water.source_url ?

{water.source_system ?? "Внешний источник"} · исходная карточка

:

Источник для описания ещё не подтверждён.

}{water.source_point_urls?.length &&

Сохранено ссылок на точки: {water.source_point_urls.length}.

}{water.source_image_urls?.length &&

Изображений-кандидатов: {water.source_image_urls.length}; публикация требует отдельной проверки.

}{water.source_checked_at &&

Проверено: {new Date(water.source_checked_at).toLocaleDateString("ru-RU")}

}
} + {water && (water.description || water.unlock_level !== undefined || water.source_url || water.source_fish_species) &&
Канонические сведения

{water.name_ru}

{water.description &&

{water.description}

}{water.unlock_level !== undefined && water.unlock_level !== null &&

Открывается с уровня: {water.unlock_level}

}{water.source_fish_species?.length &&

В карточке источника указано видов рыб: {water.source_fish_species.length}.

}{water.source_aliases?.length &&

Алиасы источника: {water.source_aliases.join(", ")}.

}
Источник{sourceHref ?

{water.source_system ?? "Внешний источник"} · исходная карточка

:

Источник для описания ещё не подтверждён.

}{water.source_point_urls?.length &&

Сохранено ссылок на точки: {water.source_point_urls.length}.

}{water.source_image_urls?.length &&

Изображений-кандидатов: {water.source_image_urls.length}; публикация требует отдельной проверки.

}{water.source_checked_at &&

Проверено: {new Date(water.source_checked_at).toLocaleDateString("ru-RU")}

}
} {water && } {unavailable ? : !water ? :
{items.length ? items.map(item => ) : }
}
diff --git a/apps/web/tests/unit/presentation.test.ts b/apps/web/tests/unit/presentation.test.ts index 477d313..23facb4 100644 --- a/apps/web/tests/unit/presentation.test.ts +++ b/apps/web/tests/unit/presentation.test.ts @@ -8,6 +8,7 @@ import { waterbodyVisual } from "../../src/lib/waterbody-visuals.ts"; import { pageHref, pageWindow } from "../../src/lib/pagination.ts"; import { adminEndsSession, adminErrorMessage } from "../../src/lib/admin-errors.ts"; import { findMediaByLabel, mediaRoleLabel } from "../../src/lib/media.ts"; +import { safeHttpUrl } from "../../src/lib/urls.ts"; test("activity levels share one complete 0-100 scale", () => { assert.deepEqual( @@ -46,6 +47,14 @@ test("coordinate precision keeps all four public states explicit", () => { assert.equal(coordinatePrecisionLabel("future"), "не указаны"); }); +test("provenance links allow only credential-free HTTP(S) URLs", () => { + assert.equal(safeHttpUrl("https://rf4db.com/ru/maps"), "https://rf4db.com/ru/maps"); + assert.equal(safeHttpUrl("http://localhost:8000/source"), "http://localhost:8000/source"); + assert.equal(safeHttpUrl("javascript:alert(1)"), null); + assert.equal(safeHttpUrl("https://user:secret@rf4db.com/source"), null); + assert.equal(safeHttpUrl("not a url"), null); +}); + test("source status makes a blocked provider explicit", () => { assert.equal(sourceStatusLabel("temporarily_limited"), "Источник временно ограничен"); assert.equal(sourceStatusLabel("healthy"), "Актуален"); diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 4f227a5..025b268 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -94,7 +94,7 @@ - [ ] **U01 · UX-контракт и scorecard.** Контракт маршрутов, смысловой порядок ответа, словарь статусов и целевые метрики зафиксированы в [ux-contract.md](ux-contract.md); для home, spot, waterbody, plan и tackle описаны первый ответ и обязательное объяснение. Сквозной Playwright task journey проверяет home → spot → «Что взять» → plan и возврат query-контекста, включая source/evidence-поля первой карточки; ручной сценарий и критерии зафиксированы в [ux-task-review-2026-09-21.md](ux-task-review-2026-09-21.md). Осталось провести review с участниками и собрать completion rate на пилоте. - [ ] **U02 · Главный сценарий «рыба → водоём → точка → снасть».** Главная сохраняет рыбу, водоём, период и сортировку в shareable URL, явно показывает контекст запроса и даёт текстовый CTA «Открыть точку» на каждой карточке, включая mobile; первый экран ограничен пятью вариантами, а остальные доступны через сохраняющую query-контекст серверную пагинацию; empty-state предлагает вернуться к полному набору данных. Осталось добавить режим map. Acceptance: первый полезный вариант виден без регистрации, back/refresh сохраняют контекст, mobile не теряет фильтры. -- [ ] **U03 · Evidence/trust card.** Общий evidence-контракт используется на activity-карточках, detail точки, водоёма и карточках снастей: freshness с текстовым `Свежо`/`Устарело`, явный период расчёта, completeness, confidence/статус, source badges и доступные доменные поля; при выборке меньше 3 явно показано отдельное состояние `Недостаточно данных`, ограниченный источник не смешивается с неполными полями, а явно переданные provenance-конфликты видны текстом. Targeted E2E и API-тест проверяют публичные пути, период, 48-часовой порог и конфликт источников; единый `passportDisplayStatus` дополнительно закрепляет приоритет `conflict` → `stale` → исходный статус unit-регрессией. +- [ ] **U03 · Evidence/trust card.** Общий evidence-контракт используется на activity-карточках, detail точки, водоёма и карточках снастей: freshness с текстовым `Свежо`/`Устарело`, явный период расчёта, completeness, confidence/статус, source badges и доступные доменные поля; при выборке меньше 3 явно показано отдельное состояние `Недостаточно данных`, ограниченный источник не смешивается с неполными полями, а явно переданные provenance-конфликты видны текстом. Targeted E2E и API-тест проверяют публичные пути, период, 48-часовой порог и конфликт источников; единый `passportDisplayStatus` дополнительно закрепляет приоритет `conflict` → `stale` → исходный статус unit-регрессией. Все публичные provenance links проходят credential-free HTTP(S) allowlist. - [ ] **U04 · List/map и progressive disclosure.** List остаётся честным базовым режимом: фильтры, сортировка, URL-состояние и evidence-карточки уже работают без имитации координатной карты. Следующий шаг — единый list/map-контракт после подтверждения геометрии; на mobile карта должна открываться отдельным действием. Вторичные raw/provenance-поля не исчезают и раскрываются по запросу. - [ ] **U05 · Mobile-first и сохранённый план рыбалки.** `/plan` поддерживает список до 5 уникальных локальных вариантов, удаление, очистку, переход к точке, print/PDF и восстановление из shareable URL; кнопка «Поделиться планом» использует native share или clipboard fallback и явно сообщает, если план пуст или share-ссылка повреждена. Detail-кнопка сохраняет данные с `aria-pressed` и восстанавливается после reload. Print/mobile-контракт проверяет 320/390 px, лимит импорта и отсутствие горизонтального overflow; импорт дедуплицирует точки, нормализует длины локальных текстовых полей, принимает confidence только в диапазоне `0..100` и не теряет реальное значение `0`, а карточка плана сравнивает подтверждённые метод/проводку и явно показывает риск по выборке/уверенности. - [ ] **U06 · Контентная и визуальная иерархия.** Для detail точки действие «Что взять» выделено отдельным заголовком, provenance и качество собраны в общем паспорте данных, а подтверждённые метод/проводка видны рядом с последними уловами; статусы дополнительно передаются текстом, малая выборка не маркируется как готовая рекомендация, а дублирующие catches/players/confidence убраны из вторичной колонки activity-карточки. Mobile route-contract проверяет первый ответ и действие на home, spot, waterbody, plan и tackle, а task regression требует source/evidence-поля; остаётся ручной review визуальных деталей по [ux-task-review-2026-09-21.md](ux-task-review-2026-09-21.md).