Add Korean Japanese and English user interfaces
This commit is contained in:
@@ -13,6 +13,9 @@ import { existsSync } from 'node:fs';
|
||||
import { z } from 'zod';
|
||||
import {
|
||||
PASSWORD_MIN_LENGTH,
|
||||
initialLanguage,
|
||||
preferredLanguage,
|
||||
translateSystemMessage,
|
||||
devicePatch,
|
||||
requestInput,
|
||||
spaceInput,
|
||||
@@ -246,6 +249,27 @@ export async function createApp(cfg: Config, options: { mailer?: Mailer } = {})
|
||||
}
|
||||
};
|
||||
const id = (req: FastifyRequest) => z.object({ id: z.uuid() }).parse(req.params).id;
|
||||
// Only system messages are localized. Transferred content and account data stay byte-for-byte intact.
|
||||
const requestLanguage = (req: FastifyRequest) =>
|
||||
preferredLanguage(req.headers['accept-language'], 'ko');
|
||||
app.addHook('onSend', async (req, reply, payload) => {
|
||||
if (
|
||||
!req.url.startsWith('/api/') ||
|
||||
req.url.startsWith('/api/admin/') ||
|
||||
!String(reply.getHeader('content-type')).includes('application/json') ||
|
||||
typeof payload !== 'string'
|
||||
)
|
||||
return payload;
|
||||
const language = requestLanguage(req);
|
||||
if (language === 'ko') return payload;
|
||||
const body = JSON.parse(payload);
|
||||
if (body && typeof body.message === 'string') {
|
||||
body.message = translateSystemMessage(body.message, language);
|
||||
reply.header('Content-Language', language);
|
||||
return JSON.stringify(body);
|
||||
}
|
||||
return payload;
|
||||
});
|
||||
app.addHook('onRequest', async (req, reply) => {
|
||||
reply
|
||||
.header('X-Content-Type-Options', 'nosniff')
|
||||
@@ -479,6 +503,10 @@ export async function createApp(cfg: Config, options: { mailer?: Mailer } = {})
|
||||
device: service.device(guest.id),
|
||||
csrfToken: service.csrf(token!),
|
||||
expiresAt: guest.expiresAt,
|
||||
defaultLanguage: initialLanguage(
|
||||
visitorRegion(req, cfg).countryCode,
|
||||
req.headers['accept-language'],
|
||||
),
|
||||
servicePolicy: service.admin.publicPolicy(),
|
||||
};
|
||||
});
|
||||
@@ -884,6 +912,8 @@ export async function createApp(cfg: Config, options: { mailer?: Mailer } = {})
|
||||
input.password,
|
||||
input.displayName,
|
||||
ip(req) ?? 'unknown',
|
||||
undefined,
|
||||
requestLanguage(req),
|
||||
);
|
||||
});
|
||||
app.post('/api/auth/setup', async (req) => {
|
||||
@@ -895,6 +925,7 @@ export async function createApp(cfg: Config, options: { mailer?: Mailer } = {})
|
||||
input.displayName,
|
||||
ip(req) ?? 'unknown',
|
||||
input.setupToken,
|
||||
requestLanguage(req),
|
||||
);
|
||||
});
|
||||
app.post('/api/auth/verify-email', async (req, reply) => {
|
||||
@@ -914,6 +945,7 @@ export async function createApp(cfg: Config, options: { mailer?: Mailer } = {})
|
||||
.parse(req.body).email,
|
||||
'verify',
|
||||
ip(req) ?? 'unknown',
|
||||
requestLanguage(req),
|
||||
),
|
||||
);
|
||||
app.post('/api/auth/login', { bodyLimit: 16384 }, async (req, reply) => {
|
||||
@@ -949,6 +981,7 @@ export async function createApp(cfg: Config, options: { mailer?: Mailer } = {})
|
||||
.parse(req.body).email,
|
||||
'reset',
|
||||
ip(req) ?? 'unknown',
|
||||
requestLanguage(req),
|
||||
),
|
||||
);
|
||||
app.post('/api/auth/reset-password', async (req) => {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { randomUUID, timingSafeEqual } from 'node:crypto';
|
||||
import { and, eq, gt, isNull, inArray } from 'drizzle-orm';
|
||||
import argon2 from 'argon2';
|
||||
import type { Service } from './service.js';
|
||||
import { PASSWORD_MIN_LENGTH, type Account } from '@hop/shared';
|
||||
import { PASSWORD_MIN_LENGTH, translate, type Account } from '@hop/shared';
|
||||
import * as s from './db/schema.js';
|
||||
import { fail, hash, randomToken } from './security.js';
|
||||
export type Mail = { to: string; subject: string; text: string; idempotencyKey?: string };
|
||||
@@ -88,7 +88,12 @@ export class Auth {
|
||||
fail(400, 'PASSWORD_INVALID', `비밀번호는 ${minimum}~1024자로 입력해 주세요.`);
|
||||
return argon2.hash(value, passwordOptions);
|
||||
}
|
||||
async token(user: User, purpose: 'verify' | 'reset' | 'email-change', value?: string) {
|
||||
async token(
|
||||
user: User,
|
||||
purpose: 'verify' | 'reset' | 'email-change',
|
||||
value?: string,
|
||||
language = 'ko',
|
||||
) {
|
||||
if (!this.mailReady)
|
||||
fail(503, 'MAIL_NOT_CONFIGURED', '이메일 기능이 아직 설정되지 않았습니다.');
|
||||
const token = randomToken(),
|
||||
@@ -127,8 +132,23 @@ export class Auth {
|
||||
await this.sendMail({
|
||||
idempotencyKey: `auth-${purpose}-${hash(token)}`,
|
||||
to: value ?? user.email!,
|
||||
subject: purpose === 'reset' ? 'hop. 비밀번호 재설정' : 'hop. 이메일 인증',
|
||||
text: `${purpose === 'reset' ? '비밀번호를 다시 설정하려면' : '이메일 인증을 완료하려면'} 다음 주소를 열어 주세요.\n${this.service.cfg.appUrl}/#${action}=${token}\n이 링크는 한 번만 사용할 수 있습니다.`,
|
||||
subject: translate(
|
||||
purpose === 'reset' ? 'hop. 비밀번호 재설정' : 'hop. 이메일 인증',
|
||||
language,
|
||||
),
|
||||
text: translate(
|
||||
'{p0} 다음 주소를 열어 주세요.\n{p1}/#{p2}={p3}\n이 링크는 한 번만 사용할 수 있습니다.',
|
||||
language,
|
||||
{
|
||||
p0: translate(
|
||||
purpose === 'reset' ? '비밀번호를 다시 설정하려면' : '이메일 인증을 완료하려면',
|
||||
language,
|
||||
),
|
||||
p1: this.service.cfg.appUrl,
|
||||
p2: action,
|
||||
p3: token,
|
||||
},
|
||||
),
|
||||
});
|
||||
}
|
||||
async register(
|
||||
@@ -137,6 +157,7 @@ export class Auth {
|
||||
displayName: string,
|
||||
ip: string,
|
||||
setupToken?: string,
|
||||
language = 'ko',
|
||||
) {
|
||||
const { db, cfg, limiter } = this.service;
|
||||
limiter.hit(`register-ip:${ip}`, 5, 3600000);
|
||||
@@ -174,7 +195,7 @@ export class Auth {
|
||||
.run();
|
||||
return this.user(id);
|
||||
});
|
||||
await this.token(user, 'verify');
|
||||
await this.token(user, 'verify', undefined, language);
|
||||
return { message: '가입을 진행할 수 있는 주소라면 인증 메일이 발송됩니다.' };
|
||||
}
|
||||
consume(
|
||||
@@ -324,7 +345,7 @@ export class Auth {
|
||||
}
|
||||
}
|
||||
}
|
||||
async resend(email: string, purpose: 'verify' | 'reset', ip: string) {
|
||||
async resend(email: string, purpose: 'verify' | 'reset', ip: string, language = 'ko') {
|
||||
const normalized = normalizeEmail(email);
|
||||
this.service.limiter.hit(`mail-ip:${ip}`, 10, 3600000);
|
||||
this.service.limiter.hit(`mail-email:${hash(normalized)}`, 3, 3600000);
|
||||
@@ -334,7 +355,7 @@ export class Auth {
|
||||
((purpose === 'verify' && user.status === 'pending') ||
|
||||
(purpose === 'reset' && ['active', 'pending'].includes(user.status)))
|
||||
)
|
||||
await this.token(user, purpose);
|
||||
await this.token(user, purpose, undefined, language);
|
||||
return { message: '처리 가능한 주소라면 안내 메일이 발송됩니다.' };
|
||||
}
|
||||
async resetPassword(token: string, password: string) {
|
||||
|
||||
148
apps/server/test/localization.test.js
Normal file
148
apps/server/test/localization.test.js
Normal file
@@ -0,0 +1,148 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
initialLanguage,
|
||||
normalizeLanguage,
|
||||
preferredLanguage,
|
||||
translate,
|
||||
translateSystemMessage,
|
||||
} from '../../../packages/shared/src/localization.js';
|
||||
import { messages } from '../../../packages/shared/src/messages.js';
|
||||
import { fixture } from './helpers.js';
|
||||
|
||||
test('language priority uses a known country, supported browser languages, quality and safe defaults', () => {
|
||||
assert.equal(initialLanguage('KR', 'ja-JP,en;q=0.9'), 'ko');
|
||||
assert.equal(initialLanguage('JP', 'ko-KR'), 'ja');
|
||||
assert.equal(initialLanguage('CN', 'ko'), 'en');
|
||||
assert.equal(initialLanguage('US', 'ja'), 'en');
|
||||
assert.equal(initialLanguage(null, 'fr-FR,ja-JP;q=0.8,en;q=0.5'), 'ja');
|
||||
assert.equal(initialLanguage('XX', 'ko;q=0,en;q=0.2'), 'en');
|
||||
assert.equal(initialLanguage(null, undefined), 'en');
|
||||
assert.equal(preferredLanguage('en;q=oops,ja;q=0.8,ko;q=0.9'), 'ko');
|
||||
assert.equal(preferredLanguage('ja;q=0,en;q=0', 'ko'), 'ko');
|
||||
assert.equal(normalizeLanguage('EN-us'), 'en');
|
||||
assert.equal(normalizeLanguage('fr'), null);
|
||||
});
|
||||
|
||||
test('English/Japanese catalog is complete, contains matching placeholders, and interpolates once without treating content as HTML', () => {
|
||||
for (const [source, translations] of Object.entries(messages)) {
|
||||
const placeholders = (text) => [...new Set(text.match(/\{p\d+\}/g) || [])].sort();
|
||||
for (const language of ['en', 'ja']) {
|
||||
assert.ok(translations[language]?.trim(), `${language}: ${source}`);
|
||||
assert.equal(/[가-힣]/.test(translations[language]), false, `${language}: ${source}`);
|
||||
assert.deepEqual(
|
||||
placeholders(translations[language]),
|
||||
placeholders(source),
|
||||
`${language}: ${source}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
const name = '<script>日本語 한글 {p1}</script>';
|
||||
assert.equal(translate('{p0}에 보내기', 'en', { p0: name }), 'Send to ' + name);
|
||||
assert.equal(
|
||||
translateSystemMessage('비밀번호는 8~1024자로 입력해 주세요.', 'ja'),
|
||||
'パスワードは8~1,024文字で入力してください。',
|
||||
);
|
||||
assert.equal(
|
||||
translateSystemMessage('https://example.test/한글', 'en'),
|
||||
'https://example.test/한글',
|
||||
);
|
||||
assert.equal(translateSystemMessage('Email verified.', 'ja'), 'メールアドレスを確認しました。');
|
||||
assert.equal(
|
||||
translateSystemMessage('Mac declined the transfer.', 'ja'),
|
||||
'Macが受信を辞退しました。',
|
||||
);
|
||||
});
|
||||
|
||||
test('guest default language trusts verified country headers only, and user API system messages use the chosen language', async () => {
|
||||
const f = await fixture(false, undefined, { clientIpSource: 'cloudflare' });
|
||||
try {
|
||||
for (const [country, browser, expected] of [
|
||||
['KR', 'en', 'ko'],
|
||||
['JP', 'en', 'ja'],
|
||||
['CN', 'ja', 'en'],
|
||||
['XX', 'ja-JP', 'ja'],
|
||||
]) {
|
||||
const response = await f.call(null, '/guest/session', 'POST', {}, undefined, {
|
||||
'cf-connecting-ip': '8.8.8.8',
|
||||
'cf-ipcountry': country,
|
||||
'accept-language': browser,
|
||||
});
|
||||
assert.equal(response.statusCode, 200, response.body);
|
||||
assert.equal(response.json().defaultLanguage, expected);
|
||||
assert.equal(response.headers['cache-control'], 'no-store');
|
||||
}
|
||||
f.cfg.trustedProxies = [];
|
||||
const spoofed = await f.call(null, '/guest/session', 'POST', {}, undefined, {
|
||||
'cf-connecting-ip': '8.8.8.8',
|
||||
'cf-ipcountry': 'JP',
|
||||
'accept-language': 'ko-KR',
|
||||
});
|
||||
assert.equal(spoofed.json().defaultLanguage, 'ko');
|
||||
const client = await f.create();
|
||||
for (const language of ['en', 'ja']) {
|
||||
const response = await f.call(
|
||||
client,
|
||||
'/auth/login',
|
||||
'POST',
|
||||
{ email: 'nobody@hop.test', password: '12345678' },
|
||||
undefined,
|
||||
{ 'accept-language': language },
|
||||
);
|
||||
assert.equal(response.statusCode, 401);
|
||||
assert.equal(response.json().code, 'LOGIN_FAILED');
|
||||
assert.equal(
|
||||
response.json().message,
|
||||
translate('이메일 또는 비밀번호를 확인해 주세요.', language),
|
||||
);
|
||||
const invalid = await f.call(client, '/device/me', 'PATCH', { name: '' }, undefined, {
|
||||
'accept-language': language,
|
||||
});
|
||||
assert.equal(invalid.json().message, translate('입력 내용을 확인해 주세요.', language));
|
||||
}
|
||||
const korean = await f.call(client, '/device/me', 'PATCH', { name: '' });
|
||||
assert.equal(korean.json().message, '입력 내용을 확인해 주세요.');
|
||||
} finally {
|
||||
await f.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('verification and reset emails use the chosen language without changing one-use URLs or user data', async () => {
|
||||
const sent = [];
|
||||
const f = await fixture(false, async (mail) => sent.push(mail), { registrationMode: 'open' });
|
||||
try {
|
||||
const client = await f.create();
|
||||
for (const language of ['ja', 'en']) {
|
||||
const email = language + '@hop.test';
|
||||
const response = await f.call(
|
||||
client,
|
||||
'/auth/register',
|
||||
'POST',
|
||||
{
|
||||
email,
|
||||
password: '12345678',
|
||||
passwordConfirmation: '12345678',
|
||||
displayName: '日本語 한글 😀',
|
||||
},
|
||||
undefined,
|
||||
{ 'accept-language': language },
|
||||
);
|
||||
assert.equal(response.statusCode, 200, response.body);
|
||||
assert.equal(
|
||||
response.json().message,
|
||||
translate('가입을 진행할 수 있는 주소라면 인증 메일이 발송됩니다.', language),
|
||||
);
|
||||
assert.equal(sent.at(-1).subject, translate('hop. 이메일 인증', language));
|
||||
assert.equal(/[가-힣]/.test(sent.at(-1).text), false);
|
||||
assert.match(sent.at(-1).text, /http:\/\/hop.test\/#verify=[\w-]+/);
|
||||
const reset = await f.call(client, '/auth/forgot-password', 'POST', { email }, undefined, {
|
||||
'accept-language': language,
|
||||
});
|
||||
assert.equal(reset.statusCode, 200, reset.body);
|
||||
assert.equal(sent.at(-1).subject, translate('hop. 비밀번호 재설정', language));
|
||||
assert.match(sent.at(-1).text, /#reset-password=[\w-]+/);
|
||||
}
|
||||
} finally {
|
||||
await f.close();
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user