aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--names/server.js8
-rw-r--r--names/translate.js41
-rw-r--r--tools/deepl-translate.test.js38
3 files changed, 84 insertions, 3 deletions
diff --git a/names/server.js b/names/server.js
index aff4e54..77d758c 100644
--- a/names/server.js
+++ b/names/server.js
@@ -28,7 +28,7 @@ import { mnemonicToSeedSync } from '@scure/bip39';
import { lnBackend } from '../bridge/ln.js';
import { ArkManager } from '../src/ark/manager.js';
import { fetchPreview } from './preview.js';
-import { translate, languages } from './translate.js';
+import { translate, translateDeepl, languages } from './translate.js';
import { decodeAddress } from '../src/ark/proto.js';
import { decodeNoffer } from '../src/noffer.js';
import { decodeBolt11 } from '../src/ark/lightning.js';
@@ -904,7 +904,7 @@ const json = (body, status = 200) => {
headers: {
'content-type': 'application/json',
'access-control-allow-origin': '*',
- 'access-control-allow-headers': 'content-type,authorization',
+ 'access-control-allow-headers': 'content-type,authorization,x-deepl-key',
'access-control-allow-methods': 'GET,POST,DELETE,OPTIONS',
},
});
@@ -1056,7 +1056,9 @@ Bun.serve({
let body = null;
try { body = await req.json(); } catch {}
if (!body || typeof body.text !== 'string' || typeof body.target !== 'string') return json({ error: 'bad request' }, 400);
- try { return json(await translate(body.text, body.target, body.source)); }
+ // a reader's own DeepL key rides in a header: passed on, never kept or logged
+ const dk = (req.headers.get('x-deepl-key') || '').trim();
+ try { return json(dk ? await translateDeepl(body.text, body.target, dk) : await translate(body.text, body.target, body.source)); }
catch (e) { return json({ error: e.message || 'translation failed' }, e.status || 503); }
}
diff --git a/names/translate.js b/names/translate.js
index 5382711..36ab8cf 100644
--- a/names/translate.js
+++ b/names/translate.js
@@ -80,3 +80,44 @@ export async function translate(text, target, source = 'auto') {
return out;
} finally { release(); }
}
+
+// DeepL, with the reader's own API key (sent per request; never stored, and
+// not part of the cache key beyond the provider). DeepL refuses browsers
+// (no CORS), which is the only reason this passes through here. A free key
+// ends in ":fx" and has its own host.
+const DEEPL_TARGET = { en: 'EN-US', pt: 'PT-BR', zh: 'ZH-HANS' };
+export async function translateDeepl(text, target, key, fetchImpl = fetch) {
+ text = String(text || '');
+ target = norm(target);
+ if (!text.trim()) return { text: '', source: null };
+ if (text.length > MAX_CHARS) throw Object.assign(new Error('too long'), { status: 413 });
+ if (!/^[0-9a-f-]{20,}(:fx)?$/i.test(key)) throw Object.assign(new Error('bad DeepL key'), { status: 401 });
+ const ck = createHash('sha256').update('deepl|' + target + '|' + text).digest('hex');
+ const hit = cache.get(ck);
+ if (hit) { cache.delete(ck); cache.set(ck, hit); return hit; }
+ const { prose, tags } = translatable(text, target);
+ if (!prose.length && !tags.length) return { text, source: null, nothing: true };
+ const host = /:fx$/i.test(key) ? 'https://api-free.deepl.com' : 'https://api.deepl.com';
+ let from = null;
+ const translated = await translatePieces(text, async (qs) => {
+ const r = await fetchImpl(host + '/v2/translate', {
+ method: 'POST',
+ headers: { 'content-type': 'application/json', authorization: 'DeepL-Auth-Key ' + key },
+ body: JSON.stringify({ text: qs, target_lang: DEEPL_TARGET[target] || target.toUpperCase() }),
+ signal: AbortSignal.timeout(20_000),
+ });
+ if (r.status === 403 || r.status === 401) throw Object.assign(new Error('DeepL refused the key'), { status: 401 });
+ if (r.status === 456) throw Object.assign(new Error('DeepL quota exceeded'), { status: 402 });
+ if (r.status === 400) throw Object.assign(new Error('DeepL can’t translate into that language'), { status: 422 });
+ if (!r.ok) throw Object.assign(new Error('DeepL ' + r.status), { status: 502 });
+ const j = await r.json();
+ const out = (j.translations || []).map((x) => x.text);
+ from ||= norm(j.translations?.[0]?.detected_source_language) || null;
+ if (out.length !== qs.length) throw Object.assign(new Error('DeepL answered short'), { status: 502 });
+ return out;
+ }, target);
+ const out = { text: from === target ? text : translated, source: from };
+ cache.set(ck, out);
+ if (cache.size > CACHE_MAX) cache.delete(cache.keys().next().value);
+ return out;
+}
diff --git a/tools/deepl-translate.test.js b/tools/deepl-translate.test.js
new file mode 100644
index 0000000..39975d0
--- /dev/null
+++ b/tools/deepl-translate.test.js
@@ -0,0 +1,38 @@
+// names/translate.js translateDeepl: the reader's key goes to the right
+// DeepL host as a DeepL-Auth-Key header, only the prose is sent (links and
+// tags kept), the detected language comes back, and DeepL's refusals turn
+// into statuses the client can word (401 bad key, 402 quota).
+// Run: bun test tools/deepl-translate.test.js
+import { test, expect } from 'bun:test';
+import { translateDeepl } from '../names/translate.js';
+
+const fake = (status = 200, seen = []) => async (url, init) => {
+ const body = JSON.parse(init.body);
+ seen.push({ url, auth: init.headers.authorization, body });
+ if (status !== 200) return new Response('{}', { status });
+ return new Response(JSON.stringify({ translations: body.text.map((t) => ({ text: '[it] ' + t, detected_source_language: 'EN' })) }), { status: 200 });
+};
+
+test('a free key goes to api-free, as DeepL-Auth-Key, prose only', async () => {
+ const seen = [];
+ const out = await translateDeepl('Good morning everyone, look at this https://example.com/a.png #nostr', 'it', 'abcdef01-2345-6789-abcd-ef0123456789:fx', fake(200, seen));
+ expect(seen[0].url).toBe('https://api-free.deepl.com/v2/translate');
+ expect(seen[0].auth).toBe('DeepL-Auth-Key abcdef01-2345-6789-abcd-ef0123456789:fx');
+ expect(seen[0].body.target_lang).toBe('IT');
+ expect(seen[0].body.text.join(' ')).not.toContain('https://');
+ expect(out.source).toBe('en');
+ expect(out.text).toContain('[it] ');
+ expect(out.text).toContain('https://example.com/a.png');
+ expect(out.text).toContain('#nostr');
+});
+test('a paid key goes to api.deepl.com; English means EN-US', async () => {
+ const seen = [];
+ await translateDeepl('Buongiorno a tutti, come state oggi?', 'en', 'abcdef01-2345-6789-abcd-ef0123456780', fake(200, seen));
+ expect(seen[0].url).toBe('https://api.deepl.com/v2/translate');
+ expect(seen[0].body.target_lang).toBe('EN-US');
+});
+test('refusals: bad key 401, quota 402', async () => {
+ await expect(translateDeepl('Hello there my friends, how are you', 'it', 'abcdef01-2345-6789-abcd-ef0123456781:fx', fake(403))).rejects.toMatchObject({ status: 401 });
+ await expect(translateDeepl('Hello there my friends, how are you all', 'it', 'abcdef01-2345-6789-abcd-ef0123456781:fx', fake(456))).rejects.toMatchObject({ status: 402 });
+ await expect(translateDeepl('Hello', 'it', 'not a key')).rejects.toMatchObject({ status: 401 });
+});