Files
2026-09-08 17:51:19 +02:00

425 lines
13 KiB
JavaScript

// 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/<path> -> 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/<path> -> 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}`);
});