Saving terms for a domain/direction that has no glossary yet skips the
delete-before-create step, so the create runs against an account whose
glossary allowance may already be spent. DeepL answers 456 with a bare
{"message":"Too many glossaries"}, which was passed straight to the browser
and reads like a bug in this app.
On the API Free plan the allowance is one glossary for the entire account,
so this triggers as soon as a second domain or direction is used — the UI
offers eight slots (4 domains x 2 directions) that the plan cannot back.
The 456 case now reports which glossaries hold the slots and names the two
ways out: free one up, or upgrade the plan.
Co-Authored-By: Claude <noreply@anthropic.com>
567 lines
22 KiB
JavaScript
567 lines
22 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 });
|
|
}
|
|
|
|
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}`);
|
|
});
|