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:
parent
fc366811f7
commit
f2f1bd41e1
5 changed files with 251 additions and 210 deletions
146
src/server.ts
146
src/server.ts
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue