diff --git a/client/src/components/Map/MapView.tsx b/client/src/components/Map/MapView.tsx index 0635c50c1..585ce36f7 100644 --- a/client/src/components/Map/MapView.tsx +++ b/client/src/components/Map/MapView.tsx @@ -159,20 +159,30 @@ function createPlaceIcon(place, orderNumbers, isSelected) { // Small coloured pin for an OSM "explore" POI — distinct from the photo-circle // markers of planned places; the colour matches its pill category. const poiIconCache = new Map() -function createPoiIcon(category: string) { - const cached = poiIconCache.get(category) +function createPoiIcon(category: string, brandWikidata?: string | null) { + // A chain shows its logo instead of the category icon — on a corridor full of petrol + // stations the brand is what the eye looks for. The bytes come from TREK, which + // proxies them, so the browser never tells Wikimedia what is on screen. + const brand = brandWikidata && /^Q[1-9][0-9]{0,11}$/.test(brandWikidata) ? brandWikidata : null + const cacheKey = brand ? `${category}:${brand}` : category + const cached = poiIconCache.get(cacheKey) if (cached) return cached const cat = POI_CATEGORY_BY_KEY[category] const color = cat?.color || '#6b7280' const svg = cat ? renderIconMarkup(createElement(cat.Icon, { size: 13, color: 'white', strokeWidth: 2.5 })) : '' + // onerror empties the wrapper: the route answers 204 for a brand Wikidata has no + // logo for, and an empty circle in the category colour is the right fallback. + const inner = brand + ? `` + : svg const icon = L.divIcon({ className: '', - html: `
${svg}
`, + html: `
${inner}
`, iconSize: [26, 26], iconAnchor: [13, 13], tooltipAnchor: [0, -14], }) - poiIconCache.set(category, icon) + poiIconCache.set(cacheKey, icon) return icon } @@ -547,7 +557,7 @@ export const MapView = memo(function MapView({ onPoiClick?.(poi) }} > diff --git a/client/src/components/Map/MapViewGL.tsx b/client/src/components/Map/MapViewGL.tsx index 6134c63ae..077aa6c2a 100644 --- a/client/src/components/Map/MapViewGL.tsx +++ b/client/src/components/Map/MapViewGL.tsx @@ -395,16 +395,33 @@ function buildPluginMarkerPopup(mk: PluginMapMarker): HTMLDivElement { } // Small coloured pin for an OSM "explore" POI (matches the pill category colour). -function createPoiMarkerElement(category: string): HTMLDivElement { +// A chain shows its logo instead of the category icon: on a corridor full of petrol +// stations the brand is what the eye is looking for, and the server proxies it so the +// browser never asks Wikimedia which ones are on screen. +function createPoiMarkerElement(category: string, brandWikidata?: string | null): HTMLDivElement { const cat = POI_CATEGORY_BY_KEY[category] const color = cat?.color || '#6b7280' const svg = cat ? renderIconMarkup(createElement(cat.Icon, { size: 13, color: 'white', strokeWidth: 2.5 })) : '' const el = document.createElement('div') - el.style.cssText = 'width:26px;height:26px;cursor:pointer;' - el.innerHTML = `
${svg}
` + el.style.cssText = 'width:26px;height:26px;cursor:pointer;will-change:transform;' + const inner = brandLogoMarkup(brandWikidata) || svg + const background = brandWikidata ? '#fff' : color + el.innerHTML = `
${inner}
` return el } +/** + * The for a brand logo, or '' when there is no brand. + * + * `onerror` empties the wrapper rather than leaving a broken-image glyph: the route + * answers 204 for a brand Wikidata has no logo for, and an empty circle in the + * category colour is the right fallback. + */ +function brandLogoMarkup(brandWikidata?: string | null): string { + if (!brandWikidata || !/^Q[1-9][0-9]{0,11}$/.test(brandWikidata)) return '' + return `` +} + export function MapViewGL({ places = NO_PLACES, dayPlaces = NO_PLACES, @@ -1234,7 +1251,7 @@ export function MapViewGL({ poiMarkersRef.current.forEach(m => m.remove()) poiMarkersRef.current = [] for (const poi of (pois as Poi[])) { - const el = createPoiMarkerElement(poi.category) + const el = createPoiMarkerElement(poi.category, poi.brand_wikidata) el.addEventListener('mouseenter', () => { popupRef.current?.setLngLat([poi.lng, poi.lat]).setHTML(buildPoiPopupHtml(poi)).addTo(map) }) diff --git a/client/src/components/Map/poiCategories.ts b/client/src/components/Map/poiCategories.ts index fa388c9d4..8967d3f00 100644 --- a/client/src/components/Map/poiCategories.ts +++ b/client/src/components/Map/poiCategories.ts @@ -47,6 +47,9 @@ export interface Poi { lng: number category: string poi_type: string + /** Brand name and its Wikidata id, when OSM carries them (road categories mostly do). */ + brand?: string | null + brand_wikidata?: string | null address: string | null website: string | null phone: string | null diff --git a/server/src/nest/maps/maps.controller.ts b/server/src/nest/maps/maps.controller.ts index c17b240b7..c574f8f7a 100644 --- a/server/src/nest/maps/maps.controller.ts +++ b/server/src/nest/maps/maps.controller.ts @@ -246,6 +246,28 @@ export class MapsController { } } + /** + * A brand's logo, by Wikidata id — what makes a corridor full of petrol stations + * readable at a glance. + * + * Proxied rather than linked: the browser never announces to Wikimedia which brands + * a user is looking at, and one instance asking for a handful of logos is a very + * different egress profile from every visitor doing it. An unknown or logo-less + * brand answers 204, like the photo route above and for the same reason — a marker + * per petrol station would otherwise be a burst of 404s from one address. + */ + @Get('brand-logo/:wikidataId') + async brandLogo(@Param('wikidataId') wikidataId: string, @Res() res: Response): Promise { + const logo = await this.maps.brandLogo(wikidataId); + if (!logo) { + this.emptyPhoto(res); + return; + } + res.set('Cache-Control', 'public, max-age=2592000, immutable'); + res.type(logo.contentType); + res.send(logo.bytes); + } + // 204 for "no bytes to serve". Overrides the immutable Cache-Control the hit // path already set — a photo that reappears in the cache must not stay hidden // behind a month-old empty response. diff --git a/server/src/nest/maps/maps.helpers.ts b/server/src/nest/maps/maps.helpers.ts index 2a42f75ff..76ff4d9e1 100644 --- a/server/src/nest/maps/maps.helpers.ts +++ b/server/src/nest/maps/maps.helpers.ts @@ -261,6 +261,9 @@ export interface OverpassPoi { phone: string | null; opening_hours: string | null; cuisine: string | null; + /** Brand name and its Wikidata id, when OSM carries them — the logo is looked up from the id. */ + brand: string | null; + brand_wikidata: string | null; /** * Which index the row came from. Overture is not OpenStreetMap: it carries * OSM among other sources under other licences, so a row from the TREK index diff --git a/server/src/nest/maps/maps.service.ts b/server/src/nest/maps/maps.service.ts index bd448e7a7..a0fdfbb3c 100644 --- a/server/src/nest/maps/maps.service.ts +++ b/server/src/nest/maps/maps.service.ts @@ -379,6 +379,29 @@ function isGoogleMapsHost(hostname: string): boolean { const WIKI_TIMEOUT_MS = 6000; +// ── Brand logos ────────────────────────────────────────────────────────────── +// +// A road trip corridor is mostly chains — Shell, Aral, JET — and the brand is the +// fastest thing to recognise on a map. OSM carries `brand:wikidata` on most of them, +// Wikidata carries the logo (P154), and Commons serves it. +// +// The bytes are proxied rather than linked so the browser never talks to Wikimedia: +// one self-hosted instance asking for a handful of logos is a very different egress +// profile from every visitor's browser announcing which petrol stations they are +// looking at. The cache is in memory on purpose — a GET must not write to the DB, and +// there are only so many fuel brands. +const BRAND_LOGO_CACHE_TTL_MS = 24 * 60 * 60 * 1000; +const BRAND_LOGO_CACHE_MAX = 300; +/** Well past any logo; a Commons original can be a multi-megabyte SVG or print-res PNG. */ +const BRAND_LOGO_MAX_BYTES = 512 * 1024; +const BRAND_LOGO_WIDTH = 96; +const WIKIDATA_ID_RE = /^Q[1-9][0-9]{0,11}$/; + +export interface BrandLogo { + bytes: Buffer; + contentType: string; +} + // Tighter than the wiki calls, because this one sits at the FRONT of a chain: // identity, then sitelinks, then the extract. Nominatim answers a bounded // search in 0.2-0.7s in practice, so anything past a couple of seconds is a bad @@ -547,6 +570,10 @@ export class MapsService { private readonly photoCache: PlacePhotoCacheService, ) {} + /** Brand id → logo bytes, or null for "asked, has none". Insertion-ordered, so the + * oldest entry is the one evicted when it fills up. */ + private readonly brandLogoCache = new Map(); + private isSettingDisabled(key: string): boolean { const row = this.database.get<{ value: string }>( 'SELECT value FROM app_settings WHERE key = ?', @@ -641,6 +668,70 @@ export class MapsService { return this.resolveGoogleMapsUrl(url) as Promise; } + /** + * The logo of a brand, by its Wikidata id, as bytes. + * + * Two hops: Wikidata says which Commons file is the logo (property P154), Commons + * serves a thumbnail of it. Both answers are cached, including "this brand has no + * logo" — otherwise every map pan would ask Wikidata about the same supermarket + * chain again. Returns null whenever anything is missing or unreadable; a marker + * without a logo falls back to its category icon, which is a fine outcome. + */ + async brandLogo(wikidataId: string): Promise { + if (!WIKIDATA_ID_RE.test(wikidataId)) return null; + + const cached = this.brandLogoCache.get(wikidataId); + if (cached && Date.now() - cached.at < BRAND_LOGO_CACHE_TTL_MS) return cached.logo; + + const remember = (logo: BrandLogo | null): BrandLogo | null => { + if (this.brandLogoCache.size >= BRAND_LOGO_CACHE_MAX) { + const oldest = this.brandLogoCache.keys().next().value; + if (oldest !== undefined) this.brandLogoCache.delete(oldest); + } + this.brandLogoCache.set(wikidataId, { at: Date.now(), logo }); + return logo; + }; + + try { + const params = new URLSearchParams({ + action: 'wbgetclaims', + entity: wikidataId, + property: 'P154', + format: 'json', + }); + const claimRes = await fetch(`https://www.wikidata.org/w/api.php?${params}`, { + headers: { 'User-Agent': UA }, + signal: AbortSignal.timeout(WIKI_TIMEOUT_MS), + }); + if (!claimRes.ok) return remember(null); + const claims = (await claimRes.json()) as { + claims?: { P154?: { mainsnak?: { datavalue?: { value?: unknown } } }[] }; + }; + const file = claims.claims?.P154?.[0]?.mainsnak?.datavalue?.value; + if (typeof file !== 'string' || !file.trim()) return remember(null); + + // Special:FilePath renders a thumbnail at the width asked for and redirects to + // the CDN, so each hop is re-checked by the guard rather than trusted. + const url = `https://commons.wikimedia.org/wiki/Special:FilePath/${encodeURIComponent(file)}?width=${BRAND_LOGO_WIDTH}`; + const imgRes = await safeFetchFollow(url, undefined, { bypassInternalIpAllowed: true }); + if (!imgRes.ok) return remember(null); + + const declared = Number(imgRes.headers.get('content-length') ?? '0'); + if (declared > BRAND_LOGO_MAX_BYTES) return remember(null); + const bytes = Buffer.from(await imgRes.arrayBuffer()); + // Checked again after reading: a chunked response has no length to check first. + if (bytes.byteLength === 0 || bytes.byteLength > BRAND_LOGO_MAX_BYTES) return remember(null); + + const contentType = imgRes.headers.get('content-type') ?? ''; + if (!contentType.startsWith('image/')) return remember(null); + + return remember({ bytes, contentType }); + } catch (err) { + if (err instanceof SsrfBlockedError) return remember(null); + return remember(null); + } + } + // POI search by category within a viewport. Never calls Google. // // The index answers first: it holds the same places, indexed in an R-Tree, and @@ -695,6 +786,11 @@ export class MapsService { website: p.contact?.website ?? null, phone: p.contact?.phone ?? null, opening_hours: p.hours?.osm ?? null, + // The index carries the chain and its Wikidata item, which is what + // the logo on the pin is looked up from — so a branch of a chain + // gets its own mark here exactly as it does on the Overpass path. + brand: p.brand?.name ?? null, + brand_wikidata: p.brand?.wikidata ?? null, // The index has no cuisine field, so this null is the truth // rather than a field being dropped on the way through. cuisine: null, @@ -1083,6 +1179,10 @@ export class MapsService { phone: tags.phone || tags['contact:phone'] || null, opening_hours: tags.opening_hours || null, cuisine: tags.cuisine || null, + brand: tags.brand || tags.operator || null, + // Only the plain Q-id form is passed on; anything else would be a lookup we + // would have to guess at. + brand_wikidata: /^Q[0-9]+$/.test(tags['brand:wikidata'] || '') ? tags['brand:wikidata'] : null, source: 'openstreetmap', }); } diff --git a/server/tests/unit/nest/maps.service.test.ts b/server/tests/unit/nest/maps.service.test.ts index a67158511..ac4e4e041 100644 --- a/server/tests/unit/nest/maps.service.test.ts +++ b/server/tests/unit/nest/maps.service.test.ts @@ -3253,3 +3253,92 @@ describe('readWikiIdentity', () => { expect(identity).toEqual({ wikidata: null, wikipedia: null, wikimedia_commons: null }); }); }); + +describe('brandLogo', () => { + // A fresh service per case: the logo cache lives on the instance, and a hit from + // one case would answer the next one's question before its fetch stub ran. + const service = (): MapsService => new MapsService(new DatabaseService(db as never), photoCacheStub); + + const claimResponse = (file: string | null) => ({ + ok: true, + json: async () => (file === null ? { claims: {} } : { claims: { P154: [{ mainsnak: { datavalue: { value: file } } }] } }), + }); + + const imageResponse = (bytes: number, contentType = 'image/png') => ({ + ok: true, + headers: new Headers({ 'content-type': contentType, 'content-length': String(bytes) }), + arrayBuffer: async () => new ArrayBuffer(bytes), + }); + + it('MAPS-147: reads the logo claim and serves the bytes Commons returns', async () => { + const fetchMock = vi.fn() + .mockResolvedValueOnce(claimResponse('Aral Logo.svg')) + .mockResolvedValueOnce(imageResponse(2278)); + vi.stubGlobal('fetch', fetchMock); + + const logo = await service().brandLogo('Q565734'); + + expect(logo?.bytes.byteLength).toBe(2278); + expect(logo?.contentType).toBe('image/png'); + const [claimUrl] = fetchMock.mock.calls[0]; + expect(claimUrl).toContain('wbgetclaims'); + expect(claimUrl).toContain('property=P154'); + const [fileUrl] = fetchMock.mock.calls[1]; + // Special:FilePath renders the thumbnail and redirects, so the width rides along. + expect(fileUrl).toContain('Special:FilePath/Aral%20Logo.svg'); + expect(fileUrl).toContain('width=96'); + }); + + it('MAPS-148: asks Wikidata nothing when the id is not one', async () => { + const fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + + for (const bad of ['', 'Q', 'P154', 'Q0', '../secrets', 'Q12; DROP TABLE places']) { + expect(await service().brandLogo(bad)).toBeNull(); + } + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('MAPS-149: answers a repeat from memory instead of asking again', async () => { + const fetchMock = vi.fn() + .mockResolvedValueOnce(claimResponse('Esso.png')) + .mockResolvedValueOnce(imageResponse(1024)); + vi.stubGlobal('fetch', fetchMock); + + const svcOne = service(); + await svcOne.brandLogo('Q867662'); + await svcOne.brandLogo('Q867662'); + + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it('MAPS-150: remembers that a brand has no logo, so a map pan does not ask again', async () => { + const fetchMock = vi.fn().mockResolvedValue(claimResponse(null)); + vi.stubGlobal('fetch', fetchMock); + + const svcOne = service(); + expect(await svcOne.brandLogo('Q89432390')).toBeNull(); + expect(await svcOne.brandLogo('Q89432390')).toBeNull(); + + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it('MAPS-151: refuses an image that is too big, and one that is not an image', async () => { + vi.stubGlobal('fetch', vi.fn() + .mockResolvedValueOnce(claimResponse('Huge.png')) + .mockResolvedValueOnce(imageResponse(4 * 1024 * 1024))); + expect(await service().brandLogo('Q1')).toBeNull(); + + vi.stubGlobal('fetch', vi.fn() + .mockResolvedValueOnce(claimResponse('NotAnImage.svg')) + .mockResolvedValueOnce(imageResponse(500, 'text/html'))); + expect(await service().brandLogo('Q2')).toBeNull(); + }); + + it('MAPS-152: a refused hop leaves the marker without a logo rather than failing', async () => { + mockCheckSsrf.mockResolvedValue({ allowed: false, error: 'blocked' } as SsrfResult); + vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce(claimResponse('Blocked.png'))); + + expect(await service().brandLogo('Q3')).toBeNull(); + }); +})