Upload via GUI (16 Dateien)

This commit is contained in:
2026-09-14 18:57:05 +00:00
parent 6733901d99
commit 98d23de73f
16 changed files with 1930 additions and 0 deletions
+360
View File
@@ -0,0 +1,360 @@
// webserver.js — small local HTTP server so the Fellakte web client (tablet/Mac
// browser) can reach this PC over the practice WiFi. No cloud, no internet, no
// external registration: only devices on the same local network can connect, and
// only after being paired here with a one-time code.
//
// Phase 4 note: PDF generation and email sending both need a real Electron/Node
// process (Electron's printToPDF for the former, SMTP for the latter) — neither is
// possible from inside a plain browser. Because this server *is* that same process
// (webserver.js runs inside the desktop app, not as a separate relay), the tablet
// gets both "for free" by calling the exact same documents.js/mailer.js code the
// desktop UI already uses, over HTTP, rather than needing new browser-side tech.
"use strict";
const path = require("path");
const os = require("os");
const fs = require("fs");
const crypto = require("crypto");
const express = require("express");
const QRCode = require("qrcode");
const db = require("./db");
const lock = require("./lock");
const mailer = require("./mailer");
const documents = require("./documents");
const PORT = 51823;
const PAIRING_TTL_MS = 10 * 60 * 1000;
const MAX_PAIRING_ATTEMPTS = 10;
let httpServer = null;
let dataFolder = null;
let pairing = null; // { code, expiresAt, attempts }
function lanAddress() {
const nets = os.networkInterfaces();
for (const name of Object.keys(nets)) {
for (const net of nets[name] || []) {
if (net.family === "IPv4" && !net.internal) return net.address;
}
}
return "127.0.0.1";
}
function getDevices() {
try {
return JSON.parse(db.getSetting("webDevices") || "[]");
} catch {
return [];
}
}
function saveDevices(list) {
db.setSetting("webDevices", JSON.stringify(list));
}
function startPairing() {
const code = String(crypto.randomInt(0, 1000000)).padStart(6, "0");
pairing = { code, expiresAt: Date.now() + PAIRING_TTL_MS, attempts: 0, pairedDeviceId: null };
return pairingInfo();
}
function pairingInfo() {
if (!pairing) return null;
const url = `http://${lanAddress()}:${PORT}/?pair=${pairing.code}`;
return {
code: pairing.code,
url,
address: lanAddress(),
port: PORT,
expiresAt: pairing.expiresAt,
pairedDeviceId: pairing.pairedDeviceId,
};
}
async function pairingQrDataUri() {
if (!pairing) return null;
const url = `http://${lanAddress()}:${PORT}/?pair=${pairing.code}`;
return QRCode.toDataURL(url, { margin: 1, width: 240 });
}
function checkAuth(req, res, next) {
const header = req.headers.authorization || "";
const token = header.startsWith("Bearer ") ? header.slice(7) : req.query.t;
if (!token) return res.status(401).json({ error: "not-authenticated" });
const devices = getDevices();
const device = devices.find((d) => d.token === token);
if (!device) return res.status(401).json({ error: "not-authenticated" });
device.lastSeen = new Date().toISOString();
saveDevices(devices);
req.deviceToken = token;
next();
}
// Same advisory check the desktop app runs at startup — surfaced here on every
// write instead, since the tablet has no "startup" moment of its own to catch it at.
function doubleUseWarning() {
const { active, info } = lock.checkOther(dataFolder);
if (!active) return null;
return { host: info.host, updatedAt: info.updatedAt };
}
function fileUrl(relativePath, token) {
const normalized = String(relativePath).replace(/\\/g, "/");
return `/data/${normalized}?t=${encodeURIComponent(token)}`;
}
function buildApp() {
const app = express();
app.disable("x-powered-by");
app.use(express.json());
// Static: the tablet web-client shell, then the same renderer files the desktop
// app itself uses (app.js, styles.css) — single source of truth, nothing duplicated.
app.use(express.static(path.join(__dirname, "..", "web-client")));
app.use("/assets", express.static(path.join(__dirname, "..", "assets")));
app.use(express.static(path.join(__dirname, "..", "renderer")));
app.post("/api/pair", (req, res) => {
if (!pairing || Date.now() > pairing.expiresAt) {
return res.status(410).json({ error: "expired" });
}
pairing.attempts++;
if (pairing.attempts > MAX_PAIRING_ATTEMPTS) {
pairing = null;
return res.status(429).json({ error: "too-many-attempts" });
}
const code = String((req.body && req.body.code) || "").trim();
if (code !== pairing.code) {
return res.status(401).json({ error: "wrong-code" });
}
const device = {
id: crypto.randomUUID(),
token: crypto.randomBytes(24).toString("hex"),
name: (req.body && req.body.deviceName) || "Tablet",
pairedAt: new Date().toISOString(),
lastSeen: new Date().toISOString(),
};
const devices = getDevices();
devices.push(device);
saveDevices(devices);
pairing.pairedDeviceId = device.id;
res.json({ token: device.token, deviceId: device.id });
});
app.get("/api/directory", checkAuth, (_req, res) => {
res.json(db.listOwnersWithAnimals());
});
app.get("/api/animal/:id", checkAuth, (req, res) => {
const bundle = db.getAnimalBundle(req.params.id);
if (!bundle) return res.status(404).json({ error: "not-found" });
const token = req.deviceToken;
bundle.photos = bundle.photos.map((p) => ({ ...p, fileUrl: fileUrl(p.relative_path, token) }));
if (bundle.consent && bundle.consent.signature_path) {
bundle.consent.signature_url = fileUrl(bundle.consent.signature_path, token);
}
if (bundle.contract && bundle.contract.signature_path) {
bundle.contract.signature_url = fileUrl(bundle.contract.signature_path, token);
}
res.json(bundle);
});
app.get("/api/document-url/:id", checkAuth, (req, res) => {
const doc = db.getDocument(req.params.id);
if (!doc) return res.status(404).json({ error: "not-found" });
res.json({ url: fileUrl(doc.relative_path, req.deviceToken) });
});
app.get("/api/appointments", checkAuth, (req, res) => {
res.json(db.listAppointments(req.query.from || "", req.query.to || ""));
});
app.get("/api/remedies", checkAuth, (req, res) => {
res.json(db.listRemedies(req.query.q || ""));
});
app.get("/api/search-notes", checkAuth, (req, res) => {
res.json(db.searchAnimalIdsByNoteText(req.query.q || ""));
});
// ---- Phase 2 writes: Notizen, Befunde, Termine ----
app.post("/api/note", checkAuth, (req, res) => {
const { animalId, text } = req.body || {};
if (!animalId || !text) return res.status(400).json({ error: "missing-fields" });
db.addNote(animalId, text);
res.json({ ok: true, warning: doubleUseWarning() });
});
app.post("/api/finding", checkAuth, (req, res) => {
const { animalId, x, y, severity, text } = req.body || {};
if (!animalId) return res.status(400).json({ error: "missing-fields" });
const id = db.addFinding(animalId, { x, y, severity, text });
res.json({ ok: true, id, warning: doubleUseWarning() });
});
app.patch("/api/finding/:id", checkAuth, (req, res) => {
const { severity, text } = req.body || {};
db.updateFinding(req.params.id, { severity, text });
res.json({ ok: true, warning: doubleUseWarning() });
});
app.delete("/api/finding/:id", checkAuth, (req, res) => {
db.deleteFinding(req.params.id);
res.json({ ok: true, warning: doubleUseWarning() });
});
app.post("/api/appointment", checkAuth, (req, res) => {
const id = db.addAppointment(req.body || {});
res.json({ ok: true, id, warning: doubleUseWarning() });
});
app.patch("/api/appointment/:id", checkAuth, (req, res) => {
db.updateAppointment(req.params.id, req.body || {});
res.json({ ok: true, warning: doubleUseWarning() });
});
app.delete("/api/appointment/:id", checkAuth, (req, res) => {
db.deleteAppointment(req.params.id);
res.json({ ok: true, warning: doubleUseWarning() });
});
// ---- Phase 4: Unterschrift, PDFs, E-Mail ----
app.post("/api/consent", checkAuth, (req, res) => {
const { animalId, granted, method, signaturePath, marketingOk, itemsJson } = req.body || {};
db.setConsent(animalId, { granted, method, signaturePath, marketingOk, itemsJson });
res.json({ ok: true, warning: doubleUseWarning() });
});
app.post("/api/consent/signature", checkAuth, (req, res) => {
const { animalId, dataUrl } = req.body || {};
const base64 = String(dataUrl).replace(/^data:image\/png;base64,/, "");
const dir = documents.filesDirFor(dataFolder, animalId);
const filename = `unterschrift-${Date.now()}.png`;
const absPath = path.join(dir, filename);
fs.writeFileSync(absPath, Buffer.from(base64, "base64"));
const relativePath = path.join("files", animalId, filename);
const existing = db.getAnimalBundle(animalId).consent;
db.setConsent(animalId, {
granted: true,
method: "Unterschrift auf dem Tablet erfasst",
signaturePath: relativePath,
marketingOk: existing ? !!existing.marketing_ok : false,
});
const consent = db.getAnimalBundle(animalId).consent;
if (consent && consent.signature_path) consent.signature_url = fileUrl(consent.signature_path, req.deviceToken);
res.json({ ...consent, warning: doubleUseWarning() });
});
app.post("/api/contract", checkAuth, (req, res) => {
const { animalId, granted, method, signaturePath, briefingConfirmed } = req.body || {};
db.setContract(animalId, { granted, method, signaturePath, briefingConfirmed });
res.json({ ok: true, warning: doubleUseWarning() });
});
app.post("/api/contract/signature", checkAuth, (req, res) => {
const { animalId, dataUrl } = req.body || {};
const base64 = String(dataUrl).replace(/^data:image\/png;base64,/, "");
const dir = documents.filesDirFor(dataFolder, animalId);
const filename = `vertrag-unterschrift-${Date.now()}.png`;
const absPath = path.join(dir, filename);
fs.writeFileSync(absPath, Buffer.from(base64, "base64"));
const relativePath = path.join("files", animalId, filename);
const existing = db.getAnimalBundle(animalId).contract;
db.setContract(animalId, {
granted: true,
method: "Unterschrift auf dem Tablet erfasst",
signaturePath: relativePath,
briefingConfirmed: existing ? !!existing.briefing_confirmed : true,
});
const contract = db.getAnimalBundle(animalId).contract;
if (contract && contract.signature_path) contract.signature_url = fileUrl(contract.signature_path, req.deviceToken);
res.json({ ...contract, warning: doubleUseWarning() });
});
app.post("/api/document/pdf", checkAuth, async (req, res) => {
const { animalId, kind, title, ownerBlockHtml, bodyHtml, signedInfoHtml } = req.body || {};
const bundle = db.getAnimalBundle(animalId);
const record = kind === "vertrag" ? bundle.contract : bundle.consent;
const signatureDataUri = record && record.signature_path ? documents.fileToDataUri(path.join(dataFolder, record.signature_path)) : null;
const docLabel = kind === "vertrag" ? "Behandlungsvertrag" : "Einwilligung";
try {
const result = await documents.savePdfAsDocument(dataFolder, animalId, docLabel, { title, ownerBlockHtml, bodyHtml, signedInfoHtml, signatureDataUri });
res.json({ ...result, warning: doubleUseWarning() });
} catch (e) {
res.status(500).json({ error: String((e && e.message) || e) });
}
});
app.post("/api/document/template-pdf", checkAuth, async (req, res) => {
const { animalId, filenamePrefix, title, ownerBlockHtml, bodyHtml } = req.body || {};
try {
const result = await documents.savePdfAsDocument(dataFolder, animalId, filenamePrefix, { title, ownerBlockHtml, bodyHtml, signedInfoHtml: "", signatureDataUri: null });
res.json({ ...result, warning: doubleUseWarning() });
} catch (e) {
res.status(500).json({ error: String((e && e.message) || e) });
}
});
app.post("/api/document/email", checkAuth, async (req, res) => {
const { animalId, kind, absPath, filename } = req.body || {};
const smtpJson = db.getSetting("smtp");
if (!smtpJson) return res.json({ ok: false, error: "not-configured" });
const smtp = JSON.parse(smtpJson);
const bundle = db.getAnimalBundle(animalId);
if (!bundle.owner.email) return res.json({ ok: false, error: "no-owner-email" });
const docLabel = kind === "vertrag" ? "Behandlungsvertrag" : "DSGVO-Einwilligung";
try {
await mailer.sendMail(smtp, {
to: bundle.owner.email,
subject: `${docLabel} ${bundle.animal.name} Tierheilpraxis Iris Hack`,
text: `Guten Tag ${bundle.owner.name},\n\nanbei erhalten Sie die unterschriebenen Unterlagen (${docLabel}) für ${bundle.animal.name}.\n\nMit freundlichen Grüßen\nIris Hack\nTierheilpraxis Iris Hack`,
attachments: [{ filename, path: absPath }],
});
if (kind === "vertrag") db.markContractEmailed(animalId);
else db.markConsentEmailed(animalId);
res.json({ ok: true });
} catch (e) {
res.json({ ok: false, error: String((e && e.message) || e) });
}
});
// Serves photos/signatures/documents. Auth via ?t= since <img>/<a> tags can't set
// an Authorization header.
app.get("/data/*splat", (req, res) => {
const token = req.query.t;
const devices = getDevices();
if (!token || !devices.some((d) => d.token === token)) return res.status(401).end();
if (!dataFolder) return res.status(500).end();
const rel = Array.isArray(req.params.splat) ? req.params.splat.join("/") : req.params.splat;
const abs = path.join(dataFolder, rel);
// Guard against escaping the data folder via a crafted relative path.
if (!abs.startsWith(path.join(dataFolder))) return res.status(400).end();
if (!fs.existsSync(abs)) return res.status(404).end();
res.sendFile(abs);
});
return app;
}
function start(folder) {
dataFolder = folder;
if (httpServer) return;
const app = buildApp();
httpServer = app.listen(PORT, "0.0.0.0");
}
function stop() {
if (httpServer) {
httpServer.close();
httpServer = null;
}
}
function listDevices() {
return getDevices().map((d) => ({ id: d.id, name: d.name, pairedAt: d.pairedAt, lastSeen: d.lastSeen }));
}
function revokeDevice(id) {
saveDevices(getDevices().filter((d) => d.id !== id));
}
module.exports = { start, stop, startPairing, pairingInfo, pairingQrDataUri, listDevices, revokeDevice, lanAddress, PORT };