425 lines
13 KiB
JavaScript
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}`);
|
|
});
|