diff --git a/middleware.test.ts b/middleware.test.ts index 8e96b9d2a5163..6e8f6e7763517 100644 --- a/middleware.test.ts +++ b/middleware.test.ts @@ -100,6 +100,92 @@ describe('middleware redirect set selection', () => { }); }); +describe('production build-url redirect to canonical (Deployment Protection off)', () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + + function makeHostRequest(url: string, method = 'GET'): NextRequest { + return new NextRequest(new URL(url), {method}); + } + + it('redirects a non-canonical production host to the canonical host', async () => { + const {middleware} = await importMiddleware({}); + vi.stubEnv('VERCEL_ENV', 'production'); + const res = middleware( + makeHostRequest('https://sentry-docs-abc123.vercel.app/platforms/javascript/') + ); + expect(res.status).toBe(308); + expect(res.headers.get('location')).toBe( + 'https://docs.sentry.io/platforms/javascript/' + ); + }); + + it('preserves query strings when redirecting', async () => { + const {middleware} = await importMiddleware({}); + vi.stubEnv('VERCEL_ENV', 'production'); + const res = middleware( + makeHostRequest('https://sentry-docs-git-master-getsentry.vercel.app/search/?q=x') + ); + expect(res.status).toBe(308); + expect(res.headers.get('location')).toBe('https://docs.sentry.io/search/?q=x'); + }); + + it('redirects to develop.sentry.dev in developer docs mode', async () => { + const {middleware} = await importMiddleware({NEXT_PUBLIC_DEVELOPER_DOCS: '1'}); + vi.stubEnv('VERCEL_ENV', 'production'); + const res = middleware( + makeHostRequest('https://develop-docs-abc123.vercel.app/getting-started/') + ); + expect(res.status).toBe(308); + expect(res.headers.get('location')).toBe( + 'https://develop.sentry.dev/getting-started/' + ); + }); + + it('does not redirect requests already on the canonical host', async () => { + const {middleware} = await importMiddleware({}); + vi.stubEnv('VERCEL_ENV', 'production'); + const res = middleware(makeHostRequest('https://docs.sentry.io/platforms/')); + expect(res.status).not.toBe(308); + }); + + it('leaves preview deployments accessible (no redirect)', async () => { + const {middleware} = await importMiddleware({}); + vi.stubEnv('VERCEL_ENV', 'preview'); + const res = middleware( + makeHostRequest('https://sentry-docs-git-my-branch.sentry.dev/getting-started/') + ); + expect(res.status).not.toBe(308); + }); + + it('does nothing locally (VERCEL_ENV unset)', async () => { + const {middleware} = await importMiddleware({}); + vi.stubEnv('VERCEL_ENV', ''); + const res = middleware(makeHostRequest('https://sentry-docs-abc123.vercel.app/foo/')); + expect(res.status).not.toBe(308); + }); + + it('redirects HEAD requests', async () => { + const {middleware} = await importMiddleware({}); + vi.stubEnv('VERCEL_ENV', 'production'); + const res = middleware( + makeHostRequest('https://sentry-docs-abc123.vercel.app/foo/', 'HEAD') + ); + expect(res.status).toBe(308); + expect(res.headers.get('location')).toBe('https://docs.sentry.io/foo/'); + }); + + it('does not redirect non-page requests', async () => { + const {middleware} = await importMiddleware({}); + vi.stubEnv('VERCEL_ENV', 'production'); + const res = middleware( + makeHostRequest('https://sentry-docs-abc123.vercel.app/foo/', 'POST') + ); + expect(res.status).not.toBe(308); + }); +}); + describe('canonical Link header on .md responses', () => { afterEach(() => { vi.unstubAllEnvs(); @@ -145,9 +231,12 @@ describe('canonical Link header on .md responses', () => { describe('wantsMarkdownViaAccept: text/plain no longer triggers markdown', () => { it('does not serve markdown for Accept: application/json, text/plain, */*', async () => { const {middleware} = await importMiddleware({}); - const req = new NextRequest(new URL('/platforms/apple/cocoa/', 'http://localhost:3000'), { - headers: {'Accept': 'application/json, text/plain, */*'}, - }); + const req = new NextRequest( + new URL('/platforms/apple/cocoa/', 'http://localhost:3000'), + { + headers: {Accept: 'application/json, text/plain, */*'}, + } + ); const res = middleware(req); // Should not rewrite to .md — Next rewrite changes the URL; a plain next() keeps it expect(res.headers.get('x-middleware-rewrite')).toBeNull(); @@ -155,9 +244,12 @@ describe('wantsMarkdownViaAccept: text/plain no longer triggers markdown', () => it('still serves markdown for explicit text/markdown Accept header', async () => { const {middleware} = await importMiddleware({}); - const req = new NextRequest(new URL('/platforms/apple/cocoa/', 'http://localhost:3000'), { - headers: {'Accept': 'text/markdown'}, - }); + const req = new NextRequest( + new URL('/platforms/apple/cocoa/', 'http://localhost:3000'), + { + headers: {Accept: 'text/markdown'}, + } + ); const res = middleware(req); // A rewrite to .md will set x-middleware-rewrite expect(res.headers.get('x-middleware-rewrite')).toContain('.md'); diff --git a/middleware.ts b/middleware.ts index 5fcf32699f2c0..222e1a94ef5ac 100644 --- a/middleware.ts +++ b/middleware.ts @@ -16,6 +16,8 @@ const BASE_URL = isDeveloperDocs ? 'https://develop.sentry.dev' : 'https://docs.sentry.io'; +const CANONICAL_HOST = new URL(BASE_URL).hostname; + // Production domains whose content should be indexable by search engines. // All other hostnames (Vercel preview/deployment URLs, old production deployments) // get X-Robots-Tag: noindex to prevent search engines from indexing stale content. @@ -35,6 +37,11 @@ export const config = { // This function can be marked `async` if using `await` inside export function middleware(request: NextRequest) { + const buildUrlRedirect = redirectProductionBuildUrlToCanonical(request); + if (buildUrlRedirect) { + return buildUrlRedirect; + } + // Classify once per request and record it as a counter. This metric — not // trace sampling — is the source of truth for agent/bot/user traffic: the // middleware root span is created by Next.js before any request data reaches @@ -55,6 +62,26 @@ export function middleware(request: NextRequest) { return response; } +/** Redirects page requests on noncanonical production hosts, leaving previews public. */ +function redirectProductionBuildUrlToCanonical( + request: NextRequest +): NextResponse | null { + if ( + process.env.VERCEL_ENV !== 'production' || + (request.method !== 'GET' && request.method !== 'HEAD') + ) { + return null; + } + if (request.nextUrl.hostname === CANONICAL_HOST) { + return null; + } + const url = request.nextUrl.clone(); + url.protocol = 'https:'; + url.host = CANONICAL_HOST; + url.port = ''; + return NextResponse.redirect(url, 308); +} + /** * Adds X-Robots-Tag: noindex to responses served from non-production domains. * This prevents search engines from indexing stale Vercel deployment URLs diff --git a/next.config.ts b/next.config.ts index 4662d206569fe..71bc6673facde 100644 --- a/next.config.ts +++ b/next.config.ts @@ -141,6 +141,8 @@ const nextConfig = { // Inline NEXT_PUBLIC_DEVELOPER_DOCS into edge middleware at build time. // Edge runtime doesn't have access to server env vars at request time. DEVELOPER_DOCS: process.env.NEXT_PUBLIC_DEVELOPER_DOCS, + // Freeze this per-deployment value for middleware, matching the pattern above. + VERCEL_ENV: process.env.VERCEL_ENV, }, redirects, rewrites: () => [