Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 16 additions & 15 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

84 changes: 84 additions & 0 deletions packages/api/src/routes/alltrails.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { Elysia, status } from 'elysia';
import { z } from 'zod';

const ALLTRAILS_HOSTNAME_RE = /^(?:[a-z0-9-]+\.)?alltrails\.com$/;
Copy link

Copilot AI Apr 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ALLTRAILS_HOSTNAME_RE only allows at most one subdomain level ((?:...\.)?). That rejects valid multi-level subdomains like foo.bar.alltrails.com even though the PR description says subdomains are supported. Consider changing this to allow any number of subdomain labels (e.g. (?:[a-z0-9-]+\.)*alltrails\.com).

Suggested change
const ALLTRAILS_HOSTNAME_RE = /^(?:[a-z0-9-]+\.)?alltrails\.com$/;
const ALLTRAILS_HOSTNAME_RE = /^(?:[a-z0-9-]+\.)*alltrails\.com$/;

Copilot uses AI. Check for mistakes.
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(`<meta[^>]+property=["']${property}["'][^>]+content=["']([^"']+)["']`, 'i'),
) ??
html.match(
new RegExp(`<meta[^>]+content=["']([^"']+)["'][^>]+property=["']${property}["']`, 'i'),
);
return match?.[1] ?? null;
}

export const alltrailsRoutes = new Elysia({ prefix: '/alltrails' }).post(
'/preview',
async ({ body }) => {
const { url } = body;

Comment on lines +18 to +22
Copy link

Copilot AI Apr 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This endpoint proxies/scrapes a third-party site and currently appears to be callable without authentication (no isAuthenticated: true). That makes it easy to abuse for unauthenticated scraping/egress, even if the hostname is restricted. Consider gating it behind Bearer auth (e.g. add isAuthenticated: true + security: [{ bearerAuth: [] }], and ensure the auth macro is available for this router) to align with other external-fetch endpoints like /knowledge-base/reader/extract.

Copilot uses AI. Check for mistakes.
let parsed: URL;
try {
parsed = new URL(url);
} catch {
return status(400, { error: 'Invalid URL' });
}
Comment on lines +23 to +28
Copy link

Copilot AI Apr 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Because the route already declares body: z.object({ url: z.string().url() }), Elysia validation should reject invalid URLs before the handler runs (via the app-level onError handler). This try/catch + Invalid URL branch is likely dead code and the manual parse can be simplified to const parsed = new URL(url) to avoid redundant validation paths and inconsistent error messaging.

Suggested change
let parsed: URL;
try {
parsed = new URL(url);
} catch {
return status(400, { error: 'Invalid URL' });
}
const parsed = new URL(url);

Copilot uses AI. Check for mistakes.

if (parsed.protocol !== 'https:' || !ALLTRAILS_HOSTNAME_RE.test(parsed.hostname)) {
return status(400, { error: 'URL must be an https://alltrails.com (or subdomain) URL' });
}
Comment on lines +30 to +32
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Error message understates what's accepted.

The host regex permits any single-level subdomain (www., es., …) but the message implies only the bare apex. A user posting https://es.alltrails.com/... that fails parsing will see a misleading error. Minor wording fix:

-      return status(400, { error: 'URL must be an https://alltrails.com URL' });
+      return status(400, { error: 'URL must be an https://(*.)alltrails.com URL' });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (parsed.protocol !== 'https:' || !ALLTRAILS_HOSTNAME_RE.test(parsed.hostname)) {
return status(400, { error: 'URL must be an https://alltrails.com URL' });
}
if (parsed.protocol !== 'https:' || !ALLTRAILS_HOSTNAME_RE.test(parsed.hostname)) {
return status(400, { error: 'URL must be an https://(*.)alltrails.com URL' });
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/api/src/routes/alltrails.ts` around lines 30 - 32, Update the 400
error message in the alltrails route to accurately reflect allowed hosts: when
checking parsed.protocol and ALLTRAILS_HOSTNAME_RE, change the returned message
from "URL must be an https://alltrails.com URL" to something that mentions https
and allows alltrails.com and its single-level subdomains (e.g., "URL must be an
https://alltrails.com or subdomain URL"); locate the check that uses parsed and
ALLTRAILS_HOSTNAME_RE and update the status(400, { error: ... }) string
accordingly.


let response: Response;
try {
response = await fetch(url, {
headers: { 'User-Agent': UA },
signal: AbortSignal.timeout(8000),
});
Comment on lines +36 to +39
Copy link

Copilot AI Apr 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The redirect check happens after fetch() completes, but fetch() will follow redirects by default. That means a crafted alltrails.com URL could still cause the worker to make an outbound request to a non-AllTrails host before you return the 400, which defeats the intent of the redirect guard. Consider using redirect: 'manual' and explicitly validating/following 3xx Location targets only when they remain within *.alltrails.com (or disable redirects entirely).

Copilot uses AI. Check for mistakes.
} 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.',
},
},
Comment on lines +73 to +83
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🌐 Web query:

Does Elysia 1.4 support Zod schemas directly for the body validator via Standard Schema, or is a plugin/adapter required?

💡 Result:

Yes, Elysia 1.4 supports Zod schemas directly for the body validator via Standard Schema out of the box. No plugin or adapter is required.

Citations:


🏁 Script executed:

head -100 packages/api/src/routes/alltrails.ts

Repository: PackRat-AI/PackRat

Length of output: 2588


Use OpenAPIHono with createRoute per API guidelines instead of Elysia. Elysia 1.4 validates Zod schemas directly via Standard Schema out of the box, so the original concern about unvalidated bodies is not applicable—the schema will be honored. However, this file violates the mandatory requirement that API routes in packages/api/src/**/*.ts must use OpenAPIHono with createRoute and Zod schemas for type-safe, documented endpoints.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/api/src/routes/alltrails.ts` around lines 73 - 83, This route
currently uses Elysia-style route metadata and must be rewritten to use
OpenAPIHono's createRoute with the existing Zod body schema; replace the current
Elysia route definition in alltrails.ts with a createRoute call that declares
method (POST), path, and the Zod body schema (z.object({ url: z.string().url()
})) as the request schema, move the tags/summary/description into the OpenAPI
metadata for createRoute, and ensure the handler function signature matches
OpenAPIHono expectations and returns the same OG preview response; import
createRoute from OpenAPIHono and keep the Zod schema identifier so type-safe
validation and generated docs work.

);
4 changes: 3 additions & 1 deletion packages/api/src/routes/index.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -39,4 +40,5 @@ export const routes = new Elysia({ prefix: '/api' })
.use(uploadRoutes)
.use(trailConditionsRoutes)
.use(wildlifeRoutes)
.use(knowledgeBaseRoutes);
.use(knowledgeBaseRoutes)
.use(alltrailsRoutes);
Loading
Loading