// Mind-o-Mat Webapp & Notes-API Production Server // Serviert: // 1. Statische Frontend-Dateien aus webapp/dist // 2. /landkarte.json und /note direkt aus dem Vault // 3. /api/notes, /api/notes/:id, /api/sync // 4. HMAC-Bearer-Token-Auth und CORS import express from 'express'; import { createHmac, timingSafeEqual } from 'node:crypto'; import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync } from 'node:fs'; import { join, resolve } from 'node:path'; import { spawn } from 'node:child_process'; import matter from 'gray-matter'; const app = express(); const PORT = Number(process.env.PORT) || 5173; const VAULT_PATH = process.env.MINDOMAT_VAULT_PATH || '/vault'; const NOTES_API_TOKEN = process.env.NOTES_API_TOKEN || ''; const ALLOWED_ORIGINS = (process.env.NOTES_API_ALLOWED_ORIGINS || '') .split(',') .map((o) => o.trim()) .filter(Boolean); // Pfade für Webapp und Tool-Binary const distPath = existsSync(join(process.cwd(), 'webapp', 'dist')) ? join(process.cwd(), 'webapp', 'dist') : existsSync(join(process.cwd(), 'dist', 'webapp')) ? join(process.cwd(), 'dist', 'webapp') : join(process.cwd(), 'dist'); const TOOL_BIN_PATH = existsSync(join(process.cwd(), 'bin', 'mindomat.mjs')) ? join(process.cwd(), 'bin', 'mindomat.mjs') : '/app/bin/mindomat.mjs'; console.log(`[Mind-o-Mat] Server starting...`); console.log(`[Mind-o-Mat] Port: ${PORT}`); console.log(`[Mind-o-Mat] Vault Path: ${VAULT_PATH}`); console.log(`[Mind-o-Mat] Webapp Dist: ${distPath}`); console.log(`[Mind-o-Mat] Allowed Origins: ${ALLOWED_ORIGINS.join(', ') || 'ALL (offen)'}`); console.log(`[Mind-o-Mat] Auth aktiv: ${NOTES_API_TOKEN ? 'JA' : 'NEIN (offen)'}`); // CORS Middleware app.use((req, res, next) => { const origin = req.headers.origin; if ( ALLOWED_ORIGINS.length === 0 || ALLOWED_ORIGINS.includes('*') || (origin && ALLOWED_ORIGINS.includes(origin)) ) { if (origin) { res.setHeader('Access-Control-Allow-Origin', origin); res.setHeader('Access-Control-Allow-Methods', 'GET, PUT, POST, OPTIONS'); res.setHeader('Access-Control-Allow-Headers', 'Authorization, Content-Type'); } } if (req.method === 'OPTIONS') { res.status(204).end(); return; } next(); }); // Auth Middleware für geschützte Routen function checkAuth(req, res, next) { if (!NOTES_API_TOKEN) return next(); const auth = req.headers.authorization; if (!auth) { res.status(401).json({ error: 'Authorization-Header fehlt' }); return; } const [scheme, token] = auth.split(' '); if (scheme !== 'Bearer' || !token) { res.status(401).json({ error: 'Authorization-Schema ungueltig (erwartet: Bearer)' }); return; } const [ts, hmac] = token.split('.'); if (!ts || !hmac) { res.status(401).json({ error: 'Token-Format ungueltig' }); return; } const expected = createHmac('sha256', NOTES_API_TOKEN).update(ts).digest('hex'); if (expected.length !== hmac.length) { res.status(401).json({ error: 'Token ungueltig' }); return; } if (!timingSafeEqual(Buffer.from(expected), Buffer.from(hmac))) { res.status(401).json({ error: 'Token ungueltig' }); return; } next(); } // 1. Landkarte.json ausliefern (aus dem Vault) app.get('/landkarte.json', (_req, res) => { const landkartePath = join(VAULT_PATH, 'landkarte.json'); if (existsSync(landkartePath)) { res.setHeader('Content-Type', 'application/json'); res.sendFile(landkartePath); } else { // Fallback: Leere Landkarte, damit Frontend nicht abstürzt res.json({ nodes: [], edges: [], generated: new Date().toISOString(), vault: 'mindomat', }); } }); // 2. /note Route für den Notiz-Loader app.get('/note', (req, res) => { const noteRelPath = req.query.path; if (!noteRelPath || typeof noteRelPath !== 'string') { res.status(400).json({ error: 'path query parameter missing' }); return; } const notePath = join(VAULT_PATH, noteRelPath); if (!existsSync(notePath)) { res.status(404).json({ error: 'Note not found' }); return; } try { const content = readFileSync(notePath, 'utf-8'); res.json({ content }); } catch (err) { res.status(500).json({ error: err.message }); } }); // 3. Notes API // GET /api/notes -> Liste aller Notiz-Pfade app.get('/api/notes', checkAuth, (_req, res) => { try { const notes = []; const dirs = ['00_Inbox', '10_Wiki/Seiten', '01_Daily', '20_Projekte']; for (const d of dirs) { const full = join(VAULT_PATH, d); if (!existsSync(full)) continue; const files = readdirSync(full).filter((f) => f.endsWith('.md')); for (const f of files) { notes.push(join(d, f).replace(/\\/g, '/')); } } res.json({ notes }); } catch (err) { res.status(500).json({ error: err.message }); } }); // GET /api/notes/ -> Einzelne Notiz lesen app.get(/^\/api\/notes\/(.+)$/, checkAuth, (req, res) => { try { const noteId = decodeURIComponent(req.params[0]); const notePath = join(VAULT_PATH, noteId); if (!existsSync(notePath)) { res.status(404).send('Not found'); return; } const raw = readFileSync(notePath, 'utf-8'); const parsed = matter(raw); res.json({ id: noteId, content: raw, frontmatter: parsed.data, lastModified: new Date().toISOString(), }); } catch (err) { res.status(500).json({ error: err.message }); } }); // PUT /api/notes/ -> Notiz speichern app.put(/^\/api\/notes\/(.+)$/, checkAuth, express.json({ limit: '10mb' }), (req, res) => { try { const noteId = decodeURIComponent(req.params[0]); const notePath = join(VAULT_PATH, noteId); const content = req.body?.content ?? ''; mkdirSync(join(notePath, '..'), { recursive: true }); writeFileSync(notePath, content, 'utf-8'); const parsed = matter(content); res.json({ id: noteId, content, frontmatter: parsed.data, lastModified: new Date().toISOString(), }); } catch (err) { res.status(500).json({ error: err.message }); } }); // POST /api/sync -> mindomat sync ausführen app.post('/api/sync', checkAuth, async (_req, res) => { if (!existsSync(TOOL_BIN_PATH)) { res.status(500).json({ ok: false, message: `Tool CLI nicht gefunden: ${TOOL_BIN_PATH}` }); return; } try { const child = spawn('node', [TOOL_BIN_PATH, 'sync', '--vault', VAULT_PATH], { stdio: 'pipe', }); let stdout = ''; let stderr = ''; child.stdout.on('data', (chunk) => { stdout += chunk.toString(); }); child.stderr.on('data', (chunk) => { stderr += chunk.toString(); }); child.on('close', (code) => { if (code === 0) { res.json({ ok: true, message: stdout.trim() || 'Sync erfolgreich' }); } else { res.status(500).json({ ok: false, message: stderr.trim() || `Exit code ${code}` }); } }); } catch (err) { res.status(500).json({ ok: false, message: err.message }); } }); // PWA Assets app.get('/manifest.webmanifest', (_req, res) => { const p = join(distPath, 'manifest.webmanifest'); if (existsSync(p)) res.sendFile(p); else res.status(404).send('Not found'); }); app.get('/service-worker.js', (_req, res) => { const p = join(distPath, 'service-worker.js'); if (existsSync(p)) res.sendFile(p); else res.status(404).send('Not found'); }); // Statische Dateien ausliefern if (existsSync(distPath)) { app.use(express.static(distPath)); } // SPA Fallback für alle anderen GET-Anfragen app.use((req, res, next) => { if (req.method !== 'GET') return next(); if (req.path.startsWith('/api/') || req.path === '/landkarte.json' || req.path === '/note') { return next(); } const indexPath = join(distPath, 'index.html'); if (existsSync(indexPath)) { res.sendFile(indexPath); } else { res.status(404).send('index.html nicht gefunden'); } }); app.listen(PORT, '0.0.0.0', () => { console.log(`[Mind-o-Mat] Laeuft auf http://0.0.0.0:${PORT}`); });