/* Slim Uiltjies AfterCare — Express + JSON file backend ------------------------------------------------------------ • Serves the PWA as static files • Stores all data in a single JSON file (mounted to /data on Coolify) • PIN-based auth, simple session tokens held in memory • Atomic file writes (write to .tmp, rename) • Write queue to prevent races between concurrent pushes ENV vars: PORT (default 3000) ADMIN_PIN (default 847291) — change before deploying DATA_FILE (default ./data.json — mount a volume here in production) */ const express = require('express'); const fs = require('fs'); const fsp = fs.promises; const path = require('path'); const crypto = require('crypto'); const PORT = Number(process.env.PORT) || 3000; const ADMIN_PIN = process.env.ADMIN_PIN || '847291'; const DATA_FILE = process.env.DATA_FILE || path.join(__dirname, 'data', 'data.json'); const N8N_WEBHOOK_URL = process.env.N8N_WEBHOOK_URL || ''; const N8N_WEBHOOK_TOKEN = process.env.N8N_WEBHOOK_TOKEN || ''; const app = express(); app.disable('x-powered-by'); app.use(express.json({ limit: '2mb' })); // CORS for local dev (when running frontend separately). In prod both // are served from the same origin so CORS is a no-op. app.use((req, res, next) => { res.setHeader('Access-Control-Allow-Origin', '*'); res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); res.setHeader('Access-Control-Allow-Headers', 'Content-Type, X-Auth-Token'); if (req.method === 'OPTIONS') return res.sendStatus(204); next(); }); // ============================================================ // Data layer // ============================================================ function defaultState() { return { version: 1, business: null, parents: [], children: [], invoices: [], payments: [], meta: { seeded: false, nextReceipt: 1, lastUpdated: new Date().toISOString() }, _updatedAt: new Date().toISOString(), }; } async function ensureDataDir() { const dir = path.dirname(DATA_FILE); await fsp.mkdir(dir, { recursive: true }); } async function readState() { try { const raw = await fsp.readFile(DATA_FILE, 'utf8'); const data = JSON.parse(raw); // Sanity check + fill missing fields return Object.assign(defaultState(), data, { _updatedAt: data._updatedAt || new Date().toISOString(), }); } catch (e) { if (e.code === 'ENOENT') return defaultState(); console.error('[data] read failed:', e.message); throw e; } } // Serialize writes so concurrent pushes don't clobber let writeQueue = Promise.resolve(); let writeInProgress = false; function writeState(state) { const job = writeQueue.then(async () => { writeInProgress = true; try { state._updatedAt = new Date().toISOString(); if (state.meta) state.meta.lastUpdated = state._updatedAt; const tmp = DATA_FILE + '.tmp'; await fsp.writeFile(tmp, JSON.stringify(state, null, 2)); await fsp.rename(tmp, DATA_FILE); // atomic on the same filesystem // Fire-and-forget n8n backup if (N8N_WEBHOOK_URL) notifyN8n(state).catch(() => {}); } finally { writeInProgress = false; } }); writeQueue = job.catch((e) => { console.error('[data] write failed:', e.message); }); return job; } async function notifyN8n(state) { try { const headers = { 'Content-Type': 'application/json' }; if (N8N_WEBHOOK_TOKEN) headers['Authorization'] = `Bearer ${N8N_WEBHOOK_TOKEN}`; const body = { ...state, _backupAt: new Date().toISOString() }; const res = await fetch(N8N_WEBHOOK_URL, { method: 'POST', headers, body: JSON.stringify(body), }); if (!res.ok) console.warn(`[n8n] webhook returned ${res.status}`); } catch (e) { console.warn('[n8n] webhook failed:', e.message); } } // ============================================================ // Auth — PIN + session tokens // ============================================================ const sessions = new Map(); const SESSION_TTL_MS = 1000 * 60 * 60 * 24 * 30; // 30 days function newToken() { return crypto.randomBytes(24).toString('base64url'); } function isAuthenticated(req) { const token = req.headers['x-auth-token']; if (!token) return false; const createdAt = sessions.get(token); if (!createdAt) return false; if (Date.now() - createdAt > SESSION_TTL_MS) { sessions.delete(token); return false; } return true; } // Garbage-collect expired sessions every hour setInterval(() => { const now = Date.now(); for (const [t, ts] of sessions) if (now - ts > SESSION_TTL_MS) sessions.delete(t); }, 1000 * 60 * 60).unref(); // ============================================================ // API routes // ============================================================ // Public app.get('/api/health', (_req, res) => res.json({ ok: true, ts: Date.now() })); app.post('/api/auth', (req, res) => { const { pin } = req.body || {}; if (typeof pin !== 'string' || !pin) return res.status(400).json({ error: 'PIN required' }); if (pin !== ADMIN_PIN) return res.status(401).json({ error: 'Invalid PIN' }); const token = newToken(); sessions.set(token, Date.now()); res.json({ token, ttlSeconds: SESSION_TTL_MS / 1000 }); }); // Protected app.get('/api/state', async (req, res, next) => { if (!isAuthenticated(req)) return res.status(401).json({ error: 'Unauthorized' }); try { const state = await readState(); res.json(state); } catch (e) { next(e); } }); app.post('/api/state', async (req, res, next) => { if (!isAuthenticated(req)) return res.status(401).json({ error: 'Unauthorized' }); const state = req.body; if (!state || typeof state !== 'object') return res.status(400).json({ error: 'Invalid state object' }); // Lightweight validation const known = ['business', 'parents', 'children', 'invoices', 'payments', 'meta', 'version']; const cleaned = {}; for (const k of known) if (k in state) cleaned[k] = state[k]; for (const k of ['parents', 'children', 'invoices', 'payments']) { if (cleaned[k] !== undefined && !Array.isArray(cleaned[k])) { return res.status(400).json({ error: `${k} must be an array` }); } } cleaned.version = cleaned.version || 1; try { await writeState(cleaned); res.json({ ok: true, updatedAt: cleaned._updatedAt }); } catch (e) { next(e); } }); app.post('/api/logout', (req, res) => { const token = req.headers['x-auth-token']; if (token) sessions.delete(token); res.json({ ok: true }); }); // ============================================================ // Static frontend (serves index.html, css, js, vendor, icons, etc.) // ============================================================ app.use(express.static(__dirname, { extensions: ['html'], setHeaders: (res, filePath) => { // Don't cache HTML so updates ship immediately if (filePath.endsWith('.html')) res.setHeader('Cache-Control', 'no-cache'); }, })); // SPA fallback for client-side routing (everything else → index.html) app.get('*', (_req, res) => res.sendFile(path.join(__dirname, 'index.html'))); // ============================================================ // Error handler // ============================================================ app.use((err, _req, res, _next) => { console.error('[server] error:', err); res.status(500).json({ error: err.message || 'Internal error' }); }); // ============================================================ // Boot // ============================================================ (async () => { try { await ensureDataDir(); await readState(); // touch the file console.log(`[server] data file: ${DATA_FILE}`); } catch (e) { console.error('[server] failed to initialise data:', e.message); process.exit(1); } app.listen(PORT, '0.0.0.0', () => { console.log(`[server] Slim Uiltjies listening on :${PORT}`); console.log(`[server] PIN: ${ADMIN_PIN === '847291' ? '(default 847291 — change ADMIN_PIN!)' : '(custom)'}`); }); })();