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.
This commit is contained in:
Sameh Khamis committed 2026-11-05 16:05:02 -08:00
1 parent 82138478d0
commit 3cb30c3f97
16 files changed
+45 -29

No files matched your search

+2 -1
View File
@@ -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
-1
View File
@@ -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",
-1
View File
@@ -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",
-1
View File
@@ -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",
-1
View File
@@ -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",
-1
View File
@@ -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",
-1
View File
@@ -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",
-1
View File
@@ -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",
-1
View File
@@ -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",
-1
View File
@@ -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",
-1
View File
@@ -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",
-1
View File
@@ -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",
+4 -13
View File
@@ -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,
@@ -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:[email protected]/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/',
);
}
});
+3 -2
View File
@@ -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
</div>
</component>
<p v-if="acartia || taxa === 'whales'" class="wildlife-sighting-card-guidance">
<a v-if="acartia && !/^https:\/\/acartia\.io\/?$/.test(link || '')" href="https://acartia.io" target="_blank" rel="noopener noreferrer">{{ t('Open on Acartia ↗') }}</a>
<a href="https://www.bewhalewise.org" target="_blank" rel="noopener noreferrer">{{ t('Be Whale Wise') }}</a>
</p>
</section>
+30
View File
@@ -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),