fix: sanitize Accept-Language locale parsing (#746)
* fix: sanitize Accept-Language locale parsing * refactor(accept-language): prefer the highest quality language
This commit is contained in:
parent
afc77f55b8
commit
bae43c0d90
3 changed files with 79 additions and 1 deletions
25
app/server/lib/__tests__/accept-language.test.ts
Normal file
25
app/server/lib/__tests__/accept-language.test.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
import { describe, expect, test } from "vitest";
|
||||||
|
import { getLocaleFromAcceptLanguage } from "~/server/lib/accept-language";
|
||||||
|
|
||||||
|
describe("getLocaleFromAcceptLanguage", () => {
|
||||||
|
test("strips quality values from a single preferred language", () => {
|
||||||
|
expect(getLocaleFromAcceptLanguage("en;q=0.5")).toBe("en");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("prefers the highest quality language", () => {
|
||||||
|
expect(getLocaleFromAcceptLanguage("en;q=0.5, fr;q=0.9")).toBe("fr");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("treats language tags without q as the highest priority", () => {
|
||||||
|
expect(getLocaleFromAcceptLanguage("en;q=0.9, fr-CH")).toBe("fr-CH");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("skips invalid language tags and uses the next valid one", () => {
|
||||||
|
expect(getLocaleFromAcceptLanguage("*, en-GB;q=0.8")).toBe("en-GB");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("falls back to en-US when no valid language is present", () => {
|
||||||
|
expect(getLocaleFromAcceptLanguage(";;;, ???")).toBe("en-US");
|
||||||
|
expect(getLocaleFromAcceptLanguage(null)).toBe("en-US");
|
||||||
|
});
|
||||||
|
});
|
||||||
52
app/server/lib/accept-language.ts
Normal file
52
app/server/lib/accept-language.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
const DEFAULT_LOCALE = "en-US";
|
||||||
|
|
||||||
|
export function getLocaleFromAcceptLanguage(acceptLanguage?: string | null) {
|
||||||
|
if (!acceptLanguage) {
|
||||||
|
return DEFAULT_LOCALE;
|
||||||
|
}
|
||||||
|
|
||||||
|
let locale = DEFAULT_LOCALE;
|
||||||
|
let highestQuality = -1;
|
||||||
|
|
||||||
|
for (const value of acceptLanguage.split(",")) {
|
||||||
|
const [rawLanguageTag, ...parameters] = value.split(";");
|
||||||
|
const languageTag = rawLanguageTag?.trim();
|
||||||
|
|
||||||
|
if (!languageTag) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let quality = 1;
|
||||||
|
|
||||||
|
for (const parameter of parameters) {
|
||||||
|
const [key, value] = parameter.split("=");
|
||||||
|
|
||||||
|
if (key?.trim().toLowerCase() !== "q") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsedQuality = Number(value?.trim());
|
||||||
|
|
||||||
|
if (!Number.isFinite(parsedQuality) || parsedQuality < 0 || parsedQuality > 1) {
|
||||||
|
quality = -1;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
quality = parsedQuality;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (quality <= highestQuality) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
locale = Intl.getCanonicalLocales(languageTag)[0] || DEFAULT_LOCALE;
|
||||||
|
highestQuality = quality;
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return locale;
|
||||||
|
}
|
||||||
|
|
@ -2,6 +2,7 @@ import { createServerFn } from "@tanstack/react-start";
|
||||||
import { getCookie, getRequestHeaders } from "@tanstack/react-start/server";
|
import { getCookie, getRequestHeaders } from "@tanstack/react-start/server";
|
||||||
import { THEME_COOKIE_NAME } from "~/client/components/theme-provider";
|
import { THEME_COOKIE_NAME } from "~/client/components/theme-provider";
|
||||||
import type { DateFormatPreference, TimeFormatPreference } from "~/client/lib/datetime";
|
import type { DateFormatPreference, TimeFormatPreference } from "~/client/lib/datetime";
|
||||||
|
import { getLocaleFromAcceptLanguage } from "~/server/lib/accept-language";
|
||||||
import { auth } from "~/server/lib/auth";
|
import { auth } from "~/server/lib/auth";
|
||||||
|
|
||||||
export const getRootLoaderData = createServerFn({ method: "GET" }).handler(async () => {
|
export const getRootLoaderData = createServerFn({ method: "GET" }).handler(async () => {
|
||||||
|
|
@ -12,7 +13,7 @@ export const getRootLoaderData = createServerFn({ method: "GET" }).handler(async
|
||||||
|
|
||||||
return {
|
return {
|
||||||
theme: (themeCookie === "light" ? "light" : "dark") as "light" | "dark",
|
theme: (themeCookie === "light" ? "light" : "dark") as "light" | "dark",
|
||||||
locale: (acceptLanguage?.split(",")[0] || "en-US") as string,
|
locale: getLocaleFromAcceptLanguage(acceptLanguage),
|
||||||
timeZone: process.env.TZ || Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC",
|
timeZone: process.env.TZ || Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC",
|
||||||
dateFormat: (session?.user.dateFormat ?? "MM/DD/YYYY") as DateFormatPreference,
|
dateFormat: (session?.user.dateFormat ?? "MM/DD/YYYY") as DateFormatPreference,
|
||||||
timeFormat: (session?.user.timeFormat ?? "12h") as TimeFormatPreference,
|
timeFormat: (session?.user.timeFormat ?? "12h") as TimeFormatPreference,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue