diff --git a/bun.lock b/bun.lock index af83564e93..c9f60b1fd4 100644 --- a/bun.lock +++ b/bun.lock @@ -19,8 +19,9 @@ }, "apps/admin": { "name": "packrat-admin-app", - "version": "2.0.23", + "version": "2.0.24", "dependencies": { + "@packrat/guards": "workspace:*", "@packrat/web-ui": "workspace:*", "@radix-ui/react-alert-dialog": "catalog:", "@radix-ui/react-avatar": "catalog:", @@ -59,7 +60,7 @@ }, "apps/expo": { "name": "packrat-expo-app", - "version": "2.0.23", + "version": "2.0.24", "dependencies": { "@ai-sdk/react": "^3.0.170", "@expo/react-native-action-sheet": "^4.1.1", @@ -182,7 +183,7 @@ }, "apps/guides": { "name": "packrat-guides-app", - "version": "2.0.23", + "version": "2.0.24", "dependencies": { "@ai-sdk/openai": "^3.0.53", "@hookform/resolvers": "^5.2.2", @@ -264,7 +265,7 @@ }, "apps/landing": { "name": "packrat-landing-app", - "version": "2.0.23", + "version": "2.0.24", "dependencies": { "@emotion/is-prop-valid": "^1.3.1", "@hookform/resolvers": "^5.2.2", @@ -329,7 +330,7 @@ }, "packages/analytics": { "name": "@packrat/analytics", - "version": "2.0.23", + "version": "2.0.24", "dependencies": { "@duckdb/node-api": "1.5.0-r.1", "@packrat/env": "workspace:*", @@ -345,7 +346,7 @@ }, "packages/api": { "name": "@packrat/api", - "version": "2.0.23", + "version": "2.0.24", "dependencies": { "@ai-sdk/google": "^3.0.64", "@ai-sdk/openai": "^3.0.53", @@ -399,7 +400,7 @@ }, "packages/api-client": { "name": "@packrat/api-client", - "version": "2.0.23", + "version": "2.0.24", "dependencies": { "@elysiajs/eden": "catalog:", }, @@ -416,11 +417,11 @@ }, "packages/checks": { "name": "@packrat/checks", - "version": "2.0.23", + "version": "2.0.24", }, "packages/cli": { "name": "@packrat/cli", - "version": "2.0.23", + "version": "2.0.24", "bin": { "packrat": "./src/index.ts", }, @@ -440,18 +441,18 @@ }, "packages/config": { "name": "@packrat/config", - "version": "2.0.23", + "version": "2.0.24", }, "packages/env": { "name": "@packrat/env", - "version": "2.0.23", + "version": "2.0.24", "dependencies": { "zod": "catalog:", }, }, "packages/guards": { "name": "@packrat/guards", - "version": "2.0.23", + "version": "2.0.24", "dependencies": { "radash": "catalog:", "ts-extras": "catalog:", @@ -460,7 +461,7 @@ }, "packages/mcp": { "name": "@packrat/mcp", - "version": "2.0.23", + "version": "2.0.24", "dependencies": { "@modelcontextprotocol/sdk": "^1.11.0", "@packrat/api-client": "workspace:*", @@ -478,14 +479,14 @@ }, "packages/ui": { "name": "@packrat/ui", - "version": "2.0.23", + "version": "2.0.24", "dependencies": { "@packrat-ai/nativewindui": "^2.0.2", }, }, "packages/web-ui": { "name": "@packrat/web-ui", - "version": "2.0.23", + "version": "2.0.24", "dependencies": { "@packrat/guards": "workspace:*", "@radix-ui/react-accordion": "catalog:", diff --git a/packages/api/src/routes/alltrails.ts b/packages/api/src/routes/alltrails.ts index e69de29bb2..a71ef5f4d8 100644 --- a/packages/api/src/routes/alltrails.ts +++ b/packages/api/src/routes/alltrails.ts @@ -0,0 +1,84 @@ +import { Elysia, status } from 'elysia'; +import { z } from 'zod'; + +const ALLTRAILS_HOSTNAME_RE = /^(?:[a-z0-9-]+\.)?alltrails\.com$/; +const UA = 'Mozilla/5.0 (compatible; PackRat/1.0; +https://packrat.world)'; + +function extractOgTag(html: string, property: string): string | null { + const match = + html.match( + new RegExp(`]+property=["']${property}["'][^>]+content=["']([^"']+)["']`, 'i'), + ) ?? + html.match( + new RegExp(`]+content=["']([^"']+)["'][^>]+property=["']${property}["']`, 'i'), + ); + return match?.[1] ?? null; +} + +export const alltrailsRoutes = new Elysia({ prefix: '/alltrails' }).post( + '/preview', + async ({ body }) => { + const { url } = body; + + let parsed: URL; + try { + parsed = new URL(url); + } catch { + return status(400, { error: 'Invalid URL' }); + } + + if (parsed.protocol !== 'https:' || !ALLTRAILS_HOSTNAME_RE.test(parsed.hostname)) { + return status(400, { error: 'URL must be an https://alltrails.com (or subdomain) URL' }); + } + + let response: Response; + try { + response = await fetch(url, { + headers: { 'User-Agent': UA }, + signal: AbortSignal.timeout(8000), + }); + } catch (e) { + if (e instanceof DOMException && e.name === 'TimeoutError') { + return status(504, { error: 'Request to AllTrails timed out' }); + } + return status(502, { error: 'Failed to fetch AllTrails URL' }); + } + + if (!response.ok) { + return status(502, { error: `AllTrails returned status ${response.status}` }); + } + + const finalUrl = response.url || url; + try { + const finalHostname = new URL(finalUrl).hostname; + if (!ALLTRAILS_HOSTNAME_RE.test(finalHostname)) { + return status(400, { error: 'URL redirected outside alltrails.com' }); + } + } catch { + return status(502, { error: 'Could not parse redirect URL' }); + } + + const html = await response.text(); + + const title = extractOgTag(html, 'og:title'); + if (!title) { + return status(422, { error: 'No og:title found in AllTrails page' }); + } + + const description = extractOgTag(html, 'og:description'); + const image = extractOgTag(html, 'og:image'); + + return { title, description, image, url: finalUrl }; + }, + { + body: z.object({ + url: z.string().url(), + }), + detail: { + tags: ['AllTrails'], + summary: 'Fetch AllTrails OG preview', + description: + 'Scrapes OpenGraph metadata (title, description, image) from an AllTrails trail page.', + }, + }, +); diff --git a/packages/api/src/routes/index.ts b/packages/api/src/routes/index.ts index ccf6b43776..617f81ffd1 100644 --- a/packages/api/src/routes/index.ts +++ b/packages/api/src/routes/index.ts @@ -1,6 +1,7 @@ import { Elysia } from 'elysia'; import { adminRoutes } from './admin'; import { aiRoutes } from './ai'; +import { alltrailsRoutes } from './alltrails'; import { authRoutes } from './auth'; import { catalogRoutes } from './catalog'; import { chatRoutes } from './chat'; @@ -39,4 +40,5 @@ export const routes = new Elysia({ prefix: '/api' }) .use(uploadRoutes) .use(trailConditionsRoutes) .use(wildlifeRoutes) - .use(knowledgeBaseRoutes); + .use(knowledgeBaseRoutes) + .use(alltrailsRoutes); diff --git a/packages/api/test/alltrails.test.ts b/packages/api/test/alltrails.test.ts new file mode 100644 index 0000000000..d05383bda3 --- /dev/null +++ b/packages/api/test/alltrails.test.ts @@ -0,0 +1,177 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { api } from './utils/test-helpers'; + +const PREVIEW_PATH = '/alltrails/preview'; + +function post(body: unknown) { + return api(PREVIEW_PATH, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); +} + +// CF Workers treats Response.url as read-only, so we return a plain object +// that satisfies the duck-typed interface used by the alltrails route handler. +function mockFetchResponse( + html: string, + opts: { status?: number; responseUrl?: string } = {}, +): Response { + const { status = 200, responseUrl = 'https://www.alltrails.com/trail/us/california/test' } = opts; + return { + ok: status >= 200 && status < 300, + status, + url: responseUrl, + text: () => Promise.resolve(html), + json: () => Promise.resolve({}), + headers: new Headers(), + } as unknown as Response; +} + +function mockFetch(html: string, opts: { status?: number; responseUrl?: string } = {}) { + return vi.fn().mockResolvedValue(mockFetchResponse(html, opts)); +} + +const SAMPLE_HTML = ` + + + + + + +`; + +describe('POST /alltrails/preview', () => { + let originalFetch: typeof globalThis.fetch; + + beforeEach(() => { + originalFetch = globalThis.fetch; + vi.clearAllMocks(); + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + describe('request validation', () => { + it('rejects missing url field', async () => { + const res = await post({}); + expect(res.status).toBe(400); + }); + + it('rejects non-url string', async () => { + const res = await post({ url: 'not-a-url' }); + expect(res.status).toBe(400); + }); + + it('rejects http (non-https) alltrails URL', async () => { + const res = await post({ url: 'http://www.alltrails.com/trail/test' }); + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error).toContain('https'); + }); + + it('rejects non-alltrails domain', async () => { + const res = await post({ url: 'https://evil.com/trail/test' }); + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error).toContain('alltrails.com'); + }); + + it('rejects alltrails.com.evil.com lookalike', async () => { + const res = await post({ url: 'https://alltrails.com.evil.com/trail' }); + expect(res.status).toBe(400); + }); + }); + + describe('successful response', () => { + it('returns OG metadata from a valid AllTrails page', async () => { + globalThis.fetch = mockFetch(SAMPLE_HTML) as unknown as typeof fetch; + + const res = await post({ url: 'https://www.alltrails.com/trail/us/california/test' }); + expect(res.status).toBe(200); + + const body = await res.json(); + expect(body.title).toBe('Test Trail'); + expect(body.description).toBe('A beautiful trail'); + expect(body.image).toBe('https://cdn.alltrails.com/image.jpg'); + expect(body.url).toContain('alltrails.com'); + }); + + it('returns null for missing optional og tags', async () => { + const htmlNoOg = ``; + globalThis.fetch = mockFetch(htmlNoOg) as unknown as typeof fetch; + + const res = await post({ url: 'https://www.alltrails.com/trail/us/test' }); + expect(res.status).toBe(200); + + const body = await res.json(); + expect(body.title).toBe('Just a Title'); + expect(body.description).toBeNull(); + expect(body.image).toBeNull(); + }); + + it('accepts subdomain alltrails URLs', async () => { + globalThis.fetch = mockFetch(SAMPLE_HTML, { + responseUrl: 'https://es.alltrails.com/senderos/test', + }) as unknown as typeof fetch; + + const res = await post({ url: 'https://es.alltrails.com/senderos/test' }); + expect(res.status).toBe(200); + }); + }); + + describe('upstream error handling', () => { + it('returns 504 on timeout', async () => { + globalThis.fetch = vi + .fn() + .mockRejectedValue( + new DOMException('The operation was aborted', 'TimeoutError'), + ) as unknown as typeof fetch; + + const res = await post({ url: 'https://www.alltrails.com/trail/test' }); + expect(res.status).toBe(504); + const body = await res.json(); + expect(body.error).toContain('timed out'); + }); + + it('returns 502 on network error', async () => { + globalThis.fetch = vi + .fn() + .mockRejectedValue(new Error('connection refused')) as unknown as typeof fetch; + + const res = await post({ url: 'https://www.alltrails.com/trail/test' }); + expect(res.status).toBe(502); + }); + + it('returns 502 when AllTrails returns a 4xx', async () => { + globalThis.fetch = mockFetch('Not Found', { status: 404 }) as unknown as typeof fetch; + + const res = await post({ url: 'https://www.alltrails.com/trail/test' }); + expect(res.status).toBe(502); + const body = await res.json(); + expect(body.error).toContain('404'); + }); + + it('returns 422 when og:title is missing', async () => { + const noTitleHtml = ``; + globalThis.fetch = mockFetch(noTitleHtml) as unknown as typeof fetch; + + const res = await post({ url: 'https://www.alltrails.com/trail/test' }); + expect(res.status).toBe(422); + const body = await res.json(); + expect(body.error).toContain('og:title'); + }); + + it('returns 400 when redirect lands outside alltrails.com', async () => { + globalThis.fetch = mockFetch(SAMPLE_HTML, { + responseUrl: 'https://malicious.com/page', + }) as unknown as typeof fetch; + + const res = await post({ url: 'https://www.alltrails.com/trail/test' }); + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error).toContain('redirected outside'); + }); + }); +}); diff --git a/packages/mcp/src/index.ts b/packages/mcp/src/index.ts index 17c5c15331..57e439d49e 100644 --- a/packages/mcp/src/index.ts +++ b/packages/mcp/src/index.ts @@ -28,6 +28,7 @@ import { registerCatalogTools } from './tools/catalog'; import { registerKnowledgeTools } from './tools/knowledge'; import { registerPackTools } from './tools/packs'; import { registerTrailConditionTools } from './tools/trail-conditions'; +import { registerTrailTools } from './tools/trails'; import { registerTripTools } from './tools/trips'; import { registerWeatherTools } from './tools/weather'; @@ -101,6 +102,7 @@ export class PackRatMCP extends McpAgent> { registerWeatherTools(this); registerKnowledgeTools(this); registerTrailConditionTools(this); + registerTrailTools(this); registerResources(this); registerPrompts(this); } diff --git a/packages/mcp/src/tools/trails.ts b/packages/mcp/src/tools/trails.ts new file mode 100644 index 0000000000..64735afef5 --- /dev/null +++ b/packages/mcp/src/tools/trails.ts @@ -0,0 +1,24 @@ +import { z } from 'zod'; +import { err, ok } from '../client'; +import type { AgentContext } from '../types'; + +export function registerTrailTools(agent: AgentContext): void { + agent.server.registerTool( + 'preview_alltrails_url', + { + description: + 'Fetch trail metadata (title, description, image) from an AllTrails URL using OpenGraph tags. Use this to enrich a trip or pack with information from an AllTrails link a user shares.', + inputSchema: { + url: z.string().url().describe('Full AllTrails URL (must be https://alltrails.com/...)'), + }, + }, + async ({ url }) => { + try { + const data = await agent.api.post('/alltrails/preview', { url }); + return ok(data); + } catch (e) { + return err(e); + } + }, + ); +}