/** * Scheer Enterprise Translation & Transcription Tool — local proxy server. * * Why this file exists: the DeepL API blocks direct calls from browser * JavaScript (CORS), and your API key must never sit in public client code. * This tiny Node server (no external dependencies, uses Node 18+ built-in * fetch/FormData/Blob) does two things: * 1. Serves the single-page UI (index.html) * 2. Relays translate/document/usage requests to DeepL using the API key * configured here on the server (via DEEPL_API_KEY env var or a local * .env file). The key is never sent to or stored in the browser. * * Setup: copy .env.example to .env and fill in DEEPL_API_KEY, or export * DEEPL_API_KEY as an environment variable before starting. * Run: node server.js * Open: http://localhost:3000 */ const http = require('http'); const fs = require('fs'); const path = require('path'); // ---- Minimal .env loader (no dependency) ------------------------------- // Reads KEY=VALUE lines from a .env file next to this script, without // overriding variables already set in the real environment. (function loadDotEnv() { const envPath = path.join(__dirname, '.env'); if (!fs.existsSync(envPath)) return; const lines = fs.readFileSync(envPath, 'utf-8').split('\n'); for (const line of lines) { const trimmed = line.trim(); if (!trimmed || trimmed.startsWith('#')) continue; const idx = trimmed.indexOf('='); if (idx === -1) continue; const key = trimmed.slice(0, idx).trim(); let value = trimmed.slice(idx + 1).trim(); if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) { value = value.slice(1, -1); } if (!(key in process.env)) process.env[key] = value; } })(); const PORT = process.env.PORT || 3000; const DEEPL_API_KEY = process.env.DEEPL_API_KEY || ''; if (!DEEPL_API_KEY) { console.warn( '\n[warning] DEEPL_API_KEY is not set. Set it in a .env file (see .env.example) ' + 'or as an environment variable before running the server. All translation ' + 'requests will fail until it is configured.\n' ); } function baseUrlFor(apiKey) { return apiKey && apiKey.trim().endsWith(':fx') ? 'https://api-free.deepl.com' : 'https://api.deepl.com'; } // ---- Domain customization (glossaries) --------------------------------- // Each of the four Scheer entities gets two fixed-direction glossaries — // German -> English and English -> German — persisted locally in // domains.json (which glossary_id belongs to which domain/direction; the // actual glossary content lives on DeepL's servers). domains.json lives on // the server disk, not in any user's browser, so every visitor to the site // sees and edits the same shared glossaries. // The path is overridable via DOMAINS_FILE so a container deployment can point // it at a mounted volume (e.g. /app/data/domains.json) instead of the image's // own filesystem, which is recreated on every redeploy. const DOMAINS_FILE = process.env.DOMAINS_FILE || path.join(__dirname, 'domains.json'); const VALID_DOMAINS = ['scheer-group', 'scheer-ids', 'scheer-pas', 'scheer-imc']; const DOMAIN_LABELS = { 'scheer-group': 'Scheer Group', 'scheer-ids': 'Scheer IDS', 'scheer-pas': 'Scheer PAS', 'scheer-imc': 'Scheer IMC', }; const GLOSSARY_DIRECTIONS = { 'de-en': { source_lang: 'DE', target_lang: 'EN', label: 'German → English' }, 'en-de': { source_lang: 'EN', target_lang: 'DE', label: 'English → German' }, }; function emptyDomain(id) { return { label: DOMAIN_LABELS[id], glossaries: { 'de-en': null, 'en-de': null } }; } function loadDomains() { let data = {}; if (fs.existsSync(DOMAINS_FILE)) { try { data = JSON.parse(fs.readFileSync(DOMAINS_FILE, 'utf-8')); } catch (e) { data = {}; } } let changed = false; for (const id of VALID_DOMAINS) { if (!data[id]) { data[id] = emptyDomain(id); changed = true; continue; } const d = data[id]; // Drop retired style-rules/translation-memory fields from older versions. if ('style' in d || 'translationMemory' in d) { delete d.style; delete d.translationMemory; changed = true; } // Migrate the old single "glossary" field (one selectable language // pair) into the new fixed two-direction "glossaries" map. if ('glossary' in d) { if (!d.glossaries) d.glossaries = { 'de-en': null, 'en-de': null }; const g = d.glossary; if (g && g.source_lang && g.target_lang) { const src = String(g.source_lang).toUpperCase(); const tgt = String(g.target_lang).split('-')[0].toUpperCase(); if (src === 'DE' && tgt === 'EN') d.glossaries['de-en'] = g; else if (src === 'EN' && tgt === 'DE') d.glossaries['en-de'] = g; // Any other legacy direction is no longer representable and is dropped. } delete d.glossary; changed = true; } if (!d.glossaries) { d.glossaries = { 'de-en': null, 'en-de': null }; changed = true; } for (const dir of Object.keys(GLOSSARY_DIRECTIONS)) { if (!(dir in d.glossaries)) { d.glossaries[dir] = null; changed = true; } } } if (changed || !fs.existsSync(DOMAINS_FILE)) saveDomains(data); return data; } function saveDomains(data) { fs.mkdirSync(path.dirname(DOMAINS_FILE), { recursive: true }); fs.writeFileSync(DOMAINS_FILE, JSON.stringify(data, null, 2)); } // Decide whether one of a domain's two glossaries applies to a given // translation request (the request's language pair has to exactly match // the fixed direction the glossary was created for), and build the extra // DeepL params. function computeDomainParams(domains, domainId, sourceLang, targetLang) { const applied = { glossary: false }; const extra = {}; const d = domainId && domains[domainId]; if (!d || !d.glossaries) return { extra, applied }; const targetBase = String(targetLang || '').split('-')[0].toUpperCase(); const srcUpper = sourceLang ? String(sourceLang).toUpperCase() : ''; for (const [dir, dirCfg] of Object.entries(GLOSSARY_DIRECTIONS)) { const g = d.glossaries[dir]; if (g && g.id && srcUpper === dirCfg.source_lang && targetBase === dirCfg.target_lang) { extra.glossary_id = g.id; applied.glossary = true; break; } } return { extra, applied }; } async function handleGetDomains(req, res) { sendJson(res, 200, loadDomains()); } async function handleSaveGlossary(req, res, domainId, direction) { if (!VALID_DOMAINS.includes(domainId)) return sendJson(res, 400, { error: 'Unknown domain.' }); if (!GLOSSARY_DIRECTIONS[direction]) return sendJson(res, 400, { error: 'Unknown glossary direction.' }); if (!DEEPL_API_KEY) return sendJson(res, 500, { error: 'Server is missing DEEPL_API_KEY.' }); const { entries } = await parseJsonBody(req); if (!Array.isArray(entries) || entries.length === 0) { return sendJson(res, 400, { error: 'At least one glossary entry is required.' }); } const cleanEntries = entries .filter((e) => e && e.source != null && e.target != null) .map((e) => ({ source: String(e.source).trim(), target: String(e.target).trim() })) .filter((e) => e.source && e.target); if (cleanEntries.length === 0) return sendJson(res, 400, { error: 'No valid (source, target) entries provided.' }); const { source_lang, target_lang, label } = GLOSSARY_DIRECTIONS[direction]; const name = `${DOMAIN_LABELS[domainId]} ${label} Glossary`; const tsv = cleanEntries.map((e) => `${e.source}\t${e.target}`).join('\n'); const base = baseUrlFor(DEEPL_API_KEY); // Delete the old glossary for this domain/direction FIRST, before creating // the replacement. DeepL accounts have a cap on total glossary count // ("Too many glossaries" / 456 error), so freeing this slot up front // avoids briefly needing N+1 slots for what is logically still N // glossaries (one per domain per direction). const domains = loadDomains(); const existing = domains[domainId].glossaries[direction]; if (existing && existing.id) { const delResp = await fetch(`${base}/v2/glossaries/${existing.id}`, { method: 'DELETE', headers: { Authorization: `DeepL-Auth-Key ${DEEPL_API_KEY}` }, }).catch(() => null); // A 404 here just means it was already gone (e.g. deleted manually on // DeepL's side) - fine to proceed. Other failures are logged but not // fatal; we still attempt the create below. if (delResp && !delResp.ok && delResp.status !== 404) { console.warn(`Warning: failed to delete old glossary ${existing.id} (status ${delResp.status}) before replacing it.`); } } const createResp = await fetch(`${base}/v2/glossaries`, { method: 'POST', headers: { Authorization: `DeepL-Auth-Key ${DEEPL_API_KEY}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ name, source_lang, target_lang, entries: tsv, entries_format: 'tsv' }), }); if (!createResp.ok) { // The old glossary is already gone at this point, so make sure that's // reflected in domains.json even though the new save failed, instead of // leaving a dangling reference to a now-deleted glossary ID. if (existing && existing.id) { domains[domainId].glossaries[direction] = null; saveDomains(domains); } const detail = await forwardDeepLError(createResp); // DeepL answers 456 "Too many glossaries" once the account's glossary // allowance is used up. On the API Free plan that allowance is a single // glossary for the whole account, so this fires as soon as a second // domain/direction is used - which reads like an app bug unless we say // which glossary is holding the only slot. if (createResp.status === 456 && /too many glossaries/i.test(detail)) { return sendJson(res, 456, { error: await describeGlossaryLimit(base, domainId, direction), }); } return sendJson(res, createResp.status, { error: detail }); } const created = await createResp.json(); domains[domainId].glossaries[direction] = { id: created.glossary_id, name: created.name, source_lang: created.source_lang, target_lang: created.target_lang, entry_count: created.entry_count, entries: cleanEntries, }; saveDomains(domains); sendJson(res, 200, domains[domainId].glossaries[direction]); } async function handleDeleteGlossary(req, res, domainId, direction) { if (!VALID_DOMAINS.includes(domainId)) return sendJson(res, 400, { error: 'Unknown domain.' }); if (!GLOSSARY_DIRECTIONS[direction]) return sendJson(res, 400, { error: 'Unknown glossary direction.' }); const domains = loadDomains(); const existing = domains[domainId].glossaries[direction]; if (existing && existing.id && DEEPL_API_KEY) { const base = baseUrlFor(DEEPL_API_KEY); await fetch(`${base}/v2/glossaries/${existing.id}`, { method: 'DELETE', headers: { Authorization: `DeepL-Auth-Key ${DEEPL_API_KEY}` }, }).catch(() => {}); } domains[domainId].glossaries[direction] = null; saveDomains(domains); sendJson(res, 200, { ok: true }); } function sendJson(res, status, obj) { const body = JSON.stringify(obj); res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Content-Length': Buffer.byteLength(body), }); res.end(body); } function readBody(req) { return new Promise((resolve, reject) => { const chunks = []; let size = 0; req.on('data', (c) => { size += c.length; if (size > 60 * 1024 * 1024) { reject(new Error('Payload too large (max 60MB)')); req.destroy(); return; } chunks.push(c); }); req.on('end', () => resolve(Buffer.concat(chunks))); req.on('error', reject); }); } async function parseJsonBody(req) { const raw = await readBody(req); if (!raw.length) return {}; try { return JSON.parse(raw.toString('utf-8')); } catch (e) { const err = new Error('Invalid JSON body'); err.statusCode = 400; throw err; } } // Turns DeepL's bare "Too many glossaries" into something the person in the // browser can act on: which glossaries already occupy the account's slots, // and what the two ways out are. Falls back to a generic message if the // account can't be listed for some reason. async function describeGlossaryLimit(base, domainId, direction) { const wanted = `${DOMAIN_LABELS[domainId]} (${GLOSSARY_DIRECTIONS[direction].label})`; let inUse = []; try { const resp = await fetch(`${base}/v2/glossaries`, { headers: { Authorization: `DeepL-Auth-Key ${DEEPL_API_KEY}` }, }); if (resp.ok) inUse = (await resp.json()).glossaries || []; } catch (_) { /* fall through to the generic wording below */ } const listed = inUse.length ? ` Currently in use: ${inUse.map((g) => `"${g.name}"`).join(', ')}.` : ''; return ( `Your DeepL plan's glossary limit is already reached, so the glossary for ` + `${wanted} could not be created.${listed} ` + `Delete a glossary you no longer need to free up a slot, or upgrade the ` + `DeepL API plan (the Free plan allows only one glossary per account, ` + `which is not enough for one glossary per domain and direction).` ); } async function forwardDeepLError(resp) { let detail = ''; try { detail = await resp.text(); } catch (_) { /* ignore */ } return `DeepL API error (${resp.status} ${resp.statusText}): ${detail || 'no detail'}`; } // ---- Route handlers --------------------------------------------------- async function handleTranslate(req, res) { const { texts, target_lang, source_lang, domain } = await parseJsonBody(req); const apiKey = DEEPL_API_KEY; if (!apiKey) return sendJson(res, 500, { error: 'Server is missing DEEPL_API_KEY. Ask an admin to configure it (see .env.example).' }); if (!Array.isArray(texts) || texts.length === 0) { return sendJson(res, 400, { error: 'No text provided to translate.' }); } if (!target_lang) return sendJson(res, 400, { error: 'Missing target_lang.' }); const domains = loadDomains(); const { extra, applied } = computeDomainParams(domains, domain, source_lang, target_lang); const base = baseUrlFor(apiKey); const params = new URLSearchParams(); for (const t of texts) params.append('text', t); params.append('target_lang', target_lang); if (source_lang) params.append('source_lang', source_lang); params.append('preserve_formatting', '1'); if (extra.glossary_id) params.append('glossary_id', extra.glossary_id); const resp = await fetch(`${base}/v2/translate`, { method: 'POST', headers: { Authorization: `DeepL-Auth-Key ${apiKey}`, 'Content-Type': 'application/x-www-form-urlencoded', }, body: params, }); if (!resp.ok) return sendJson(res, resp.status, { error: await forwardDeepLError(resp) }); const data = await resp.json(); data.applied = applied; sendJson(res, 200, data); } async function handleUsage(req, res) { await parseJsonBody(req); // drain any request body (unused — key comes from server env) const apiKey = DEEPL_API_KEY; if (!apiKey) return sendJson(res, 500, { error: 'Server is missing DEEPL_API_KEY. Ask an admin to configure it (see .env.example).' }); const base = baseUrlFor(apiKey); const resp = await fetch(`${base}/v2/usage`, { headers: { Authorization: `DeepL-Auth-Key ${apiKey}` }, }); if (!resp.ok) return sendJson(res, resp.status, { error: await forwardDeepLError(resp) }); sendJson(res, 200, await resp.json()); } async function handleDocumentUpload(req, res) { const { filename, fileBase64, target_lang, source_lang, domain } = await parseJsonBody(req); const apiKey = DEEPL_API_KEY; if (!apiKey) return sendJson(res, 500, { error: 'Server is missing DEEPL_API_KEY. Ask an admin to configure it (see .env.example).' }); if (!fileBase64 || !filename) return sendJson(res, 400, { error: 'Missing file data.' }); if (!target_lang) return sendJson(res, 400, { error: 'Missing target_lang.' }); const domains = loadDomains(); const { extra, applied } = computeDomainParams(domains, domain, source_lang, target_lang); const base = baseUrlFor(apiKey); const buf = Buffer.from(fileBase64, 'base64'); const blob = new Blob([buf]); const form = new FormData(); form.append('file', blob, filename); form.append('target_lang', target_lang); if (source_lang) form.append('source_lang', source_lang); if (extra.glossary_id) form.append('glossary_id', extra.glossary_id); const resp = await fetch(`${base}/v2/document`, { method: 'POST', headers: { Authorization: `DeepL-Auth-Key ${apiKey}` }, body: form, }); if (!resp.ok) return sendJson(res, resp.status, { error: await forwardDeepLError(resp) }); const data = await resp.json(); data.applied = applied; sendJson(res, 200, data); } async function handleDocumentStatus(req, res) { const { document_id, document_key } = await parseJsonBody(req); const apiKey = DEEPL_API_KEY; if (!apiKey) return sendJson(res, 500, { error: 'Server is missing DEEPL_API_KEY. Ask an admin to configure it (see .env.example).' }); if (!document_id || !document_key) { return sendJson(res, 400, { error: 'Missing document_id or document_key.' }); } const base = baseUrlFor(apiKey); const resp = await fetch(`${base}/v2/document/${encodeURIComponent(document_id)}`, { method: 'POST', headers: { Authorization: `DeepL-Auth-Key ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ document_key }), }); if (!resp.ok) return sendJson(res, resp.status, { error: await forwardDeepLError(resp) }); sendJson(res, 200, await resp.json()); } async function handleDocumentResult(req, res) { const { document_id, document_key } = await parseJsonBody(req); const apiKey = DEEPL_API_KEY; if (!apiKey) return sendJson(res, 500, { error: 'Server is missing DEEPL_API_KEY. Ask an admin to configure it (see .env.example).' }); if (!document_id || !document_key) { return sendJson(res, 400, { error: 'Missing document_id or document_key.' }); } const base = baseUrlFor(apiKey); const resp = await fetch(`${base}/v2/document/${encodeURIComponent(document_id)}/result`, { method: 'POST', headers: { Authorization: `DeepL-Auth-Key ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ document_key }), }); if (!resp.ok) return sendJson(res, resp.status, { error: await forwardDeepLError(resp) }); const arrayBuf = await resp.arrayBuffer(); sendJson(res, 200, { fileBase64: Buffer.from(arrayBuf).toString('base64') }); } // ---- Static file + router --------------------------------------------- const MIME = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript', '.css': 'text/css' }; function serveStatic(req, res) { const urlPath = req.url.split('?')[0]; const file = urlPath === '/' ? 'index.html' : urlPath.replace(/^\//, ''); const filePath = path.join(__dirname, file); if (!filePath.startsWith(__dirname)) return res.writeHead(403).end('Forbidden'); fs.readFile(filePath, (err, data) => { if (err) { res.writeHead(404, { 'Content-Type': 'text/plain' }); return res.end('Not found'); } const ext = path.extname(filePath); res.writeHead(200, { 'Content-Type': MIME[ext] || 'application/octet-stream', // Prevent browsers/proxies from caching a stale copy of the app shell — // an outdated cached index.html calling routes that no longer exist on // a newer server.js is a common source of confusing 404s after updates. 'Cache-Control': 'no-cache, no-store, must-revalidate', }); res.end(data); }); } // Liveness probe for container platforms (Docker HEALTHCHECK, Dokploy). // Deliberately does not call DeepL: it answers "is this process serving?", // not "is the upstream API up", so a DeepL outage doesn't restart the app. function handleHealth(req, res) { sendJson(res, 200, { status: 'ok', apiKeyConfigured: Boolean(DEEPL_API_KEY) }); } const routes = { 'GET /health': handleHealth, 'POST /api/translate': handleTranslate, 'POST /api/usage': handleUsage, 'POST /api/document/upload': handleDocumentUpload, 'POST /api/document/status': handleDocumentStatus, 'POST /api/document/result': handleDocumentResult, 'GET /api/domains': handleGetDomains, }; // Routes with :domain and :direction path segments // (e.g. /api/domains/scheer-group/glossary/de-en). const DYNAMIC_ROUTES = [ { method: 'POST', regex: /^\/api\/domains\/([a-z-]+)\/glossary\/(de-en|en-de)$/, handler: handleSaveGlossary }, { method: 'DELETE', regex: /^\/api\/domains\/([a-z-]+)\/glossary\/(de-en|en-de)$/, handler: handleDeleteGlossary }, ]; const server = http.createServer(async (req, res) => { const urlPath = req.url.split('?')[0]; for (const route of DYNAMIC_ROUTES) { if (route.method !== req.method) continue; const m = urlPath.match(route.regex); if (m) { try { await route.handler(req, res, ...m.slice(1)); } catch (e) { sendJson(res, e.statusCode || 500, { error: e.message || 'Internal server error' }); } return; } } const key = `${req.method} ${urlPath}`; const handler = routes[key]; if (handler) { try { await handler(req, res); } catch (e) { sendJson(res, e.statusCode || 500, { error: e.message || 'Internal server error' }); } return; } if (req.method === 'GET') return serveStatic(req, res); // Old (pre-direction) glossary route shape, e.g. POST/DELETE // /api/domains/scheer-group/glossary (no /:direction suffix). This can't // be handled — we don't know which of the two directional lists was // intended — but a bare 404 here looks like a server bug rather than what // it usually is: a browser tab with an old cached copy of index.html // still calling the old API shape. Point the user at the actual fix. const legacyGlossaryMatch = urlPath.match(/^\/api\/domains\/([a-z-]+)\/glossary$/); if (legacyGlossaryMatch && (req.method === 'POST' || req.method === 'DELETE')) { return sendJson(res, 400, { error: 'This endpoint is outdated (missing the glossary direction, e.g. /de-en or /en-de). ' + 'This usually means your browser is running a cached, older version of the page. ' + 'Please hard-refresh (Ctrl+F5 or Cmd+Shift+R) and try saving the terms again.', }); } sendJson(res, 404, { error: 'Not found' }); }); server.listen(PORT, () => { console.log(`Scheer Translation Tool running at http://localhost:${PORT}`); });