diff --git a/apps/docs/app/api/guides-md/[...slug]/route.ts b/apps/docs/app/api/guides-md/[...slug]/route.ts index b942ff2f73..3e877b3181 100644 --- a/apps/docs/app/api/guides-md/[...slug]/route.ts +++ b/apps/docs/app/api/guides-md/[...slug]/route.ts @@ -1,14 +1,15 @@ -import { promises as fs } from 'fs' -import path from 'path' +import { promises as fs } from 'node:fs' +import path from 'node:path' +import { BASE_PATH, IS_PRODUCTION, PROD_URL } from '~/lib/constants' import { NextResponse } from 'next/server' -export async function GET(_request: Request, { params }: { params: Promise<{ slug: string[] }> }) { +export async function GET(request: Request, { params }: { params: Promise<{ slug: string[] }> }) { const { slug } = await params const baseDir = path.join(process.cwd(), 'public/docs/guides') const filePath = path.join(baseDir, `${slug.join('/')}.md`) if (!filePath.startsWith(baseDir + path.sep) && filePath !== baseDir) { - return new NextResponse('Not found', { status: 404 }) + return notFoundResponse(request, slug) } try { @@ -20,6 +21,86 @@ export async function GET(_request: Request, { params }: { params: Promise<{ slu }, }) } catch { - return new NextResponse('Not found', { status: 404 }) + return notFoundResponse(request, slug) } } + +/** + * Resolves the docs base URL from trusted sources only. + * Avoids host-header-driven SSRF from request origin. + */ +function resolveDocsBaseUrl() { + if (IS_PRODUCTION) return PROD_URL + if (process.env.VERCEL_URL) return `https://${process.env.VERCEL_URL}${BASE_PATH}` + if (process.env.NEXT_PUBLIC_SITE_URL) return `${process.env.NEXT_PUBLIC_SITE_URL}${BASE_PATH}` + return undefined +} + +async function notFoundResponse(_request: Request, slug: string[]) { + const baseUrl = resolveDocsBaseUrl() + const query = slug.join(' ').replace(/[_-]/g, ' ') + const suggestions = baseUrl ? await searchForSuggestions(baseUrl, query) : [] + const markdown = buildNotFoundMarkdown(slug, suggestions) + return new NextResponse(markdown, { + status: 404, + headers: { + 'Content-Type': 'text/markdown; charset=utf-8', + 'Cache-Control': 'no-store', + }, + }) +} + +interface SearchSuggestion { + title: string + href: string +} + +async function searchForSuggestions(baseUrl: string, query: string): Promise { + try { + const response = await fetch(`${baseUrl}/api/graphql`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + query: ` + query SearchDocs($query: String!) { + searchDocs(query: $query, limit: 5) { + nodes { + title + href + } + } + } + `, + variables: { query }, + }), + }) + + if (!response.ok) return [] + + const data = await response.json() + return (data?.data?.searchDocs?.nodes ?? []).filter( + (n: Partial): n is SearchSuggestion => !!n.title && !!n.href + ) + } catch { + return [] + } +} + +function buildNotFoundMarkdown(slug: string[], suggestions: SearchSuggestion[]): string { + const pagePath = slug.join('/') + + const suggestionsMd = + suggestions.length > 0 + ? `## You might be looking for...\n\n${suggestions + .map(({ title, href }) => `- [${title}](${href}.md)`) + .join('\n')}\n` + : '' + + return `# 404 - Page Not Found + +The page \`/docs/guides/${pagePath}.md\` does not exist. + +${suggestionsMd} +See also: [Changelog](https://supabase.com/changelog.md) +` +} diff --git a/apps/docs/turbo.jsonc b/apps/docs/turbo.jsonc index 3c1148eb29..b025460e4a 100644 --- a/apps/docs/turbo.jsonc +++ b/apps/docs/turbo.jsonc @@ -30,6 +30,7 @@ "VERCEL", "VERCEL_ENV", "VERCEL_GIT_COMMIT_SHA", + "VERCEL_URL", // These envs are used in the packages "NEXT_PUBLIC_STORAGE_KEY", "NEXT_PUBLIC_AUTH_DEBUG_KEY",