diff --git a/README.md b/README.md index 27cfcb4..74ea6a1 100644 --- a/README.md +++ b/README.md @@ -150,3 +150,7 @@ NAS 파일은 `/volume1/docker/projects/apps/hop/uploads/originals` 안에서 ** 관리자 용량은 기본적으로 값만 표시하며 **수정 → 설정 저장/취소**로 변경합니다. 계정별 보관함·기기 세션별/전체 업로드·파일 한 개·NAS 최소 여유 공간도 설정하며 저장한 값은 DB에 보존됩니다. GB 입력은 소수점 셋째 자리까지 지원하고 입력창을 벗어나면 `20.000`처럼 정리합니다. **기본값으로 돌리기**는 NAS `.env`의 초기 설정값을 초안으로 불러오며 **설정 저장**을 눌러야 적용됩니다. 네트워크 토글과 규칙 추가·제거는 언제든 각각 즉시 적용하고, 자동 블랙리스트의 숫자 감지 기준만 해당 영역의 **수정 → 감지 기준 저장/취소**로 변경합니다. 서로 다른 영역의 변경이 입력 중인 감지 기준을 함께 저장하지 않습니다. IP 대역은 CIDR 외에 `203.0.113.*`처럼 뒤쪽 전체 자리의 IPv4 와일드카드도 입력할 수 있고, `203.0.113.0/24`로 정규화합니다. 저장 파일 목록은 업로더 ID와 계정 이메일(로그인하지 않은 사용자는 ‘게스트(Guest)’)을 표시합니다. 네트워크의 **파일 전송 금지**를 켜면 새 파일 전송/저장 메뉴를 숨기고 링크·텍스트만 허용합니다. 이미 수락한 파일 업로드와 기존 다운로드는 유지하며, 변경 사항은 접속 중인 사용자에게도 반영합니다. 용량 표시는 천 단위 쉼표를 사용하고, 정수 부분은 선명하게, 소수점 부분은 더 작고 흐리게 표시합니다. 단위 앞에 시각적 간격을 두며 전송·수신·보관함·관리자 화면에 같은 표시를 적용합니다. 관리자 저장 파일은 `업로더UUID · 게스트(Guest)` 또는 `업로더UUID · 이메일`로 표시하고 `접속 지역 · 일본(JP) · Tokyo · IP`처럼 지역과 IP도 구분합니다. + +## 한국어·일본어·영어 + +사용자 화면은 세 언어를 지원하며 상단의 **한국어 / 日本語 / English** 선택창에서 언제든 바꿀 수 있습니다. 직접 선택한 언어를 브라우저에 저장하고, 첫 방문은 확인된 국가(한국→한국어, 일본→일본어, 그 외→영어), 국가 미확인 시 브라우저 언어 순서로 결정합니다. 관리자 화면은 한국어를 유지합니다. 전송 원문과 기기 이름·파일명은 번역하지 않으며 언어를 바꿔도 작성 중인 내용과 기존 계정·파일은 유지합니다. 자세한 범위와 검증은 [언어 지원](docs/LANGUAGES.md)을 참고하세요. diff --git a/apps/server/src/app.ts b/apps/server/src/app.ts index 2cc74a3..c23096e 100644 --- a/apps/server/src/app.ts +++ b/apps/server/src/app.ts @@ -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) => { diff --git a/apps/server/src/auth.ts b/apps/server/src/auth.ts index 3975b4c..35223e7 100644 --- a/apps/server/src/auth.ts +++ b/apps/server/src/auth.ts @@ -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) { diff --git a/apps/server/test/localization.test.js b/apps/server/test/localization.test.js new file mode 100644 index 0000000..ee293a0 --- /dev/null +++ b/apps/server/test/localization.test.js @@ -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 = ''; + 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(); + } +}); diff --git a/apps/web/index.html b/apps/web/index.html index 0eefe4e..0b4b1bd 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -4,6 +4,10 @@ + hop. — 기기 사이를 가볍게. diff --git a/apps/web/public/manifest.en.webmanifest b/apps/web/public/manifest.en.webmanifest new file mode 100644 index 0000000..d262255 --- /dev/null +++ b/apps/web/public/manifest.en.webmanifest @@ -0,0 +1,32 @@ +{ + "id": "/", + "name": "hop. — A lighter way between devices.", + "short_name": "hop.", + "description": "Send files, links and text between devices, without signing up.", + "lang": "en", + "start_url": "/", + "scope": "/", + "display": "standalone", + "background_color": "#000000", + "theme_color": "#000000", + "icons": [ + { + "src": "/icon-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "any" + }, + { + "src": "/icon-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "any" + }, + { + "src": "/icon-maskable.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ] +} diff --git a/apps/web/public/manifest.ja.webmanifest b/apps/web/public/manifest.ja.webmanifest new file mode 100644 index 0000000..e402f98 --- /dev/null +++ b/apps/web/public/manifest.ja.webmanifest @@ -0,0 +1,32 @@ +{ + "id": "/", + "name": "hop. — デバイスの間を、軽やかに。", + "short_name": "hop.", + "description": "登録せずに、デバイス間でファイル・リンク・テキストを送信。", + "lang": "ja", + "start_url": "/", + "scope": "/", + "display": "standalone", + "background_color": "#000000", + "theme_color": "#000000", + "icons": [ + { + "src": "/icon-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "any" + }, + { + "src": "/icon-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "any" + }, + { + "src": "/icon-maskable.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ] +} diff --git a/apps/web/public/sw.js b/apps/web/public/sw.js index 1e705eb..ff7cbfc 100644 --- a/apps/web/public/sw.js +++ b/apps/web/public/sw.js @@ -7,7 +7,7 @@ self.addEventListener('fetch', (event) => { fetch(event.request).catch( () => new Response( - 'hop.

hop.

인터넷 연결이 필요해요.

연결한 뒤 다시 열어주세요.

다시 시도', + 'hop.

hop.

인터넷 연결이 필요해요.

연결한 뒤 다시 열어주세요.

다시 시도', { status: 503, headers: { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' }, diff --git a/apps/web/src/AccountPanel.vue b/apps/web/src/AccountPanel.vue index 5642574..5ebe032 100644 --- a/apps/web/src/AccountPanel.vue +++ b/apps/web/src/AccountPanel.vue @@ -1,7 +1,8 @@