// 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 / 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 };