diff --git a/packages/api/src/routes/trails/index.ts b/packages/api/src/routes/trails/index.ts index f30b3bf6b4..f0fd9873b1 100644 --- a/packages/api/src/routes/trails/index.ts +++ b/packages/api/src/routes/trails/index.ts @@ -5,17 +5,6 @@ import { sql } from 'drizzle-orm'; import { Elysia, status } from 'elysia'; import { z } from 'zod'; -// ── OG meta extraction (AllTrails preview) ─────────────────────────────────── -// Two attribute orderings are valid per the HTML spec: property-then-content and -// content-then-property. Static top-level regexes avoid dynamic RegExp construction. - -const OG_TITLE_A = /]+property=["']og:title["'][^>]+content=["']([^"']+)["']/i; -const OG_TITLE_B = /]+content=["']([^"']+)["'][^>]+property=["']og:title["']/i; -const OG_DESC_A = /]+property=["']og:description["'][^>]+content=["']([^"']+)["']/i; -const OG_DESC_B = /]+content=["']([^"']+)["'][^>]+property=["']og:description["']/i; -const OG_IMAGE_A = /]+property=["']og:image["'][^>]+content=["']([^"']+)["']/i; -const OG_IMAGE_B = /]+content=["']([^"']+)["'][^>]+property=["']og:image["']/i; - // ── Zod schemas ───────────────────────────────────────────────────────────── const OsmMemberSchema = z.object({ @@ -102,21 +91,26 @@ export const trailsRoutes = new Elysia({ prefix: '/trails' }) ORDER BY CASE WHEN name IS NOT NULL THEN 0 ELSE 1 END, name - LIMIT ${limit} OFFSET ${offset} + LIMIT ${limit + 1} OFFSET ${offset} `); const rows = z.array(RouteSearchRowSchema).parse(result.rows); + const hasMore = rows.length > limit; + const page = rows.slice(0, limit); - return rows.map((row) => ({ - osmId: row.osm_id, - name: row.name, - sport: row.sport, - network: row.network, - distance: row.distance, - difficulty: row.difficulty, - description: row.description, - bbox: row.bbox ? JSON.parse(row.bbox) : null, - })); + return { + trails: page.map((row) => ({ + osmId: row.osm_id, + name: row.name, + sport: row.sport, + network: row.network, + distance: row.distance, + difficulty: row.difficulty, + description: row.description, + bbox: row.bbox ? JSON.parse(row.bbox) : null, + })), + hasMore, + }; } catch (error) { if (error instanceof Error && error.message.includes('not configured')) { return status(503, { error: 'Trail features are not enabled on this server' }); @@ -279,96 +273,4 @@ export const trailsRoutes = new Elysia({ prefix: '/trails' }) security: [{ bearerAuth: [] }], }, }, - ) - - /** - * POST /api/trails/alltrails-preview - * - * Fetches an AllTrails URL server-side and extracts OpenGraph metadata. - */ - .post( - '/alltrails-preview', - async ({ body }) => { - const { url } = body; - - let parsed: URL; - try { - parsed = new URL(url); - } catch { - return status(400, { error: 'Invalid URL' }); - } - - const { hostname, protocol } = parsed; - if ( - protocol !== 'https:' || - (hostname !== 'alltrails.com' && !hostname.endsWith('.alltrails.com')) - ) { - return status(400, { error: 'Only https://alltrails.com URLs are supported' }); - } - - const AT_UA = 'Mozilla/5.0 (compatible; PackRat/1.0; +https://packrat.world)'; - - try { - let response = await fetch(url, { - headers: { 'User-Agent': AT_UA, Accept: 'text/html' }, - redirect: 'manual', - signal: AbortSignal.timeout(8000), - }); - - if (response.status >= 300 && response.status < 400) { - const location = response.headers.get('location'); - if (!location) - return status(502, { error: 'AllTrails redirected without Location header' }); - let redirectUrl: URL; - try { - redirectUrl = new URL(location, url); - } catch { - return status(502, { error: 'Invalid redirect URL' }); - } - if ( - redirectUrl.protocol !== 'https:' || - (redirectUrl.hostname !== 'alltrails.com' && - !redirectUrl.hostname.endsWith('.alltrails.com')) - ) { - return status(400, { error: 'Redirect target is not alltrails.com' }); - } - response = await fetch(redirectUrl.toString(), { - headers: { 'User-Agent': AT_UA, Accept: 'text/html' }, - redirect: 'error', - signal: AbortSignal.timeout(8000), - }); - } - - if (!response.ok) { - return status(502, { error: `AllTrails returned ${response.status}` }); - } - - const html = await response.text(); - - const title = (html.match(OG_TITLE_A) ?? html.match(OG_TITLE_B))?.[1] ?? null; - const description = (html.match(OG_DESC_A) ?? html.match(OG_DESC_B))?.[1] ?? null; - const image = (html.match(OG_IMAGE_A) ?? html.match(OG_IMAGE_B))?.[1] ?? null; - - if (!title) { - return status(422, { error: 'Could not extract trail metadata from page' }); - } - - return { title, description, image, url: response.url }; - } catch (error) { - if (error instanceof Error && error.name === 'TimeoutError') { - return status(504, { error: 'AllTrails request timed out' }); - } - console.error('AllTrails preview error:', error); - return status(502, { error: 'Failed to fetch AllTrails page' }); - } - }, - { - body: z.object({ url: z.string().url() }), - isAuthenticated: true, - detail: { - tags: ['Trails'], - summary: 'Fetch trail card metadata from an AllTrails URL via OG tags', - security: [{ bearerAuth: [] }], - }, - }, ); diff --git a/packages/api/test/trails.test.ts b/packages/api/test/trails.test.ts index d528a54f45..f3c86943bb 100644 --- a/packages/api/test/trails.test.ts +++ b/packages/api/test/trails.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { beforeEach, describe, expect, it } from 'vitest'; import { TEST_GEOMETRY_LAT, TEST_GEOMETRY_LON } from './fixtures/trail-fixtures'; import { seedOsmRoute, seedOsmWay } from './utils/osm-db-helpers'; import { @@ -164,8 +164,10 @@ describe('Trails Routes', () => { const res = await apiWithAuth('/trails/search?q=John+Muir+Test'); const data = await expectJsonResponse(res); - expect(Array.isArray(data)).toBe(true); - const found = data.find((t: { osmId: string }) => t.osmId === String(RELATION_WITH_GEOM_ID)); + expect(Array.isArray(data.trails)).toBe(true); + const found = data.trails.find( + (t: { osmId: string }) => t.osmId === String(RELATION_WITH_GEOM_ID), + ); expect(found).toBeDefined(); expect(found.name).toBe('John Muir Test Trail'); expect(found.network).toBe('rwn'); @@ -176,15 +178,18 @@ describe('Trails Routes', () => { const res = await apiWithAuth('/trails/search?q=john+muir+test'); const data = await expectJsonResponse(res); - const found = data.find((t: { osmId: string }) => t.osmId === String(RELATION_WITH_GEOM_ID)); + const found = data.trails.find( + (t: { osmId: string }) => t.osmId === String(RELATION_WITH_GEOM_ID), + ); expect(found).toBeDefined(); }); it('returns empty array for a query that matches nothing', async () => { const res = await apiWithAuth('/trails/search?q=zzz_no_match_zzz'); const data = await expectJsonResponse(res); - expect(Array.isArray(data)).toBe(true); - expect(data).toHaveLength(0); + expect(Array.isArray(data.trails)).toBe(true); + expect(data.trails).toHaveLength(0); + expect(data.hasMore).toBe(false); }); it('does spatial search by lat/lon and returns nearby trails', async () => { @@ -194,9 +199,9 @@ describe('Trails Routes', () => { ); const data = await expectJsonResponse(res); - expect(Array.isArray(data)).toBe(true); + expect(Array.isArray(data.trails)).toBe(true); // At least the relation with pre-built geometry should be within 50 km - const osmIds = data.map((t: { osmId: string }) => t.osmId); + const osmIds = data.trails.map((t: { osmId: string }) => t.osmId); expect(osmIds).toContain(String(RELATION_WITH_GEOM_ID)); }); @@ -206,16 +211,16 @@ describe('Trails Routes', () => { `/trails/search?q=John+Muir+Test&lat=${TEST_GEOMETRY_LAT}&lon=${TEST_GEOMETRY_LON}&radius=50`, ); const hit = await expectJsonResponse(resHit); - expect(hit.some((t: { osmId: string }) => t.osmId === String(RELATION_WITH_GEOM_ID))).toBe( - true, - ); + expect( + hit.trails.some((t: { osmId: string }) => t.osmId === String(RELATION_WITH_GEOM_ID)), + ).toBe(true); // Correct name but very far location → no match const resMiss = await apiWithAuth('/trails/search?q=John+Muir+Test&lat=0&lon=0&radius=1'); const miss = await expectJsonResponse(resMiss); - expect(miss.some((t: { osmId: string }) => t.osmId === String(RELATION_WITH_GEOM_ID))).toBe( - false, - ); + expect( + miss.trails.some((t: { osmId: string }) => t.osmId === String(RELATION_WITH_GEOM_ID)), + ).toBe(false); }); it('returns 400 for out-of-range coordinates', async () => { @@ -236,7 +241,7 @@ describe('Trails Routes', () => { ); const data = await expectJsonResponse(res); - const osmIds = data.map((t: { osmId: string }) => t.osmId); + const osmIds = data.trails.map((t: { osmId: string }) => t.osmId); expect(osmIds).toContain(String(RELATION_HIKING_ID)); expect(osmIds).not.toContain(String(RELATION_CYCLING_ID)); }); @@ -244,7 +249,9 @@ describe('Trails Routes', () => { it('returns sport field in search results', async () => { const res = await apiWithAuth('/trails/search?q=Pacific+Crest+Hiking'); const data = await expectJsonResponse(res); - const found = data.find((t: { osmId: string }) => t.osmId === String(RELATION_HIKING_ID)); + const found = data.trails.find( + (t: { osmId: string }) => t.osmId === String(RELATION_HIKING_ID), + ); expect(found).toBeDefined(); expect(found.sport).toBe('hiking'); }); @@ -254,21 +261,24 @@ describe('Trails Routes', () => { `/trails/search?lat=${TEST_GEOMETRY_LAT}&lon=${TEST_GEOMETRY_LON}&radius=500&limit=1&offset=0`, ); const page1 = await expectJsonResponse(res1); - expect(page1).toHaveLength(1); + expect(page1.trails).toHaveLength(1); + expect(page1.hasMore).toBe(true); const res2 = await apiWithAuth( `/trails/search?lat=${TEST_GEOMETRY_LAT}&lon=${TEST_GEOMETRY_LON}&radius=500&limit=1&offset=1`, ); const page2 = await expectJsonResponse(res2); - expect(page2).toHaveLength(1); + expect(page2.trails).toHaveLength(1); - expect(page1[0].osmId).not.toBe(page2[0].osmId); + expect(page1.trails[0].osmId).not.toBe(page2.trails[0].osmId); }); it('returns bbox when geometry is present', async () => { const res = await apiWithAuth('/trails/search?q=John+Muir+Test'); const data = await expectJsonResponse(res); - const found = data.find((t: { osmId: string }) => t.osmId === String(RELATION_WITH_GEOM_ID)); + const found = data.trails.find( + (t: { osmId: string }) => t.osmId === String(RELATION_WITH_GEOM_ID), + ); expect(found.bbox).not.toBeNull(); expect(found.bbox.type).toBe('Polygon'); }); @@ -276,7 +286,9 @@ describe('Trails Routes', () => { it('returns null bbox when geometry is null', async () => { const res = await apiWithAuth('/trails/search?q=Unstored+Geometry'); const data = await expectJsonResponse(res); - const found = data.find((t: { osmId: string }) => t.osmId === String(RELATION_NO_GEOM_ID)); + const found = data.trails.find( + (t: { osmId: string }) => t.osmId === String(RELATION_NO_GEOM_ID), + ); expect(found).toBeDefined(); expect(found.bbox).toBeNull(); }); @@ -347,11 +359,10 @@ describe('Trails Routes', () => { expect(data.geometry.type).toMatch(/^(LineString|MultiLineString)$/); }); - it('returns stitched geometry on repeated calls when stored geometry is null', async () => { - // First call: triggers stitching and caching + it('stitches geometry on every call when stored geometry is null (no write-back)', async () => { + // Both calls stitch from member ways — the route does not write the result back. await apiWithAuth(`/trails/${RELATION_NO_GEOM_ID}/geometry`); - // Second call: should now hit the cached geometry branch const res2 = await apiWithAuth(`/trails/${RELATION_NO_GEOM_ID}/geometry`); const data2 = await expectJsonResponse(res2, ['osmId', 'geometry']); @@ -397,126 +408,4 @@ describe('Trails Routes', () => { expect(data.geometry.type).toBe('MultiLineString'); }); }); - - // ── POST /trails/alltrails-preview ──────────────────────────────────────── - - describe('POST /trails/alltrails-preview', () => { - afterEach(() => { - vi.unstubAllGlobals(); - }); - - it('returns 400 for a non-alltrails.com URL', async () => { - const res = await apiWithAuth('/trails/alltrails-preview', { - method: 'POST', - body: JSON.stringify({ url: 'https://example.com/trail' }), - headers: { 'Content-Type': 'application/json' }, - }); - expectBadRequest(res); - }); - - it('returns 400 for an invalid (non-URL) string', async () => { - const res = await apiWithAuth('/trails/alltrails-preview', { - method: 'POST', - body: JSON.stringify({ url: 'not-a-url' }), - headers: { 'Content-Type': 'application/json' }, - }); - expectBadRequest(res); - }); - - it('returns 400 for a URL on an alltrails subdomain that is not alltrails.com', async () => { - const res = await apiWithAuth('/trails/alltrails-preview', { - method: 'POST', - body: JSON.stringify({ url: 'https://evil.alltrails.com.attacker.com/trail' }), - headers: { 'Content-Type': 'application/json' }, - }); - expectBadRequest(res); - }); - - it('extracts OG metadata from a valid AllTrails page', async () => { - const mockHtml = ` - -
- - - - - - - `; - - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue( - new Response(mockHtml, { - status: 200, - headers: { 'Content-Type': 'text/html' }, - }), - ), - ); - - const testUrl = 'https://www.alltrails.com/trail/us/utah/angels-landing-trail'; - const res = await apiWithAuth('/trails/alltrails-preview', { - method: 'POST', - body: JSON.stringify({ url: testUrl }), - headers: { 'Content-Type': 'application/json' }, - }); - - const data = await expectJsonResponse(res, ['title', 'url']); - expect(data.title).toBe('Angels Landing Trail'); - expect(data.description).toBe('One of the most popular hikes in Zion.'); - expect(data.image).toBe('https://images.alltrails.com/angels-landing.jpg'); - expect(data.url).toBe(testUrl); - }); - - it('extracts OG tags regardless of attribute order in the meta tag', async () => { - // Some pages write content before property in the attribute order - const mockHtml = ` - - - - - `; - - vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(mockHtml, { status: 200 }))); - - const res = await apiWithAuth('/trails/alltrails-preview', { - method: 'POST', - body: JSON.stringify({ url: 'https://www.alltrails.com/trail/us/co/summit-peak' }), - headers: { 'Content-Type': 'application/json' }, - }); - - const data = await expectJsonResponse(res, ['title']); - expect(data.title).toBe('Summit Peak Trail'); - expect(data.description).toBe('Challenging summit hike.'); - }); - - it('returns 422 when the page has no OG title', async () => { - vi.stubGlobal( - 'fetch', - vi - .fn() - .mockResolvedValue( - new Response('