diff --git a/apps/web/src/lib/search/geocode-input.test.ts b/apps/web/src/lib/search/geocode-input.test.ts new file mode 100644 index 0000000..3016fc2 --- /dev/null +++ b/apps/web/src/lib/search/geocode-input.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from 'vitest'; +import * as v from 'valibot'; +import { geocodeInput } from './geocode-input'; + +describe('geocodeInput', () => { + it('accepts a place query and trims it', () => { + expect(v.parse(geocodeInput, { q: ' Louisville, KY ' })).toEqual({ q: 'Louisville, KY' }); + }); + + it('rejects queries that are empty after trimming', () => { + expect(() => v.parse(geocodeInput, { q: ' ' })).toThrow(); + }); + + it('rejects oversized queries so arbitrary payloads are not forwarded upstream', () => { + expect(() => v.parse(geocodeInput, { q: 'x'.repeat(201) })).toThrow(); + }); +}); diff --git a/apps/web/src/lib/search/geocode-input.ts b/apps/web/src/lib/search/geocode-input.ts new file mode 100644 index 0000000..f52ea68 --- /dev/null +++ b/apps/web/src/lib/search/geocode-input.ts @@ -0,0 +1,17 @@ +// Input contract for the geocode remote command. Pure module (no server-only +// imports) so production validation is directly testable. +import * as v from 'valibot'; + +/** Generous for any address; small enough to not relay arbitrary payloads. */ +export const MAX_GEOCODE_QUERY_LENGTH = 200; + +export const geocodeInput = v.object({ + q: v.pipe( + v.string(), + v.trim(), + v.minLength(1), + v.maxLength(MAX_GEOCODE_QUERY_LENGTH) + ) +}); + +export type GeocodeInput = v.InferOutput; diff --git a/apps/web/src/lib/search/geocode.remote.ts b/apps/web/src/lib/search/geocode.remote.ts new file mode 100644 index 0000000..d5ca806 --- /dev/null +++ b/apps/web/src/lib/search/geocode.remote.ts @@ -0,0 +1,16 @@ +import { command } from '$app/server'; +import { error } from '@sveltejs/kit'; +import { geocodeLocation } from './server/geocode'; +import { geocodeInput } from './geocode-input'; + +/** Resolve a free-text address / zip / place name to coordinates so the + * near-me page works without device location. Runs server-side so the + * geocoder sees the Worker, not the user. Returns null when nothing + * matches (distinct from upstream failure, which is a 502). */ +export const geocodeQuery = command(geocodeInput, async ({ q }) => { + try { + return await geocodeLocation(q); + } catch { + error(502, 'Location lookup is unavailable right now'); + } +}); diff --git a/apps/web/src/lib/search/server/geocode.test.ts b/apps/web/src/lib/search/server/geocode.test.ts new file mode 100644 index 0000000..676b351 --- /dev/null +++ b/apps/web/src/lib/search/server/geocode.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it, vi } from 'vitest'; +import { geocodeLocation } from './geocode'; + +function fakeFetch(status: number, body: unknown) { + return vi.fn(async () => new Response(JSON.stringify(body), { status })); +} + +const louisville = [ + { lat: '38.2542', + lon: '-85.7594', + display_name: 'Louisville, Jefferson County, Kentucky, United States' } +]; + +describe('geocodeLocation', () => { + it('queries Nominatim and returns the top result as numeric coords', async () => { + const fetchImpl = fakeFetch(200, louisville); + + const result = await geocodeLocation('Louisville, KY', fetchImpl); + + expect(fetchImpl).toHaveBeenCalledTimes(1); + const [input, init] = fetchImpl.mock.calls[0] as unknown as [URL | string, RequestInit]; + const url = new URL(String(input)); + expect(url.hostname).toBe('nominatim.openstreetmap.org'); + expect(url.pathname).toBe('/search'); + expect(url.searchParams.get('q')).toBe('Louisville, KY'); + expect(url.searchParams.get('format')).toBe('jsonv2'); + expect(url.searchParams.get('limit')).toBe('1'); + // Nominatim's usage policy requires an identifying User-Agent. + expect(new Headers(init.headers).get('user-agent')).toMatch(/openmeet/i); + + expect(result).toEqual({ + lat: 38.2542, + lng: -85.7594, + label: 'Louisville, Jefferson County, Kentucky, United States' + }); + }); + + it('returns null when nothing matches', async () => { + expect(await geocodeLocation('zzzz no such place', fakeFetch(200, []))).toBeNull(); + }); + + it('returns null when the result has non-numeric coordinates', async () => { + const garbage = [{ lat: 'not-a-number', lon: '-85.7594', display_name: 'x' }]; + expect(await geocodeLocation('somewhere', fakeFetch(200, garbage))).toBeNull(); + }); + + it('throws on upstream failure without including the response body', async () => { + const fetchImpl = fakeFetch(503, { secret: 'internal upstream detail' }); + await expect(geocodeLocation('Louisville', fetchImpl)).rejects.toThrow( + 'geocode request failed: 503' + ); + await expect(geocodeLocation('Louisville', fetchImpl)).rejects.not.toThrow(/internal/); + }); +}); diff --git a/apps/web/src/lib/search/server/geocode.ts b/apps/web/src/lib/search/server/geocode.ts new file mode 100644 index 0000000..322a959 --- /dev/null +++ b/apps/web/src/lib/search/server/geocode.ts @@ -0,0 +1,45 @@ +// Forward geocoding for the near-me page: free-text address / zip / place +// name → coordinates, so users can search a location without sharing their +// device position. Uses Nominatim (OpenStreetMap); its usage policy requires +// an identifying User-Agent and tolerates only light traffic, which fits a +// user-initiated search box (never called in a loop). +export type GeocodeResult = { + lat: number; + lng: number; + /** Display name of the match, shown so users can spot a wrong match. */ + label: string; +}; + +const NOMINATIM_SEARCH_URL = 'https://nominatim.openstreetmap.org/search'; +const USER_AGENT = 'openmeet-atmo (https://openmeet.net)'; + +export async function geocodeLocation( + q: string, + fetchImpl: typeof fetch = fetch +): Promise { + const url = new URL(NOMINATIM_SEARCH_URL); + url.searchParams.set('q', q); + url.searchParams.set('format', 'jsonv2'); + url.searchParams.set('limit', '1'); + + const response = await fetchImpl(url, { + headers: { accept: 'application/json', 'user-agent': USER_AGENT } + }); + if (!response.ok) { + throw new Error(`geocode request failed: ${response.status}`); + } + + const results = (await response.json()) as { + lat?: string; + lon?: string; + display_name?: string; + }[]; + const top = results[0]; + if (!top) return null; + + const lat = Number(top.lat); + const lng = Number(top.lon); + if (!Number.isFinite(lat) || !Number.isFinite(lng)) return null; + + return { lat, lng, label: top.display_name ?? q }; +} diff --git a/apps/web/src/routes/(app)/near-me/+page.svelte b/apps/web/src/routes/(app)/near-me/+page.svelte index 6bcee5e..aa99df3 100644 --- a/apps/web/src/routes/(app)/near-me/+page.svelte +++ b/apps/web/src/routes/(app)/near-me/+page.svelte @@ -1,8 +1,9 @@