Files
deepl-christina/server.js
Pascal Linxweiler 96d2f10346 Manage the DeepL account's glossaries from the domain settings
Until now the UI could only reach glossaries recorded in domains.json. A
glossary can also sit on the DeepL account with no domain pointing at it —
created before this app, left over from testing, or because domains.json was
reset, which is the normal state of a fresh deployment volume. Such a
glossary is invisible and unreachable here while still consuming the
account's glossary allowance, so on a small plan one forgotten glossary
blocks every save with no way to clear it from the UI.

Adds three endpoints backed by a new section in the domain settings modal:

  GET    /api/glossaries              what is really on the account, each
                                      annotated with the domain using it
  DELETE /api/glossaries/:id          remove one, freeing a slot, and drop
                                      any local reference to it
  POST   /api/glossaries/:id/adopt    claim an existing glossary for a
                                      domain, fetching its entries so they
                                      become editable

Adopting is a move, not a copy: if another domain already referenced the
glossary, that slot is released. Two domains pointing at one glossary would
mean editing through either silently replaces what the other still lists.

This does not raise the plan's glossary limit; it makes the limit visible
and the single slot reusable without hand-editing domains.json.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-31 12:04:44 +02:00

727 lines
30 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);
}
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 });
}
// ---- Account-level glossary management ---------------------------------
// The endpoints above only reach glossaries that domains.json already knows
// about. That is not the whole picture: glossaries can exist on the DeepL
// account without being tracked here (created before this app, left over
// from testing, or simply because domains.json was reset - a fresh
// deployment volume starts empty). Those untracked glossaries still occupy
// the account's glossary allowance, so on a small plan they can block every
// save while being invisible and unreachable from the UI.
//
// These three endpoints work against the DeepL account directly, so an
// admin can see what is really there, delete what is not needed, and pull an
// untracked glossary back under a domain.
// Which of the two supported directions a glossary's language pair maps to,
// or null for a pair this app does not model (e.g. FR->EN).
function directionForLangs(sourceLang, targetLang) {
const s = String(sourceLang || '').toUpperCase();
const t = String(targetLang || '').split('-')[0].toUpperCase();
if (s === 'DE' && t === 'EN') return 'de-en';
if (s === 'EN' && t === 'DE') return 'en-de';
return null;
}
// glossary_id -> which domain/direction currently references it.
function trackedGlossaryOwners(domains) {
const owners = {};
for (const [domainId, d] of Object.entries(domains)) {
for (const [direction, g] of Object.entries((d && d.glossaries) || {})) {
if (g && g.id) {
owners[g.id] = { domain: domainId, domainLabel: d.label, direction };
}
}
}
return owners;
}
async function handleListAccountGlossaries(req, res) {
if (!DEEPL_API_KEY) return sendJson(res, 500, { error: 'Server is missing DEEPL_API_KEY.' });
const base = baseUrlFor(DEEPL_API_KEY);
const resp = await fetch(`${base}/v2/glossaries`, {
headers: { Authorization: `DeepL-Auth-Key ${DEEPL_API_KEY}` },
});
if (!resp.ok) return sendJson(res, resp.status, { error: await forwardDeepLError(resp) });
const { glossaries } = await resp.json();
const owners = trackedGlossaryOwners(loadDomains());
sendJson(res, 200, {
glossaries: (glossaries || []).map((g) => ({
glossary_id: g.glossary_id,
name: g.name,
source_lang: g.source_lang,
target_lang: g.target_lang,
entry_count: g.entry_count,
creation_time: g.creation_time,
ready: g.ready,
// null when no domain references it - i.e. it is consuming a slot
// without being editable through the normal domain settings.
tracked: owners[g.glossary_id] || null,
// null when the language pair is outside the de<->en pairs this app
// models, in which case it can be deleted but not adopted.
direction: directionForLangs(g.source_lang, g.target_lang),
})),
});
}
async function handleDeleteAccountGlossary(req, res, glossaryId) {
if (!DEEPL_API_KEY) return sendJson(res, 500, { error: 'Server is missing DEEPL_API_KEY.' });
const base = baseUrlFor(DEEPL_API_KEY);
const resp = await fetch(`${base}/v2/glossaries/${encodeURIComponent(glossaryId)}`, {
method: 'DELETE',
headers: { Authorization: `DeepL-Auth-Key ${DEEPL_API_KEY}` },
});
// 404 means it is already gone on DeepL's side; still worth clearing any
// local reference to it below, so treat it as success.
if (!resp.ok && resp.status !== 404) {
return sendJson(res, resp.status, { error: await forwardDeepLError(resp) });
}
const domains = loadDomains();
const owner = trackedGlossaryOwners(domains)[glossaryId];
if (owner) {
domains[owner.domain].glossaries[owner.direction] = null;
saveDomains(domains);
}
sendJson(res, 200, { ok: true, untracked: owner || null });
}
// Pulls an existing DeepL glossary (including its entries) into a domain
// slot, so it becomes editable through the normal domain settings instead of
// sitting on the account unreachable.
async function handleAdoptGlossary(req, res, glossaryId) {
if (!DEEPL_API_KEY) return sendJson(res, 500, { error: 'Server is missing DEEPL_API_KEY.' });
const { domain: domainId } = await parseJsonBody(req);
if (!VALID_DOMAINS.includes(domainId)) return sendJson(res, 400, { error: 'Unknown domain.' });
const base = baseUrlFor(DEEPL_API_KEY);
const metaResp = await fetch(`${base}/v2/glossaries/${encodeURIComponent(glossaryId)}`, {
headers: { Authorization: `DeepL-Auth-Key ${DEEPL_API_KEY}` },
});
if (!metaResp.ok) return sendJson(res, metaResp.status, { error: await forwardDeepLError(metaResp) });
const meta = await metaResp.json();
const direction = directionForLangs(meta.source_lang, meta.target_lang);
if (!direction) {
return sendJson(res, 400, {
error: `This glossary translates ${meta.source_lang} → ${meta.target_lang}, which is not one of the two directions this tool manages (German → English, English → German). You can delete it to free a slot, but not load it into a domain.`,
});
}
const entriesResp = await fetch(`${base}/v2/glossaries/${encodeURIComponent(glossaryId)}/entries`, {
headers: { Authorization: `DeepL-Auth-Key ${DEEPL_API_KEY}`, Accept: 'text/tab-separated-values' },
});
if (!entriesResp.ok) return sendJson(res, entriesResp.status, { error: await forwardDeepLError(entriesResp) });
const entries = (await entriesResp.text())
.split('\n')
.map((line) => line.split('\t'))
.filter((parts) => parts.length >= 2 && parts[0].trim() && parts[1].trim())
.map((parts) => ({ source: parts[0].trim(), target: parts[1].trim() }));
const domains = loadDomains();
// Refuse to silently overwrite a different glossary already in this slot -
// that would orphan it on the account, which is the exact problem this
// whole section exists to clean up.
const occupying = domains[domainId].glossaries[direction];
if (occupying && occupying.id && occupying.id !== glossaryId) {
return sendJson(res, 409, {
error: `${DOMAIN_LABELS[domainId]} (${GLOSSARY_DIRECTIONS[direction].label}) already has a glossary. Delete that one first, then load this glossary in.`,
});
}
// A glossary must be referenced by at most one domain/direction. If some
// other domain already points at it, this is a move: release the old slot
// rather than leaving two domains aliasing one glossary, where editing via
// one silently replaces what the other still claims to use.
const previousOwner = trackedGlossaryOwners(domains)[glossaryId];
const moved =
previousOwner && !(previousOwner.domain === domainId && previousOwner.direction === direction)
? previousOwner
: null;
if (moved) domains[moved.domain].glossaries[moved.direction] = null;
domains[domainId].glossaries[direction] = {
id: meta.glossary_id,
name: meta.name,
source_lang: meta.source_lang,
target_lang: meta.target_lang,
entry_count: entries.length,
entries,
};
saveDomains(domains);
sendJson(res, 200, {
domain: domainId,
direction,
movedFrom: moved ? { domain: moved.domain, domainLabel: moved.domainLabel, direction: moved.direction } : null,
glossary: domains[domainId].glossaries[direction],
});
}
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,
'GET /api/glossaries': handleListAccountGlossaries,
};
// 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 },
// Account-level glossary management, keyed by DeepL's glossary_id (a UUID).
{ method: 'DELETE', regex: /^\/api\/glossaries\/([0-9a-fA-F-]{36})$/, handler: handleDeleteAccountGlossary },
{ method: 'POST', regex: /^\/api\/glossaries\/([0-9a-fA-F-]{36})\/adopt$/, handler: handleAdoptGlossary },
];
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}`);
});