feat(lang): supprime l'écran Redirection…, 302 serveur par Accept-Language/cookie, et traductions par fichier de langue (data/i18n/{fr,en}.json, éditables via Forgejo)

This commit is contained in:
riricdev 2026-09-07 12:55:35 +02:00
commit f2f1bd41e1
5 changed files with 251 additions and 210 deletions

View file

@ -65,7 +65,9 @@ const RATE_MAX = 200
const SITE_URL = "https://riricdev.tail5ea5cd.ts.net"
const DATA_ROOT = normalize(join(import.meta.dir, "..", "data"))
const ARTICLES_FILE = normalize(join(DATA_ROOT, "articles.json"))
const I18N_FILE = normalize(join(DATA_ROOT, "i18n.json"))
const I18N_DIR = normalize(join(DATA_ROOT, "i18n"))
const I18N_LANGS = ["fr", "en"] as const
type Lang = (typeof I18N_LANGS)[number]
const ARTICLE_SLUG_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/
const ARTICLE_DATE_RE = /^\d{4}-\d{2}-\d{2}$/
const RULES_FILE = process.env.RULES_FILE || "/home/riricdev/zektyc-bot/data/rules.md"
@ -139,55 +141,67 @@ function html(body: string, cache = "no-cache") {
})
}
function baseUrl(req: Request, fallback = SITE_URL): string {
const host = (req.headers.get("host") || "").trim()
if (!host) return fallback
// Tor = HTTP, tout le reste est servi en HTTPS (le schéma suit l'hôte courant,
// pour que la redirection de langue reste sur le domaine actuel, .onion compris).
const scheme = host.toLowerCase().endsWith(".onion") ? "http" : "https"
return `${scheme}://${host}`
function i18nPath(lang: Lang): string {
return normalize(join(I18N_DIR, `${lang}.json`))
}
function langRedirectPage(rest: string, base: string): string {
const fr = `${base}/fr${rest}`
const en = `${base}/en${rest}`
const restJs = JSON.stringify(rest)
return `<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="robots" content="noindex, follow" />
<title>Zektyc</title>
<link rel="canonical" href="${fr}" />
<link rel="alternate" hreflang="fr" href="${fr}" />
<link rel="alternate" hreflang="en" href="${en}" />
<link rel="alternate" hreflang="x-default" href="${fr}" />
<meta http-equiv="refresh" content="1.5; url=/fr${rest}" />
<script>(function(){
var rest=${restJs};
var saved=null;
try{ saved=localStorage.getItem("zektyc-lang"); }catch(e){}
var target="/fr"+rest;
if(saved==="en"||saved==="fr"){
target="/"+saved+rest;
} else {
var ln=(navigator.languages&&navigator.languages.length?navigator.languages[0]:(navigator.language||navigator.userLanguage||"")).toLowerCase();
if(ln.indexOf("en")===0) target="/en"+rest;
function parseCookies(header: string | null): Record<string, string> {
const out: Record<string, string> = {}
if (!header) return out
for (const part of header.split(";")) {
const eq = part.indexOf("=")
if (eq < 0) continue
const k = part.slice(0, eq).trim()
const v = part.slice(eq + 1).trim()
if (k) out[k] = decodeURIComponent(v)
}
return out
}
// Langue préférée du visiteur : cookie `zektyc-lang` (choix explicite), sinon
// header Accept-Language (q-values). Anglais uniquement si clairement favori,
// Français par défaut.
function detectLang(req: Request): Lang {
const cookie = parseCookies(req.headers.get("cookie"))["zektyc-lang"]
if (cookie === "fr" || cookie === "en") return cookie
const als = req.headers.get("accept-language") || ""
let enQ = 0
let frQ = 0
for (const part of als.split(",")) {
const [tag, ...params] = part.trim().split(";")
const base = (tag || "").trim().toLowerCase().split("-")[0]
if (base !== "fr" && base !== "en") continue
let q = 1
for (const p of params) {
const m = p.trim().match(/^q=([0-9.]+)$/i)
if (m) {
const v = parseFloat(m[1]!)
if (v >= 0 && v <= 1) q = v
}
if(target.indexOf("/en")===0){
fetch(target,{method:"HEAD",cache:"no-store"}).then(function(r){
location.replace(r.ok?target:"/fr"+rest);
}).catch(function(){ location.replace("/fr"+rest); });
} else {
location.replace(target);
}
})();</script>
</head>
<body>
<p>Redirection… / Redirecting…</p>
</body>
</html>`
}
if (base === "en") enQ = Math.max(enQ, q)
else frQ = Math.max(frQ, q)
}
return enQ > frQ ? "en" : "fr"
}
function langCookie(lang: Lang): string {
return `zektyc-lang=${lang}; Path=/; Max-Age=31536000; HttpOnly; SameSite=Lax`
}
// Remplace l'ancienne page interstitielle : redirect 302 vers /fr… ou /en…
// choisi côté serveur, aucun écran « Redirection… » affiché au visiteur.
function langRedirect(rest: string, req: Request): Response {
const lang = detectLang(req)
return new Response(null, {
status: 302,
headers: {
Location: `/${lang}${rest}`,
"Cache-Control": "no-store",
"Set-Cookie": langCookie(lang),
},
})
}
function bearerToken(req: Request): string | null {
@ -350,7 +364,11 @@ function writeArticles(articles: Article[]) {
}
function readI18n(): I18nDict {
return readJson<I18nDict>(I18N_FILE, { fr: {}, en: {} })
const out: I18nDict = { fr: {}, en: {} }
for (const lang of I18N_LANGS) {
out[lang] = readJson<Record<string, string>>(i18nPath(lang), {})
}
return out
}
function readRules(): string {
@ -1017,7 +1035,9 @@ async function handleAdmin(req: Request, url: URL): Promise<Response> {
if (!r.ok) return r.res
const err = validateI18n(r.data)
if (err) return json({ error: err }, 400)
writeJson(I18N_FILE, r.data)
for (const lang of I18N_LANGS) {
writeJson(i18nPath(lang), r.data[lang])
}
console.log(`[admin] traductions mises à jour (ip ${ip})`)
return json({ ok: true })
}
@ -1271,9 +1291,22 @@ server = serve({
if (pathname === "/api/discord") return jsonRaw(await getDiscordPresence())
if (pathname === "/api/time") return jsonRaw({ t: Date.now() })
if (pathname === "/data/i18n.json") {
// Sélecteur de langue explicite : /lang?l=en&next=/… (écrit le cookie puis redirige).
if (pathname === "/lang") {
const l = String(url.searchParams.get("l") || "").toLowerCase()
const next = String(url.searchParams.get("next") || "/")
const valid = l === "fr" || l === "en"
const dst = valid && next.startsWith("/") && !next.startsWith("//") ? next : "/"
const res = new Response(null, { status: 302, headers: { Location: dst, "Cache-Control": "no-store" } })
if (valid) res.headers.set("Set-Cookie", langCookie(l))
return res
}
// Traductions par langue : un fichier JSON par langue, éditable via Forgejo.
const i18nMatch = pathname.match(/^\/data\/i18n\/(fr|en)\.json$/)
if (i18nMatch) {
let body = "{}"
try { body = readFileSync(I18N_FILE, "utf8") } catch {}
try { body = readFileSync(i18nPath(i18nMatch[1] as Lang), "utf8") } catch {}
return new Response(body, { headers: { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-cache" } })
}
@ -1284,10 +1317,9 @@ server = serve({
if (page) return html(page, "public, max-age=300")
}
// Les chemins sans préfixe de langue (/fr/ ou /en/) sont des points d'entrée :
// on ne sert jamais de page ici, on renvoie la page-redirect qui choisit la langue
// (localStorage puis navigateur, sinon fr). Les assets, /api, /docs et le honeypot
// WordPress restent intacts (ils ne se résolvent pas en page HTML).
// Chemins sans préfixe : 302 serveur vers la langue du visiteur (Accept-Language
// ou cookie), aucun écran interstitiel. Les assets, /api, /docs, /lang et le
// honeypot WordPress restent intacts (ils ne se résolvent pas en page HTML).
const articles = readArticles()
if (!/^\/(fr|en)(\/|$)/.test(pathname)) {
// Anciennes URLs EN de l'ère « /zmap/en/... » : 301 direct vers /en/zmap/...
@ -1297,8 +1329,8 @@ server = serve({
return new Response(null, { status: 301, headers: { Location: `/en/zmap${sub}/` } })
}
const artMatch = matchArticle(pathname, articles)
if (artMatch) return html(langRedirectPage(artPath(artMatch), baseUrl(req)), "no-cache")
if (isHtmlFile(pathname)) return html(langRedirectPage(pathname === "/" ? "/" : pathname, baseUrl(req)), "no-cache")
if (artMatch) return langRedirect(artPath(artMatch), req)
if (isHtmlFile(pathname)) return langRedirect(pathname === "/" ? "/" : pathname, req)
}
const p = resolveLangPath(pathname) ?? pathname