fix(routing): enforce slashless public URLs

This commit is contained in:
DeCentN2Madness committed 2026-10-25 13:13:46 -07:00
1 parent 7a6cab29db
commit f41af1966e
5 files changed
+87 -5

No files matched your search

+14
View File
@@ -1,3 +1,17 @@
## Include these directives inside the public HTTPS server block, before the
## generic static `location /` handler. Root (`/`) is the only trailing-slash
## public URL; every other terminal slash receives a query-preserving 308.
location ~ ^(.+?)/+$ {
return 308 $1$is_args$args;
}
## The static handler must serve the generated directory artifact at its
## slashless public path. Do not use `$uri/`, which permits nginx's automatic
## directory-slash redirect before `$uri/index.html` can be served.
location / {
try_files $uri $uri/index.html $uri.html =404;
}
rewrite ^/aba$ /aba-therapy permanent; rewrite ^/aba$ /aba-therapy permanent;
rewrite ^/autismevaluations$ /autism-evaluations permanent; rewrite ^/autismevaluations$ /autism-evaluations permanent;
rewrite ^/learnersocialclub$ /learner-social-club permanent; rewrite ^/learnersocialclub$ /learner-social-club permanent;
@@ -0,0 +1,27 @@
# Phase 10 Slashless URL Normalization — 2026-10-25
## Scope
This local-only change aligns static-serving behavior with the live site's URL convention: root remains `/`; every other public page is served at a slashless path such as `/aba-therapy` or `/lp/aba-therapy`.
Astro was already configured with `trailingSlash: 'never'`. Generated canonicals, hreflang URLs, sitemap entries, and source links therefore use slashless paths. The remaining deployment requirement is that a static web server must serve a generated `path/index.html` artifact for a slashless request without first redirecting to a directory URL.
## Required server contract
The reviewed nginx template in `reports/nginx-rewrites.conf` must be included inside the public HTTPS server block before general static routing. It requires the server to:
- return a query-preserving `308` from `/path/` to `/path`;
- serve `/path` from `path/index.html` without nginx's automatic directory-slash redirect;
- preserve query strings, for example `/search/?term=aba` redirects to `/search?term=aba`;
- keep root `/` unchanged; and
- retain explicit legacy route redirects after URL normalization.
This is a deployment template only. No Contabo, nginx, DNS, TLS, Cloudflare, or live-site configuration was changed.
## Regression protection
`tools/phase10-artifact-verify.mjs` now rejects generated root-relative `href` or `action` URLs with a terminal slash, excluding root `/`. `tools/phase10-release-rehearsal.mjs` models the required static behavior on loopback: slashless routes return `200`, trailing variants return `308`, and query strings survive the redirect.
## Acceptance still required
The template must be applied and tested by the approved hosting owner on the actual Contabo staging topology. Phase 10 must verify HTTPS, canonical host behavior, cache headers, redirects, assets, real 404s, forms/counter isolation, and rollback before any production cutover. This change does not authorize deployment.
+14
View File
@@ -24,6 +24,18 @@ function formActions(html) {
match[1].match(/\baction=["']([^"']*)["']/i)?.[1] ?? null); match[1].match(/\baction=["']([^"']*)["']/i)?.[1] ?? null);
} }
function rootRelativeUrls(html) {
const urls = [];
for (const match of html.matchAll(/\b(?:href|action)=["']([^"']+)["']/gi)) urls.push(match[1]);
return urls;
}
function hasTerminalSlash(url) {
if (!url.startsWith('/') || url.startsWith('//')) return false;
const pathname = url.split(/[?#]/, 1)[0];
return pathname !== '/' && pathname.endsWith('/');
}
function unsafeSignals(html) { function unsafeSignals(html) {
const signals = []; const signals = [];
if (/<iframe\b[^>]*\bsrc=["'][^"']*jotform/i.test(html)) signals.push('active Jotform iframe'); if (/<iframe\b[^>]*\bsrc=["'][^"']*jotform/i.test(html)) signals.push('active Jotform iframe');
@@ -52,10 +64,12 @@ export async function verifyArtifact(root, mode = 'staging') {
const title = html.match(/<title>([^<]*)<\/title>/i)?.[1]?.trim() ?? ''; const title = html.match(/<title>([^<]*)<\/title>/i)?.[1]?.trim() ?? '';
const actions = formActions(html); const actions = formActions(html);
const nonSearchActions = actions.filter((action) => action && action !== '/search'); const nonSearchActions = actions.filter((action) => action && action !== '/search');
const trailingSlashUrls = rootRelativeUrls(html).filter(hasTerminalSlash);
const signals = unsafeSignals(html); const signals = unsafeSignals(html);
if (!title) errors.push(`${route}: missing title`); if (!title) errors.push(`${route}: missing title`);
if (!canonical) errors.push(`${route}: missing canonical`); if (!canonical) errors.push(`${route}: missing canonical`);
if (!robots) errors.push(`${route}: missing robots directive`); if (!robots) errors.push(`${route}: missing robots directive`);
if (trailingSlashUrls.length) errors.push(`${route}: root-relative URL(s) end in a slash: ${trailingSlashUrls.join(', ')}`);
if (mode === 'staging' && robots !== 'noindex,nofollow') errors.push(`${route}: staging robots is ${robots ?? 'missing'}`); if (mode === 'staging' && robots !== 'noindex,nofollow') errors.push(`${route}: staging robots is ${robots ?? 'missing'}`);
if (canonical) { if (canonical) {
const prior = canonicalMap.get(canonical); const prior = canonicalMap.get(canonical);
+8
View File
@@ -49,6 +49,14 @@ test('artifact rejects live form mode and production API origin', async () => {
assert.ok(result.errors.some((error) => error.includes('unsafe artifact signals'))); assert.ok(result.errors.some((error) => error.includes('unsafe artifact signals')));
}); });
test('artifact rejects root-relative URLs with terminal slashes', async () => {
const root = await fixture('staging');
await fs.appendFile(path.join(root, 'index.html'), '<a href="/lp/aba-therapy/">Campaign</a>');
const result = await verifyArtifact(root, 'staging');
assert.equal(result.passed, false);
assert.ok(result.errors.some((error) => error.includes('root-relative URL(s) end in a slash')));
});
test('artifact digest is deterministic for unchanged files', async () => { test('artifact digest is deterministic for unchanged files', async () => {
const root = await fixture('staging'); const root = await fixture('staging');
const first = await verifyArtifact(root, 'staging'); const first = await verifyArtifact(root, 'staging');
+24 -5
View File
@@ -23,9 +23,15 @@ await fs.symlink(releaseA, current);
const contentTypes = { '.html': 'text/html', '.xml': 'application/xml', '.txt': 'text/plain', '.css': 'text/css', '.js': 'text/javascript', '.svg': 'image/svg+xml', '.webp': 'image/webp', '.png': 'image/png', '.jpg': 'image/jpeg', '.woff2': 'font/woff2' }; const contentTypes = { '.html': 'text/html', '.xml': 'application/xml', '.txt': 'text/plain', '.css': 'text/css', '.js': 'text/javascript', '.svg': 'image/svg+xml', '.webp': 'image/webp', '.png': 'image/png', '.jpg': 'image/jpeg', '.woff2': 'font/woff2' };
const server = http.createServer(async (request, response) => { const server = http.createServer(async (request, response) => {
try { try {
const pathname = decodeURIComponent(new URL(request.url ?? '/', 'http://127.0.0.1').pathname); const requestUrl = new URL(request.url ?? '/', 'http://127.0.0.1');
const clean = pathname.replace(/^\/+/, ''); const pathname = requestUrl.pathname;
const candidates = [clean, clean.endsWith('/') ? `${clean}index.html` : `${clean}/index.html`, clean === '' ? 'index.html' : null].filter(Boolean); if (pathname !== '/' && /\/+$/u.test(pathname)) {
response.writeHead(308, { location: `${pathname.replace(/\/+$/u, '')}${requestUrl.search}`, 'cache-control': 'no-store' });
response.end();
return;
}
const clean = decodeURIComponent(pathname).replace(/^\/+/, '');
const candidates = [clean, clean === '' ? 'index.html' : `${clean}/index.html`, clean === '' ? null : `${clean}.html`].filter(Boolean);
let target; let target;
for (const candidate of candidates) { for (const candidate of candidates) {
const candidatePath = path.resolve(current, candidate); const candidatePath = path.resolve(current, candidate);
@@ -41,7 +47,7 @@ await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
const address = server.address(); const address = server.address();
const origin = `http://127.0.0.1:${address.port}`; const origin = `http://127.0.0.1:${address.port}`;
const request = (pathname) => new Promise((resolve, reject) => { const request = (pathname) => new Promise((resolve, reject) => {
const req = http.get(origin + pathname, (res) => { const chunks = []; res.on('data', (chunk) => chunks.push(chunk)); res.on('end', () => resolve({ status: res.statusCode, type: res.headers['content-type'], cache: res.headers['cache-control'], body: Buffer.concat(chunks).toString('utf8') })); }); const req = http.get(origin + pathname, (res) => { const chunks = []; res.on('data', (chunk) => chunks.push(chunk)); res.on('end', () => resolve({ status: res.statusCode, type: res.headers['content-type'], cache: res.headers['cache-control'], location: res.headers.location, body: Buffer.concat(chunks).toString('utf8') })); });
req.on('error', reject); req.on('error', reject);
}); });
const checks = []; const checks = [];
@@ -49,7 +55,20 @@ const check = async (name, fn) => { await fn(); checks.push(name); };
await check('staging artifact verifier', async () => assert.equal((await verifyArtifact(releaseA, 'staging')).passed, true)); await check('staging artifact verifier', async () => assert.equal((await verifyArtifact(releaseA, 'staging')).passed, true));
await check('root route', async () => assert.equal((await request('/')).status, 200)); await check('root route', async () => assert.equal((await request('/')).status, 200));
await check('locale route', async () => assert.equal((await request('/es')).status, 200)); await check('locale route', async () => assert.equal((await request('/es')).status, 200));
await check('slashless and trailing-slash routes', async () => { assert.equal((await request('/library')).status, 200); assert.equal((await request('/library/')).status, 200); }); await check('slashless routes serve and trailing-slash variants redirect', async () => {
assert.equal((await request('/library')).status, 200);
const libraryTrailing = await request('/library/');
assert.equal(libraryTrailing.status, 308);
assert.equal(libraryTrailing.location, '/library');
const landingTrailing = await request('/lp/aba-therapy/');
assert.equal(landingTrailing.status, 308);
assert.equal(landingTrailing.location, '/lp/aba-therapy');
});
await check('trailing-slash redirects preserve query strings', async () => {
const response = await request('/search/?term=aba&lang=es');
assert.equal(response.status, 308);
assert.equal(response.location, '/search?term=aba&lang=es');
});
await check('query strings preserved by static serving', async () => assert.equal((await request('/search?term=aba&lang=es')).status, 200)); await check('query strings preserved by static serving', async () => assert.equal((await request('/search?term=aba&lang=es')).status, 200));
await check('asset MIME type', async () => assert.match((await request('/assets/images/favicon.svg')).type, /image\/svg/)); await check('asset MIME type', async () => assert.match((await request('/assets/images/favicon.svg')).type, /image\/svg/));
await check('robots and empty staging sitemap', async () => { const robots = await request('/robots.txt'); const sitemap = await request('/sitemap.xml'); assert.equal(robots.status, 200); assert.equal((sitemap.body.match(/<url>/g) ?? []).length, 0); }); await check('robots and empty staging sitemap', async () => { const robots = await request('/robots.txt'); const sitemap = await request('/sitemap.xml'); assert.equal(robots.status, 200); assert.equal((sitemap.body.match(/<url>/g) ?? []).length, 0); });