Files
fellakte/main/webserver.js
T
2026-09-14 18:57:05 +00:00

361 lines
14 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.
// 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 };