Auto-Commit: 2026-09-06 12:51:12
This commit is contained in:
1 parent
39ea9ddf11
commit
1fd0dcbfa3
4 files changed
+348
No files matched your search
@@ -0,0 +1,10 @@
|
||||
node_modules
|
||||
webapp/node_modules
|
||||
dist
|
||||
webapp/dist
|
||||
.git
|
||||
*.log
|
||||
*.log.err
|
||||
coverage
|
||||
test-results
|
||||
.qmd
|
||||
+63
@@ -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"]
|
||||
@@ -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
@@ -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}`);
|
||||
});
|
||||
Reference in new issue
Block a user