fix: send a Lite link without a language to the same page in the reader's language instead of a bare 404

This commit is contained in:
Linus Rath
2026-10-28 03:46:40 +01:00
parent 1386b12908
commit 6994dc2ce6
3 changed files with 60 additions and 8 deletions
+23 -1
View File
@@ -1,4 +1,4 @@
import { describe, expect, it } from 'vitest';
import { describe, expect, it, vi } from 'vitest';
import { createHash } from 'node:crypto';
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync, existsSync } from 'node:fs';
import { tmpdir } from 'node:os';
@@ -208,6 +208,28 @@ describe('lite build helpers', () => {
expect(LITE_PENDING_PATH_KEY).toBe(APP_LITE_PENDING_PATH_KEY);
});
it('sends a path without a locale to the same path in the reader locale', () => {
const html = buildNotFoundShim({ basePath: '/webmail', locales: ['de', 'en', 'zh-TW'], defaultLocale: 'en' });
const script = html.match(/<script>([\s\S]*?)<\/script>/)![1];
const run = (pathname: string, languages: string[], stored: string | null = null) => {
const replace = vi.fn();
const location = { pathname, search: '?a=1', hash: '', replace };
const storage = { getItem: () => stored, setItem: () => {} };
new Function('location', 'navigator', 'localStorage', 'sessionStorage', script)(
location, { languages, language: languages[0] }, storage, storage,
);
return replace.mock.calls[0]?.[0];
};
expect(run('/webmail/login', ['de-DE'])).toBe('/webmail/de/login?a=1');
expect(run('/webmail/mail/thread/x', ['fr-FR'])).toBe('/webmail/en/mail/thread/x?a=1');
expect(run('/webmail/login', ['en'], JSON.stringify({ state: { locale: 'zh-TW' } }))).toBe('/webmail/zh-TW/login?a=1');
// With a locale the surface logic applies as before; unknown pages stay a 404.
expect(run('/webmail/en/nope', ['de'])).toBeUndefined();
expect(run('/webmail/de/mail/thread/x', ['en'])).toBe('/webmail/de/mail/');
// Outside the mount nothing happens.
expect(run('/other/login', ['de'])).toBeUndefined();
});
it('renders host snippets and the README for the built variant', () => {
expect(buildNginxExample({ basePath: '' })).toContain('(?<surface>mail|calendar|contacts|files|settings)');
// Root-relative everywhere: a sub-path mount means the files live in <root><basePath>
+36 -6
View File
@@ -297,8 +297,13 @@ export function buildRootRedirect({ basePath = "", locales, defaultLocale = "en"
* hands the link over (hooks/use-lite-link-segments.ts). Only `<locale>` and
* `<surface>` from the allowlists ever reach `location.replace`, so a crafted
* URL cannot turn this into an open redirect.
*
* A path without a locale (/login, /mail/...) goes to the same path under
* the reader's locale, picked as the root index.html picks it; the target
* stays on this origin and starts with an allowlisted locale.
*/
export function buildNotFoundShim({ basePath = "", locales, surfaces = LITE_SURFACES }) {
export function buildNotFoundShim({ basePath = "", locales, surfaces = LITE_SURFACES, defaultLocale = "en" }) {
const fallback = locales.includes(defaultLocale) ? defaultLocale : locales[0];
return `<!doctype html>
<html lang="en">
<head>
@@ -317,6 +322,31 @@ export function buildNotFoundShim({ basePath = "", locales, surfaces = LITE_SURF
path = path.slice(base.length) || "/";
}
var parts = path.split("/").filter(Boolean);
if (parts.length > 0 && locales.indexOf(parts[0]) === -1) {
var fallback = ${JSON.stringify(fallback)};
var pick = function (tag) {
if (!tag) return null;
tag = String(tag);
if (locales.indexOf(tag) !== -1) return tag;
var lower = tag.toLowerCase();
if (lower.indexOf("zh") === 0) {
if (/zh-(tw|hk|mo|hant)/.test(lower) && locales.indexOf("zh-TW") !== -1) return "zh-TW";
if (locales.indexOf("zh") !== -1) return "zh";
}
var short = lower.split("-")[0];
for (var i = 0; i < locales.length; i++) if (locales[i].toLowerCase() === short) return locales[i];
return null;
};
var chosen = null;
try {
var stored = JSON.parse(localStorage.getItem(${JSON.stringify(LOCALE_STORAGE_KEY)}) || "null");
chosen = pick(stored && stored.state && stored.state.locale);
} catch (e) {}
var langs = navigator.languages || [navigator.language];
for (var j = 0; !chosen && j < langs.length; j++) chosen = pick(langs[j]);
location.replace(base + "/" + (chosen || fallback) + "/" + parts.join("/") + location.search + location.hash);
return;
}
if (parts.length < 2 || locales.indexOf(parts[0]) === -1 || surfaces.indexOf(parts[1]) === -1) return;
var target = base + "/" + parts[0] + "/" + parts[1] + "/";
// The shell itself is missing (host misconfigured): show the 404 instead of looping.
@@ -326,12 +356,12 @@ export function buildNotFoundShim({ basePath = "", locales, surfaces = LITE_SURF
})();
</script>
<style>
body { margin: 0; min-height: 100vh; display: flex; align-items: center; justify-content: center; font-family: system-ui, sans-serif; background: #fff; color: #111; }
@media (prefers-color-scheme: dark) { body { background: #0a0a0a; color: #ededed; } }
body { margin: 0; min-height: 100vh; display: flex; align-items: center; justify-content: center; font-family: system-ui, sans-serif; background: #fff; color: #0a0a0a; }
@media (prefers-color-scheme: dark) { body { background: #0a0a0a; color: #fafafa; } a { background: #fafafa; color: #0a0a0a; } }
main { text-align: center; padding: 1rem; }
h1 { font-size: 2.25rem; margin: 0 0 .5rem; }
p { margin: 0 0 1.5rem; opacity: .7; }
a { color: inherit; }
h1 { font-size: 2.25rem; font-weight: 700; margin: 0 0 .5rem; }
p { margin: 0 0 1.5rem; color: #737373; }
a { display: inline-block; padding: .5rem 1rem; border-radius: .375rem; background: #0a0a0a; color: #fafafa; font-size: .875rem; font-weight: 500; text-decoration: none; }
</style>
</head>
<body>
+1 -1
View File
@@ -78,7 +78,7 @@ function staticFiles({ config, basePath, locales, defaultLocale, version, commit
"index.html": buildRootRedirect({ basePath, locales, defaultLocale }),
// Replaces Next's default not-found page: only this shim replays deep links
// on hosts without rewrite rules (see buildNotFoundShim).
"404.html": buildNotFoundShim({ basePath, locales }),
"404.html": buildNotFoundShim({ basePath, locales, defaultLocale }),
"_redirects": buildRedirects({ basePath, locales }),
"_headers": buildHeaders({ basePath, connectSrc }),
"nginx.conf.example": buildNginxExample({ basePath, connectSrc }),