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