diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..c4d1a5a --- /dev/null +++ b/.dockerignore @@ -0,0 +1,10 @@ +node_modules +webapp/node_modules +dist +webapp/dist +.git +*.log +*.log.err +coverage +test-results +.qmd diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..d72c882 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,63 @@ +# === Stage 1: Build Tool & Webapp === +FROM node:22-bookworm-slim AS builder +WORKDIR /app + +# Install root dependencies +COPY package*.json ./ +RUN npm ci || npm install + +# Install webapp dependencies +COPY webapp/package*.json ./webapp/ +WORKDIR /app/webapp +RUN npm ci || npm install +WORKDIR /app + +# Copy full source tree +COPY tsconfig.json ./ +COPY src/ ./src/ +COPY bin/ ./bin/ +COPY webapp/ ./webapp/ + +# Build tool CLI and Webapp Vite assets +RUN npm run build +RUN cd webapp && npm run build + +# === Stage 2: Final Production Image === +FROM node:22-bookworm-slim +WORKDIR /app + +# System dependencies (git fuer Vault sync, tini fuer PID 1, curl fuer Healthchecks) +RUN apt-get update && apt-get install -y --no-install-recommends \ + tini \ + git \ + bash \ + curl \ + ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +# QMD Hybrid-Suche +RUN npm install -g @tobilu/qmd + +# Production Server dependencies +COPY package*.json ./ +RUN npm install --omit=dev express gray-matter + +# Compiled Tool CLI & Server +COPY --from=builder /app/dist ./dist +COPY --from=builder /app/bin ./bin +COPY server.mjs ./ +COPY entrypoint.sh ./ +RUN chmod +x entrypoint.sh ./bin/*.mjs + +# Webapp Build Assets +COPY --from=builder /app/webapp/dist ./webapp/dist + +# Environment Defaults +ENV NODE_ENV=production +ENV PORT=5173 +ENV MINDOMAT_VAULT_PATH=/vault +ENV QMD_CACHE_DIR=/data + +EXPOSE 5173 8181 + +ENTRYPOINT ["/usr/bin/tini", "--", "/app/entrypoint.sh"] diff --git a/entrypoint.sh b/entrypoint.sh new file mode 100644 index 0000000..5fa8ddf --- /dev/null +++ b/entrypoint.sh @@ -0,0 +1,16 @@ +#!/bin/sh +set -e + +echo "=== Mind-o-Mat Container starting ===" + +# 1. QMD MCP-Dienst im Hintergrund starten (Port 8181) +if command -v qmd >/dev/null 2>&1; then + echo "[1/2] Starting QMD MCP daemon on port 8181..." + qmd mcp --http --daemon --host 0.0.0.0 --port 8181 & +else + echo "[1/2] Warning: qmd command not found, skipping QMD startup" +fi + +# 2. Webapp / Notes-API Server starten (Port 5173) +echo "[2/2] Starting Mind-o-Mat Webapp & API server on port ${PORT:-5173}..." +exec node server.mjs diff --git a/server.mjs b/server.mjs new file mode 100644 index 0000000..552a144 --- /dev/null +++ b/server.mjs @@ -0,0 +1,259 @@ +// 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}`); +});