diff --git a/shared/src/maps/maps.schema.spec.ts b/shared/src/maps/maps.schema.spec.ts index bc97643b6..fa6efcec2 100644 --- a/shared/src/maps/maps.schema.spec.ts +++ b/shared/src/maps/maps.schema.spec.ts @@ -3,6 +3,10 @@ import { mapsAutocompleteRequestSchema, mapsReverseQuerySchema, mapsResolveUrlRequestSchema, + mapsPlaceEnrichmentRequestSchema, + mapsPlaceEnrichmentResultSchema, + placePhotoCandidateSchema, + placeDescriptionSchema, } from './maps.schema'; import { describe, it, expect } from 'vitest'; @@ -60,3 +64,94 @@ describe('mapsResolveUrlRequestSchema', () => { expect(mapsResolveUrlRequestSchema.safeParse({ url: '' }).success).toBe(false); }); }); + +describe('mapsPlaceEnrichmentRequestSchema', () => { + const base = { lat: 52.5, lng: 13.4, name: 'Museum Ludwig' }; + + it('needs coordinates and a name, but not a place id', () => { + expect(mapsPlaceEnrichmentRequestSchema.safeParse(base).success).toBe(true); + expect(mapsPlaceEnrichmentRequestSchema.safeParse({ ...base, placeId: 'ChIJx' }).success).toBe(true); + expect(mapsPlaceEnrichmentRequestSchema.safeParse({ lat: 52.5, lng: 13.4 }).success).toBe(false); + expect(mapsPlaceEnrichmentRequestSchema.safeParse({ ...base, name: '' }).success).toBe(false); + }); + + it('rejects coordinates that arrived as strings or null', () => { + // The client reads these off a provider blob, where lat can be a string. + expect(mapsPlaceEnrichmentRequestSchema.safeParse({ ...base, lat: '52.5' }).success).toBe(false); + expect(mapsPlaceEnrichmentRequestSchema.safeParse({ ...base, lng: null }).success).toBe(false); + }); + + it('caps the free-text fields so a pasted blob cannot reach the providers', () => { + expect(mapsPlaceEnrichmentRequestSchema.safeParse({ ...base, name: 'x'.repeat(301) }).success).toBe(false); + expect(mapsPlaceEnrichmentRequestSchema.safeParse({ ...base, placeId: 'x'.repeat(301) }).success).toBe(false); + }); +}); + +describe('placePhotoCandidateSchema', () => { + const candidate = { + key: 'ChIJx~p0', + url: '/api/maps/place-photo/ChIJx~p0/bytes', + attribution: 'Jane Doe', + license: 'CC BY-SA 4.0', + licenseUrl: 'https://creativecommons.org/licenses/by-sa/4.0/', + sourceUrl: 'https://commons.wikimedia.org/wiki/File:X.jpg', + source: 'wikimedia', + }; + + it('accepts a fully attributed candidate', () => { + expect(placePhotoCandidateSchema.safeParse(candidate).success).toBe(true); + }); + + it('allows every licence field to be null, because Google supplies none of them', () => { + expect( + placePhotoCandidateSchema.safeParse({ + ...candidate, + source: 'google', + attribution: null, + license: null, + licenseUrl: null, + sourceUrl: null, + }).success, + ).toBe(true); + }); + + it('requires the fields to be present even when null, so the column cannot silently skip attribution', () => { + const { attribution, ...withoutAttribution } = candidate; + void attribution; + expect(placePhotoCandidateSchema.safeParse(withoutAttribution).success).toBe(false); + }); + + it('rejects an unknown source', () => { + expect(placePhotoCandidateSchema.safeParse({ ...candidate, source: 'unsplash' }).success).toBe(false); + }); +}); + +describe('placeDescriptionSchema', () => { + it('keeps the source alongside the text', () => { + expect( + placeDescriptionSchema.safeParse({ + text: 'A museum in Cologne.', + source: 'wikipedia', + sourceUrl: 'https://de.wikipedia.org/wiki/Museum_Ludwig', + license: 'CC BY-SA 4.0', + }).success, + ).toBe(true); + expect(placeDescriptionSchema.safeParse({ text: 'A museum.' }).success).toBe(false); + }); +}); + +describe('mapsPlaceEnrichmentResultSchema', () => { + it('accepts an empty result, which is what a keyless instance with no Commons hit returns', () => { + expect(mapsPlaceEnrichmentResultSchema.safeParse({ photos: [], description: null }).success).toBe(true); + }); + + it('accepts the kill-switch response', () => { + expect( + mapsPlaceEnrichmentResultSchema.safeParse({ photos: [], description: null, disabled: true }).success, + ).toBe(true); + }); + + it('requires description to be explicitly null rather than absent', () => { + expect(mapsPlaceEnrichmentResultSchema.safeParse({ photos: [] }).success).toBe(false); + }); +}); diff --git a/shared/src/maps/maps.schema.ts b/shared/src/maps/maps.schema.ts index 5daf767ef..94cc49d16 100644 --- a/shared/src/maps/maps.schema.ts +++ b/shared/src/maps/maps.schema.ts @@ -3,12 +3,12 @@ import { z } from 'zod'; /** * Maps / geo API contract — single source of truth for the /api/maps endpoints. * - * The legacy Express route (server/src/routes/maps.ts) is a thin layer over - * services/mapsService.ts, which talks to Nominatim/Overpass (and optionally - * Google Places when a key is configured) and applies the SSRF guard on every - * outbound URL. The place objects these return are provider-shaped and vary by - * source, so the response schemas keep them as open records — the contract pins - * down the request shapes and the stable envelope fields, not the provider blobs. + * server/src/nest/maps/maps.service.ts talks to Nominatim/Overpass (and + * optionally Google Places when a key is configured) and applies the SSRF guard + * on every outbound URL. The place objects these return are provider-shaped and + * vary by source, so the response schemas keep them as open records — the + * contract pins down the request shapes and the stable envelope fields, not the + * provider blobs. * * Since the maps body-contract ratchet, the request schemas below are enforced * on the server via createZodDto wrappers (maps.dto.ts) and the global @@ -94,3 +94,62 @@ export const mapsResolveUrlResultSchema = z.object({ google_ftid: z.string().nullable().optional(), }); export type MapsResolveUrlResult = z.infer; + +/** + * Place enrichment — the photo candidates and description shown next to the + * search field while adding a place. + * + * Unlike the endpoints above this one is not provider-shaped: the whole point + * is that a Commons image and a Google photo arrive in the same shape, so the + * column renders one strip regardless of which sources the instance has. + * Everything nullable is genuinely optional per source — Commons gives us a + * licence and an author, Google gives us neither in a form we may reproduce. + */ +export const placePhotoSourceSchema = z.enum(['google', 'wikimedia', 'wikipedia', 'cached']); +export type PlacePhotoSource = z.infer; + +export const placePhotoCandidateSchema = z.object({ + /** Cache key, also the React key. Candidates use `~p`. */ + key: z.string(), + /** Proxy URL (/api/maps/place-photo//bytes) — never a provider URL. */ + url: z.string(), + /** Author/creator as the provider names them, not the provider itself. */ + attribution: z.string().nullable(), + /** Short licence name, e.g. "CC BY-SA 4.0". */ + license: z.string().nullable(), + licenseUrl: z.string().nullable(), + /** The file description page, where the full terms live. */ + sourceUrl: z.string().nullable(), + source: placePhotoSourceSchema, +}); +export type PlacePhotoCandidate = z.infer; + +export const placeDescriptionSourceSchema = z.enum(['google', 'osm', 'wikipedia']); +export type PlaceDescriptionSource = z.infer; + +export const placeDescriptionSchema = z.object({ + text: z.string(), + source: placeDescriptionSourceSchema, + sourceUrl: z.string().nullable(), + license: z.string().nullable(), +}); +export type PlaceDescription = z.infer; + +export const mapsPlaceEnrichmentRequestSchema = z.object({ + /** Google place id or `osm:/`; empty for a coordinate-only lookup. */ + placeId: z.string().max(300).optional(), + lat: z.number(), + lng: z.number(), + /** Used to resolve a Wikipedia article when the place carries no wiki tag. */ + name: z.string().min(1).max(300), + lang: z.string().max(35).optional(), +}); +export type MapsPlaceEnrichmentRequest = z.infer; + +export const mapsPlaceEnrichmentResultSchema = z.object({ + photos: z.array(placePhotoCandidateSchema), + description: placeDescriptionSchema.nullable(), + /** True when the admin switched enrichment off; the column then stays quiet. */ + disabled: z.boolean().optional(), +}); +export type MapsPlaceEnrichmentResult = z.infer;