260 lines
7.8 KiB
JavaScript
260 lines
7.8 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 } 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/<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);
|
|
|
|
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}`);
|
|
});
|