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
104 changes: 98 additions & 6 deletions middleware.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -145,19 +231,25 @@ 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();
});

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');
Expand Down
27 changes: 27 additions & 0 deletions middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand All @@ -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
Expand Down
2 changes: 2 additions & 0 deletions next.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: () => [
Expand Down
Loading