From 288de4fd5888cc87c3ea13d3fecf4fa35cfe41d5 Mon Sep 17 00:00:00 2001 From: Eric Larson Date: Wed, 19 Aug 2026 11:54:06 -0500 Subject: [PATCH] Fix OpenIndexMap v1 links and thumbnails --- .env.example | 8 +- README.md | 20 +- docker-compose.yml | 2 +- docs/README.md | 2 +- docs/backend/turnstile.md | 4 +- docs/development.md | 4 +- docs/frontend/README.md | 4 +- .../geoblacklight/layer_index_map.test.ts | 155 +++++++++++++ .../src/components/resource/ResourceView.tsx | 2 +- frontend/src/geoblacklight/layer_index_map.ts | 213 ++++++++++++++++++ frontend/src/pages/ResourceView.tsx | 2 +- frontend/vite.config.ts | 6 + 12 files changed, 399 insertions(+), 23 deletions(-) create mode 100644 frontend/src/__tests__/geoblacklight/layer_index_map.test.ts create mode 100644 frontend/src/geoblacklight/layer_index_map.ts diff --git a/.env.example b/.env.example index 7067e659..22a81829 100644 --- a/.env.example +++ b/.env.example @@ -29,12 +29,14 @@ KITHE_BRIDGE_VERIFY_SSL=true # ============================================================================= APPLICATION_URL=http://localhost:8000/api/v1/ +# Host port published by the Docker Compose frontend service. +FRONTEND_PORT=5173 # Base URL for the Geoportal (resource pages). Used for citation URLs. # When unset, derived from APPLICATION_URL by stripping /api/v1. -# GEOPORTAL_BASE_URL=http://localhost:3000 +# GEOPORTAL_BASE_URL=http://localhost:5173 LOG_PATH=logs -# Comma-separated CORS origins (use * for all). Local dev: 3000 (React Router) or 5173 (Vite) -CORS_ORIGINS=http://localhost:3000,http://localhost:5173 +# Comma-separated CORS origins (use * for all). Local dev: 5173 (Docker/Vite) or 3000 (direct npm) +CORS_ORIGINS=http://localhost:5173,http://localhost:3000 # Enable crawler indexing and advertise the sitemap in robots.txt. # Keep this false for local/dev/staging environments. SEARCH_ENGINE_INDEXING_ENABLED=false diff --git a/README.md b/README.md index 5b3001e0..c803ade0 100644 --- a/README.md +++ b/README.md @@ -61,7 +61,7 @@ workflows, and future agent-facing discovery tools. ## Local Proxy The local browser-facing proxy is provided by the React Router frontend dev -server at `http://localhost:3000`. It is not a separate service: start the +server at `http://localhost:5173`. It is not a separate service: start the normal Docker stack and open the frontend URL. ```bash @@ -74,18 +74,18 @@ but the frontend uses same-origin proxy routes for request paths that should run through server-side loaders, carry the server-side API key, or avoid browser CORS/rate-limit surprises. Common local proxy routes include: -- `http://localhost:3000/search/results` -> `/api/v1/search` -- `http://localhost:3000/search/facets/:facetName` -> +- `http://localhost:5173/search/results` -> `/api/v1/search` +- `http://localhost:5173/search/facets/:facetName` -> `/api/v1/search/facets/:facetName` -- `http://localhost:3000/map/h3` -> `/api/v1/map/h3` -- `http://localhost:3000/home/blog-posts` -> `/api/v1/home/blog-posts` -- `http://localhost:3000/places/suggest` -> `/api/v1/places/suggest` -- `http://localhost:3000/resources/:id/thumbnail` -> +- `http://localhost:5173/map/h3` -> `/api/v1/map/h3` +- `http://localhost:5173/home/blog-posts` -> `/api/v1/home/blog-posts` +- `http://localhost:5173/places/suggest` -> `/api/v1/places/suggest` +- `http://localhost:5173/resources/:id/thumbnail` -> `/api/v1/resources/:id/thumbnail` -- `http://localhost:3000/resources/:id/static-map` -> +- `http://localhost:5173/resources/:id/static-map` -> `/api/v1/resources/:id/static-map` -- `http://localhost:3000/static-maps/...` and - `http://localhost:3000/thumbnails/...` for generated map and thumbnail assets +- `http://localhost:5173/static-maps/...` and + `http://localhost:5173/thumbnails/...` for generated map and thumbnail assets There are two API base URLs to keep straight in local development: diff --git a/docker-compose.yml b/docker-compose.yml index 4f5b392d..b21ffd46 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -104,7 +104,7 @@ services: - ./frontend/eslint.config.js:/app/eslint.config.js - ./frontend/vitest.config.ts:/app/vitest.config.ts ports: - - "3000:3000" + - "${FRONTEND_PORT:-5173}:3000" depends_on: api: condition: service_started diff --git a/docs/README.md b/docs/README.md index c6a3011c..79feef70 100644 --- a/docs/README.md +++ b/docs/README.md @@ -93,7 +93,7 @@ docker compose down Local URLs: -- Frontend: `http://localhost:3000` +- Frontend: `http://localhost:5173` - API docs: `http://localhost:8000/api/docs` - OpenAPI JSON: `http://localhost:8000/api/openapi.json` - Flower: `http://localhost:5555` diff --git a/docs/backend/turnstile.md b/docs/backend/turnstile.md index 30018fc6..36b85338 100644 --- a/docs/backend/turnstile.md +++ b/docs/backend/turnstile.md @@ -97,7 +97,7 @@ traffic can still use the server-side API key without receiving `turnstile_required`. The frontend stores the returned session token in `sessionStorage` so local -cross-origin dev traffic from `localhost:3000` to `localhost:8000` can carry +cross-origin Docker traffic from `localhost:5173` to `localhost:8000` can carry `X-Turnstile-Session` without relying on cross-origin cookies. Production same-origin traffic also receives an HttpOnly cookie. @@ -105,7 +105,7 @@ To review the branded gate shell without Cloudflare keys or backend Turnstile verification, run the Vite dev server and open: ```text -http://localhost:3000/turnstile-preview +http://localhost:5173/turnstile-preview ``` This route only works in Vite dev mode. It renders the same BTAA Geoportal gate diff --git a/docs/development.md b/docs/development.md index 3aa49a43..ef43d698 100644 --- a/docs/development.md +++ b/docs/development.md @@ -26,7 +26,7 @@ with one command. 4. Open the local services: - - Website: `http://localhost:3000` + - Website: `http://localhost:5173` - API docs: `http://localhost:8000/api/docs` - Flower: `http://localhost:5555` @@ -43,7 +43,7 @@ The local stack is defined in `docker-compose.yml`. | Service | Container name | Purpose | | --- | --- | --- | | `api` | `btaa-geospatial-api-app` | FastAPI app on port 8000 | -| `frontend` | `btaa-geospatial-api-frontend` | Vite/React dev server on port 3000 | +| `frontend` | `btaa-geospatial-api-frontend` | Vite/React dev server published on port 5173 | | `elasticsearch` | `btaa-geospatial-api-elasticsearch` | Elasticsearch on port 9200 | | `paradedb` | `btaa-geospatial-api-paradedb` | PostgreSQL/ParadeDB | | `redis` | `btaa-geospatial-api-redis` | Redis cache and Celery broker | diff --git a/docs/frontend/README.md b/docs/frontend/README.md index 4916dff4..d38819d6 100644 --- a/docs/frontend/README.md +++ b/docs/frontend/README.md @@ -1,8 +1,8 @@ # Frontend Documentation The frontend is the React/TypeScript public Geoportal interface for this -repository. It runs locally as the `frontend` Docker Compose service on port -3000, or directly with npm from `frontend/`. +repository. It runs locally as the `frontend` Docker Compose service on host +port 5173, or directly with npm on port 3000 from `frontend/`. ## Available Documentation diff --git a/frontend/src/__tests__/geoblacklight/layer_index_map.test.ts b/frontend/src/__tests__/geoblacklight/layer_index_map.test.ts new file mode 100644 index 00000000..e4108c1f --- /dev/null +++ b/frontend/src/__tests__/geoblacklight/layer_index_map.test.ts @@ -0,0 +1,155 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + availabilityStyle, + normalizeIndexMapProperties, + updateInformation, +} from '../../geoblacklight/layer_index_map'; + +describe('OpenIndexMap v1 information', () => { + beforeEach(() => { + document.body.innerHTML = '
'; + }); + + afterEach(() => { + vi.restoreAllMocks(); + document.body.innerHTML = ''; + }); + + it('normalizes v1 link, thumbnail, and identifier property names', () => { + expect( + normalizeIndexMapProperties({ + thumbUrl: 'https://example.com/thumb.jpg', + download: 'https://example.com/map.tif', + recId: 'map-27', + }) + ).toMatchObject({ + thumbnailUrl: 'https://example.com/thumb.jpg', + downloadUrl: 'https://example.com/map.tif', + recordIdentifier: 'map-27', + }); + }); + + it('renders v1 thumbnails and links for a selected feature', async () => { + await updateInformation({ + title: 'Jaguaribe', + label: 'SB 24', + websiteUrl: 'https://example.com/item', + download: 'https://example.com/map.tif', + digHold: 'https://example.com/digital-holdings', + physHold: 'Held in the map library', + iiifUrl: 'https://example.com/manifest.json', + thumbUrl: 'https://example.com/thumb.jpg', + recId: 'am002175', + }); + + const information = document.querySelector('.viewer-information'); + const image = information?.querySelector('img'); + const links = Array.from(information?.querySelectorAll('a') || []); + + expect(image).toHaveAttribute('src', 'https://example.com/thumb.jpg'); + expect(image).toHaveAttribute('alt', 'Thumbnail for Jaguaribe'); + expect(links.map((link) => link.getAttribute('href'))).toEqual( + expect.arrayContaining([ + 'https://example.com/item', + 'https://example.com/map.tif', + 'https://example.com/digital-holdings', + 'https://example.com/manifest.json', + ]) + ); + expect(information).toHaveTextContent('Record identifier'); + expect(information).toHaveTextContent('am002175'); + expect(information).toHaveTextContent('Held in the map library'); + }); + + it('renders digHold when it is the feature’s only item link', async () => { + await updateInformation({ + label: '19,20', + digHold: 'https://collections.example.edu/digital/item/25010', + recId: 'am002722', + }); + + const information = document.querySelector('.viewer-information'); + const link = information?.querySelector('a'); + + expect(information).toHaveTextContent('Digital holdings'); + expect(link).toHaveAttribute( + 'href', + 'https://collections.example.edu/digital/item/25010' + ); + }); + + it('continues to support legacy OpenIndexMap property names', () => { + expect( + normalizeIndexMapProperties({ + thumbnailUrl: 'https://example.com/legacy-thumb.jpg', + downloadUrl: 'https://example.com/legacy-map.tif', + recordIdentifier: 'legacy-1', + }) + ).toMatchObject({ + thumbnailUrl: 'https://example.com/legacy-thumb.jpg', + downloadUrl: 'https://example.com/legacy-map.tif', + recordIdentifier: 'legacy-1', + }); + }); + + it('uses a IIIF manifest thumbnail when thumbUrl is absent', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response( + JSON.stringify({ + thumbnail: [{ id: 'https://example.com/iiif-thumb.jpg' }], + }), + { status: 200, headers: { 'Content-Type': 'application/json' } } + ) + ); + + await updateInformation({ + label: '27', + iiifUrl: 'https://example.com/manifest.json', + }); + + expect(document.querySelector('.viewer-information img')).toHaveAttribute( + 'src', + 'https://example.com/iiif-thumb.jpg' + ); + }); + + it('escapes remote text and does not link unsafe URL schemes', async () => { + await updateInformation({ + title: '', + websiteUrl: 'javascript:alert(1)', + }); + + const information = document.querySelector('.viewer-information'); + + expect(information?.querySelector('img')).not.toBeInTheDocument(); + expect(information?.querySelector('a')).not.toBeInTheDocument(); + expect(information).toHaveTextContent(''); + }); + + it('applies availability and opacity without mutating configured styles', () => { + const defaultStyle = { color: 'green' }; + const unavailableStyle = { color: 'yellow' }; + const options = { + opacity: 0.5, + LAYERS: { + INDEX: { + DEFAULT: defaultStyle, + UNAVAILABLE: unavailableStyle, + }, + }, + }; + + expect(availabilityStyle(true, options)).toEqual({ + color: 'green', + fillOpacity: 0.5, + opacity: 0.5, + }); + expect(availabilityStyle(false, options)).toEqual({ + color: 'yellow', + fillOpacity: 0.5, + opacity: 0.5, + }); + expect(defaultStyle).toEqual({ color: 'green' }); + expect(unavailableStyle).toEqual({ color: 'yellow' }); + }); +}); diff --git a/frontend/src/components/resource/ResourceView.tsx b/frontend/src/components/resource/ResourceView.tsx index aadd3af0..9f656001 100644 --- a/frontend/src/components/resource/ResourceView.tsx +++ b/frontend/src/components/resource/ResourceView.tsx @@ -38,7 +38,7 @@ interface ItemData { // New component for index map function IndexMap() { - return
; + return
; } // New component for the attribute table diff --git a/frontend/src/geoblacklight/layer_index_map.ts b/frontend/src/geoblacklight/layer_index_map.ts new file mode 100644 index 00000000..0bb844a7 --- /dev/null +++ b/frontend/src/geoblacklight/layer_index_map.ts @@ -0,0 +1,213 @@ +interface IndexMapProperties { + [key: string]: unknown; +} + +interface NormalizedIndexMapProperties { + title?: string; + label?: string; + note?: string; + websiteUrl?: string; + downloadUrl?: string; + thumbnailUrl?: string; + iiifUrl?: string; + digitalHoldings?: string; + physicalHoldings?: string; + recordIdentifier?: string; +} + +interface IndexMapStyle { + [key: string]: unknown; + fillOpacity?: number; + opacity?: number; +} + +interface IndexMapLeafletOptions { + opacity?: number; + LAYERS: { + INDEX: { + DEFAULT: IndexMapStyle; + UNAVAILABLE: IndexMapStyle; + }; + }; +} + +let informationRequestId = 0; + +function stringValue(value: unknown): string | undefined { + if (typeof value !== 'string') return undefined; + + const trimmed = value.trim(); + return trimmed || undefined; +} + +function firstString(...values: unknown[]): string | undefined { + return values.map(stringValue).find(Boolean); +} + +function escapeHtml(value: string): string { + return value + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", '''); +} + +function safeHttpUrl(value: string | undefined): string | undefined { + if (!value) return undefined; + + try { + const url = new URL(value); + return url.protocol === 'http:' || url.protocol === 'https:' + ? url.toString() + : undefined; + } catch { + return undefined; + } +} + +function renderLinkOrText(value: string): string { + const url = safeHttpUrl(value); + const escapedValue = escapeHtml(value); + + if (!url) return escapedValue; + + return `${escapedValue}`; +} + +function renderDefinition(label: string, value: string | undefined): string { + if (!value) return ''; + + return ` +
+
${escapeHtml(label)}
+
${renderLinkOrText(value)}
+
`; +} + +function extractIiifThumbnail(manifest: unknown): string | undefined { + if (!manifest || typeof manifest !== 'object') return undefined; + + const thumbnail = (manifest as Record).thumbnail; + const candidate = Array.isArray(thumbnail) ? thumbnail[0] : thumbnail; + + if (typeof candidate === 'string') return safeHttpUrl(candidate); + if (!candidate || typeof candidate !== 'object') return undefined; + + const thumbnailObject = candidate as Record; + return safeHttpUrl(firstString(thumbnailObject.id, thumbnailObject['@id'])); +} + +export function normalizeIndexMapProperties( + properties: IndexMapProperties +): NormalizedIndexMapProperties { + return { + title: stringValue(properties.title), + label: stringValue(properties.label), + note: stringValue(properties.note), + websiteUrl: stringValue(properties.websiteUrl), + // OpenIndexMaps v1 shortened these three legacy property names. + downloadUrl: firstString(properties.download, properties.downloadUrl), + thumbnailUrl: firstString(properties.thumbUrl, properties.thumbnailUrl), + recordIdentifier: firstString( + properties.recId, + properties.recordIdentifier + ), + iiifUrl: stringValue(properties.iiifUrl), + digitalHoldings: stringValue(properties.digHold), + physicalHoldings: stringValue(properties.physHold), + }; +} + +export function renderIndexMapInformation( + properties: NormalizedIndexMapProperties +): string { + const thumbnailUrl = safeHttpUrl(properties.thumbnailUrl); + const thumbnailLink = + safeHttpUrl(properties.websiteUrl) || + safeHttpUrl(properties.digitalHoldings) || + safeHttpUrl(properties.downloadUrl); + const thumbnailAlt = `Thumbnail for ${properties.title || properties.label || 'selected map sheet'}`; + const digitalHoldings = + properties.digitalHoldings === properties.websiteUrl + ? undefined + : properties.digitalHoldings; + + const thumbnail = thumbnailUrl + ? `${escapeHtml(thumbnailAlt)}` + : ''; + const linkedThumbnail = + thumbnail && thumbnailLink + ? `${thumbnail}` + : thumbnail; + + return ` +
+
+ ${properties.title ? `

${escapeHtml(properties.title)}

` : '

Selected map sheet

'} + ${linkedThumbnail ? `
${linkedThumbnail}
` : ''} +
+
+ ${renderDefinition('Website', properties.websiteUrl)} + ${renderDefinition('Download', properties.downloadUrl)} + ${renderDefinition('Digital holdings', digitalHoldings)} + ${renderDefinition('Physical holdings', properties.physicalHoldings)} + ${renderDefinition('IIIF manifest', properties.iiifUrl)} + ${renderDefinition('Record identifier', properties.recordIdentifier)} + ${renderDefinition('Label', properties.label)} + ${renderDefinition('Note', properties.note)} +
+
`; +} + +async function fetchIiifThumbnail( + iiifUrl: string | undefined +): Promise { + const manifestUrl = safeHttpUrl(iiifUrl); + if (!manifestUrl) return undefined; + + try { + const response = await fetch(manifestUrl); + if (!response.ok) return undefined; + + return extractIiifThumbnail(await response.json()); + } catch { + return undefined; + } +} + +export const availabilityStyle = ( + availability: unknown, + leafletOptions: IndexMapLeafletOptions +): IndexMapStyle => { + const baseStyle = + availability || typeof availability === 'undefined' + ? leafletOptions.LAYERS.INDEX.DEFAULT + : leafletOptions.LAYERS.INDEX.UNAVAILABLE; + const opacity = leafletOptions.opacity || 0.65; + + return { ...baseStyle, fillOpacity: opacity, opacity }; +}; + +export async function updateInformation( + rawProperties: IndexMapProperties +): Promise { + const informationElement = document.querySelector( + '.viewer-information' + ); + if (!informationElement) return; + + const requestId = ++informationRequestId; + const properties = normalizeIndexMapProperties(rawProperties); + informationElement.innerHTML = renderIndexMapInformation(properties); + + if (properties.thumbnailUrl || !properties.iiifUrl) return; + + const thumbnailUrl = await fetchIiifThumbnail(properties.iiifUrl); + if (!thumbnailUrl || requestId !== informationRequestId) return; + + informationElement.innerHTML = renderIndexMapInformation({ + ...properties, + thumbnailUrl, + }); +} diff --git a/frontend/src/pages/ResourceView.tsx b/frontend/src/pages/ResourceView.tsx index 12d29df8..30b76705 100644 --- a/frontend/src/pages/ResourceView.tsx +++ b/frontend/src/pages/ResourceView.tsx @@ -125,7 +125,7 @@ interface ResourceData extends GeoDocument { // New component for index map function IndexMap() { - return
; + return
; } // New component for the attribute table diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 1604d094..ba3bd161 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -19,6 +19,12 @@ export default defineConfig({ // GeoBlacklight's source files import internal modules via `geoblacklight/...`. // Map that prefix to the package's source directory so Vite can resolve them. alias: { + // Local override: GeoBlacklight 5.1.0 still renders legacy OpenIndexMap + // property names. Normalize the v1 schema's links and thumbnails locally. + "geoblacklight/leaflet/layer_index_map": path.resolve( + __dirname, + "src/geoblacklight/layer_index_map.ts", + ), // Local override: GeoBlacklight 5.1.0 does not pass its MAP options to L.map. // This keeps the upstream controller intact except for map creation. "geoblacklight/controllers/leaflet_viewer_controller": path.resolve(