// 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, spawnSync } 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 origin = req.headers.origin || ''; const referer = req.headers.referer || ''; const isSameOrigin = req.headers['sec-fetch-site'] === 'same-origin' || ALLOWED_ORIGINS.some( (o) => (origin && origin.startsWith(o)) || (referer && referer.startsWith(o)), ); if (isSameOrigin && !req.headers.authorization) { 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(); } // Helper: Vault im Hintergrund indizieren function triggerIndex() { if (existsSync(TOOL_BIN_PATH)) { try { spawn('node', [TOOL_BIN_PATH, 'index', '--vault', VAULT_PATH], { stdio: 'pipe' }); } catch { // non-fatal } } } // 1. Landkarte.json ausliefern (aus dem Vault) app.get('/landkarte.json', (_req, res) => { const landkartePath = join(VAULT_PATH, 'landkarte.json'); // Falls Landkarte nicht existiert, versuchen on-the-fly zu indizieren if (!existsSync(landkartePath) && existsSync(TOOL_BIN_PATH)) { try { spawnSync('node', [TOOL_BIN_PATH, 'index', '--vault', VAULT_PATH], { stdio: 'pipe' }); } catch { // non-fatal } } 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 // 2b. Status / Ping Route für Verbindungstests (z. B. aus Android-App) app.get('/api/status', (_req, res) => { res.json({ ok: true, name: 'Mind-o-Mat', version: '1.0.0', authRequired: !!NOTES_API_TOKEN, }); }); // 3. Notes API // GET /api/notes -> Liste aller Notiz-Pfade + Metadaten app.get('/api/notes', checkAuth, (_req, res) => { try { const notes = []; const items = []; 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) { const id = join(d, f).replace(/\\/g, '/'); notes.push(id); try { const raw = readFileSync(join(full, f), 'utf-8'); const parsed = matter(raw); const title = parsed.data?.title || raw.match(/^#\s+(.+)$/m)?.[1] || f.replace(/\.md$/, ''); const tags = Array.isArray(parsed.data?.tags) ? parsed.data.tags.map(String) : []; const cleanContent = raw.replace(/^---[\s\S]*?---/, '').trim(); const snippet = cleanContent.slice(0, 160).replace(/\s+/g, ' '); items.push({ id, title, folder: d, tags, snippet, lastModified: new Date().toISOString(), }); } catch { items.push({ id, title: f, folder: d, tags: [], snippet: '', lastModified: '' }); } } } res.json({ notes, items }); } catch (err) { res.status(500).json({ error: err.message }); } }); // POST /api/capture -> Quick Capture Notiz mit optionalem Ingest & Sync app.post('/api/capture', checkAuth, express.json({ limit: '10mb' }), async (req, res) => { try { const { text, title, tags, autoIngest = true, autoSync = true, targetFolder = '00_Inbox' } = req.body || {}; if (!text || typeof text !== 'string' || !text.trim()) { res.status(400).json({ ok: false, error: 'Text darf nicht leer sein' }); return; } const now = new Date(); const dateStr = now.toISOString().slice(0, 10); const firstLine = text.trim().split('\n')[0] || ''; const rawTitle = title?.trim() || firstLine.replace(/^[#\s*-_]+/, '').slice(0, 40).trim() || 'Notiz'; const cleanTitle = rawTitle.replace(/[^a-zA-Z0-9äöüÄÖÜß\-_ ]/g, '').trim() || 'Notiz'; const safeFilenameTitle = cleanTitle.replace(/\s+/g, '_'); const timeStr = `${String(now.getHours()).padStart(2, '0')}${String(now.getMinutes()).padStart(2, '0')}${String(now.getSeconds()).padStart(2, '0')}`; const filename = `${dateStr}_${safeFilenameTitle}_${timeStr}.md`; const noteId = `${targetFolder}/${filename}`; const notePath = join(VAULT_PATH, noteId); const tagsArray = Array.isArray(tags) && tags.length > 0 ? tags : ['capture']; const tagsYaml = `tags: [${tagsArray.join(', ')}]`; const bodyContent = text.startsWith('#') ? text : `# ${cleanTitle}\n\n${text}`; const fullContent = `--- title: "${cleanTitle}" created: ${dateStr} aktualisiert: ${dateStr} ${tagsYaml} --- ${bodyContent} `; mkdirSync(join(notePath, '..'), { recursive: true }); writeFileSync(notePath, fullContent, 'utf-8'); // Index aktualisieren triggerIndex(); let ingested = false; let synced = false; // Optionaler Ingest if (autoIngest && existsSync(TOOL_BIN_PATH)) { try { spawnSync('node', [TOOL_BIN_PATH, 'ingest', '--vault', VAULT_PATH, '--note', notePath], { stdio: 'pipe', timeout: 30000, }); ingested = true; } catch (e) { console.error('[Capture] Ingest Fehler:', e.message); } } // Optionaler Sync if (autoSync && existsSync(TOOL_BIN_PATH)) { try { const syncProc = spawnSync('node', [TOOL_BIN_PATH, 'sync', '--vault', VAULT_PATH], { stdio: 'pipe', timeout: 30000, }); synced = syncProc.status === 0; } catch (e) { console.error('[Capture] Sync Fehler:', e.message); } } res.json({ ok: true, noteId, title: cleanTitle, ingested, synced, message: 'Notiz erfolgreich erfasst', }); } catch (err) { res.status(500).json({ ok: false, 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); // Landkarte im Hintergrund aktualisieren triggerIndex(); 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'); } }); // Automatischer Hintergrund-Sync alle 5 Minuten (falls Vault ein Git-Repo ist) const SYNC_INTERVAL_MS = 5 * 60 * 1000; setInterval(() => { if (existsSync(join(VAULT_PATH, '.git')) && existsSync(TOOL_BIN_PATH)) { console.log('[Mind-o-Mat] Automatischer Hintergrund-Sync...'); const child = spawn('node', [TOOL_BIN_PATH, 'sync', '--vault', VAULT_PATH], { stdio: 'pipe', }); child.on('close', (code) => { if (code === 0) { console.log('[Mind-o-Mat] Hintergrund-Sync erfolgreich, aktualisiere Landkarte...'); triggerIndex(); } }); } }, SYNC_INTERVAL_MS); app.listen(PORT, '0.0.0.0', () => { console.log(`[Mind-o-Mat] Laeuft auf http://0.0.0.0:${PORT}`); });