openvibers/openvibe.tours Commits
No broken images from share links (#20)
Changes
@@@ -81,7 +81,7 @@ function createPageRoutes(ctx) {const attractions = places.slice(0, 10).map((p) => ({'@type': 'TouristAttraction', name: p.name, url: `${base()}/place/${p.id}`,geo: { '@type': 'GeoCoordinates', latitude: p.lat, longitude: p.lon },-- ...(p.description ? { description: p.description } : {}), ...(p.image_url ? { image: p.image_url } : {}),++ ...(p.description ? { description: p.description } : {}), ...(views.safeImage(p.image_url) ? { image: p.image_url } : {}),}));const tripLd = routes.slice(0, 3).map((rt) => ({'@type': 'TouristTrip', name: rt.title, url: `${base()}/routes/${rt.id}`, description: rt.summary || undefined,@@@ -117,7 +117,7 @@ function createPageRoutes(ctx) {url: `${base()}/place/${place.id}`, description: place.description || undefined,geo: { '@type': 'GeoCoordinates', latitude: place.lat, longitude: place.lon },...(place.address ? { address: { '@type': 'PostalAddress', streetAddress: place.address, addressLocality: place.city_name } } : {}),-- ...(place.image_url ? { image: place.image_url } : {}),++ ...(views.safeImage(place.image_url) ? { image: place.image_url } : {}),...(place.fee === 'no' ? { isAccessibleForFree: true } : place.fee === 'yes' ? { isAccessibleForFree: false } : {}),sameAs: [place.osm_url],},@@@ -22,6 +22,16 @@ const day = (iso) => (iso ? esc(String(iso).slice(0, 10)) : '—');const when = (iso) => (iso ? esc(String(iso).replace('T', ' ').replace(/\.\d+Z$/, ' UTC').replace(/Z$/, ' UTC')) : '—');const km = (m) => (Number(m) >= 1000 ? `${(Number(m) / 1000).toFixed(1)} km` : `${Math.round(Number(m) || 0)} m`);const safeHttp = (v) => (typeof v === 'string' && /^https?:\/\//i.test(v) ? v : null);++// A picture is drawn only from a URL that is one: Commons, or a path ending in an image type. A Wikivoyage listing's++// image field sometimes holds a share link (photos.app.goo.gl), which drew a broken image on the home (2026-11-01).++const safeImage = (v) => {++ const u = safeHttp(v);++ if (!u) return null;++ try {++ const { hostname, pathname } = new URL(u);++ return /(^|\.)wikimedia\.org$/i.test(hostname) || /\.(jpe?g|png|webp|gif|avif|svg)$/i.test(pathname) ? u : null;++ } catch { return null; }++};const money = (n, c) => `${Number(n).toLocaleString('en-US', { maximumFractionDigits: 2 })} ${c}`;const pct = (n, total) => (total ? Math.round((Number(n) / Number(total)) * 100) : 0);@@@ -51,7 +61,7 @@ const cityCard = (c) => kit.card({href: `/city/${c.slug}`,title: c.name,type: 'map',-- image: safeHttp(c.image_url) || '',++ image: safeImage(c.image_url) || '',alt: c.image_credit ? `${c.name} (${c.image_credit})` : c.name,badge: c.country || '',who: c.country || '',@@@ -64,7 +74,7 @@ const placeCard = (p) => kit.card({href: `/place/${p.id}`,title: p.name,type: 'map',-- image: safeHttp(p.image_url) || '',++ image: safeImage(p.image_url) || '',badge: p.kind ? p.kind.replace(/_/g, ' ') : '',who: p.city_name || '',peek: p.description ? String(p.description).slice(0, 120) : '',@@@ -115,7 +125,7 @@ function placeOfDayCard(p) {if (!p) return '';return `<article class="tr-pod" data-place="${esc(p.id)}"><a class="tr-pod-link" href="/place/${esc(p.id)}">--${safeHttp(p.image_url) ? `<img src="${esc(p.image_url)}" alt="${esc(p.name)}" loading="lazy" decoding="async">` : '<span class="tr-pod-noimg" aria-hidden="true">◆</span>'}++${safeImage(p.image_url) ? `<img src="${esc(p.image_url)}" alt="${esc(p.name)}" loading="lazy" decoding="async">` : '<span class="tr-pod-noimg" aria-hidden="true">◆</span>'}<span class="tr-pod-body"><span class="tr-kicker">Place of the day · ${esc(p.city_name || '')}</span><strong>${esc(p.name)}</strong><span class="tr-muted">${esc(p.kind ? p.kind.replace(/_/g, ' ') : 'place')}${p.description ? ` · ${esc(clip(p.description, 150))}` : ''}</span></span></a></article>`;@@@ -213,7 +223,7 @@ function citiesPage({ cities = [], q = '' } = {}) {<p class="tr-lede">Coordinates and population from Wikidata, the page and its See list from Wikivoyage, places from OpenStreetMap. A city page lists its places, routes, live tours and stays.</p><form class="tr-form tr-form-inline" method="get" action="/cities" role="search"><label class="tr-field"><span>Find a city</span><input type="search" name="q" value="${esc(q)}" maxlength="80" placeholder="Lisbon"></label><button class="tr-btn" type="submit">Search</button></form></header>${cities.length ? `<ol class="tr-city-list">${cities.map((c) => `<li class="tr-city-row"><a href="/city/${esc(c.slug)}">--<span class="tr-city-photo">${safeHttp(c.image_url) ? `<img src="${esc(c.image_url)}" alt="" loading="lazy" decoding="async">` : '<span aria-hidden="true">◆</span>'}</span>++<span class="tr-city-photo">${safeImage(c.image_url) ? `<img src="${esc(c.image_url)}" alt="" loading="lazy" decoding="async">` : '<span aria-hidden="true">◆</span>'}</span><span class="tr-city-text"><strong>${esc(c.name)}</strong><span class="tr-muted">${esc(c.country)}${c.population ? ` · ${Number(c.population).toLocaleString('en-US')} people` : ''}</span><span class="tr-muted">${Number(c.place_count) || 0} places · ${Number(c.route_count) || 0} routes</span></span></a></li>`).join('')}</ol>` : empty('No city matched that search.', '/cities', 'See all cities')}<p class="tr-fine">${SOURCE.wikidata} · ${SOURCE.wikivoyage} · ${SOURCE.osm}</p>@@@ -221,7 +231,7 @@ ${cities.length ? `<ol class="tr-city-list">${cities.map((c) => `<li class="tr-c}function cityPage({ city, places = [], routes = [], live = [], stays = [] } = {}) {-- const img = safeHttp(city.image_url);++ const img = safeImage(city.image_url);return `<div class="tr-wrap" id="tr-city-page"><header class="tr-page-h tr-city-head"><p class="tr-kicker">${esc(city.country)}${city.region ? ` · ${esc(city.region)}` : ''}</p>@@@ -246,7 +256,7 @@ function placePage({ place, routeList = [], trips = [] } = {}) {<header class="tr-page-h"><p class="tr-kicker">${esc(String(place.kind || '').replace(/_/g, ' '))}</p><h1>${esc(place.name)}</h1><p class="tr-lede">${esc(place.description || `${place.name} in ${place.city_name}, from OpenStreetMap.`)}</p><p class="tr-fine">${place.osm_url ? `<a href="${esc(place.osm_url)}" rel="noopener">OpenStreetMap ${esc(place.osm_type)} ${esc(place.osm_id)}</a> · ` : ''}<a href="${esc(place.source_url)}" rel="noopener">${esc(place.source === 'wikivoyage' ? 'Wikivoyage' : 'source')}</a> · fetched ${when(place.fetched_at)}${place.wikidata_id ? ` · <a href="https://www.wikidata.org/wiki/${esc(place.wikidata_id)}" rel="noopener">Wikidata</a>` : ''}</p></header>--${safeHttp(place.image_url) ? `<figure class="tr-place-img"><img src="${esc(place.image_url)}" alt="${esc(place.name)}" loading="lazy" decoding="async"><figcaption>${esc(place.image_credit || 'OpenStreetMap contributor')}${place.image_license ? ` · ${esc(place.image_license)}` : ''}</figcaption></figure>` : ''}++${safeImage(place.image_url) ? `<figure class="tr-place-img"><img src="${esc(place.image_url)}" alt="${esc(place.name)}" loading="lazy" decoding="async"><figcaption>${esc(place.image_credit || 'OpenStreetMap contributor')}${place.image_license ? ` · ${esc(place.image_license)}` : ''}</figcaption></figure>` : ''}<dl class="tr-facts">${place.address ? `<div><dt>Address</dt><dd>${esc(place.address)}</dd></div>` : ''}${place.opening_hours ? `<div><dt>Opening hours</dt><dd>${esc(place.opening_hours)}</dd></div>` : ''}@@@ -426,4 +436,4 @@ function notFound() {return `<div class="tr-wrap tr-narrow tr-page-pad" id="tr-not-found"><p class="tr-kicker">404</p><h1>Not here</h1><p class="tr-lede">That city, place, route or trip does not exist, or it is a private trip.</p><p><a class="tr-btn" href="/">Go home</a> <a class="tr-btn tr-btn-quiet" href="/cities">Browse cities</a></p></div>`;}--module.exports = { home, citiesPage, cityPage, placePage, routePage, livePage, staysPage, flightsPage, signIn, tripNew, tripsPage, tripPage, developersPage, notFound, placeOfDayCard, heroMotif, miniMap, SOURCE };++module.exports = { safeImage, home, citiesPage, cityPage, placePage, routePage, livePage, staysPage, flightsPage, signIn, tripNew, tripsPage, tripPage, developersPage, notFound, placeOfDayCard, heroMotif, miniMap, SOURCE };nnew file mode 100644@@@ -0,0 +1,23 @@++'use strict';++// A picture is drawn only from a URL that is one (2026-11-01: a Wikivoyage listing's image field held a Google Photos++// share link, and the home drew a broken image). The function is server/views/index.js's own, run here.++// node test/safe-image.test.js++const assert = require('assert');++const fs = require('fs');++const path = require('path');++++const src = fs.readFileSync(path.join(__dirname, '..', 'server', 'views', 'index.js'), 'utf8');++const http = /const safeHttp = [^\n]+\n/.exec(src);++const img = /const safeImage = \(v\) => \{[\s\S]*?\n\};\n/.exec(src);++assert.ok(http && img, 'views has safeHttp and safeImage');++const safeImage = new Function(`${http[0]}${img[0]}; return safeImage;`)();++++assert.strictEqual(safeImage('https://photos.app.goo.gl/A7PbTNW1RbBbZUrTA'), null, 'a share link is not a picture');++assert.ok(safeImage('https://upload.wikimedia.org/wikipedia/commons/a/ab/Hachiko.jpg'), 'Commons uploads');++assert.ok(safeImage('https://commons.wikimedia.org/wiki/Special:FilePath/Hachiko.jpg?width=640'), 'Commons file paths');++assert.ok(safeImage('https://example.com/photos/tower.webp'), 'a path ending in an image type');++assert.strictEqual(safeImage('https://example.com/gallery'), null, 'a page is not a picture');++assert.strictEqual(safeImage('javascript:alert(1)'), null);++assert.strictEqual(safeImage(null), null);++assert.doesNotMatch(src, /safeHttp\((c|p|city|place)\.image_url\)/, 'every picture goes through safeImage');++console.log('safe image: all checks passed');
@@ -81,7 +81,7 @@ function createPageRoutes(ctx) {
@@ -81,7 +81,7 @@ function createPageRoutes(ctx) {
const attractions = places.slice(0, 10).map((p) => ({
const attractions = places.slice(0, 10).map((p) => ({
'@type': 'TouristAttraction', name: p.name, url: `${base()}/place/${p.id}`,
'@type': 'TouristAttraction', name: p.name, url: `${base()}/place/${p.id}`,
geo: { '@type': 'GeoCoordinates', latitude: p.lat, longitude: p.lon },
geo: { '@type': 'GeoCoordinates', latitude: p.lat, longitude: p.lon },
- ...(p.description ? { description: p.description } : {}), ...(p.image_url ? { image: p.image_url } : {}),
+ ...(p.description ? { description: p.description } : {}), ...(views.safeImage(p.image_url) ? { image: p.image_url } : {}),
}));
}));
const tripLd = routes.slice(0, 3).map((rt) => ({
const tripLd = routes.slice(0, 3).map((rt) => ({
'@type': 'TouristTrip', name: rt.title, url: `${base()}/routes/${rt.id}`, description: rt.summary || undefined,
'@type': 'TouristTrip', name: rt.title, url: `${base()}/routes/${rt.id}`, description: rt.summary || undefined,
@@ -117,7 +117,7 @@ function createPageRoutes(ctx) {
@@ -117,7 +117,7 @@ function createPageRoutes(ctx) {
url: `${base()}/place/${place.id}`, description: place.description || undefined,
url: `${base()}/place/${place.id}`, description: place.description || undefined,
geo: { '@type': 'GeoCoordinates', latitude: place.lat, longitude: place.lon },
geo: { '@type': 'GeoCoordinates', latitude: place.lat, longitude: place.lon },
...(place.address ? { address: { '@type': 'PostalAddress', streetAddress: place.address, addressLocality: place.city_name } } : {}),
...(place.address ? { address: { '@type': 'PostalAddress', streetAddress: place.address, addressLocality: place.city_name } } : {}),
- ...(place.image_url ? { image: place.image_url } : {}),
+ ...(views.safeImage(place.image_url) ? { image: place.image_url } : {}),
...(place.fee === 'no' ? { isAccessibleForFree: true } : place.fee === 'yes' ? { isAccessibleForFree: false } : {}),
...(place.fee === 'no' ? { isAccessibleForFree: true } : place.fee === 'yes' ? { isAccessibleForFree: false } : {}),
sameAs: [place.osm_url],
sameAs: [place.osm_url],
},
},
@@ -22,6 +22,16 @@ const day = (iso) => (iso ? esc(String(iso).slice(0, 10)) : '—');
@@ -22,6 +22,16 @@ const day = (iso) => (iso ? esc(String(iso).slice(0, 10)) : '—');
const when = (iso) => (iso ? esc(String(iso).replace('T', ' ').replace(/\.\d+Z$/, ' UTC').replace(/Z$/, ' UTC')) : '—');
const when = (iso) => (iso ? esc(String(iso).replace('T', ' ').replace(/\.\d+Z$/, ' UTC').replace(/Z$/, ' UTC')) : '—');
const km = (m) => (Number(m) >= 1000 ? `${(Number(m) / 1000).toFixed(1)} km` : `${Math.round(Number(m) || 0)} m`);
const km = (m) => (Number(m) >= 1000 ? `${(Number(m) / 1000).toFixed(1)} km` : `${Math.round(Number(m) || 0)} m`);
const safeHttp = (v) => (typeof v === 'string' && /^https?:\/\//i.test(v) ? v : null);
const safeHttp = (v) => (typeof v === 'string' && /^https?:\/\//i.test(v) ? v : null);
+// A picture is drawn only from a URL that is one: Commons, or a path ending in an image type. A Wikivoyage listing's
+// image field sometimes holds a share link (photos.app.goo.gl), which drew a broken image on the home (2026-11-01).
+const safeImage = (v) => {
+ const u = safeHttp(v);
+ if (!u) return null;
+ try {
+ const { hostname, pathname } = new URL(u);
+ return /(^|\.)wikimedia\.org$/i.test(hostname) || /\.(jpe?g|png|webp|gif|avif|svg)$/i.test(pathname) ? u : null;
+ } catch { return null; }
+};
const money = (n, c) => `${Number(n).toLocaleString('en-US', { maximumFractionDigits: 2 })} ${c}`;
const money = (n, c) => `${Number(n).toLocaleString('en-US', { maximumFractionDigits: 2 })} ${c}`;
const pct = (n, total) => (total ? Math.round((Number(n) / Number(total)) * 100) : 0);
const pct = (n, total) => (total ? Math.round((Number(n) / Number(total)) * 100) : 0);
@@ -51,7 +61,7 @@ const cityCard = (c) => kit.card({
@@ -51,7 +61,7 @@ const cityCard = (c) => kit.card({
href: `/city/${c.slug}`,
href: `/city/${c.slug}`,
title: c.name,
title: c.name,
type: 'map',
type: 'map',
- image: safeHttp(c.image_url) || '',
+ image: safeImage(c.image_url) || '',
alt: c.image_credit ? `${c.name} (${c.image_credit})` : c.name,
alt: c.image_credit ? `${c.name} (${c.image_credit})` : c.name,
badge: c.country || '',
badge: c.country || '',
who: c.country || '',
who: c.country || '',
@@ -64,7 +74,7 @@ const placeCard = (p) => kit.card({
@@ -64,7 +74,7 @@ const placeCard = (p) => kit.card({
href: `/place/${p.id}`,
href: `/place/${p.id}`,
title: p.name,
title: p.name,
type: 'map',
type: 'map',
- image: safeHttp(p.image_url) || '',
+ image: safeImage(p.image_url) || '',
badge: p.kind ? p.kind.replace(/_/g, ' ') : '',
badge: p.kind ? p.kind.replace(/_/g, ' ') : '',
who: p.city_name || '',
who: p.city_name || '',
peek: p.description ? String(p.description).slice(0, 120) : '',
peek: p.description ? String(p.description).slice(0, 120) : '',
@@ -115,7 +125,7 @@ function placeOfDayCard(p) {
@@ -115,7 +125,7 @@ function placeOfDayCard(p) {
if (!p) return '';
if (!p) return '';
return `<article class="tr-pod" data-place="${esc(p.id)}">
return `<article class="tr-pod" data-place="${esc(p.id)}">
<a class="tr-pod-link" href="/place/${esc(p.id)}">
<a class="tr-pod-link" href="/place/${esc(p.id)}">
-${safeHttp(p.image_url) ? `<img src="${esc(p.image_url)}" alt="${esc(p.name)}" loading="lazy" decoding="async">` : '<span class="tr-pod-noimg" aria-hidden="true">◆</span>'}
+${safeImage(p.image_url) ? `<img src="${esc(p.image_url)}" alt="${esc(p.name)}" loading="lazy" decoding="async">` : '<span class="tr-pod-noimg" aria-hidden="true">◆</span>'}
<span class="tr-pod-body"><span class="tr-kicker">Place of the day · ${esc(p.city_name || '')}</span><strong>${esc(p.name)}</strong>
<span class="tr-pod-body"><span class="tr-kicker">Place of the day · ${esc(p.city_name || '')}</span><strong>${esc(p.name)}</strong>
<span class="tr-muted">${esc(p.kind ? p.kind.replace(/_/g, ' ') : 'place')}${p.description ? ` · ${esc(clip(p.description, 150))}` : ''}</span></span>
<span class="tr-muted">${esc(p.kind ? p.kind.replace(/_/g, ' ') : 'place')}${p.description ? ` · ${esc(clip(p.description, 150))}` : ''}</span></span>
</a></article>`;
</a></article>`;
@@ -213,7 +223,7 @@ function citiesPage({ cities = [], q = '' } = {}) {
@@ -213,7 +223,7 @@ function citiesPage({ cities = [], q = '' } = {}) {
<p class="tr-lede">Coordinates and population from Wikidata, the page and its See list from Wikivoyage, places from OpenStreetMap. A city page lists its places, routes, live tours and stays.</p>
<p class="tr-lede">Coordinates and population from Wikidata, the page and its See list from Wikivoyage, places from OpenStreetMap. A city page lists its places, routes, live tours and stays.</p>
<form class="tr-form tr-form-inline" method="get" action="/cities" role="search"><label class="tr-field"><span>Find a city</span><input type="search" name="q" value="${esc(q)}" maxlength="80" placeholder="Lisbon"></label><button class="tr-btn" type="submit">Search</button></form></header>
<form class="tr-form tr-form-inline" method="get" action="/cities" role="search"><label class="tr-field"><span>Find a city</span><input type="search" name="q" value="${esc(q)}" maxlength="80" placeholder="Lisbon"></label><button class="tr-btn" type="submit">Search</button></form></header>
${cities.length ? `<ol class="tr-city-list">${cities.map((c) => `<li class="tr-city-row"><a href="/city/${esc(c.slug)}">
${cities.length ? `<ol class="tr-city-list">${cities.map((c) => `<li class="tr-city-row"><a href="/city/${esc(c.slug)}">
-<span class="tr-city-photo">${safeHttp(c.image_url) ? `<img src="${esc(c.image_url)}" alt="" loading="lazy" decoding="async">` : '<span aria-hidden="true">◆</span>'}</span>
+<span class="tr-city-photo">${safeImage(c.image_url) ? `<img src="${esc(c.image_url)}" alt="" loading="lazy" decoding="async">` : '<span aria-hidden="true">◆</span>'}</span>
<span class="tr-city-text"><strong>${esc(c.name)}</strong><span class="tr-muted">${esc(c.country)}${c.population ? ` · ${Number(c.population).toLocaleString('en-US')} people` : ''}</span>
<span class="tr-city-text"><strong>${esc(c.name)}</strong><span class="tr-muted">${esc(c.country)}${c.population ? ` · ${Number(c.population).toLocaleString('en-US')} people` : ''}</span>
<span class="tr-muted">${Number(c.place_count) || 0} places · ${Number(c.route_count) || 0} routes</span></span></a></li>`).join('')}</ol>` : empty('No city matched that search.', '/cities', 'See all cities')}
<span class="tr-muted">${Number(c.place_count) || 0} places · ${Number(c.route_count) || 0} routes</span></span></a></li>`).join('')}</ol>` : empty('No city matched that search.', '/cities', 'See all cities')}
<p class="tr-fine">${SOURCE.wikidata} · ${SOURCE.wikivoyage} · ${SOURCE.osm}</p>
<p class="tr-fine">${SOURCE.wikidata} · ${SOURCE.wikivoyage} · ${SOURCE.osm}</p>
@@ -221,7 +231,7 @@ ${cities.length ? `<ol class="tr-city-list">${cities.map((c) => `<li class="tr-c
@@ -221,7 +231,7 @@ ${cities.length ? `<ol class="tr-city-list">${cities.map((c) => `<li class="tr-c
}
}
function cityPage({ city, places = [], routes = [], live = [], stays = [] } = {}) {
function cityPage({ city, places = [], routes = [], live = [], stays = [] } = {}) {
- const img = safeHttp(city.image_url);
+ const img = safeImage(city.image_url);
return `<div class="tr-wrap" id="tr-city-page">
return `<div class="tr-wrap" id="tr-city-page">
<header class="tr-page-h tr-city-head">
<header class="tr-page-h tr-city-head">
<p class="tr-kicker">${esc(city.country)}${city.region ? ` · ${esc(city.region)}` : ''}</p>
<p class="tr-kicker">${esc(city.country)}${city.region ? ` · ${esc(city.region)}` : ''}</p>
@@ -246,7 +256,7 @@ function placePage({ place, routeList = [], trips = [] } = {}) {
@@ -246,7 +256,7 @@ function placePage({ place, routeList = [], trips = [] } = {}) {
<header class="tr-page-h"><p class="tr-kicker">${esc(String(place.kind || '').replace(/_/g, ' '))}</p><h1>${esc(place.name)}</h1>
<header class="tr-page-h"><p class="tr-kicker">${esc(String(place.kind || '').replace(/_/g, ' '))}</p><h1>${esc(place.name)}</h1>
<p class="tr-lede">${esc(place.description || `${place.name} in ${place.city_name}, from OpenStreetMap.`)}</p>
<p class="tr-lede">${esc(place.description || `${place.name} in ${place.city_name}, from OpenStreetMap.`)}</p>
<p class="tr-fine">${place.osm_url ? `<a href="${esc(place.osm_url)}" rel="noopener">OpenStreetMap ${esc(place.osm_type)} ${esc(place.osm_id)}</a> · ` : ''}<a href="${esc(place.source_url)}" rel="noopener">${esc(place.source === 'wikivoyage' ? 'Wikivoyage' : 'source')}</a> · fetched ${when(place.fetched_at)}${place.wikidata_id ? ` · <a href="https://www.wikidata.org/wiki/${esc(place.wikidata_id)}" rel="noopener">Wikidata</a>` : ''}</p></header>
<p class="tr-fine">${place.osm_url ? `<a href="${esc(place.osm_url)}" rel="noopener">OpenStreetMap ${esc(place.osm_type)} ${esc(place.osm_id)}</a> · ` : ''}<a href="${esc(place.source_url)}" rel="noopener">${esc(place.source === 'wikivoyage' ? 'Wikivoyage' : 'source')}</a> · fetched ${when(place.fetched_at)}${place.wikidata_id ? ` · <a href="https://www.wikidata.org/wiki/${esc(place.wikidata_id)}" rel="noopener">Wikidata</a>` : ''}</p></header>
-${safeHttp(place.image_url) ? `<figure class="tr-place-img"><img src="${esc(place.image_url)}" alt="${esc(place.name)}" loading="lazy" decoding="async"><figcaption>${esc(place.image_credit || 'OpenStreetMap contributor')}${place.image_license ? ` · ${esc(place.image_license)}` : ''}</figcaption></figure>` : ''}
+${safeImage(place.image_url) ? `<figure class="tr-place-img"><img src="${esc(place.image_url)}" alt="${esc(place.name)}" loading="lazy" decoding="async"><figcaption>${esc(place.image_credit || 'OpenStreetMap contributor')}${place.image_license ? ` · ${esc(place.image_license)}` : ''}</figcaption></figure>` : ''}
<dl class="tr-facts">
<dl class="tr-facts">
${place.address ? `<div><dt>Address</dt><dd>${esc(place.address)}</dd></div>` : ''}
${place.address ? `<div><dt>Address</dt><dd>${esc(place.address)}</dd></div>` : ''}
${place.opening_hours ? `<div><dt>Opening hours</dt><dd>${esc(place.opening_hours)}</dd></div>` : ''}
${place.opening_hours ? `<div><dt>Opening hours</dt><dd>${esc(place.opening_hours)}</dd></div>` : ''}
@@ -426,4 +436,4 @@ function notFound() {
@@ -426,4 +436,4 @@ function notFound() {
return `<div class="tr-wrap tr-narrow tr-page-pad" id="tr-not-found"><p class="tr-kicker">404</p><h1>Not here</h1><p class="tr-lede">That city, place, route or trip does not exist, or it is a private trip.</p><p><a class="tr-btn" href="/">Go home</a> <a class="tr-btn tr-btn-quiet" href="/cities">Browse cities</a></p></div>`;
return `<div class="tr-wrap tr-narrow tr-page-pad" id="tr-not-found"><p class="tr-kicker">404</p><h1>Not here</h1><p class="tr-lede">That city, place, route or trip does not exist, or it is a private trip.</p><p><a class="tr-btn" href="/">Go home</a> <a class="tr-btn tr-btn-quiet" href="/cities">Browse cities</a></p></div>`;
}
}
-module.exports = { home, citiesPage, cityPage, placePage, routePage, livePage, staysPage, flightsPage, signIn, tripNew, tripsPage, tripPage, developersPage, notFound, placeOfDayCard, heroMotif, miniMap, SOURCE };
+module.exports = { safeImage, home, citiesPage, cityPage, placePage, routePage, livePage, staysPage, flightsPage, signIn, tripNew, tripsPage, tripPage, developersPage, notFound, placeOfDayCard, heroMotif, miniMap, SOURCE };
new file mode 100644
new file mode 100644
@@ -0,0 +1,23 @@
@@ -0,0 +1,23 @@
+'use strict';
+// A picture is drawn only from a URL that is one (2026-11-01: a Wikivoyage listing's image field held a Google Photos
+// share link, and the home drew a broken image). The function is server/views/index.js's own, run here.
+// node test/safe-image.test.js
+const assert = require('assert');
+const fs = require('fs');
+const path = require('path');
+
+const src = fs.readFileSync(path.join(__dirname, '..', 'server', 'views', 'index.js'), 'utf8');
+const http = /const safeHttp = [^\n]+\n/.exec(src);
+const img = /const safeImage = \(v\) => \{[\s\S]*?\n\};\n/.exec(src);
+assert.ok(http && img, 'views has safeHttp and safeImage');
+const safeImage = new Function(`${http[0]}${img[0]}; return safeImage;`)();
+
+assert.strictEqual(safeImage('https://photos.app.goo.gl/A7PbTNW1RbBbZUrTA'), null, 'a share link is not a picture');
+assert.ok(safeImage('https://upload.wikimedia.org/wikipedia/commons/a/ab/Hachiko.jpg'), 'Commons uploads');
+assert.ok(safeImage('https://commons.wikimedia.org/wiki/Special:FilePath/Hachiko.jpg?width=640'), 'Commons file paths');
+assert.ok(safeImage('https://example.com/photos/tower.webp'), 'a path ending in an image type');
+assert.strictEqual(safeImage('https://example.com/gallery'), null, 'a page is not a picture');
+assert.strictEqual(safeImage('javascript:alert(1)'), null);
+assert.strictEqual(safeImage(null), null);
+assert.doesNotMatch(src, /safeHttp\((c|p|city|place)\.image_url\)/, 'every picture goes through safeImage');
+console.log('safe image: all checks passed');