360 lines
12 KiB
JavaScript
360 lines
12 KiB
JavaScript
import { createServer } from "node:http";
|
|
import { randomBytes, timingSafeEqual, scryptSync, createHash } from "node:crypto";
|
|
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
|
import { existsSync } from "node:fs";
|
|
import path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
const publicDir = path.join(__dirname, "public");
|
|
const dataDir = process.env.DATA_DIR || path.join(__dirname, "data");
|
|
const dbPath = path.join(dataDir, "state.json");
|
|
|
|
const port = Number(process.env.PORT || 8080);
|
|
const appOrigin = process.env.APP_ORIGIN || "https://mc.texisoft.com";
|
|
const sessionSecret = mustEnv("SESSION_SECRET");
|
|
const adminEmail = mustEnv("ADMIN_EMAIL").toLowerCase();
|
|
const adminPassword = mustEnv("ADMIN_PASSWORD");
|
|
const adminName = process.env.ADMIN_NAME || "Maxi";
|
|
const openClawBaseUrl = (process.env.OPENCLAW_BASE_URL || "http://openclaw-gateway-maxi:18790").replace(/\/+$/, "");
|
|
const openClawToken = process.env.OPENCLAW_GATEWAY_TOKEN || "";
|
|
|
|
const states = ["Backlog", "In progress", "Review", "Done", "Blocked"];
|
|
const priorities = ["Low", "Medium", "High"];
|
|
const assignees = ["luna", "maxi"];
|
|
let db;
|
|
|
|
function mustEnv(name) {
|
|
const value = process.env[name];
|
|
if (!value || value.length < 12) {
|
|
throw new Error(`${name} must be set to a non-trivial value`);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function hashPassword(password, salt = randomBytes(16).toString("hex")) {
|
|
const hash = scryptSync(password, salt, 64).toString("hex");
|
|
return `${salt}:${hash}`;
|
|
}
|
|
|
|
function verifyPassword(password, stored) {
|
|
const [salt, hash] = stored.split(":");
|
|
if (!salt || !hash) return false;
|
|
const candidate = scryptSync(password, salt, 64);
|
|
const expected = Buffer.from(hash, "hex");
|
|
return candidate.length === expected.length && timingSafeEqual(candidate, expected);
|
|
}
|
|
|
|
function signSession(payload) {
|
|
const body = Buffer.from(JSON.stringify(payload)).toString("base64url");
|
|
const sig = createHash("sha256").update(`${body}.${sessionSecret}`).digest("base64url");
|
|
return `${body}.${sig}`;
|
|
}
|
|
|
|
function verifySession(token) {
|
|
if (!token || !token.includes(".")) return null;
|
|
const [body, sig] = token.split(".");
|
|
const expected = createHash("sha256").update(`${body}.${sessionSecret}`).digest("base64url");
|
|
if (!timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) return null;
|
|
const payload = JSON.parse(Buffer.from(body, "base64url").toString("utf8"));
|
|
if (!payload.exp || payload.exp < Date.now()) return null;
|
|
return payload;
|
|
}
|
|
|
|
async function loadDb() {
|
|
await mkdir(dataDir, { recursive: true });
|
|
if (existsSync(dbPath)) {
|
|
db = JSON.parse(await readFile(dbPath, "utf8"));
|
|
db.settings = { openClawBaseUrl, defaultAssignee: "luna", timezone: "Europe/Berlin", ...(db.settings || {}) };
|
|
db.settings.defaultAssignee = normalizeAssignee(db.settings.defaultAssignee);
|
|
for (const task of db.tasks || []) {
|
|
if (task.state === "In Progress") task.state = "In progress";
|
|
if (task.priority === "Normal" || task.priority === "Urgent") task.priority = task.priority === "Urgent" ? "High" : "Medium";
|
|
task.assignee = normalizeAssignee(task.assignee);
|
|
}
|
|
await saveDb();
|
|
} else {
|
|
db = {
|
|
users: [{
|
|
id: "admin",
|
|
email: adminEmail,
|
|
name: adminName,
|
|
role: "Admin",
|
|
passwordHash: hashPassword(adminPassword),
|
|
createdAt: new Date().toISOString()
|
|
}],
|
|
tasks: seedTasks(),
|
|
settings: {
|
|
openClawBaseUrl,
|
|
defaultAssignee: "luna",
|
|
timezone: "Europe/Berlin"
|
|
}
|
|
};
|
|
await saveDb();
|
|
}
|
|
}
|
|
|
|
function seedTasks() {
|
|
const now = new Date().toISOString();
|
|
return [
|
|
{
|
|
id: randomBytes(12).toString("hex"),
|
|
title: "Maxis Mission Control verifizieren",
|
|
detail: "Login, Board und Einstellungen pruefen; OpenClaw-Verbindung muss auf Maxis Gateway zeigen.",
|
|
state: "Review",
|
|
priority: "High",
|
|
assignee: "luna",
|
|
createdAt: now,
|
|
updatedAt: now
|
|
},
|
|
{
|
|
id: randomBytes(12).toString("hex"),
|
|
title: "Erste Maxi-Aufgaben sammeln",
|
|
detail: "Backlog als Eingang fuer Maxis operative Aufgaben nutzen.",
|
|
state: "Backlog",
|
|
priority: "Medium",
|
|
assignee: "luna",
|
|
createdAt: now,
|
|
updatedAt: now
|
|
}
|
|
];
|
|
}
|
|
|
|
async function saveDb() {
|
|
const tmp = `${dbPath}.${process.pid}.tmp`;
|
|
await writeFile(tmp, JSON.stringify(db, null, 2));
|
|
await rename(tmp, dbPath);
|
|
}
|
|
|
|
function send(res, status, value, headers = {}) {
|
|
const isJson = typeof value !== "string" && !Buffer.isBuffer(value);
|
|
const body = isJson ? JSON.stringify(value) : value;
|
|
res.writeHead(status, {
|
|
"content-type": isJson ? "application/json; charset=utf-8" : headers["content-type"] || "text/plain; charset=utf-8",
|
|
"cache-control": "no-store",
|
|
"x-content-type-options": "nosniff",
|
|
"referrer-policy": "no-referrer",
|
|
...headers
|
|
});
|
|
res.end(body);
|
|
}
|
|
|
|
function normalizeAssignee(value) {
|
|
const normalized = String(value || "").trim().toLowerCase();
|
|
return assignees.includes(normalized) ? normalized : "luna";
|
|
}
|
|
|
|
function getCookie(req, name) {
|
|
const header = req.headers.cookie || "";
|
|
return header.split(";").map((x) => x.trim()).find((x) => x.startsWith(`${name}=`))?.slice(name.length + 1);
|
|
}
|
|
|
|
async function readJson(req) {
|
|
const chunks = [];
|
|
for await (const chunk of req) chunks.push(chunk);
|
|
if (!chunks.length) return {};
|
|
return JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
}
|
|
|
|
function requireAuth(req, res) {
|
|
const session = verifySession(getCookie(req, "mc_session"));
|
|
if (!session) {
|
|
send(res, 401, { error: "Not authenticated" });
|
|
return null;
|
|
}
|
|
return session;
|
|
}
|
|
|
|
async function openClawStatus() {
|
|
const started = Date.now();
|
|
try {
|
|
const health = await fetch(`${openClawBaseUrl}/healthz`, {
|
|
headers: openClawToken ? { authorization: `Bearer ${openClawToken}` } : {},
|
|
signal: AbortSignal.timeout(4000)
|
|
});
|
|
let toolResult = null;
|
|
let toolOk = false;
|
|
try {
|
|
const tool = await fetch(`${openClawBaseUrl}/tools/invoke`, {
|
|
method: "POST",
|
|
headers: {
|
|
"content-type": "application/json",
|
|
...(openClawToken ? { authorization: `Bearer ${openClawToken}` } : {})
|
|
},
|
|
body: JSON.stringify({ tool: "get_goal", args: {} }),
|
|
signal: AbortSignal.timeout(5000)
|
|
});
|
|
toolOk = tool.ok;
|
|
toolResult = tool.ok ? "tool invoke accepted" : `tool invoke HTTP ${tool.status}`;
|
|
} catch (error) {
|
|
toolResult = error.message;
|
|
}
|
|
return {
|
|
ok: health.ok,
|
|
baseUrl: openClawBaseUrl,
|
|
healthStatus: health.status,
|
|
toolOk,
|
|
toolResult,
|
|
latencyMs: Date.now() - started,
|
|
checkedAt: new Date().toISOString()
|
|
};
|
|
} catch (error) {
|
|
return {
|
|
ok: false,
|
|
baseUrl: openClawBaseUrl,
|
|
error: error.message,
|
|
latencyMs: Date.now() - started,
|
|
checkedAt: new Date().toISOString()
|
|
};
|
|
}
|
|
}
|
|
|
|
async function handleApi(req, res) {
|
|
const url = new URL(req.url, "http://localhost");
|
|
if (url.pathname === "/api/login" && req.method === "POST") {
|
|
const body = await readJson(req);
|
|
const user = db.users.find((x) => x.email === String(body.email || "").toLowerCase());
|
|
if (!user || !verifyPassword(String(body.password || ""), user.passwordHash)) {
|
|
send(res, 401, { error: "Invalid email or password" });
|
|
return;
|
|
}
|
|
const token = signSession({ sub: user.id, email: user.email, name: user.name, role: user.role, exp: Date.now() + 1000 * 60 * 60 * 12 });
|
|
send(res, 200, { user: publicUser(user) }, {
|
|
"set-cookie": `mc_session=${token}; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=43200`
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (url.pathname === "/api/logout" && req.method === "POST") {
|
|
send(res, 200, { ok: true }, { "set-cookie": "mc_session=; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=0" });
|
|
return;
|
|
}
|
|
|
|
const session = requireAuth(req, res);
|
|
if (!session) return;
|
|
|
|
if (url.pathname === "/api/session" && req.method === "GET") {
|
|
const user = db.users.find((x) => x.id === session.sub);
|
|
send(res, 200, { user: user ? publicUser(user) : null });
|
|
return;
|
|
}
|
|
|
|
if (url.pathname === "/api/tasks" && req.method === "GET") {
|
|
send(res, 200, { tasks: db.tasks });
|
|
return;
|
|
}
|
|
|
|
if (url.pathname === "/api/tasks" && req.method === "POST") {
|
|
const body = await readJson(req);
|
|
const title = String(body.title || "").trim();
|
|
if (!title) {
|
|
send(res, 400, { error: "Title is required" });
|
|
return;
|
|
}
|
|
const now = new Date().toISOString();
|
|
const task = {
|
|
id: randomBytes(12).toString("hex"),
|
|
title,
|
|
detail: String(body.detail || "").trim(),
|
|
state: states.includes(body.state) ? body.state : "Backlog",
|
|
priority: priorities.includes(body.priority) ? body.priority : "Medium",
|
|
assignee: normalizeAssignee(body.assignee || db.settings.defaultAssignee),
|
|
createdAt: now,
|
|
updatedAt: now
|
|
};
|
|
db.tasks.unshift(task);
|
|
await saveDb();
|
|
send(res, 201, { task });
|
|
return;
|
|
}
|
|
|
|
const taskMatch = url.pathname.match(/^\/api\/tasks\/([a-f0-9]+)$/);
|
|
if (taskMatch && req.method === "PATCH") {
|
|
const task = db.tasks.find((x) => x.id === taskMatch[1]);
|
|
if (!task) {
|
|
send(res, 404, { error: "Task not found" });
|
|
return;
|
|
}
|
|
const body = await readJson(req);
|
|
if (typeof body.title === "string" && body.title.trim()) task.title = body.title.trim();
|
|
if (typeof body.detail === "string") task.detail = body.detail.trim();
|
|
if (states.includes(body.state)) task.state = body.state;
|
|
if (priorities.includes(body.priority)) task.priority = body.priority;
|
|
if (typeof body.assignee === "string") task.assignee = normalizeAssignee(body.assignee);
|
|
task.updatedAt = new Date().toISOString();
|
|
await saveDb();
|
|
send(res, 200, { task });
|
|
return;
|
|
}
|
|
|
|
if (taskMatch && req.method === "DELETE") {
|
|
db.tasks = db.tasks.filter((x) => x.id !== taskMatch[1]);
|
|
await saveDb();
|
|
send(res, 200, { ok: true });
|
|
return;
|
|
}
|
|
|
|
if (url.pathname === "/api/settings" && req.method === "GET") {
|
|
const status = await openClawStatus();
|
|
send(res, 200, { settings: db.settings, openClaw: status });
|
|
return;
|
|
}
|
|
|
|
if (url.pathname === "/api/settings" && req.method === "PATCH") {
|
|
const body = await readJson(req);
|
|
if (typeof body.defaultAssignee === "string") db.settings.defaultAssignee = normalizeAssignee(body.defaultAssignee);
|
|
if (typeof body.timezone === "string") db.settings.timezone = body.timezone.trim() || "Europe/Berlin";
|
|
await saveDb();
|
|
send(res, 200, { settings: db.settings, openClaw: await openClawStatus() });
|
|
return;
|
|
}
|
|
|
|
if (url.pathname === "/api/openclaw/status" && req.method === "GET") {
|
|
send(res, 200, await openClawStatus());
|
|
return;
|
|
}
|
|
|
|
send(res, 404, { error: "Not found" });
|
|
}
|
|
|
|
function publicUser(user) {
|
|
return { id: user.id, email: user.email, name: user.name, role: user.role };
|
|
}
|
|
|
|
async function serveStatic(req, res) {
|
|
const url = new URL(req.url, "http://localhost");
|
|
const relative = url.pathname === "/" ? "index.html" : url.pathname.slice(1);
|
|
const safe = path.normalize(relative).replace(/^(\.\.[/\\])+/, "");
|
|
let filePath = path.join(publicDir, safe);
|
|
if (!filePath.startsWith(publicDir)) filePath = path.join(publicDir, "index.html");
|
|
try {
|
|
const file = await readFile(filePath);
|
|
const ext = path.extname(filePath);
|
|
const type = ext === ".html" ? "text/html; charset=utf-8" : ext === ".css" ? "text/css; charset=utf-8" : ext === ".js" ? "text/javascript; charset=utf-8" : "application/octet-stream";
|
|
send(res, 200, file, { "content-type": type, "cache-control": ext === ".html" ? "no-store" : "public, max-age=3600" });
|
|
} catch {
|
|
const file = await readFile(path.join(publicDir, "index.html"));
|
|
send(res, 200, file, { "content-type": "text/html; charset=utf-8" });
|
|
}
|
|
}
|
|
|
|
await loadDb();
|
|
|
|
createServer(async (req, res) => {
|
|
try {
|
|
if (req.url === "/health/live") {
|
|
send(res, 200, { ok: true, status: "live" });
|
|
return;
|
|
}
|
|
if (req.url?.startsWith("/api/")) {
|
|
await handleApi(req, res);
|
|
return;
|
|
}
|
|
await serveStatic(req, res);
|
|
} catch (error) {
|
|
send(res, 500, { error: "Internal server error", detail: process.env.NODE_ENV === "production" ? undefined : error.message });
|
|
}
|
|
}).listen(port, () => {
|
|
console.log(`Maxi's Mission Control listening on ${port}`);
|
|
});
|