Skip to content

Commit

Permalink
feat(routing): external redirects
Browse files Browse the repository at this point in the history
  • Loading branch information
ematipico committed Jan 13, 2025
1 parent 3d89e62 commit 69681fb
Show file tree
Hide file tree
Showing 4 changed files with 61 additions and 16 deletions.
14 changes: 14 additions & 0 deletions packages/astro/src/core/errors/errors-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -995,6 +995,20 @@ export const RedirectWithNoLocation = {
name: 'RedirectWithNoLocation',
title: 'A redirect must be given a location with the `Location` header.',
} satisfies ErrorData;

/**
* @docs
* @see
* - [Astro.redirect](https://docs.astro.build/en/reference/api-reference/#redirect)
* @description
* An external redirect must start with http or https, and must be a valid URL
*/
export const UnsupportedExternalRedirect = {
name: 'UnsupportedExternalRedirect',
title: 'Unsupported or malformed URL.',
message: 'An external redirect must start with http or https, and must be a valid URL',
} satisfies ErrorData;

/**
* @docs
* @see
Expand Down
32 changes: 26 additions & 6 deletions packages/astro/src/core/redirects/render.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
import type { RedirectConfig } from '../../types/public/index.js';
import type { RenderContext } from '../render-context.js';

export function redirectIsExternal(redirect: RedirectConfig): boolean {
if (typeof redirect === 'string') {
return redirect.startsWith('http') || redirect.startsWith('https');
} else {
return redirect.destination.startsWith('http') || redirect.destination.startsWith('https');
}
}

export async function renderRedirect(renderContext: RenderContext) {
const {
request: { method },
Expand All @@ -9,6 +18,13 @@ export async function renderRedirect(renderContext: RenderContext) {
const status =
redirectRoute && typeof redirect === 'object' ? redirect.status : method === 'GET' ? 301 : 308;
const headers = { location: encodeURI(redirectRouteGenerate(renderContext)) };
if (redirect && redirectIsExternal(redirect)) {
if (typeof redirect === 'string') {
return Response.redirect(redirect, status);
} else {
return Response.redirect(redirect.destination, status);
}
}
return new Response(null, { status, headers });
}

Expand All @@ -21,13 +37,17 @@ function redirectRouteGenerate(renderContext: RenderContext): string {
if (typeof redirectRoute !== 'undefined') {
return redirectRoute?.generate(params) || redirectRoute?.pathname || '/';
} else if (typeof redirect === 'string') {
// TODO: this logic is duplicated between here and manifest/create.ts
let target = redirect;
for (const param of Object.keys(params)) {
const paramValue = params[param]!;
target = target.replace(`[${param}]`, paramValue).replace(`[...${param}]`, paramValue);
if (redirectIsExternal(redirect)) {
return redirect;
} else {
// TODO: this logic is duplicated between here and manifest/create.ts
let target = redirect;
for (const param of Object.keys(params)) {
const paramValue = params[param]!;
target = target.replace(`[${param}]`, paramValue).replace(`[...${param}]`, paramValue);
}
return target;
}
return target;
} else if (typeof redirect === 'undefined') {
return '/';
}
Expand Down
17 changes: 11 additions & 6 deletions packages/astro/src/core/routing/manifest/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,10 @@ import { getPrerenderDefault } from '../../../prerender/utils.js';
import type { AstroConfig } from '../../../types/public/config.js';
import type { RouteData, RoutePart } from '../../../types/public/internal.js';
import { SUPPORTED_MARKDOWN_FILE_EXTENSIONS } from '../../constants.js';
import { MissingIndexForInternationalization } from '../../errors/errors-data.js';
import {
MissingIndexForInternationalization,
UnsupportedExternalRedirect,
} from '../../errors/errors-data.js';
import { AstroError } from '../../errors/index.js';
import { removeLeadingForwardSlash, slash } from '../../path.js';
import { injectServerIslandRoute } from '../../server-islands/endpoint.js';
Expand Down Expand Up @@ -348,11 +351,13 @@ function createRedirectRoutes(
destination = to.destination;
}

if (/^https?:\/\//.test(destination)) {
logger.warn(
'redirects',
`Redirecting to an external URL is not officially supported: ${from} -> ${destination}`,
);
// URLs that don't start with leading slash should be considered external
if (!destination.startsWith('/')) {
// check if the link starts with http or https; if not, log a warning
if (!/^https?:\/\//.test(destination) && !URL.canParse(destination)) {
logger.warn('redirects', 'Only links that start with http or https are supported.');
throw new AstroError(UnsupportedExternalRedirect);
}
}

routes.push({
Expand Down
14 changes: 10 additions & 4 deletions packages/astro/test/redirects.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -36,13 +36,12 @@ describe('Astro.redirect', () => {
assert.equal(response.headers.get('location'), '/login');
});

// ref: https://github.com/withastro/astro/pull/9287#discussion_r1420739810
it.skip('Ignores external redirect', async () => {
it('Allows external redirect', async () => {
const app = await fixture.loadTestAdapterApp();
const request = new Request('http://example.com/external/redirect');
const response = await app.render(request);
assert.equal(response.status, 404);
assert.equal(response.headers.get('location'), null);
assert.equal(response.status, 301);
assert.equal(response.headers.get('location'), 'https://example.com/');
});

it('Warns when used inside a component', async () => {
Expand Down Expand Up @@ -131,6 +130,7 @@ describe('Astro.redirect', () => {
'/more/old/[dynamic]': '/more/[dynamic]',
'/more/old/[dynamic]/[route]': '/more/[dynamic]/[route]',
'/more/old/[...spread]': '/more/new/[...spread]',
'/external/redirect': 'https://example.com/',
},
});
await fixture.build();
Expand Down Expand Up @@ -208,6 +208,12 @@ describe('Astro.redirect', () => {
assert.equal(html.includes('http-equiv="refresh'), true);
assert.equal(html.includes('url=/more/new/welcome/world'), true);
});

it('supports redirecting to an external redirects', async () => {
const html = await fixture.readFile('/external/redirect/index.html');
assert.equal(html.includes('http-equiv="refresh'), true);
assert.equal(html.includes('url=https://example.com/'), true);
});
});

describe('dev', () => {
Expand Down

0 comments on commit 69681fb

Please sign in to comment.