Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
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
2 changes: 2 additions & 0 deletions cloudflare_workers/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ import { app as ok } from '../../supabase/functions/_backend/public/ok.ts'
import { app as organization } from '../../supabase/functions/_backend/public/organization/index.ts'
import { app as replication } from '../../supabase/functions/_backend/public/replication.ts'
import { app as statistics } from '../../supabase/functions/_backend/public/statistics/index.ts'
import { app as translations } from '../../supabase/functions/_backend/public/translations/index.ts'
import { app as webhooks } from '../../supabase/functions/_backend/public/webhooks/index.ts'
import { app as credit_usage_alerts } from '../../supabase/functions/_backend/triggers/credit_usage_alerts.ts'
import { app as cron_clean_orphan_images } from '../../supabase/functions/_backend/triggers/cron_clean_orphan_images.ts'
Expand Down Expand Up @@ -88,6 +89,7 @@ app.route('/app', appEndpoint)
app.route('/build', build)
app.route('/replication', replication)
app.route('/check_cpu_usage', check_cpu_usage)
app.route('/translations', translations)

// Private API
const functionNamePrivate = 'private'
Expand Down
45 changes: 45 additions & 0 deletions src/constants/locales.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
export const languages = {
'de': 'Deutsch',
'en': 'English',
'es': 'Español',
'id': 'Bahasa Indonesia',
'it': 'Italiano',
'fr': 'Français',
'ja': '日本語',
'ko': '한국어',
'pl': 'Polski',
'pt-br': 'Português (Brasil)',
'ru': 'Русский',
'tr': 'Türkçe',
'vi': 'Tiếng Việt',
'zh-cn': '简体中文',
'hi': 'हिन्दी',
} as const

export type SupportedLocale = keyof typeof languages

export const defaultLocale: SupportedLocale = 'en'
export const supportedLocales = Object.keys(languages) as SupportedLocale[]

export function isSupportedLocale(locale: string): locale is SupportedLocale {
return locale in languages
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Validate locales with own-property checks

isSupportedLocale uses locale in languages, which also matches inherited keys like constructor and __proto__. Since /translations/:locale depends on resolveLocale, those values are currently accepted as supported locales and skip the 404 path, so the API can return a 200 response with an invalid/non-translation payload that may then be cached. Use an own-key check (for example Object.hasOwn(languages, locale)) to enforce the declared locale list only.

Useful? React with 👍 / 👎.

}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

export function resolveLocale(locale?: string | null): SupportedLocale | null {
if (!locale)
return null

const normalizedLocale = locale.toLowerCase()
if (isSupportedLocale(normalizedLocale))
return normalizedLocale

const baseLocale = normalizedLocale.split('-')[0]
if (isSupportedLocale(baseLocale))
return baseLocale

return null
}

export function normalizeLocale(locale?: string | null): SupportedLocale {
return resolveLocale(locale) ?? defaultLocale
}
90 changes: 48 additions & 42 deletions src/modules/i18n.ts
Original file line number Diff line number Diff line change
@@ -1,43 +1,26 @@
import type { Locale } from 'vue-i18n'
import type { SupportedLocale } from '../constants/locales'
import type { UserModule } from '~/types'
import { createI18n } from 'vue-i18n'
import { defaultApiHost } from '~/services/supabase'
import enMessages from '../../messages/en.json'
import { defaultLocale, languages, normalizeLocale, supportedLocales } from '../constants/locales'
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// Import i18n resources
// https://vitejs.dev/guide/features.html#glob-import
//
// Don't need this? Try vitesse-lite: https://github.com/antfu/vitesse-lite
export const i18n = createI18n({
legacy: false,
fallbackLocale: 'en',
locale: '',
messages: {},
fallbackLocale: defaultLocale,
locale: defaultLocale,
messages: {
[defaultLocale]: enMessages,
},
})

const localesMap = Object.fromEntries(
Object.entries(import.meta.glob('../../messages/*.json'))
.map(([path, loadLocale]) => [/([\w-]*)\.json$/.exec(path)?.[1], loadLocale]),
) as Record<Locale, () => Promise<{ default: Record<string, string> }>>
type MessageDictionary = Record<string, string>

export const availableLocales = Object.keys(localesMap)
export const languages = {
'de': 'Deutsch',
'en': 'English',
'es': 'Español',
'id': 'Bahasa Indonesia',
'it': 'Italiano',
'fr': 'Français',
'ja': '日本語',
'ko': '한국어',
'pl': 'Polski',
'pt-br': 'Português (Brasil)',
'ru': 'Русский',
'tr': 'Türkçe',
'vi': 'Tiếng Việt',
'zh-cn': '简体中文',
'hi': 'हिन्दी',
}
export const availableLocales = supportedLocales

Check warning on line 20 in src/modules/i18n.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use `export…from` to re-export `availableLocales`.

See more on https://sonarcloud.io/project/issues?id=Cap-go_capgo&issues=AZ2G8mcI97SflOJfLxkl&open=AZ2G8mcI97SflOJfLxkl&pullRequest=1908
export { languages }

Check warning on line 21 in src/modules/i18n.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use `export…from` to re-export `languages`.

See more on https://sonarcloud.io/project/issues?id=Cap-go_capgo&issues=AZ2G8mcI97SflOJfLxkm&open=AZ2G8mcI97SflOJfLxkm&pullRequest=1908

const loadedLanguages: string[] = []
const loadedLanguages: SupportedLocale[] = [defaultLocale]

function setI18nLanguage(lang: Locale) {
i18n.global.locale.value = lang as any
Expand All @@ -47,26 +30,49 @@
return lang
}

async function loadLocaleMessages(locale: SupportedLocale): Promise<MessageDictionary> {
if (locale === defaultLocale)
return enMessages

const response = await fetch(`${defaultApiHost}/translations/${locale}`, {
headers: {
Accept: 'application/json',
},
})

if (!response.ok)
throw new Error(`Failed to load locale "${locale}" (HTTP ${response.status})`)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

return await response.json() as MessageDictionary
}

export async function loadLanguageAsync(lang: string): Promise<Locale> {
const locale = normalizeLocale(lang)

// If the same language
if (i18n.global.locale.value === lang)
return setI18nLanguage(lang)
if (i18n.global.locale.value === locale)
return setI18nLanguage(locale)

// If the language was already loaded
if (loadedLanguages.includes(lang))
return setI18nLanguage(lang)
if (loadedLanguages.includes(locale))
return setI18nLanguage(locale)

const fallbackLocale = normalizeLocale(i18n.global.locale.value)

// If the language hasn't been loaded yet
const messages = await localesMap[lang]()
i18n.global.setLocaleMessage(lang, messages.default)
loadedLanguages.push(lang)
return setI18nLanguage(lang)
try {
const messages = await loadLocaleMessages(locale)
i18n.global.setLocaleMessage(locale, messages)
loadedLanguages.push(locale)
return setI18nLanguage(locale)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
catch (error) {
console.error('Failed to load locale messages', { locale, error })
return setI18nLanguage(fallbackLocale)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid overwriting locale preference on fetch failure

When loading a non-English locale fails, this path calls setI18nLanguage(fallbackLocale), and setI18nLanguage persists that fallback to localStorage. On first load, fallbackLocale is typically en, so a transient /translations/:locale outage permanently replaces the user's saved language with English. This regression means affected users will stay on English even after the backend recovers unless they manually reselect their language.

Useful? React with 👍 / 👎.

}
}

export const install: UserModule = ({ app }) => {
app.use(i18n)
let lang = localStorage.getItem('lang') ?? window.navigator.language.split('-')[0]
if (!(lang in languages))
lang = 'en'
const lang = normalizeLocale(localStorage.getItem('lang') ?? window.navigator.language)

Check warning on line 76 in src/modules/i18n.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer `globalThis` over `window`.

See more on https://sonarcloud.io/project/issues?id=Cap-go_capgo&issues=AZ2G8mcI97SflOJfLxkn&open=AZ2G8mcI97SflOJfLxkn&pullRequest=1908
loadLanguageAsync(lang)
}
17 changes: 17 additions & 0 deletions supabase/functions/_backend/public/translations/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { resolveLocale } from '../../../../../src/constants/locales.ts'
import { honoFactory, quickError, useCors } from '../../utils/hono.ts'
import { getLocaleMessages } from './messages.ts'

export const app = honoFactory.createApp()

app.use('*', useCors)

app.get('/:locale', (c) => {
const locale = resolveLocale(c.req.param('locale'))
if (!locale)
return quickError(404, 'unsupported_locale', 'Unsupported locale')

c.header('Cache-Control', 'public, max-age=300, s-maxage=86400')
c.header('Content-Language', locale)
return c.json(getLocaleMessages(locale))
})
40 changes: 40 additions & 0 deletions supabase/functions/_backend/public/translations/messages.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import type { SupportedLocale } from '../../../../../src/constants/locales.ts'
import deMessages from '../../../../../messages/de.json' with { type: 'json' }
import enMessages from '../../../../../messages/en.json' with { type: 'json' }
import esMessages from '../../../../../messages/es.json' with { type: 'json' }
import frMessages from '../../../../../messages/fr.json' with { type: 'json' }
import hiMessages from '../../../../../messages/hi.json' with { type: 'json' }
import idMessages from '../../../../../messages/id.json' with { type: 'json' }
import itMessages from '../../../../../messages/it.json' with { type: 'json' }
import jaMessages from '../../../../../messages/ja.json' with { type: 'json' }
import koMessages from '../../../../../messages/ko.json' with { type: 'json' }
import plMessages from '../../../../../messages/pl.json' with { type: 'json' }
import ptBrMessages from '../../../../../messages/pt-br.json' with { type: 'json' }
import ruMessages from '../../../../../messages/ru.json' with { type: 'json' }
import trMessages from '../../../../../messages/tr.json' with { type: 'json' }
import viMessages from '../../../../../messages/vi.json' with { type: 'json' }
import zhCnMessages from '../../../../../messages/zh-cn.json' with { type: 'json' }

type MessageDictionary = Record<string, string>

const localeMessages: Record<SupportedLocale, MessageDictionary> = {
'de': deMessages,
'en': enMessages,
'es': esMessages,
'fr': frMessages,
'hi': hiMessages,
'id': idMessages,
'it': itMessages,
'ja': jaMessages,
'ko': koMessages,
'pl': plMessages,
'pt-br': ptBrMessages,
'ru': ruMessages,
'tr': trMessages,
'vi': viMessages,
'zh-cn': zhCnMessages,
}

export function getLocaleMessages(locale: SupportedLocale): MessageDictionary {
return localeMessages[locale]
}
11 changes: 11 additions & 0 deletions supabase/functions/translations/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { app } from '../_backend/public/translations/index.ts'
import { createAllCatch, createHono } from '../_backend/utils/hono.ts'
import { version } from '../_backend/utils/version.ts'

const functionName = 'translations'
const appGlobal = createHono(functionName, version)

appGlobal.route('/', app)
Comment on lines +5 to +8
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Configure translations function as public

This new function is called by the frontend as an unauthenticated endpoint (/translations/:locale), but the commit does not add a matching [functions.translations] block in supabase/config.toml like other public functions. In Supabase Edge Functions, missing per-function config falls back to JWT verification being enabled, so environments that hit /functions/v1/translations/... directly will return 401 and non-English locales will never load. Please add the function config with verify_jwt = false (and the standard import_map) to keep behavior consistent with other public endpoints.

Useful? React with 👍 / 👎.

createAllCatch(appGlobal, functionName)

Deno.serve(appGlobal.fetch)
59 changes: 59 additions & 0 deletions tests/i18n-loader.unit.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'

vi.mock('~/services/supabase', () => ({
defaultApiHost: 'https://api.capgo.test',
}))

function createStorageMock() {
const store = new Map<string, string>()

return {
getItem: vi.fn((key: string) => store.get(key) ?? null),
setItem: vi.fn((key: string, value: string) => {
store.set(key, value)
}),
removeItem: vi.fn((key: string) => {
store.delete(key)
}),
}
}

describe('i18n locale loader', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.resetModules()
vi.unstubAllGlobals()

vi.stubGlobal('fetch', vi.fn())
vi.stubGlobal('localStorage', createStorageMock())
})

it('keeps English in the frontend bundle without fetching it from the backend', async () => {
const { i18n, loadLanguageAsync } = await import('../src/modules/i18n.ts')

await loadLanguageAsync('en')

expect(fetch).not.toHaveBeenCalled()
expect(i18n.global.locale.value).toBe('en')
expect(i18n.global.t('accept-invitation')).toBe('Accept Invitation')
})

it('falls back to the current locale when backend locale loading fails', async () => {
vi.mocked(fetch).mockResolvedValue({
ok: false,
status: 500,
} as Response)

const { i18n, loadLanguageAsync } = await import('../src/modules/i18n.ts')

await loadLanguageAsync('fr')

expect(fetch).toHaveBeenCalledWith('https://api.capgo.test/translations/fr', {
headers: {
Accept: 'application/json',
},
})
expect(i18n.global.locale.value).toBe('en')
expect(i18n.global.t('accept-invitation')).toBe('Accept Invitation')
})
})
41 changes: 41 additions & 0 deletions tests/translations.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { describe, expect, it } from 'vitest'

describe('[GET] /translations/:locale', () => {
it.concurrent('returns the requested locale payload with cache headers', async () => {
const { app: translations } = await import('../supabase/functions/_backend/public/translations/index.ts')
const { createAllCatch, createHono } = await import('../supabase/functions/_backend/utils/hono.ts')
const { version } = await import('../supabase/functions/_backend/utils/version.ts')

const app = createHono('translations', version)
app.route('/', translations)
createAllCatch(app, 'translations')

const response = await app.fetch(new Request('http://localhost/fr'))

expect(response.status).toBe(200)
expect(response.headers.get('access-control-allow-origin')).toBe('*')
expect(response.headers.get('cache-control')).toBe('public, max-age=300, s-maxage=86400')
expect(response.headers.get('content-language')).toBe('fr')

const data = await response.json() as Record<string, string>
expect(data['accept-invitation']).toBe('Accepter l\'invitation')
})

it.concurrent('rejects unsupported locales', async () => {
const { app: translations } = await import('../supabase/functions/_backend/public/translations/index.ts')
const { createAllCatch, createHono } = await import('../supabase/functions/_backend/utils/hono.ts')
const { version } = await import('../supabase/functions/_backend/utils/version.ts')

const app = createHono('translations', version)
app.route('/', translations)
createAllCatch(app, 'translations')

const response = await app.fetch(new Request('http://localhost/klingon'))

expect(response.status).toBe(404)
await expect(response.json()).resolves.toMatchObject({
error: 'unsupported_locale',
message: 'Unsupported locale',
})
})
})
Loading