Files
skrift-directus/extensions/directus-extension-skrift-api/dist/index.js
s4luorth d3ce505389 Versand-Seite: eine Zeile je FTP-Sendung (Buendel) statt je Auftrag
Die oeffentliche Versand-Seite listet jetzt die FTP-Sendungen (gebuendelte
batch_submissions, gruppiert nach bundle_name = FTP-Dateiname
<Zugang>_<Datum>_<HHMM>), nicht mehr einzelne Auftraege. "Versendet"
markiert das ganze Buendel (alle Uebermittlungen darin) als versendet;
danach verschwindet es aus der Liste.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-28 10:54:27 +02:00

813 lines
47 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Skrift – Public-API (v1) für Kunden
* ===========================================================================
* Eigener, versionierter API-Namespace, gedacht hinter einer eigenen Domain
* (z. B. api.skrift.de → NPM proxyt auf /skrift-api/*). Der Kunde sieht NIE das
* Directus-Dashboard.
*
* POST /v1/orders – Auftrag anlegen (Brief/Postkarte: Text ODER Datei)
* GET /v1/orders/:nummer – Status abfragen (wartend | in_bearbeitung | versendet)
* POST /v1/files – Druckdatei hochladen (Base64) → file_id
* GET /v1/openapi.json – OpenAPI-Spezifikation
* GET /docs – interaktive Doku (Scalar)
* POST /v1/clients – (Admin) neuen API-Zugang + Key erzeugen
*
* Auth: Header `X-Api-Key: <key>` (NICHT Authorization – den reserviert Directus).
* Gespeichert wird nur der SHA-256-Hash (api_clients). Jeder Zugang ist an GENAU
* EIN Produkt gebunden.
*/
import crypto from 'node:crypto';
import { Readable } from 'node:stream';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
// Verzeichnis dieser Extension (für die selbst gehosteten Swagger-UI-Assets).
const EXT_DIR = path.dirname(fileURLToPath(import.meta.url));
const BACKEND_URL = (process.env.SKRIFT_BACKEND_URL || 'http://skrift-backend:4000').replace(/\/$/, '');
const BACKEND_TOKEN = process.env.SKRIFT_BACKEND_TOKEN || '';
// Geheimer (kryptischer) Pfad-Token für die öffentliche Versand-Bestätigungsseite.
// Ohne gesetzten Wert ist die Seite aus (404). URL: /v1/versand/<SKRIFT_VERSAND_TOKEN>
const VERSAND_TOKEN = (process.env.SKRIFT_VERSAND_TOKEN || '').trim();
const STORAGE = (process.env.STORAGE_LOCATIONS || 'local').split(',')[0].trim() || 'local';
const sha256 = (s) => crypto.createHash('sha256').update(String(s)).digest('hex');
const pad3 = (n) => String(n).padStart(3, '0');
const WOCHENTAGE = ['montag', 'dienstag', 'mittwoch', 'donnerstag', 'freitag', 'samstag', 'sonntag'];
/**
* Internal order → public status (pending | processing | shipped | cancelled).
* Der Betreiber steuert im Produktions-Board `production_status`; der kaufmännische
* `status` dient als Rückfall. Beide werden berücksichtigt.
*/
function kundenStatus(order) {
const ps = String(order?.production_status || '');
const s = String(order?.status || '');
if (ps === 'storniert' || s === 'storniert') return 'cancelled';
if (ps === 'versendet' || s === 'versendet' || s === 'abgeschlossen') return 'shipped';
if (['im_druck', 'kuvertieren', 'versandfertig'].includes(ps) || ['in_produktion', 'gedruckt'].includes(s)) return 'processing';
return 'pending';
}
// Selbst ausgelieferte HTML-Doku (kein externes Script → CSP-sicher). Inline-CSS.
const DOCS_HTML = `<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Skrift API Reference</title>
<style>
*{box-sizing:border-box}
body{margin:0;font:16px/1.6 -apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;color:#1a1a1a;background:#f6f8fb}
.wrap{max-width:900px;margin:0 auto;padding:32px 20px 80px}
h1{font-size:30px;margin:0 0 4px}
h2{font-size:22px;margin:40px 0 12px;padding-top:14px;border-top:1px solid #e2e8f0}
h3{font-size:15px;margin:18px 0 6px}
code{background:#eef2f7;padding:2px 6px;border-radius:5px;font-family:ui-monospace,Menlo,Consolas,monospace;font-size:14px}
pre{background:#0f172a;color:#e6edf3;padding:16px;border-radius:10px;overflow:auto;font-family:ui-monospace,Menlo,Consolas,monospace;font-size:13px;line-height:1.5}
.card{background:#fff;border:1px solid #e2e8f0;border-radius:12px;padding:18px 20px;margin:14px 0}
.ep{display:flex;align-items:center;gap:10px;font-family:ui-monospace,monospace;font-size:15px;margin-bottom:6px}
.m{font-weight:700;color:#fff;border-radius:6px;padding:2px 8px;font-size:12px}
.post{background:#16a34a}.get{background:#2563eb}
table{border-collapse:collapse;width:100%;margin:8px 0;font-size:14px}
th,td{text-align:left;border-bottom:1px solid #e2e8f0;padding:7px 8px;vertical-align:top}
th{color:#64748b;font-weight:600}
.muted{color:#64748b}
.tag{display:inline-block;background:#eef2f7;border-radius:6px;padding:1px 8px;font-size:13px;margin:0 6px 4px 0}
a{color:#1E50A5}
</style></head>
<body><div class="wrap">
<h1>Skrift API Reference</h1>
<p class="muted">Handwritten mail as a service · API v1 · Base URL <code>https://dev.skrift.de</code></p>
<h2>Overview</h2>
<div class="card">
<p>REST API over HTTPS. Request and response bodies are JSON (<code>Content-Type: application/json</code>, UTF-8). An order yields one document and/or one envelope per recipient; the <b>quantity is derived from the number of recipients</b>. One or more products are bound to your API key; each order selects one via the <code>product</code> field.</p>
</div>
<h2>Authentication</h2>
<div class="card">
<p>Every request must include your API key in the <code>X-Api-Key</code> header:</p>
<pre>X-Api-Key: &lt;API_KEY&gt;</pre>
<p class="muted">Each key is scoped to exactly one product. Keys are secrets — never expose them in client-side code.</p>
</div>
<h2>Errors</h2>
<div class="card">
<p>Errors use standard HTTP status codes and a uniform body:</p>
<pre>{ "error": "human-readable message" }</pre>
<table>
<tr><th>Status</th><th>Meaning</th></tr>
<tr><td>200 / 201</td><td>Success / order created</td></tr>
<tr><td>400</td><td>Invalid request (missing or invalid fields)</td></tr>
<tr><td>401</td><td>Missing or invalid API key</td></tr>
<tr><td>404</td><td>Resource not found</td></tr>
<tr><td>413</td><td>Payload too large (file &gt; 80 MB)</td></tr>
<tr><td>415</td><td>Unsupported media type (only PDF is accepted)</td></tr>
<tr><td>500</td><td>Internal error</td></tr>
</table>
</div>
<h2>Order status</h2>
<div class="card">
<p>The <code>status</code> field returns one of:</p>
<span class="tag">pending</span> received, not yet in production
<span class="tag">processing</span> being written / printed
<span class="tag">shipped</span> dispatched
<span class="tag">cancelled</span> cancelled
</div>
<h2>Create an order</h2>
<div class="card">
<div class="ep"><span class="m post">POST</span> /v1/orders</div>
<p>Creates an order and returns its order number.</p>
<h3>Request body</h3>
<table>
<tr><th>Field</th><th>Type</th><th>Description</th></tr>
<tr><td>product</td><td>string</td><td>Product key to use. Required if your key allows multiple products; optional (auto) if exactly one.</td></tr>
<tr><td>recipients</td><td>array</td><td><b>Required.</b> One entry per recipient; count = quantity. Address as <code>free_text</code> (up to 5 lines, separated by <code>\\n</code>).</td></tr>
<tr><td>document_format</td><td>string</td><td>Document format: <code>a4</code>, <code>a6h</code> or <code>a6l</code>.</td></tr>
<tr><td>font</td><td>string</td><td><code>tilda</code>, <code>alva</code> or <code>ellie</code>.</td></tr>
<tr><td>realistic</td><td>boolean</td><td>Natural handwriting variation. Default <code>true</code>.</td></tr>
<tr><td>shipping_type</td><td>string</td><td><code>bulk</code> (consolidated shipment to you; default) or <code>single</code> (direct to each recipient).</td></tr>
<tr><td>shipping_day</td><td>string</td><td>Preferred dispatch weekday, e.g. <code>montag</code> (optional).</td></tr>
<tr><td>letter.text</td><td>string</td><td>Letter body, <b>fully written out — no placeholders</b>. Long text flows across multiple pages automatically; <code>[[seitenumbruch]]</code> forces a page break.</td></tr>
<tr><td>letter.files</td><td>array</td><td><code>file_id</code>(s) from <code>POST /v1/files</code> (file / PDF products).</td></tr>
<tr><td>envelope.mode</td><td>string</td><td><code>none</code> (no envelope) or <code>recipient</code> (recipient address on the envelope). Envelope size is selected automatically: A4 → DIN Lang, otherwise C6.</td></tr>
</table>
<h3>Example — text letter</h3>
<pre>curl -X POST https://dev.skrift.de/v1/orders \\
-H "X-Api-Key: &lt;KEY&gt;" -H "Content-Type: application/json" \\
-d '{
"product":"briefe","document_format":"a4","font":"tilda","realistic":true,
"shipping_type":"bulk","shipping_day":"montag",
"letter":{"text":"Dear Anna,\\n\\nthank you for ..."},
"envelope":{"mode":"recipient"},
"recipients":[
{"free_text":"Anna Meier\\nMusterstr. 1\\n12345 Musterstadt"}
]
}'</pre>
<h3>Example — print uploaded PDFs (file product)</h3>
<pre>curl -X POST https://dev.skrift.de/v1/orders \\
-H "X-Api-Key: &lt;KEY&gt;" -H "Content-Type: application/json" \\
-d '{"document_format":"a4","letter":{"files":["&lt;file_id&gt;"]},
"envelope":{"mode":"recipient"},
"recipients":[{"free_text":"Anna Meier\\nMusterstr. 1\\n12345 Musterstadt"}]}'</pre>
<h3>Example — address envelopes only</h3>
<pre>curl -X POST https://dev.skrift.de/v1/orders \\
-H "X-Api-Key: &lt;KEY&gt;" -H "Content-Type: application/json" \\
-d '{"envelope":{"mode":"recipient"},
"recipients":[{"free_text":"Anna Meier\\nMusterstr. 1\\n12345 Musterstadt"}]}'</pre>
<h3>Response <span class="muted">· 201</span></h3>
<pre>{ "order_number": "18-08-26-001", "status": "pending" }</pre>
</div>
<h2>Retrieve order status</h2>
<div class="card">
<div class="ep"><span class="m get">GET</span> /v1/orders/{order_number}</div>
<p>Returns the current status of one of your orders. <code>status_since</code> is the ISO 8601 timestamp (UTC) since which the order has been in its current status.</p>
<pre>curl https://dev.skrift.de/v1/orders/18-08-26-001 -H "X-Api-Key: &lt;KEY&gt;"
{ "order_number":"18-08-26-001", "status":"shipped", "status_since":"2026-08-20T09:14:00.000Z", "recipients":1 }</pre>
</div>
<h2>Upload a PDF</h2>
<div class="card">
<div class="ep"><span class="m post">POST</span> /v1/files</div>
<p>Uploads a base64-encoded print file. <b>PDF only</b>, max. 80 MB. Returns a <code>file_id</code> to reference in <code>letter.files</code>.</p>
<pre>curl -X POST https://dev.skrift.de/v1/files \\
-H "X-Api-Key: &lt;KEY&gt;" -H "Content-Type: application/json" \\
-d '{"filename":"letter.pdf","content_base64":"&lt;BASE64&gt;"}'
{ "file_id": "..." }</pre>
</div>
<h2>Batch submission <span class="muted">· alternative delivery</span></h2>
<div class="card">
<div class="ep"><span class="m post">POST</span> /v1/batch</div>
<p>Alternative to <code>/v1/orders</code> for a lettershop workflow: submit <b>one recipient per call</b> in a fixed schema, plus <b>either a ready print PDF</b> (used as-is) <b>or</b> letter <code>text</code>. Submissions are collected and bundled <b>per API key, once a day</b> (03:00 the next morning; Sat+Sun together) into one <code>.xlsx</code> (the schema below) and one merged print <code>.pdf</code>, then uploaded to the configured FTP target.</p>
<h3>Request body</h3>
<table>
<tr><th>Field</th><th>Type</th><th>Description</th></tr>
<tr><td>firma</td><td>string</td><td>Company (optional).</td></tr>
<tr><td>vorname</td><td>string</td><td>First name.</td></tr>
<tr><td>nachname</td><td>string</td><td>Last name.</td></tr>
<tr><td>zeile1, zeile2, zeile3</td><td>string</td><td>Free address lines.</td></tr>
<tr><td>plz</td><td>string</td><td>Postal code.</td></tr>
<tr><td>stadt</td><td>string</td><td>City.</td></tr>
<tr><td>land</td><td>string</td><td>Country.</td></tr>
<tr><td>pdf_file_id</td><td>string</td><td>A <code>file_id</code> from <code>POST /v1/files</code> — the ready print PDF (used 1:1). <b>Provide this or</b> <code>pdf_base64</code> <b>or</b> <code>text</code>.</td></tr>
<tr><td>pdf_base64</td><td>string</td><td>Alternatively the PDF inline as base64.</td></tr>
<tr><td>text</td><td>string</td><td>Alternatively a letter text (handwriting); not part of the FTP bundle.</td></tr>
</table>
<p class="muted">At least one address field is required, and exactly one of <code>pdf_file_id</code> / <code>pdf_base64</code> / <code>text</code>.</p>
<pre>curl -X POST https://dev.skrift.de/v1/batch \\
-H "X-Api-Key: &lt;KEY&gt;" -H "Content-Type: application/json" \\
-d '{"firma":"Muster GmbH","vorname":"Beate","nachname":"Schütte",
"zeile1":"Industriestraße 28","plz":"21493","stadt":"Schwarzenbek","land":"Deutschland",
"pdf_file_id":"&lt;file_id&gt;"}'
{ "id": "123", "status": "accepted" }</pre>
</div>
<p class="muted" style="margin-top:30px">Machine-readable specification: <a href="v1/openapi.json">OpenAPI (JSON)</a> — import into Postman, Insomnia, or any OpenAPI client.</p>
</div></body></html>`;
// Swagger UI (selbst gehostet). Assets liegen als swagger-ui.css + swagger-ui-bundle.js
// im Extension-Ordner; init.js wird generiert. Kein CDN → CSP-/Brave-sicher.
const SWAGGER_HTML = `<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Skrift API Reference</title>
<link rel="stylesheet" href="docs-assets/swagger-ui.css">
<style>body{margin:0;background:#fafafa}.swagger-ui .topbar{display:none}</style>
</head><body>
<div id="swagger-ui"></div>
<script src="docs-assets/swagger-ui-bundle.js"></script>
<script src="docs-assets/init.js"></script>
</body></html>`;
const INIT_JS = "window.addEventListener('DOMContentLoaded',function(){window.ui=SwaggerUIBundle({url:'v1/openapi.json',dom_id:'#swagger-ui',deepLinking:true,presets:[SwaggerUIBundle.presets.apis],layout:'BaseLayout'});});";
const handler = (router, { services, getSchema, logger }) => {
const { ItemsService, FilesService } = services;
const svcFactory = (schema) => (c) => new ItemsService(c, { schema, accountability: null });
/** Zeichen-Whitelist (wie im übrigen System) als Sanitizer laden. */
async function ladeClean(schema) {
try {
const ps = await new ItemsService('pricing_settings', { schema, accountability: null })
.readSingleton({ fields: ['char_whitelist'] });
const wl = ps?.char_whitelist;
const re = wl ? new RegExp(`[^${wl}\\n\\r\\t]`, 'g') : null;
return (v) => (typeof v === 'string' && re ? v.replace(re, '') : v);
} catch { return (v) => v; }
}
/** API-Key aus dem X-Api-Key-Header lesen und den aktiven Zugang finden.
* (NICHT Authorization: Bearer – den reserviert Directus für eigene Tokens
* und lehnt fremde Werte global mit INVALID_CREDENTIALS ab.) */
async function authClient(req) {
const raw = String(req.headers['x-api-key'] || '').trim();
if (!raw) return null;
const schema = await getSchema();
const rows = await new ItemsService('api_clients', { schema, accountability: null }).readByQuery({
filter: { key_hash: { _eq: sha256(raw) }, active: { _eq: true } },
limit: 1,
fields: ['id', 'name', 'customer', 'webhook_url', 'config',
'product.id', 'product.key', 'product.type', 'product.input_mode',
'products.product.id', 'products.product.key', 'products.product.type', 'products.product.input_mode'],
});
const client = rows?.[0] || null;
if (client) {
// Letzte Nutzung vermerken (best effort, nicht blockierend).
new ItemsService('api_clients', { schema, accountability: null })
.updateOne(client.id, { last_used: new Date().toISOString() }).catch(() => {});
}
return client;
}
const fehler = (res, code, msg) => res.status(code).json({ error: msg });
// ── POST /v1/files ─────────────────────────────────────────────────────────
// Body: { filename, content_base64 } → { file_id }. NUR PDF (Sicherheit).
router.post('/v1/files', async (req, res) => {
const client = await authClient(req);
if (!client) return fehler(res, 401, 'Missing or invalid API key.');
const { filename, content_base64 } = req.body || {};
if (!filename || !content_base64) return fehler(res, 400, 'filename and content_base64 are required.');
if (!/\.pdf$/i.test(String(filename))) return fehler(res, 415, 'Only PDF files are accepted (filename must end in .pdf).');
let buf;
try { buf = Buffer.from(String(content_base64), 'base64'); } catch { return fehler(res, 400, 'content_base64 is not valid base64.'); }
if (!buf.length) return fehler(res, 400, 'File is empty.');
if (buf.length > 80 * 1024 * 1024) return fehler(res, 413, 'File too large (max. 80 MB).');
// Inhalts-Prüfung: echte PDF-Signatur, nicht nur die Endung.
if (buf.slice(0, 5).toString('latin1') !== '%PDF-') return fehler(res, 415, 'File is not a valid PDF.');
try {
const schema = await getSchema();
const filesSvc = new FilesService({ schema, accountability: null });
const id = await filesSvc.uploadOne(Readable.from(buf), {
storage: STORAGE,
filename_download: String(filename),
title: String(filename),
type: 'application/pdf',
});
return res.json({ file_id: id });
} catch (err) {
logger.error(`[skrift-api] files: ${err.stack || err.message}`);
return fehler(res, 500, 'File upload failed.');
}
});
// ── POST /v1/batch ──────────────────────────────────────────────────────────
// Alternativer Übermittlungsweg: EIN Empfänger je Aufruf im festen Schema plus
// ENTWEDER eine fertige Druck-PDF ODER einen Schriftstücktext. Wird gespeichert
// und später je Kunde täglich zu xlsx + Sammel-Druck-PDF gebündelt (FTP).
// Ändert NICHTS an /v1/orders – eigenständiger Endpunkt.
router.post('/v1/batch', async (req, res) => {
const client = await authClient(req);
if (!client) return fehler(res, 401, 'Missing or invalid API key.');
const b = req.body || {};
const feld = (v) => (v == null ? '' : String(v));
const adr = {
firma: feld(b.firma), vorname: feld(b.vorname), nachname: feld(b.nachname),
zeile1: feld(b.zeile1), zeile2: feld(b.zeile2), zeile3: feld(b.zeile3),
plz: feld(b.plz), stadt: feld(b.stadt), land: feld(b.land),
};
if (!Object.values(adr).some((v) => v.trim())) return fehler(res, 400, 'At least one address field is required.');
const text = feld(b.text).trim();
const hatPdf = !!(b.pdf_file_id || b.pdf_base64);
if (!text && !hatPdf) return fehler(res, 400, 'Either a print PDF (pdf_file_id or pdf_base64) or letter text is required.');
if (text && hatPdf) return fehler(res, 400, 'Provide either a PDF or text, not both.');
try {
const schema = await getSchema();
const svc = svcFactory(schema);
const clean = await ladeClean(schema);
// Zeichen-Warnungen (wie /v1/orders): nicht erlaubte Zeichen werden entfernt.
const unerlaubt = new Set();
const pruefe = (s) => { for (const ch of new Set(String(s || ''))) if (clean(ch) === '') unerlaubt.add(ch); };
Object.values(adr).forEach(pruefe);
if (text) pruefe(text);
// PDF: bestehende file_id (aus /v1/files) ODER base64 hier hochladen.
let pdfFileId = null;
if (b.pdf_file_id) {
pdfFileId = String(b.pdf_file_id);
} else if (b.pdf_base64) {
let buf;
try { buf = Buffer.from(String(b.pdf_base64), 'base64'); } catch { return fehler(res, 400, 'pdf_base64 is not valid base64.'); }
if (!buf.length || buf.slice(0, 5).toString('latin1') !== '%PDF-') return fehler(res, 415, 'pdf_base64 is not a valid PDF.');
if (buf.length > 80 * 1024 * 1024) return fehler(res, 413, 'PDF too large (max. 80 MB).');
const filesSvc = new FilesService({ schema, accountability: null });
pdfFileId = await filesSvc.uploadOne(Readable.from(buf), {
storage: STORAGE, filename_download: `batch_${Date.now()}.pdf`, title: 'Batch print PDF', type: 'application/pdf',
});
}
const id = await svc('batch_submissions').createOne({
api_client: client.id, customer: client.customer || null,
firma: clean(adr.firma) || null, vorname: clean(adr.vorname) || null, nachname: clean(adr.nachname) || null,
zeile1: clean(adr.zeile1) || null, zeile2: clean(adr.zeile2) || null, zeile3: clean(adr.zeile3) || null,
plz: clean(adr.plz) || null, stadt: clean(adr.stadt) || null, land: clean(adr.land) || null,
text: text ? clean(text) : null,
pdf_file: pdfFileId,
status: 'pending',
});
const warnings = [];
if (unerlaubt.size) warnings.push({ code: 'unsupported_characters', characters: [...unerlaubt],
message: 'Some characters are not supported and were removed. See the allowed character set in the documentation.' });
return res.status(201).json({ id: String(id), status: 'accepted', ...(warnings.length ? { warnings } : {}) });
} catch (err) {
logger.error(`[skrift-api] batch: ${err.stack || err.message}`);
return fehler(res, 500, 'Submission could not be stored.');
}
});
// ── Öffentliche Versand-Bestätigung (kryptische URL, ohne Login) ────────────
// /v1/versand/<SKRIFT_VERSAND_TOKEN> : Auftragsnummer eingeben → Auftrag wird als
// „versendet" markiert (Status-API liefert dann „shipped"). Schutz = geheimer
// Pfad-Token (kein Konto). Ohne gesetzten Token ist die Seite aus.
const escH = (s) => String(s == null ? '' : s).replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
// Eine Zeile je FTP-Sendung (Bündel), benannt wie die FTP-Datei (<Zugang>_<Datum>_<HHMM>).
const versandSeite = ({ bundles = [], message = '', error = '' } = {}) => {
const rows = bundles.length ? bundles.map((b) => `
<div class="row">
<div class="info"><b>${escH(b.name)}</b><span>${Number(b.count) || 0} Empfänger · ${escH(String(b.date || '').slice(0, 10))}</span></div>
<form method="post"><input type="hidden" name="bundle" value="${escH(b.name)}"><button type="submit">Versendet ✓</button></form>
</div>`).join('') : '<p class="muted">Keine offenen Sendungen – alles versendet.</p>';
return `<!doctype html><html lang="de"><head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Versand bestätigen</title>
<style>
body{font-family:system-ui,Segoe UI,Arial,sans-serif;background:#f1f4f9;margin:0;padding:20px;color:#1a2a3a}
.card{max-width:620px;margin:5vh auto;background:#fff;border:1px solid #cdd6e2;border-radius:14px;padding:24px;box-shadow:0 4px 20px rgba(0,0,0,.06)}
h1{font-size:20px;margin:0 0 4px} p{font-size:14px;line-height:1.5;color:#33475b}
.muted{color:#8a97a8}
.ok{background:#e7f6ec;border:1px solid #34a853;color:#1e7e34;border-radius:10px;padding:12px 14px;margin:0 0 10px}
.err{background:#fdeaea;border:1px solid #c62828;color:#b3261e;border-radius:10px;padding:12px 14px;margin:0 0 10px}
.row{display:flex;justify-content:space-between;align-items:center;gap:12px;padding:12px 14px;border:1px solid #e2e8f0;border-radius:10px;margin-top:8px}
.info{display:flex;flex-direction:column;min-width:0} .info b{font-size:16px;word-break:break-all} .info span{font-size:12px;color:#8a97a8}
form{margin:0} button{font-size:14px;font-weight:700;padding:10px 16px;border:none;border-radius:9px;background:#1E50A5;color:#fff;cursor:pointer;white-space:nowrap}
</style></head><body><div class="card">
<h1>Versand bestätigen</h1>
<p>Jede Zeile ist eine FTP-Sendung. Bitte „Versendet" klicken, sobald die Sendung verschickt ist.</p>
${error ? `<div class="err">${escH(error)}</div>` : ''}
${message ? `<div class="ok">${escH(message)}</div>` : ''}
${rows}
</div></body></html>`;
};
// Offene FTP-Sendungen: gebündelte Übermittlungen, noch nicht versendet, je bundle_name gruppiert.
async function ladeBuendel(svc) {
const rows = await svc('batch_submissions').readByQuery({
filter: { status: { _eq: 'bundled' }, versendet: { _neq: true }, bundle_name: { _nnull: true } },
limit: -1, sort: ['-bundle_name'], fields: ['id', 'bundle_name', 'date_created'],
});
const m = new Map();
for (const r of rows || []) {
if (!m.has(r.bundle_name)) m.set(r.bundle_name, { name: r.bundle_name, count: 0, date: r.date_created });
m.get(r.bundle_name).count += 1;
}
return [...m.values()];
}
router.get('/v1/versand/:token', async (req, res) => {
if (!VERSAND_TOKEN || req.params.token !== VERSAND_TOKEN) return res.status(404).type('html').send('Not found.');
try {
const svc = svcFactory(await getSchema());
return res.type('html').send(versandSeite({ bundles: await ladeBuendel(svc) }));
} catch (e) {
logger.error(`[skrift-api] versand list: ${e.message}`);
return res.type('html').send(versandSeite({ error: 'Sendungen konnten nicht geladen werden.' }));
}
});
router.post('/v1/versand/:token', async (req, res) => {
if (!VERSAND_TOKEN || req.params.token !== VERSAND_TOKEN) return res.status(404).type('html').send('Not found.');
const bundle = String((req.body && (req.body.bundle ?? req.body.name)) || req.query.bundle || '').trim();
try {
const svc = svcFactory(await getSchema());
let message = '', error = '';
if (!bundle) {
error = 'Bitte eine Sendung wählen.';
} else {
const rows = await svc('batch_submissions').readByQuery({ filter: { bundle_name: { _eq: bundle }, status: { _eq: 'bundled' } }, limit: -1, fields: ['id'] });
const ids = (rows || []).map((r) => r.id);
if (!ids.length) error = `Sendung ${bundle} nicht gefunden oder bereits versendet.`;
else { await svc('batch_submissions').updateMany(ids, { versendet: true }); message = `Sendung ${bundle} als versendet markiert (${ids.length}).`; }
}
return res.type('html').send(versandSeite({ bundles: await ladeBuendel(svc), message, error }));
} catch (e) {
logger.error(`[skrift-api] versand: ${e.stack || e.message}`);
return res.type('html').send(versandSeite({ error: 'Konnte nicht gespeichert werden. Bitte erneut versuchen.' }));
}
});
// ── POST /v1/orders ─────────────────────────────────────────────────────────
router.post('/v1/orders', async (req, res) => {
const client = await authClient(req);
if (!client) return fehler(res, 401, 'Missing or invalid API key.');
const body = req.body || {};
// Erlaubte Produkte des Keys: m2m-Liste + (Fallback) Einzelprodukt, dedupliziert.
const erlaubt = {};
if (Array.isArray(client.products)) for (const j of client.products) if (j && j.product && j.product.id != null) erlaubt[j.product.id] = j.product;
if (client.product && client.product.id != null) erlaubt[client.product.id] = client.product;
const erlaubteListe = Object.values(erlaubt);
if (!erlaubteListe.length) return fehler(res, 409, 'No product is assigned to this API key.');
// Produktwahl: body.product (Key) muss erlaubt sein; bei genau einem Produkt optional.
const gewuenscht = String(body.product || '').trim();
let product;
if (gewuenscht) {
product = erlaubteListe.find((p) => String(p.key) === gewuenscht);
if (!product) return fehler(res, 400, `product "${gewuenscht}" is not allowed for this key. Allowed: ${erlaubteListe.map((p) => p.key).join(', ')}.`);
} else if (erlaubteListe.length === 1) {
product = erlaubteListe[0];
} else {
return fehler(res, 400, `Field "product" is required. Allowed: ${erlaubteListe.map((p) => p.key).join(', ')}.`);
}
const recipients = Array.isArray(body.recipients) ? body.recipients : [];
if (!recipients.length) return fehler(res, 400, 'At least one recipient is required.');
if (recipients.length > 5000) return fehler(res, 400, 'Too many recipients (max. 5000).');
const istKuvertOnly = product.type === 'envelope';
const istDatei = product.input_mode === 'datei';
const letter = body.letter || {};
const files = Array.isArray(letter.files) ? letter.files.filter(Boolean) : [];
// Kuvert-only: kein Brief nötig (nur adressierte Kuverts); PDF-Dateien optional.
if (!istKuvertOnly) {
if (istDatei && !files.length) return fehler(res, 400, 'This product requires uploaded print files (letter.files).');
if (!istDatei && !String(letter.text || '').trim()) return fehler(res, 400, 'letter.text is required.');
}
try {
const schema = await getSchema();
const svc = svcFactory(schema);
const clean = await ladeClean(schema);
// Schriftstückformat: nur A4 / A6H / A6L (freundliche Werte → interne Keys).
const FORMAT_MAP = { a4: 'a4', a6h: 'a6_hoch', a6l: 'a6_quer', a6_hoch: 'a6_hoch', a6_quer: 'a6_quer' };
const fmtKey = FORMAT_MAP[String(body.document_format || '').trim().toLowerCase()] || null;
if (!fmtKey && !istKuvertOnly) return fehler(res, 400, 'document_format must be a4, a6h or a6l.');
let formatId = null;
if (fmtKey) {
const fr = await svc('formats').readByQuery({ filter: { key: { _eq: fmtKey } }, limit: 1, fields: ['id'] });
formatId = fr?.[0]?.id ?? null;
if (!formatId) return fehler(res, 400, `Format ${fmtKey} is not active.`);
}
// Kuvert: NUR "recipient" (Empfängeradresse) oder "none" (gar kein Kuvert).
const envMode = String((body.envelope || {}).mode || '').toLowerCase() === 'recipient'
? 'recipient' : (istKuvertOnly ? 'recipient' : 'none');
// Kuvertformat automatisch aus dem Dokumentformat: A4 → DIN Lang, sonst C6.
const envFormat = envMode === 'none' ? null : (fmtKey === 'a4' ? 'dinlang' : 'c6');
const hatDateien = files.length > 0;
// ── Zeichen prüfen (WARNEN, nicht ablehnen) ──────────────────────────────
// Nicht erlaubte Zeichen werden wie bisher entfernt; der Aufrufer bekommt
// zusätzlich eine Warnung, welche Zeichen entfernt wurden.
const unerlaubt = new Set();
const pruefeZeichen = (s) => {
for (const ch of new Set(String(s || ''))) if (clean(ch) === '') unerlaubt.add(ch);
};
if (!istDatei && !istKuvertOnly) pruefeZeichen(letter.text);
for (const e of recipients) {
for (const k of ['salutation', 'first_name', 'last_name', 'street', 'house_no', 'zip', 'city', 'country', 'free_text']) {
pruefeZeichen(e && e[k]);
}
}
const warnings = [];
if (unerlaubt.size) {
warnings.push({ code: 'unsupported_characters', characters: [...unerlaubt],
message: 'Some characters are not supported and were removed. See the allowed character set in the documentation.' });
}
// ── Textlänge prüfen (ABLEHNEN) – NUR einseitige Formate (A6) ────────────
// A4 fließt automatisch über mehrere Seiten und kann nicht „zu lang" sein;
// A6-Karten sind einseitig → passt der Text nicht auf eine Seite, wird der
// Auftrag abgelehnt (die reine Umbruch-Zählung des Backends, kein Scriptalizer).
if (!istDatei && !istKuvertOnly && (fmtKey === 'a6_hoch' || fmtKey === 'a6_quer')) {
const layoutKey = fmtKey === 'a6_hoch' ? 'a6p' : 'a6l';
try {
const r = await fetch(`${BACKEND_URL}/api/order/pagecount`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-API-Token': BACKEND_TOKEN },
body: JSON.stringify({ text: clean(String(letter.text || '')), format: layoutKey }),
signal: AbortSignal.timeout(8000),
});
if (r.ok) {
const d = await r.json();
const pages = Math.max(1, ...((d.counts || []).map((c) => Number(c.pages) || 1)));
if (pages > 1) {
return fehler(res, 422, `letter.text is too long for a single ${String(body.document_format)} card (needs ${pages} pages). Please shorten it.`);
}
} else {
logger.warn(`[skrift-api] Längenprüfung: pagecount HTTP ${r.status}`);
}
} catch (e) {
// Backend nicht erreichbar → Auftrag NICHT blockieren (fail-open).
logger.warn(`[skrift-api] Längenprüfung übersprungen: ${e.message}`);
}
}
const font = ['tilda', 'alva', 'ellie'].includes(body.font) ? body.font : 'tilda';
// Versandart nur Englisch: "single" (direkt an Empfänger) / "bulk" (Sammelversand an dich).
const shippingType = String(body.shipping_type || '').toLowerCase() === 'single' ? 'einzeln' : 'sammel';
// Gewünschter Versandtag (optional), z. B. "montag".
const shippingDay = WOCHENTAGE.includes(String(body.shipping_day || '').toLowerCase())
? String(body.shipping_day).toLowerCase() : null;
const orderData = {
api_client: client.id,
customer: client.customer || null,
customer_email: (client.config && client.config.email) || null,
product: product.id,
format: formatId,
person_type: 'unternehmen',
font,
realistic: body.realistic !== false,
source: 'api',
status: 'in_queue',
production_status: 'eingegangen',
payment_status: 'offen',
payment_method: 'rechnung',
shipping_type: shippingType,
needs_envelope: envMode === 'recipient',
envelope_labeling: envMode === 'recipient' ? 'empfaenger' : 'keine',
envelope_text: null,
envelope_format: envFormat,
// Kein Brieftext bei Datei- oder Kuvert-only-Produkten.
text_template: (istDatei || istKuvertOnly) ? null : clean(String(letter.text || '')),
// Hochgeladene PDF-Briefe (Datei-Produkt ODER optional bei Kuvert-only).
source_files: (istDatei || (istKuvertOnly && hatDateien)) ? files : null,
source_file: (istDatei || (istKuvertOnly && hatDateien)) ? (files[0] || null) : null,
entries_count: recipients.length,
shipping_day: shippingDay,
// Mehrseitigkeit läuft selbstständig (Auto-Fluss); nicht vom Kunden wählbar.
multi_page: true,
// Schriftgröße nicht vom Kunden wählbar.
font_scale_letter: 1,
font_scale_envelope: 1,
};
// Auftragsnummer (Tages-Präfix) mit Kollisions-Wiederholung.
const jt = new Date();
const praefix = `${String(jt.getDate()).padStart(2, '0')}-${String(jt.getMonth() + 1).padStart(2, '0')}-${String(jt.getFullYear()).slice(2)}`;
const naechsteNummer = async () => {
const heutige = await svc('orders').readByQuery({ filter: { order_number: { _starts_with: `${praefix}-` } }, limit: -1, fields: ['id'] });
return `${praefix}-${pad3((heutige?.length ?? 0) + 1)}`;
};
let orderId, orderNumber;
for (let v = 0; v < 6; v += 1) {
orderNumber = await naechsteNummer();
try { orderId = await svc('orders').createOne({ order_number: orderNumber, ...orderData }); break; }
catch (e) { if (v === 5 || !/unique|duplicate|bereits|exists/i.test(String(e?.message || ''))) throw e; }
}
// Empfängerzeilen – Briefnummer wird vom System vergeben.
const entriesSvc = svc('order_entries');
let n = 0;
for (const e of recipients) {
n += 1;
await entriesSvc.createOne({
order: orderId, letter_number: n,
salutation: clean(e.salutation ?? null), first_name: clean(e.first_name ?? null), last_name: clean(e.last_name ?? null),
street: clean(e.street ?? null), house_no: clean(e.house_no ?? null), zip: clean(e.zip ?? null),
city: clean(e.city ?? null), country: clean(e.country ?? null), free_text: clean(e.free_text ?? null),
// Keine Platzhalter über die API – der Brieftext wird 1:1 verwendet.
placeholders: null,
});
}
await svc('status_history').createOne({ order: orderId, status: 'eingegangen', note: 'Über die Public-API angelegt.' }).catch(() => {});
// Generierung IMMER anstoßen (fire-and-forget) – für jedes Produkt. Das
// Backend entscheidet, was zu erzeugen ist: Schriftstücke bei Handschrift-
// Produkten, Kuverts immer wenn gewünscht; bei Datei-Produkten (z. B.
// Unterschriftenservice) NUR die Kuverts. Ohne zu Erzeugendes = No-Op.
fetch(`${BACKEND_URL}/api/order/from-directus`, {
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-API-Token': BACKEND_TOKEN },
body: JSON.stringify({ orderId }),
}).catch((e) => logger.warn(`[skrift-api] Generierung nicht gestartet: ${e.message}`));
return res.status(201).json({ order_number: orderNumber, status: 'pending', ...(warnings.length ? { warnings } : {}) });
} catch (err) {
logger.error(`[skrift-api] orders: ${err.stack || err.message}`);
return fehler(res, 500, 'Order could not be created.');
}
});
// ── GET /v1/orders/:nummer ──────────────────────────────────────────────────
router.get('/v1/orders/:nummer', async (req, res) => {
const client = await authClient(req);
if (!client) return fehler(res, 401, 'Missing or invalid API key.');
try {
const schema = await getSchema();
const rows = await new ItemsService('orders', { schema, accountability: null }).readByQuery({
// Nur eigene Aufträge des Zugangs.
filter: { order_number: { _eq: String(req.params.nummer) }, api_client: { _eq: client.id } },
limit: 1, fields: ['order_number', 'status', 'production_status', 'production_status_changed_at',
'versandt_am', 'entries_count', 'date_created'],
});
const order = rows?.[0];
if (!order) return fehler(res, 404, 'Order not found.');
const status = kundenStatus(order);
// Seit wann der Status so ist: Zeitpunkt der letzten Produktionsstatus-
// Änderung; bei „shipped" ersatzweise der Versandzeitpunkt, sonst Anlage.
const statusSince = order.production_status_changed_at
|| (status === 'shipped' ? order.versandt_am : null)
|| order.date_created || null;
return res.json({
order_number: order.order_number,
status,
status_since: statusSince,
recipients: order.entries_count ?? null,
created_at: order.date_created ?? null,
});
} catch (err) {
logger.error(`[skrift-api] status: ${err.stack || err.message}`);
return fehler(res, 500, 'Failed to retrieve order status.');
}
});
// ── POST /v1/clients (Admin) – neuen Zugang + Key erzeugen ───────────────────
// Nur mit Directus-Admin-Token/Session. Der Klartext-Key wird EINMALIG geliefert.
router.post('/v1/clients', async (req, res) => {
if (!req.accountability || req.accountability.admin !== true) return fehler(res, 403, 'Administrators only.');
const { name, product, products, customer, webhook_url } = req.body || {};
// Mehrere Produkte (Array von IDs) bevorzugt; „product" (einzeln) als Kurzform.
const prodIds = Array.isArray(products) ? products.filter((x) => x != null) : (product != null ? [product] : []);
if (!name || !prodIds.length) return fehler(res, 400, 'name and products (array of product IDs) are required.');
try {
const schema = await getSchema();
const key = `sk_live_${crypto.randomBytes(24).toString('base64url')}`;
const id = await new ItemsService('api_clients', { schema, accountability: null }).createOne({
name: String(name), products: prodIds.map((pid) => ({ product: pid })),
customer: customer || null, webhook_url: webhook_url || null,
key_hash: sha256(key), key_prefix: key.slice(0, 14), active: true,
});
// Klartext-Key nur JETZT – wird nirgends gespeichert.
return res.status(201).json({ id, name, api_key: key, note: 'Store this key securely — it will not be shown again.' });
} catch (err) {
logger.error(`[skrift-api] clients: ${err.stack || err.message}`);
return fehler(res, 500, 'API client could not be created.');
}
});
// ── OpenAPI + Doku ──────────────────────────────────────────────────────────
const OPENAPI = {
openapi: '3.0.3',
info: { title: 'Skrift API', version: '1.0.0', description: 'Handwritten mail as a service. Authentication: API key in the X-Api-Key header. Bodies are JSON (UTF-8).' },
servers: [{ url: 'https://dev.skrift.de' }],
components: {
securitySchemes: { ApiKey: { type: 'apiKey', in: 'header', name: 'X-Api-Key' } },
schemas: {
Recipient: {
type: 'object',
properties: {
free_text: { type: 'string', description: 'Recipient address as free text, up to 5 lines separated by \\n.' },
salutation: { type: 'string' }, first_name: { type: 'string' }, last_name: { type: 'string' },
street: { type: 'string' }, house_no: { type: 'string' }, zip: { type: 'string' }, city: { type: 'string' }, country: { type: 'string' },
},
},
OrderRequest: {
type: 'object', required: ['recipients'],
properties: {
product: { type: 'string', description: 'Product key to use. Required if the API key is bound to more than one product; optional (auto) if exactly one.' },
document_format: { type: 'string', enum: ['a4', 'a6h', 'a6l'], description: 'Document format.' },
font: { type: 'string', enum: ['tilda', 'alva', 'ellie'] },
realistic: { type: 'boolean', default: true, description: 'Natural handwriting variation.' },
shipping_type: { type: 'string', enum: ['single', 'bulk'], default: 'bulk', description: 'single = direct to each recipient; bulk = consolidated shipment to you.' },
shipping_day: { type: 'string', enum: ['montag', 'dienstag', 'mittwoch', 'donnerstag', 'freitag', 'samstag', 'sonntag'], description: 'Preferred dispatch weekday (optional).' },
letter: {
type: 'object',
properties: {
text: { type: 'string', description: 'Letter body, fully written out (no placeholders). Long text flows across multiple pages automatically; [[seitenumbruch]] forces a page break.' },
files: { type: 'array', items: { type: 'string' }, description: 'file_id(s) from POST /v1/files (file / PDF products).' },
},
},
envelope: {
type: 'object',
properties: {
mode: { type: 'string', enum: ['none', 'recipient'], default: 'none', description: 'none = no envelope; recipient = recipient address on the envelope. Envelope size is selected automatically (A4 → DIN Lang, otherwise C6).' },
},
},
recipients: { type: 'array', items: { $ref: '#/components/schemas/Recipient' } },
},
},
},
},
security: [{ ApiKey: [] }],
paths: {
'/v1/orders': {
post: {
summary: 'Create an order', description: 'Quantity equals the number of recipients. Returns the order number.',
requestBody: { required: true, content: { 'application/json': { schema: { $ref: '#/components/schemas/OrderRequest' } } } },
responses: { 201: { description: 'Order created', content: { 'application/json': { schema: { type: 'object', properties: { order_number: { type: 'string' }, status: { type: 'string' } } } } } } },
},
},
'/v1/orders/{order_number}': {
get: {
summary: 'Retrieve order status', parameters: [{ name: 'order_number', in: 'path', required: true, schema: { type: 'string' } }],
responses: { 200: { description: 'Order status', content: { 'application/json': { schema: { type: 'object', properties: { order_number: { type: 'string' }, status: { type: 'string', enum: ['pending', 'processing', 'shipped', 'cancelled'] }, status_since: { type: 'string', format: 'date-time', description: 'ISO 8601 timestamp since which the order has held its current status.' }, recipients: { type: 'integer' } } } } } } },
},
},
'/v1/files': {
post: {
summary: 'Upload a PDF (PDF only, max. 80 MB)',
requestBody: { required: true, content: { 'application/json': { schema: { type: 'object', required: ['filename', 'content_base64'], properties: { filename: { type: 'string', description: 'Must end in .pdf.' }, content_base64: { type: 'string', description: 'PDF as base64.' } } } } } },
responses: { 200: { description: 'Uploaded', content: { 'application/json': { schema: { type: 'object', properties: { file_id: { type: 'string' } } } } } } },
},
},
'/v1/batch': {
post: {
summary: 'Batch submission (alternative delivery)',
description: 'One recipient per call in a fixed schema plus either a ready print PDF (pdf_file_id/pdf_base64) or letter text. Bundled per API key once a day into an .xlsx + merged print .pdf and uploaded to FTP.',
requestBody: { required: true, content: { 'application/json': { schema: { type: 'object',
properties: {
firma: { type: 'string' }, vorname: { type: 'string' }, nachname: { type: 'string' },
zeile1: { type: 'string' }, zeile2: { type: 'string' }, zeile3: { type: 'string' },
plz: { type: 'string' }, stadt: { type: 'string' }, land: { type: 'string' },
pdf_file_id: { type: 'string', description: 'file_id from POST /v1/files (ready print PDF, used 1:1).' },
pdf_base64: { type: 'string', description: 'Alternatively the PDF inline as base64.' },
text: { type: 'string', description: 'Alternatively a letter text (handwriting); not part of the FTP bundle.' },
} } } } },
responses: { 201: { description: 'Accepted', content: { 'application/json': { schema: { type: 'object', properties: { id: { type: 'string' }, status: { type: 'string' }, warnings: { type: 'array', items: { type: 'object' } } } } } } } },
},
},
},
};
router.get('/v1/openapi.json', (_req, res) => res.json(OPENAPI));
router.get('/docs', (_req, res) => {
// Swagger UI, wenn die Assets im Extension-Ordner liegen; sonst die statische
// HTML-Doku als Fallback (nie leer). Beides CSP-/Brave-sicher (kein CDN).
let hasSwagger = false;
try { hasSwagger = fs.existsSync(path.join(EXT_DIR, 'swagger-ui-bundle.js')); } catch { /* ignore */ }
res.type('html').send(hasSwagger ? SWAGGER_HTML : DOCS_HTML);
});
// Statische Doku-Assets (Swagger UI) – von der eigenen Domain, whitelisted.
router.get('/docs-assets/:file', (req, res) => {
const name = String(req.params.file || '');
if (name === 'init.js') { res.type('application/javascript').send(INIT_JS); return; }
const TYPES = { 'swagger-ui.css': 'text/css', 'swagger-ui-bundle.js': 'application/javascript' };
if (!TYPES[name]) return res.status(404).json({ error: 'Not found.' });
try {
const buf = fs.readFileSync(path.join(EXT_DIR, name));
res.setHeader('Content-Type', TYPES[name]);
res.setHeader('Cache-Control', 'public, max-age=86400');
return res.send(buf);
} catch {
return res.status(404).json({ error: 'Docs asset missing – place swagger-ui.css and swagger-ui-bundle.js in the extension folder.' });
}
});
};
// Objekt-Form mit fester id → Mount unter /skrift-api (wie skrift-orders).
export default { id: 'skrift-api', handler };