Files
deepl-christina/server.js
Pascal Linxweiler 34ee2856b2 Make the container deployable on Dokploy
domains.json was written next to server.js, i.e. into the image filesystem,
so every redeploy discarded the domain -> glossary_id mappings. Its path is
now overridable via DOMAINS_FILE and the image defaults it to /app/data,
a subdirectory so that mounting a volume there does not shadow server.js
and index.html in /app.

Also adds a GET /health endpoint plus a Docker HEALTHCHECK that uses it. The
probe deliberately does not call DeepL, so an upstream DeepL outage does not
get the container restarted.

.gitignore is new; .env holds a real API key and was previously untracked
only by convention.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-31 11:38:53 +02:00

529 lines
20 KiB
JavaScript

/**
* 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);
}
return sendJson(res, createResp.status, { error: await forwardDeepLError(createResp) });
}
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;
}
}
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}`);
});