Auto-Commit: 2026-09-06 12:51:12
CI / webapp (push) Canceled after 0s
CI / test (push) Canceled after 0s

This commit is contained in:
Jannik committed 2026-09-06 12:51:12 +02:00
1 parent 39ea9ddf11
commit 1fd0dcbfa3
4 files changed
+348

No files matched your search

+10
View File
@@ -0,0 +1,10 @@
node_modules
webapp/node_modules
dist
webapp/dist
.git
*.log
*.log.err
coverage
test-results
.qmd
+63
View File
@@ -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"]
+16
View File
@@ -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
+259
View File
@@ -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/<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}`);
});