Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion apps/app/nuxt.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ export default defineNuxtConfig({

css: ['~/assets/css/main.css'],

colorMode: {
storageKey: 'shhh_color_mode'
},

// Defence in depth for a design where the browser holds the only decryption key: an XSS here does
// not leak a session, it leaks every key that passes through the page.
routeRules: {
Expand Down Expand Up @@ -82,7 +86,12 @@ export default defineNuxtConfig({
{ code: 'fr', language: 'fr-FR', name: 'Français', file: 'fr.json' }
],
defaultLocale: 'en',
strategy: 'no_prefix'
strategy: 'no_prefix',
detectBrowserLanguage: {
// Named rather than left on the module default: a cookie scoped to a parent domain by another
// Nuxt app would otherwise decide the language here. `server/utils/mail-locale.ts` reads it too.
cookieKey: 'shhh_i18n_locale'
}
},

// Without this, icons are fetched at runtime from /api/_nuxt_icon and log `[Icon] failed to load
Expand Down
4 changes: 3 additions & 1 deletion apps/app/server/api/admin/invitations/index.post.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,9 +59,11 @@ export default defineEventHandler(async (event) => {

// Same reasoning as paste sharing: from BETTER_AUTH_URL, not the attacker-controllable Host header, since it lands in an email.
const origin = process.env.BETTER_AUTH_URL!.replace(/\/+$/, '')
// The invitee has no account yet, so the inviting admin's UI language is the only signal available.
const mail = invitationTemplate({
url: `${origin}/register?token=${token}`,
expiresInDays: expiryDays
expiresInDays: expiryDays,
locale: mailLocaleFromEvent(event)
})
const sent = await sendMail({ to: email, ...mail })

Expand Down
4 changes: 3 additions & 1 deletion apps/app/server/api/pastes/index.post.ts
Original file line number Diff line number Diff line change
Expand Up @@ -218,7 +218,9 @@ export default defineEventHandler(async (event) => {
senderName: session.user.name || session.user.email,
senderEmail: session.user.email,
remainingReads: paste!.maxReads,
expiresAt: paste!.expiresAt
expiresAt: paste!.expiresAt,
// The recipients' own language is unknown; the sender's is the closest signal there is.
locale: mailLocaleFromEvent(event)
})
shared = result.sent
}
Expand Down
13 changes: 7 additions & 6 deletions apps/app/server/utils/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { getSetting } from './settings'
import { isSetupComplete } from './setup'
import { isInvitedSignup } from './invitations'
import { isMailEnabled, sendMail } from './mail'
import { resolveMailLocale } from './mail-locale'
import { resetPasswordTemplate, verifyEmailTemplate } from './mail-templates'

if (!process.env.BETTER_AUTH_SECRET) {
Expand Down Expand Up @@ -45,8 +46,8 @@ export const auth = betterAuth({
requireEmailVerification: isMailEnabled(),
// Without a provider there is no way to deliver a reset link, so resetting becomes a manual admin action.
sendResetPassword: isMailEnabled()
? async ({ user, url }) => {
const mail = resetPasswordTemplate({ url })
? async ({ user, url }, request) => {
const mail = resetPasswordTemplate({ url, locale: resolveMailLocale(request?.headers) })
await sendMail({ to: user.email, ...mail })
}
: undefined
Expand All @@ -58,8 +59,8 @@ export const auth = betterAuth({
sendOnSignIn: isMailEnabled(),
autoSignInAfterVerification: true,
sendVerificationEmail: isMailEnabled()
? async ({ user, url }) => {
const mail = verifyEmailTemplate({ url })
? async ({ user, url }, request) => {
const mail = verifyEmailTemplate({ url, locale: resolveMailLocale(request?.headers) })
await sendMail({ to: user.email, ...mail })
}
: undefined
Expand All @@ -72,8 +73,8 @@ export const auth = betterAuth({
// False even for unverified accounts: skipping confirmation would make the check avoidable by simply never verifying.
updateEmailWithoutVerification: false,
sendChangeEmailConfirmation: isMailEnabled()
? async ({ user, newEmail, url }) => {
const mail = changeEmailTemplate({ url, newEmail })
? async ({ user, newEmail, url }, request) => {
const mail = changeEmailTemplate({ url, newEmail, locale: resolveMailLocale(request?.headers) })
await sendMail({ to: user.email, ...mail })
}
: undefined
Expand Down
73 changes: 73 additions & 0 deletions apps/app/server/utils/mail-locale.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import type { H3Event } from 'h3'

export const MAIL_LOCALES = ['en', 'fr'] as const
export type MailLocale = typeof MAIL_LOCALES[number]
export const DEFAULT_MAIL_LOCALE: MailLocale = 'en'

// Must stay in step with `i18n.detectBrowserLanguage.cookieKey` in nuxt.config, where the switcher writes.
const LOCALE_COOKIE = 'shhh_i18n_locale'

function isMailLocale(value: string): value is MailLocale {
return (MAIL_LOCALES as readonly string[]).includes(value)
}

function fromCookie(header: string | null): MailLocale | null {
if (!header) return null

for (const part of header.split(';')) {
const separator = part.indexOf('=')
if (separator === -1) continue
if (part.slice(0, separator).trim() !== LOCALE_COOKIE) continue

// The header is attacker-controlled and `decodeURIComponent` throws on malformed escapes:
// an unusable cookie has to read as no cookie, never as a 500 on the request that sends mail.
let value: string
try {
value = decodeURIComponent(part.slice(separator + 1).trim()).toLowerCase()
} catch {
return null
}

return isMailLocale(value) ? value : null
}

return null
}

function fromAcceptLanguage(header: string | null): MailLocale | null {
if (!header) return null

const ranked = header
.split(',')
.map((entry) => {
const [tag, ...params] = entry.trim().split(';')
const quality = params.find(param => param.trim().startsWith('q='))
return { tag: (tag ?? '').trim().toLowerCase(), quality: quality ? Number(quality.split('=')[1]) : 1 }
})
.filter(entry => entry.tag !== '' && entry.tag !== '*' && Number.isFinite(entry.quality) && entry.quality > 0)
.sort((a, b) => b.quality - a.quality)

for (const { tag } of ranked) {
// 'fr-CA' and 'fr' both mean the French mail; only the primary subtag is ours to match.
const base = tag.split('-')[0]!
if (isMailLocale(base)) return base
}

return null
}

/**
* The cookie wins over Accept-Language: it carries a deliberate choice from the language switcher,
* where the header is only whatever the browser was installed with.
*/
export function resolveMailLocale(headers: Headers | null | undefined): MailLocale {
if (!headers) return DEFAULT_MAIL_LOCALE

return fromCookie(headers.get('cookie'))
?? fromAcceptLanguage(headers.get('accept-language'))
?? DEFAULT_MAIL_LOCALE
}

export function mailLocaleFromEvent(event: H3Event): MailLocale {
return resolveMailLocale(event.headers)
}
Loading