diff --git a/server/package.json b/server/package.json index 72c791366..8af2b4b42 100644 --- a/server/package.json +++ b/server/package.json @@ -42,7 +42,6 @@ "@swan-io/boxed": "2.0.0", "fast-proxy": "2.2.0", "fastify": "4.26.1", - "fastify-language-parser": "3.0.0", "get-port": "5.1.1", "graphql-request": "6.1.0", "graphql-tag": "2.12.6", diff --git a/server/src/app.ts b/server/src/app.ts index f62b8e231..7ee605669 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -9,12 +9,10 @@ import fastifyView from "@fastify/view"; import { Array, Future, Option, Result } from "@swan-io/boxed"; import fastify, { FastifyReply } from "fastify"; import mustache from "mustache"; -import { Http2SecureServer } from "node:http2"; -// @ts-expect-error -import languageParser from "fastify-language-parser"; import { randomUUID } from "node:crypto"; import { lookup } from "node:dns"; import fs from "node:fs"; +import { Http2SecureServer } from "node:http2"; import path from "pathe"; import { P, match } from "ts-pattern"; import { @@ -42,6 +40,7 @@ import { HttpsConfig, startDevServer } from "./client/devServer"; import { getProductionRequestHandler } from "./client/prodServer"; import { env } from "./env"; import { replyWithAuthError, replyWithError } from "./error"; +import { findBestLanguage } from "./utils/language"; const packageJson = JSON.parse( fs.readFileSync(path.join(__dirname, "../package.json"), "utf-8"), @@ -51,8 +50,8 @@ const COOKIE_MAX_AGE = 60 * (env.NODE_ENV !== "test" ? 5 : 60); // 5 minutes (ex const OAUTH_STATE_COOKIE_MAX_AGE = 900; // 15 minutes export type InvitationConfig = { + acceptLanguage: string | undefined; accessToken: string; - requestLanguage: string; inviteeAccountMembershipId: string; inviterAccountMembershipId: string; }; @@ -77,14 +76,13 @@ declare module "@fastify/secure-session" { declare module "fastify" { // eslint-disable-next-line @typescript-eslint/consistent-type-definitions interface FastifyRequest { + accessToken: string | undefined; config: { unauthenticatedApiUrl: string; partnerApiUrl: string; clientId: string; clientSecret: string; }; - accessToken: string | undefined; - detectedLng: string; } } @@ -240,13 +238,6 @@ export const start = async ({ }, }); - // eslint-disable-next-line @typescript-eslint/no-unsafe-argument - await app.register(languageParser, { - order: ["query"], - fallbackLng: "en", - supportedLngs: ["en", "fr"], - }); - /** * Try to refresh the tokens if expired or expiring soon */ @@ -471,8 +462,8 @@ export const start = async ({ try { const result = await sendAccountMembershipInvitation({ + acceptLanguage: request.headers["accept-language"], accessToken, - requestLanguage: request.detectedLng, inviteeAccountMembershipId: request.params.inviteeAccountMembershipId, inviterAccountMembershipId, }); diff --git a/server/src/index.swan.ts b/server/src/index.swan.ts index 7d74cb55f..93fe83b19 100644 --- a/server/src/index.swan.ts +++ b/server/src/index.swan.ts @@ -23,6 +23,7 @@ import { InvitationConfig, start } from "./app"; import { env, url as validateUrl } from "./env"; import { replyWithError } from "./error"; import { AccountCountry, GetAccountMembershipInvitationDataQuery } from "./graphql/partner"; +import { findBestLanguage } from "./utils/language"; const keysPath = path.join(__dirname, "../keys"); @@ -138,7 +139,10 @@ const sendAccountMembershipInvitation = (invitationConfig: InvitationConfig) => inviterAccountMembershipId: invitationConfig.inviterAccountMembershipId, }) .mapOkToResult(invitationData => - getMailjetInput({ invitationData, requestLanguage: invitationConfig.requestLanguage }), + getMailjetInput({ + invitationData, + requestLanguage: findBestLanguage(invitationConfig.acceptLanguage), + }), ) .flatMapOk(data => { return Future.fromPromise(mailjet.post("send", { version: "v3.1" }).request(data)); @@ -222,8 +226,8 @@ start({ .flatMapOk(accessToken => Future.fromPromise( sendAccountMembershipInvitation({ + acceptLanguage: request.headers["accept-language"], accessToken, - requestLanguage: request.detectedLng, inviteeAccountMembershipId: request.params.inviteeAccountMembershipId, inviterAccountMembershipId, }), @@ -280,7 +284,7 @@ start({ return replyWithError(app, request, reply, { status: 400, - requestId: request.id as string, + requestId: request.id, }); }) .map(() => undefined); @@ -315,7 +319,7 @@ start({ return replyWithError(app, request, reply, { status: 400, - requestId: request.id as string, + requestId: request.id, }); }) .map(() => undefined); diff --git a/server/src/utils/language.ts b/server/src/utils/language.ts new file mode 100644 index 000000000..9d6cd52b0 --- /dev/null +++ b/server/src/utils/language.ts @@ -0,0 +1,34 @@ +// From https://github.com/opentable/accept-language-parser +const ACCEPT_LANGUAGE_REGEX = /((([a-zA-Z]+(-[a-zA-Z0-9]+){0,2})|\*)(;q=[0-1](\.[0-9]+)?)?)*/g; + +const isNotNullish = (value: T | undefined): value is T => value != null; + +const parseAcceptLanguageHeader = (acceptLanguage: string | undefined): string[] => + acceptLanguage + ?.match(ACCEPT_LANGUAGE_REGEX) + ?.map(item => { + const [tag = "", q] = item.split(";"); + const quality = Number.parseFloat(q?.split("=")[1] ?? "1"); + + if (tag !== "" && Number.isFinite(quality)) { + return { tag, quality }; + } + }, []) + .filter(isNotNullish) + .sort((a, b) => b.quality - a.quality) + .map(item => { + const parts = item.tag.split("-"); + const language = parts[0]?.toLowerCase() ?? ""; + + if (language !== "") { + return language; + } + }) + .filter(isNotNullish) ?? []; + +const supportedLanguages = ["en", "fr"]; + +export const findBestLanguage = (acceptLanguage: string | undefined): string => { + const languages = parseAcceptLanguageHeader(acceptLanguage); + return languages.find(language => supportedLanguages.includes(language)) ?? "en"; // fallback to en if no match +}; diff --git a/yarn.lock b/yarn.lock index 224b2b9b0..a21742ffa 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2749,11 +2749,6 @@ abstract-logging@^2.0.1: resolved "https://registry.yarnpkg.com/abstract-logging/-/abstract-logging-2.0.1.tgz#6b0c371df212db7129b57d2e7fcf282b8bf1c839" integrity sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA== -accept-language-parser@^1.5.0: - version "1.5.0" - resolved "https://registry.yarnpkg.com/accept-language-parser/-/accept-language-parser-1.5.0.tgz#8877c54040a8dcb59e0a07d9c1fde42298334791" - integrity sha512-QhyTbMLYo0BBGg1aWbeMG4ekWtds/31BrEU+DONOg/7ax23vxpL03Pb7/zBmha2v7vdD3AyzZVWBVGEZxKOXWw== - accepts@^1.3.5: version "1.3.8" resolved "https://registry.yarnpkg.com/accepts/-/accepts-1.3.8.tgz#0bf0be125b67014adcb0b0921e62db7bffe16b2e" @@ -4367,21 +4362,6 @@ fast-url-parser@^1.1.3: dependencies: punycode "^1.3.2" -fastify-language-parser@3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/fastify-language-parser/-/fastify-language-parser-3.0.0.tgz#23ef58766c1fdc0267047f326f13e9377cd753e1" - integrity sha512-3ZhKQwrIHyc0Canyfotbnmi1pqFKeWIivAd+fRAGeqwjyRFgtmCJ76lotWQpnG60L556NZA1d6fX6onqGcUtCw== - dependencies: - accept-language-parser "^1.5.0" - fastify-plugin "^2.0.1" - -fastify-plugin@^2.0.1: - version "2.3.4" - resolved "https://registry.yarnpkg.com/fastify-plugin/-/fastify-plugin-2.3.4.tgz#b17abdc36a97877d88101fb86ad8a07f2c07de87" - integrity sha512-I+Oaj6p9oiRozbam30sh39BiuiqBda7yK2nmSPVwDCfIBlKnT8YB3MY+pRQc2Fcd07bf6KPGklHJaQ2Qu81TYQ== - dependencies: - semver "^7.3.2" - fastify-plugin@^4.0.0, fastify-plugin@^4.2.1: version "4.5.1" resolved "https://registry.yarnpkg.com/fastify-plugin/-/fastify-plugin-4.5.1.tgz#44dc6a3cc2cce0988bc09e13f160120bbd91dbee" @@ -7236,7 +7216,7 @@ secure-json-parse@^2.4.0, secure-json-parse@^2.7.0: resolved "https://registry.yarnpkg.com/secure-json-parse/-/secure-json-parse-2.7.0.tgz#5a5f9cd6ae47df23dba3151edd06855d47e09862" integrity sha512-6aU+Rwsezw7VR8/nyvKTx8QpWH9FrcYiXXlqC4z5d5XQBDRqtbfsRjnwGyqbi3gddNtWHuEk9OANUotL26qKUw== -semver@7.6.0, semver@^7.3.2, semver@^7.3.5, semver@^7.5.2, semver@^7.5.4: +semver@7.6.0, semver@^7.3.5, semver@^7.5.2, semver@^7.5.4: version "7.6.0" resolved "https://registry.yarnpkg.com/semver/-/semver-7.6.0.tgz#1a46a4db4bffcccd97b743b5005c8325f23d4e2d" integrity sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg==