From 3cb30c3f975e8dfb87a3e7105a650c509d93b263 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 5 Nov 2026 16:05:02 -0800 Subject: [PATCH] fix(wildlife): point the Acartia card link only at acartia.io The card's "Acartia" link was built from the contributor-supplied photo_url, so any https site could appear labelled as Acartia. Acartia has no documented per-sighting page, so the source link is now always the Acartia site, on the server projection and again in the card. The separate "Open on Acartia" fallback link is no longer needed. --- docs/CURRENT-STATE.md | 3 +- src/i18n/locales/ar.json | 1 - src/i18n/locales/de.json | 1 - src/i18n/locales/es.json | 1 - src/i18n/locales/fr.json | 1 - src/i18n/locales/it.json | 1 - src/i18n/locales/ja.json | 1 - src/i18n/locales/pt.json | 1 - src/i18n/locales/ru.json | 1 - src/i18n/locales/uk.json | 1 - src/i18n/locales/zh-CN.json | 1 - src/i18n/locales/zh-Hant.json | 1 - src/layers/wildlifeSightings/records.js | 17 +++-------- src/layers/wildlifeSightings/records.test.mjs | 8 +++-- src/ui/views/panels/WildlifeSightingCard.vue | 5 ++-- src/ui/wildlifeSightingCard.test.mjs | 30 +++++++++++++++++++ 16 files changed, 45 insertions(+), 29 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index b615bb70d..fbbaa19b0 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -174,7 +174,8 @@ compact anchored photo card. Selection stays pinned across camera, filter and sample changes until explicit deselection or layer disable. Its medium image uses `object-fit: contain`, with names, compact observed age and creator/licence. The card is the single source link to iNaturalist. Obscured sightings also stay -text-only; Acartia stays text-only with provider credit and whale guidance. +text-only; Acartia stays text-only with provider credit and whale guidance, +and its source link is always `https://acartia.io/`, never a contributed URL. Placement avoids actual HUD, toolbar and rail rectangles, panning only when necessary while preserving zoom. Escape, close or an outside click closes the card and clears selection. Camera/layout changes reposition the card without diff --git a/src/i18n/locales/ar.json b/src/i18n/locales/ar.json index da291e035..b491c3c93 100644 --- a/src/i18n/locales/ar.json +++ b/src/i18n/locales/ar.json @@ -3453,7 +3453,6 @@ "Loading photo...": "جارٍ تحميل الصورة…", "Photo unavailable": "الصورة غير متاحة", "Location obscured to a 0.2° cell": "الموقع مخفي ضمن خلية بمقدار 0.2°", - "Open on Acartia ↗": "فتح على Acartia ↗", "Photo of {name}": "صورة {name}", "Acartia": "Acartia", "CC BY 4.0": "CC BY 4.0", diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index 69b039fc5..d01ff292f 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -3153,7 +3153,6 @@ "Loading photo...": "Foto wird geladen…", "Photo unavailable": "Foto nicht verfügbar", "Location obscured to a 0.2° cell": "Standort auf eine 0,2°-Rasterzelle verschleiert", - "Open on Acartia ↗": "Auf Acartia öffnen ↗", "Photo of {name}": "Foto von {name}", "Acartia": "Acartia", "CC BY 4.0": "CC BY 4.0", diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index a34a9bf39..aa403f743 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -3228,7 +3228,6 @@ "Loading photo...": "Cargando foto…", "Photo unavailable": "Foto no disponible", "Location obscured to a 0.2° cell": "Ubicación oculta en una celda de 0,2°", - "Open on Acartia ↗": "Abrir en Acartia ↗", "Photo of {name}": "Foto de {name}", "Acartia": "Acartia", "CC BY 4.0": "CC BY 4.0", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index a09806cfa..b3ae72189 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -3228,7 +3228,6 @@ "Loading photo...": "Chargement de la photo…", "Photo unavailable": "Photo indisponible", "Location obscured to a 0.2° cell": "Localisation masquée dans une cellule de 0,2°", - "Open on Acartia ↗": "Ouvrir sur Acartia ↗", "Photo of {name}": "Photo de {name}", "Acartia": "Acartia", "CC BY 4.0": "CC BY 4.0", diff --git a/src/i18n/locales/it.json b/src/i18n/locales/it.json index b800b010c..4c69e1d4c 100644 --- a/src/i18n/locales/it.json +++ b/src/i18n/locales/it.json @@ -3228,7 +3228,6 @@ "Loading photo...": "Caricamento della foto…", "Photo unavailable": "Foto non disponibile", "Location obscured to a 0.2° cell": "Posizione oscurata in una cella di 0,2°", - "Open on Acartia ↗": "Apri su Acartia ↗", "Photo of {name}": "Foto di {name}", "Acartia": "Acartia", "CC BY 4.0": "CC BY 4.0", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index fbfc9dd96..9a053f869 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -3078,7 +3078,6 @@ "Loading photo...": "写真を読み込み中…", "Photo unavailable": "写真を表示できません", "Location obscured to a 0.2° cell": "位置は0.2°のセル内に秘匿されています", - "Open on Acartia ↗": "Acartiaで開く ↗", "Photo of {name}": "{name}の写真", "Acartia": "Acartia", "CC BY 4.0": "CC BY 4.0", diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index a57f4eeaf..79c507759 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -3228,7 +3228,6 @@ "Loading photo...": "Carregando foto…", "Photo unavailable": "Foto indisponível", "Location obscured to a 0.2° cell": "Localização ocultada em uma célula de 0,2°", - "Open on Acartia ↗": "Abrir no Acartia ↗", "Photo of {name}": "Foto de {name}", "Acartia": "Acartia", "CC BY 4.0": "CC BY 4.0", diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 2946ba482..11fddb3e9 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -3303,7 +3303,6 @@ "Loading photo...": "Загрузка фото…", "Photo unavailable": "Фото недоступно", "Location obscured to a 0.2° cell": "Местоположение скрыто в ячейке 0,2°", - "Open on Acartia ↗": "Открыть на Acartia ↗", "Photo of {name}": "Фото {name}", "Acartia": "Acartia", "CC BY 4.0": "CC BY 4.0", diff --git a/src/i18n/locales/uk.json b/src/i18n/locales/uk.json index 7f6c8c3e2..c0fab8533 100644 --- a/src/i18n/locales/uk.json +++ b/src/i18n/locales/uk.json @@ -3303,7 +3303,6 @@ "Loading photo...": "Завантаження фото…", "Photo unavailable": "Фото недоступне", "Location obscured to a 0.2° cell": "Місцеположення приховано в комірці 0,2°", - "Open on Acartia ↗": "Відкрити на Acartia ↗", "Photo of {name}": "Фото {name}", "Acartia": "Acartia", "CC BY 4.0": "CC BY 4.0", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 28854ce5d..8687af790 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -3078,7 +3078,6 @@ "Loading photo...": "正在加载照片…", "Photo unavailable": "照片不可用", "Location obscured to a 0.2° cell": "位置已模糊至0.2°网格", - "Open on Acartia ↗": "在Acartia上打开 ↗", "Photo of {name}": "{name}的照片", "Acartia": "Acartia", "CC BY 4.0": "CC BY 4.0", diff --git a/src/i18n/locales/zh-Hant.json b/src/i18n/locales/zh-Hant.json index 63088293a..a95e71dd5 100644 --- a/src/i18n/locales/zh-Hant.json +++ b/src/i18n/locales/zh-Hant.json @@ -3078,7 +3078,6 @@ "Loading photo...": "正在載入照片…", "Photo unavailable": "照片無法使用", "Location obscured to a 0.2° cell": "位置已模糊至0.2°網格", - "Open on Acartia ↗": "在Acartia上開啟 ↗", "Photo of {name}": "{name}的照片", "Acartia": "Acartia", "CC BY 4.0": "CC BY 4.0", diff --git a/src/layers/wildlifeSightings/records.js b/src/layers/wildlifeSightings/records.js index a1e50f52c..1af1cc296 100644 --- a/src/layers/wildlifeSightings/records.js +++ b/src/layers/wildlifeSightings/records.js @@ -247,18 +247,9 @@ export function normalizeInatObservations( return rows; } -function acartiaLink(value) { - if (typeof value !== 'string' || value.length > 500) - return 'https://acartia.io'; - try { - const url = new URL(value); - return url.protocol === 'https:' && !url.username && !url.password - ? url.href - : 'https://acartia.io'; - } catch { - return 'https://acartia.io'; - } -} +// Acartia has no documented per-sighting page, and photo_url is supplied by the +// contributor. The source link is therefore always the Acartia site itself. +export const ACARTIA_SITE = 'https://acartia.io/'; /** Acartia documents flat rows only. Name and entity are text fields; unknown * shapes are skipped rather than guessing nested provider data. */ @@ -315,7 +306,7 @@ export function normalizeAcartiaSightings(payload, nowMs) { cetacean: speciesGroup === 'cetacean', threatened: false, provider: { name, entity: cleanText(raw.data_source_entity, 60) }, - link: acartiaLink(raw.photo_url), + link: ACARTIA_SITE, photo: null, favesCount: 0, identificationAgreements: 0, diff --git a/src/layers/wildlifeSightings/records.test.mjs b/src/layers/wildlifeSightings/records.test.mjs index 18a1693d8..9909338a1 100644 --- a/src/layers/wildlifeSightings/records.test.mjs +++ b/src/layers/wildlifeSightings/records.test.mjs @@ -330,7 +330,7 @@ test('Acartia requires flat provider text, handles trust, and sanitizes type', ( ); }); -test('Acartia rejects invalid times/positions and unsafe photo links become the canonical page', () => { +test('Acartia rejects invalid times/positions and its link is always the Acartia site', () => { for (const overrides of [ { created: new Date(now - 8 * 24 * hour - 1).toISOString() }, { created: new Date(now + 10 * 60_000 + 1).toISOString() }, @@ -341,6 +341,10 @@ test('Acartia rejects invalid times/positions and unsafe photo links become the ]) assert.deepEqual(normalizeAcartiaSightings([sighting(overrides)], now), []); for (const photo_url of [ + // A contributor-supplied URL is never labelled as the Acartia source. + 'https://provider.example/photo.jpg', + 'https://phishing.example/acartia.io/login', + 'https://acartia.io.attacker.example/', 'http://provider.example/p.jpg', 'https://user:secret@provider.example/p.jpg', 'javascript:bad()', @@ -349,7 +353,7 @@ test('Acartia rejects invalid times/positions and unsafe photo links become the ]) { assert.equal( normalizeAcartiaSightings([sighting({ photo_url })], now)[0].link, - 'https://acartia.io', + 'https://acartia.io/', ); } }); diff --git a/src/ui/views/panels/WildlifeSightingCard.vue b/src/ui/views/panels/WildlifeSightingCard.vue index fc92c7ee4..9caac8fdd 100644 --- a/src/ui/views/panels/WildlifeSightingCard.vue +++ b/src/ui/views/panels/WildlifeSightingCard.vue @@ -2,7 +2,7 @@ import { computed, shallowRef, useTemplateRef, watch } from 'vue'; import { formatDate, t, translateKnown } from '../../../i18n/index.js'; import { photoCreator, formatAge } from '../../../layers/wildlifeSightings/photoText.js'; -import { inatLicenceLabel, inatPhotoUrl, isOpenPhoto } from '../../../layers/wildlifeSightings/records.js'; +import { ACARTIA_SITE, inatLicenceLabel, inatPhotoUrl, isOpenPhoto } from '../../../layers/wildlifeSightings/records.js'; const props = defineProps({ sighting: { type: Object, required: true }, @@ -36,6 +36,8 @@ const age = computed(() => { }); const observedDate = computed(() => observedDay.value ? formatDate(Date.parse(`${observedDay.value}T00:00:00Z`), { year: 'numeric', month: 'short', day: 'numeric', timeZone: 'UTC' }) : ''); const link = computed(() => { + // The "Acartia" label only ever points at the Acartia site, never a row URL. + if (acartia.value) return ACARTIA_SITE; try { const url = new URL(props.sighting.link); return url.protocol === 'https:' && !url.username && !url.password ? url.href : null; @@ -89,7 +91,6 @@ watch([() => props.sighting.stableId, () => photo.value?.src], () => { loaded.va

- {{ t('Open on Acartia ↗') }} {{ t('Be Whale Wise') }}

diff --git a/src/ui/wildlifeSightingCard.test.mjs b/src/ui/wildlifeSightingCard.test.mjs index d7837af7f..f685c75e0 100644 --- a/src/ui/wildlifeSightingCard.test.mjs +++ b/src/ui/wildlifeSightingCard.test.mjs @@ -373,6 +373,36 @@ test('missing, restricted, obscured and Acartia photos all use the same compact }); }); +test('an Acartia card labels only the Acartia site as Acartia', async (context) => { + for (const link of [ + 'https://provider.example/photos/synthetic.jpg', + 'https://acartia.io.attacker.example/', + 'https://acartia.io/', + ]) + await context.test(link, async (context) => { + const f = fixture(context, { + ...row, + origin: 'acartia', + stableId: 'acartia:456', + provider: { name: 'Synthetic network' }, + link, + }); + await f.click(); + const card = f.cardElement(); + const hrefs = [...card.querySelectorAll('a')].map((a) => + a.getAttribute('href'), + ); + assert.deepEqual(hrefs, [ + 'https://acartia.io/', + 'https://www.bewhalewise.org', + ]); + assert.equal( + card.querySelector('.wildlife-sighting-card-source').textContent, + 'Acartia ↗', + ); + }); +}); + test('safe placements near each viewport edge exclude actual HUD, toolbar and rail rects', () => { const obstacles = [ rect(0, 0, 260, 160),