Skip to content

Commit 56686d9

Browse files
authored
Fix astro redirects (#5500)
1 parent 8dd8584 commit 56686d9

3 files changed

Lines changed: 210 additions & 4 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@apostrophecms/apostrophe-astro": patch
3+
---
4+
5+
Query string parameters are no longer lost when a URL with a trailing slash is normalized, so `/articles/?page=2` now renders the same content as `/articles?page=2`. Previously such URLs were redirected to the page URL alone (e.g. `/articles`), losing the query string and showing the first page. Redirects to a different origin are now always passed through to the browser.

‎packages/apostrophe-astro/lib/aposPageFetch.js‎

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -56,15 +56,27 @@ export async function aposPageFetch(req) {
5656
// the same terms, then re-add it when constructing the retry URL.
5757
if (aposData.redirect && aposData.url !== '/') {
5858
const prefix = config.aposPrefix || '';
59-
let from = new URL(request.url).pathname.replace(/\/+$/, '');
59+
const requestUrl = new URL(request.url);
60+
let from = requestUrl.pathname.replace(/\/+$/, '');
6061
if (prefix && from.startsWith(prefix + '/')) {
6162
from = from.slice(prefix.length);
6263
} else if (prefix && from === prefix) {
6364
from = '/';
6465
}
65-
const to = (aposData.url || '').replace(/\/+$/, '');
66-
if (from === to) {
67-
const retryUrl = prefix + aposData.url;
66+
// Parse the redirect target so the trailing-slash comparison
67+
// sees only the path, even if the URL carries a query string.
68+
// Absolute redirects to other hosts never qualify for an
69+
// internal retry — they must reach the browser.
70+
const target = new URL(aposData.url || '', requestUrl);
71+
const to = target.pathname.replace(/\/+$/, '');
72+
if (target.origin === requestUrl.origin && from === to) {
73+
// Preserve the query string across the internal retry: the
74+
// target's own if present, otherwise the original request's.
75+
// Apostrophe's trailing-slash redirect drops the query string
76+
// (e.g. /articles/?page=2 redirects to /articles), so without
77+
// this the retry would render page 1.
78+
const search = target.search || requestUrl.search;
79+
const retryUrl = prefix + target.pathname + search;
6880
const retry = new Request(new URL(retryUrl, request.url), request);
6981
const retryResponse = await aposResponse(retry);
7082
headers = retryResponse.headers;
Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
import assert from 'node:assert/strict';
2+
import esmock from 'esmock';
3+
4+
const mockConfig = {
5+
aposHost: 'http://localhost:3000',
6+
aposPrefix: '',
7+
staticBuild: null,
8+
excludeRequestHeaders: []
9+
};
10+
11+
function jsonResponse(data) {
12+
return new Response(JSON.stringify(data), {
13+
headers: { 'content-type': 'application/json' }
14+
});
15+
}
16+
17+
// Returns { aposPageFetch, calls } where calls records the URL of every
18+
// request passed to the mocked aposResponse, and responses are served
19+
// from the given queue in order.
20+
async function load(responses, configOverrides = {}) {
21+
const calls = [];
22+
const queue = [ ...responses ];
23+
const mod = await esmock('../../lib/aposPageFetch.js', {
24+
'apostrophe-astro-config/config': {
25+
default: { ...mockConfig, ...configOverrides }
26+
},
27+
'../../lib/aposResponse.js': {
28+
default: async (request) => {
29+
calls.push(request.url);
30+
if (!queue.length) {
31+
throw new Error('aposResponse called more times than expected');
32+
}
33+
const next = queue.shift();
34+
return typeof next === 'function' ? next() : jsonResponse(next);
35+
}
36+
}
37+
});
38+
return {
39+
aposPageFetch: mod.default,
40+
calls
41+
};
42+
}
43+
44+
describe('aposPageFetch redirect handling', () => {
45+
beforeEach(() => {
46+
process.env.APOS_EXTERNAL_FRONT_KEY = 'test-secret-key';
47+
});
48+
49+
afterEach(() => {
50+
delete process.env.APOS_EXTERNAL_FRONT_KEY;
51+
delete process.env.APOS_ASTRO_STATIC_BUILD;
52+
});
53+
54+
it('preserves the query string when following a trailing-slash redirect', async () => {
55+
const { aposPageFetch, calls } = await load([
56+
{
57+
redirect: true,
58+
url: '/articles',
59+
status: 302
60+
},
61+
{
62+
template: 'article-page:index',
63+
currentPage: 2
64+
}
65+
]);
66+
const data = await aposPageFetch(
67+
new Request('http://localhost:4321/articles/?page=2')
68+
);
69+
assert.equal(calls.length, 2);
70+
assert.equal(calls[1], 'http://localhost:4321/articles?page=2');
71+
assert.equal(data.currentPage, 2);
72+
assert.ok(!data.redirect);
73+
});
74+
75+
it('still follows a slash-adding locale home redirect internally', async () => {
76+
const { aposPageFetch, calls } = await load([
77+
{
78+
redirect: true,
79+
url: '/fr/',
80+
status: 302
81+
},
82+
{
83+
template: '@apostrophecms/home-page:page'
84+
}
85+
]);
86+
const data = await aposPageFetch(new Request('http://localhost:4321/fr'));
87+
assert.equal(calls.length, 2);
88+
assert.equal(calls[1], 'http://localhost:4321/fr/');
89+
assert.ok(!data.redirect);
90+
});
91+
92+
it('prefers the redirect target\'s own query string over the request\'s', async () => {
93+
const { aposPageFetch, calls } = await load([
94+
{
95+
redirect: true,
96+
url: '/articles?category=tech',
97+
status: 302
98+
},
99+
{
100+
template: 'article-page:index'
101+
}
102+
]);
103+
await aposPageFetch(
104+
new Request('http://localhost:4321/articles/?page=2')
105+
);
106+
assert.equal(calls.length, 2);
107+
assert.equal(calls[1], 'http://localhost:4321/articles?category=tech');
108+
});
109+
110+
it('passes through a redirect to a different path without retrying', async () => {
111+
const { aposPageFetch, calls } = await load([
112+
{
113+
redirect: true,
114+
url: '/new-location',
115+
status: 301
116+
}
117+
]);
118+
const data = await aposPageFetch(new Request('http://localhost:4321/old-location'));
119+
assert.equal(calls.length, 1);
120+
assert.equal(data.redirect, true);
121+
assert.equal(data.url, '/new-location');
122+
assert.equal(data.status, 301);
123+
});
124+
125+
it('passes through an absolute redirect to another host even when paths match', async () => {
126+
const { aposPageFetch, calls } = await load([
127+
{
128+
redirect: true,
129+
url: 'https://other.example.com/articles',
130+
status: 302
131+
}
132+
]);
133+
const data = await aposPageFetch(new Request('http://localhost:4321/articles/'));
134+
assert.equal(calls.length, 1);
135+
assert.equal(data.redirect, true);
136+
assert.equal(data.url, 'https://other.example.com/articles');
137+
});
138+
139+
it('strips and restores the prefix around the internal retry', async () => {
140+
const { aposPageFetch, calls } = await load([
141+
{
142+
redirect: true,
143+
url: '/articles',
144+
status: 302
145+
},
146+
{
147+
template: 'article-page:index'
148+
}
149+
], { aposPrefix: '/base' });
150+
await aposPageFetch(
151+
new Request('http://localhost:4321/base/articles/?page=2')
152+
);
153+
assert.equal(calls.length, 2);
154+
assert.equal(calls[1], 'http://localhost:4321/base/articles?page=2');
155+
});
156+
157+
it('reports an infinite redirect instead of looping', async () => {
158+
const { aposPageFetch, calls } = await load([
159+
{
160+
redirect: true,
161+
url: '/articles',
162+
status: 302
163+
},
164+
{
165+
redirect: true,
166+
url: '/articles',
167+
status: 302
168+
}
169+
]);
170+
const data = await aposPageFetch(new Request('http://localhost:4321/articles/'));
171+
assert.equal(calls.length, 2);
172+
assert.ok(data.errorFetchingPage);
173+
assert.match(data.errorFetchingPage.message, /Infinite redirect/);
174+
assert.equal(data.page.type, 'apos-fetch-error');
175+
});
176+
177+
it('never retries a redirect to the site root', async () => {
178+
const { aposPageFetch, calls } = await load([
179+
{
180+
redirect: true,
181+
url: '/',
182+
status: 302
183+
}
184+
]);
185+
const data = await aposPageFetch(new Request('http://localhost:4321//'));
186+
assert.equal(calls.length, 1);
187+
assert.equal(data.redirect, true);
188+
});
189+
});

0 commit comments

Comments
 (0)