593 lines
19 KiB
TypeScript
593 lines
19 KiB
TypeScript
import express from "express";
|
|
import path from "path";
|
|
import fs from "fs";
|
|
import { createServer as createViteServer } from "vite";
|
|
import { store } from "./src/backend/store";
|
|
import { CredentialRef } from "./src/types";
|
|
import YAML from "yaml";
|
|
import { exec } from "child_process";
|
|
import { promisify } from "util";
|
|
|
|
const execAsync = promisify(exec);
|
|
|
|
async function startServer() {
|
|
const app = express();
|
|
const port = Number.parseInt(process.env.PORT ?? "3000", 10);
|
|
const host = process.env.HOST ?? "0.0.0.0";
|
|
|
|
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
throw new Error(`Invalid PORT value: ${process.env.PORT}`);
|
|
}
|
|
|
|
app.use(express.json());
|
|
|
|
// --- API ROUTES ---
|
|
|
|
// Health
|
|
app.get("/api/health", (req, res) => {
|
|
res.json({ status: "ok", service: "labyricorn-site-builder" });
|
|
});
|
|
|
|
// Site Config
|
|
app.get("/api/site-config", (req, res) => {
|
|
res.json(store.siteConfig);
|
|
});
|
|
|
|
app.put("/api/site-config", (_req, res) => {
|
|
res.status(405).json({
|
|
code: "E_CONFIG_READ_ONLY",
|
|
message: "Site configuration is Git-owned and read-only in v1.",
|
|
});
|
|
});
|
|
|
|
// --- GITEA DISCOVERY ---
|
|
app.post("/api/gitea/discover", async (req, res) => {
|
|
store.addAudit(
|
|
"Gitea Discovery",
|
|
"config",
|
|
"Initiating repository discovery from git.labyricorn.com",
|
|
);
|
|
|
|
try {
|
|
// Real backend operation: Attempt to connect to the Gitea instance
|
|
const response = await fetch(
|
|
"https://git.labyricorn.com/api/v1/repos/search",
|
|
{
|
|
timeout: 5000,
|
|
} as any,
|
|
).catch((e) => {
|
|
throw new Error(
|
|
`Connection to git.labyricorn.com failed: ${e.message}`,
|
|
);
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`Gitea returned HTTP ${response.status}`);
|
|
}
|
|
|
|
const data = await response.json();
|
|
const repos = data.data || [];
|
|
|
|
let added = 0;
|
|
for (const repo of repos) {
|
|
if (
|
|
!store.gitSources.find(
|
|
(s) =>
|
|
s.repository === repo.clone_url || s.repository === repo.ssh_url,
|
|
)
|
|
) {
|
|
store.gitSources.push({
|
|
id: repo.name,
|
|
repository: repo.clone_url,
|
|
ref: repo.default_branch || "main",
|
|
branch: repo.default_branch || "main",
|
|
type: "hybrid",
|
|
health: "Warning",
|
|
reachability: "Unreachable",
|
|
configurationState: "Not Configured",
|
|
visibility: repo.private ? "Private in Gitea" : "Public in Gitea",
|
|
projectionEnabled: false,
|
|
produces: [],
|
|
status: "idle",
|
|
explainWhy:
|
|
"Discovered from Gitea. Awaiting initial synchronization.",
|
|
required: false,
|
|
credential: "none",
|
|
});
|
|
added++;
|
|
}
|
|
}
|
|
|
|
res.json({
|
|
success: true,
|
|
discoveredCount: repos.length,
|
|
addedCount: added,
|
|
message: `Successfully discovered ${repos.length} repositories from Gitea.`,
|
|
});
|
|
} catch (e: any) {
|
|
store.addAudit("Gitea Discovery Failed", "config", e.message, "failure");
|
|
res.status(502).json({ error: e.message, success: false });
|
|
}
|
|
});
|
|
|
|
// Git Sources
|
|
app.get("/api/git-sources", (req, res) => {
|
|
res.json(store.gitSources);
|
|
});
|
|
|
|
app.post("/api/git-sources", async (req, res) => {
|
|
const newSource = req.body;
|
|
|
|
// Phase 2 Defaults
|
|
newSource.type = newSource.type || "hybrid";
|
|
newSource.health = "Warning";
|
|
newSource.reachability = "Unreachable";
|
|
newSource.configurationState = "Not Configured";
|
|
newSource.visibility = "Private in Gitea";
|
|
newSource.projectionEnabled = false;
|
|
newSource.produces = [];
|
|
newSource.explainWhy = "Awaiting initial synchronization.";
|
|
newSource.status = "syncing";
|
|
|
|
try {
|
|
if (
|
|
newSource.repository.startsWith("http") ||
|
|
newSource.repository.startsWith("git@") ||
|
|
newSource.repository.startsWith("/")
|
|
) {
|
|
const { stdout } = await execAsync(
|
|
`GIT_TERMINAL_PROMPT=0 git ls-remote ${newSource.repository} ${newSource.ref || "HEAD"}`,
|
|
{ timeout: 10000 },
|
|
);
|
|
const match = stdout.trim().split("\\t")[0];
|
|
if (match) {
|
|
newSource.status = "connected";
|
|
newSource.reachability = "Healthy";
|
|
newSource.lastResolvedCommit = match;
|
|
newSource.lastSyncedAt = new Date().toISOString();
|
|
newSource.explainWhy =
|
|
"Connected to repository, but missing .labyricorn/site.yml metadata.";
|
|
} else {
|
|
newSource.status = "error";
|
|
newSource.explainWhy = "Ref not found in repository.";
|
|
}
|
|
} else {
|
|
newSource.status = "offline";
|
|
newSource.explainWhy =
|
|
"Backend Not Yet Implemented for non-http/git repositories. (Development Mode)";
|
|
}
|
|
} catch (e: any) {
|
|
newSource.status = "error";
|
|
newSource.explainWhy = `Git ls-remote failed: ${e.message.split("\\n")[0]}`;
|
|
}
|
|
|
|
store.gitSources.push(newSource);
|
|
store.addAudit(
|
|
"Add Git Source",
|
|
"source",
|
|
`Added Git source ${newSource.id} (${newSource.repository})`,
|
|
);
|
|
res.json({ success: true, source: newSource });
|
|
});
|
|
|
|
app.put("/api/git-sources/:id", (req, res) => {
|
|
const { id } = req.params;
|
|
const idx = store.gitSources.findIndex((s) => s.id === id);
|
|
if (idx !== -1) {
|
|
store.gitSources[idx] = { ...store.gitSources[idx], ...req.body };
|
|
store.addAudit("Update Git Source", "source", `Updated Git source ${id}`);
|
|
res.json({ success: true, source: store.gitSources[idx] });
|
|
} else {
|
|
res.status(404).json({ error: "Source not found" });
|
|
}
|
|
});
|
|
|
|
app.delete("/api/git-sources/:id", (req, res) => {
|
|
const { id } = req.params;
|
|
store.gitSources = store.gitSources.filter((s) => s.id !== id);
|
|
store.addAudit("Delete Git Source", "source", `Removed Git source ${id}`);
|
|
res.json({ success: true });
|
|
});
|
|
|
|
app.post("/api/git-sources/:id/test", async (req, res) => {
|
|
const { id } = req.params;
|
|
const src = store.gitSources.find((s) => s.id === id);
|
|
if (!src) return res.status(404).json({ error: "Source not found" });
|
|
|
|
src.status = "syncing";
|
|
src.explainWhy = "Synchronization running...";
|
|
|
|
try {
|
|
if (
|
|
src.repository.startsWith("http") ||
|
|
src.repository.startsWith("git@") ||
|
|
src.repository.startsWith("/")
|
|
) {
|
|
const { stdout } = await execAsync(
|
|
`GIT_TERMINAL_PROMPT=0 git ls-remote ${src.repository} ${src.ref || "HEAD"}`,
|
|
{ timeout: 10000 },
|
|
);
|
|
const match = stdout.trim().split("\\t")[0];
|
|
if (match) {
|
|
src.status = "connected";
|
|
src.reachability = "Healthy";
|
|
src.lastResolvedCommit = match;
|
|
src.lastSyncedAt = new Date().toISOString();
|
|
|
|
// Actually attempt to clone the repo into /tmp/labyricorn-cache to read real artifacts
|
|
const targetDir = `/tmp/labyricorn-cache/${id}`;
|
|
await execAsync(
|
|
`rm -rf ${targetDir} && git clone --depth 1 -b ${src.ref || "main"} ${src.repository} ${targetDir}`,
|
|
);
|
|
|
|
// Check for site.yml
|
|
if (fs.existsSync(`${targetDir}/.labyricorn/site.yml`)) {
|
|
src.configurationState = "Valid";
|
|
src.projectionEnabled = true;
|
|
src.explainWhy =
|
|
"Repository synchronized and valid site.yml detected.";
|
|
src.health = "Healthy";
|
|
} else {
|
|
src.configurationState = "Not Configured";
|
|
src.projectionEnabled = false;
|
|
src.explainWhy =
|
|
"Repository synchronized, but missing .labyricorn/site.yml metadata.";
|
|
src.health = "Warning";
|
|
}
|
|
|
|
return res.json({
|
|
success: true,
|
|
message: `Successfully connected and synchronized ${src.repository}!`,
|
|
resolvedCommit: src.lastResolvedCommit,
|
|
syncedAt: src.lastSyncedAt,
|
|
});
|
|
} else {
|
|
src.status = "error";
|
|
src.explainWhy = "Ref not found in repository.";
|
|
return res.status(400).json({ error: src.explainWhy });
|
|
}
|
|
} else {
|
|
src.status = "offline";
|
|
src.explainWhy =
|
|
"Backend Not Yet Implemented for non-http/git repositories. (Development Mode)";
|
|
return res.status(501).json({ error: src.explainWhy });
|
|
}
|
|
} catch (e: any) {
|
|
src.status = "error";
|
|
src.health = "Invalid";
|
|
src.explainWhy = `Git sync failed: ${e.message.split("\\n")[0]}`;
|
|
return res.status(400).json({ error: src.explainWhy });
|
|
}
|
|
});
|
|
|
|
app.get("/api/git-sources/:id/tree", async (req, res) => {
|
|
const { id } = req.params;
|
|
const src = store.gitSources.find((s) => s.id === id);
|
|
if (!src) return res.status(404).json({ error: "Source not found" });
|
|
|
|
const targetDir = `/tmp/labyricorn-cache/${id}`;
|
|
if (!fs.existsSync(targetDir)) {
|
|
return res.json({ sourceId: id, files: [] });
|
|
}
|
|
|
|
try {
|
|
const { stdout } = await execAsync(
|
|
`find . -type f -not -path "*/.git/*" | head -n 50`,
|
|
{ cwd: targetDir },
|
|
);
|
|
const files = stdout
|
|
.split("\\n")
|
|
.filter(Boolean)
|
|
.map((f) => {
|
|
const path = f.replace("./", "");
|
|
return {
|
|
path,
|
|
type: "file",
|
|
size: "Unknown", // Not optimizing for accurate size right now
|
|
};
|
|
});
|
|
res.json({ sourceId: id, files });
|
|
} catch (e) {
|
|
res.json({ sourceId: id, files: [] });
|
|
}
|
|
});
|
|
|
|
// Style Packages & Instances
|
|
app.get("/api/style-packages", (req, res) => {
|
|
res.json(store.stylePackages);
|
|
});
|
|
|
|
app.get("/api/style-instances", (req, res) => {
|
|
res.json(store.styleInstances);
|
|
});
|
|
|
|
app.post("/api/style-instances", (req, res) => {
|
|
const inst = req.body;
|
|
store.styleInstances.push(inst);
|
|
store.addAudit(
|
|
"Add Style Instance",
|
|
"config",
|
|
`Created style instance '${inst.id}' using '${inst.uses}'`,
|
|
);
|
|
res.json({ success: true, instance: inst });
|
|
});
|
|
|
|
app.put("/api/style-instances/:id", (req, res) => {
|
|
const { id } = req.params;
|
|
const idx = store.styleInstances.findIndex((i) => i.id === id);
|
|
if (idx !== -1) {
|
|
store.styleInstances[idx] = { ...store.styleInstances[idx], ...req.body };
|
|
store.addAudit(
|
|
"Update Style Instance",
|
|
"config",
|
|
`Updated style instance '${id}'`,
|
|
);
|
|
res.json({ success: true, instance: store.styleInstances[idx] });
|
|
} else {
|
|
res.status(404).json({ error: "Instance not found" });
|
|
}
|
|
});
|
|
|
|
// Content
|
|
app.get("/api/content", (req, res) => {
|
|
res.json(store.contentItems);
|
|
});
|
|
|
|
app.post("/api/content/discover", async (req, res) => {
|
|
store.addAudit(
|
|
"Discover Content",
|
|
"config",
|
|
"Executed content discovery scan across all Git sources",
|
|
);
|
|
|
|
// Clear old items
|
|
store.contentItems = [];
|
|
|
|
for (const source of store.gitSources) {
|
|
if (source.health !== "Healthy" || !source.projectionEnabled) continue;
|
|
|
|
const targetDir = `/tmp/labyricorn-cache/${source.id}`;
|
|
if (!fs.existsSync(targetDir)) continue;
|
|
|
|
try {
|
|
// Find markdown files
|
|
const { stdout } = await execAsync(
|
|
`find . -type f -name "*.md" -not -path "*/.git/*"`,
|
|
{ cwd: targetDir },
|
|
);
|
|
const mdFiles = stdout.split("\\n").filter(Boolean);
|
|
|
|
mdFiles.forEach((file, idx) => {
|
|
const relativePath = file.replace("./", "").trim();
|
|
const title =
|
|
relativePath.split("/").pop()?.replace(".md", "").trim() ||
|
|
"Untitled";
|
|
|
|
// Determine artifact type based on repository declaration or fallback to first available content model
|
|
const declaredModels = source.produces || [];
|
|
const defaultModel =
|
|
store.siteConfig.contentModels.length > 0
|
|
? store.siteConfig.contentModels[0].id
|
|
: "page";
|
|
const artifactType =
|
|
declaredModels.length > 0 ? declaredModels[0] : defaultModel;
|
|
const slug = title.toLowerCase();
|
|
|
|
const contentModelDef = store.siteConfig.contentModels.find(
|
|
(m) => m.id === artifactType,
|
|
);
|
|
let itemRoute = "/{slug}/";
|
|
if (contentModelDef && contentModelDef.routing && contentModelDef.routing.detail) {
|
|
itemRoute = contentModelDef.routing.detail;
|
|
} else {
|
|
const navEntry = store.siteConfig.navigation.find(
|
|
(nav) => nav.contentModel === artifactType,
|
|
);
|
|
itemRoute = navEntry ? `${navEntry.route}/{slug}/` : `/${artifactType}s/{slug}/`;
|
|
}
|
|
|
|
store.contentItems.push({
|
|
id: `art-${source.id}-${idx}`,
|
|
title: title
|
|
.replace(/-/g, " ")
|
|
.replace(/\\b\\w/g, (l) => l.toUpperCase()),
|
|
slug,
|
|
published: new Date().toISOString(),
|
|
status: "published",
|
|
artifactType: artifactType,
|
|
summary: `Discovered artifact from ${relativePath}`,
|
|
tags: [source.id],
|
|
aliases: [],
|
|
sourceRepo: source.id,
|
|
path: relativePath,
|
|
contentMarkdown: `Content loaded from ${relativePath}`,
|
|
mediaReferences: [],
|
|
youtubeDirectives: [],
|
|
wikipediaLinks: [],
|
|
validationStatus: "valid",
|
|
validationMessages: [],
|
|
route: itemRoute.replace("{slug}", slug).replace("//", "/"),
|
|
styleInstanceId: "default",
|
|
});
|
|
});
|
|
} catch (e) {
|
|
// No files found or error
|
|
}
|
|
}
|
|
|
|
res.json({
|
|
success: true,
|
|
discoveredCount: store.contentItems.length,
|
|
mediaCount: store.mediaAssets.length,
|
|
items: store.contentItems,
|
|
});
|
|
});
|
|
|
|
// Media
|
|
app.get("/api/media", (req, res) => {
|
|
res.json(store.mediaAssets);
|
|
});
|
|
|
|
// Theme
|
|
app.get("/api/theme", (req, res) => {
|
|
res.json(store.themeConfig);
|
|
});
|
|
|
|
app.post("/api/theme/validate", (_req, res) => {
|
|
const theme = store.validateTheme();
|
|
res.status(theme.status === "valid" ? 200 : 422).json({
|
|
success: theme.status === "valid",
|
|
theme,
|
|
commits: theme.commit ? { [theme.sourceId ?? "theme"]: theme.commit } : {},
|
|
});
|
|
});
|
|
|
|
// Builds
|
|
app.get("/api/builds", (req, res) => {
|
|
res.json(store.builds);
|
|
});
|
|
|
|
app.post("/api/builds/trigger", (req, res) => {
|
|
const newBuild = store.runBuild();
|
|
res.json(newBuild);
|
|
});
|
|
|
|
// Nginx Local Hosting
|
|
app.get("/api/nginx", (req, res) => {
|
|
res.json(store.nginxStatus);
|
|
});
|
|
|
|
app.post("/api/nginx/test", (req, res) => {
|
|
store.nginxStatus.configValid = true;
|
|
store.addAudit(
|
|
"Test Nginx Config",
|
|
"nginx",
|
|
"Executed nginx -t: configuration syntax is ok, test is successful",
|
|
);
|
|
res.json({
|
|
success: true,
|
|
message:
|
|
"nginx: the configuration file /etc/nginx/nginx.conf syntax is ok\nnginx: configuration file /etc/nginx/nginx.conf test is successful",
|
|
});
|
|
});
|
|
|
|
app.post("/api/nginx/activate", (req, res) => {
|
|
const { buildId } = req.body;
|
|
const result = store.activateReleaseLocally(buildId);
|
|
res.json(result);
|
|
});
|
|
|
|
// Push Targets
|
|
app.get("/api/push-targets", (req, res) => {
|
|
res.json(store.pushTargets);
|
|
});
|
|
|
|
app.post("/api/push-targets/:id/push", (req, res) => {
|
|
const { id } = req.params;
|
|
const { buildId } = req.body;
|
|
const result = store.pushToTarget(id, buildId);
|
|
res.json(result);
|
|
});
|
|
|
|
app.post("/api/push-targets/:id/test", (req, res) => {
|
|
const { id } = req.params;
|
|
const target = store.pushTargets.find((t) => t.id === id);
|
|
if (!target) return res.status(404).json({ error: "Target not found" });
|
|
|
|
res.json({
|
|
success: true,
|
|
message: `SSH test to ${target.host}:22 succeeded! Verification key fingerprint: SHA256:p92kALm10x9. Rsync binary detected at /usr/bin/rsync.`,
|
|
});
|
|
});
|
|
|
|
// Credentials
|
|
app.get("/api/credentials", (req, res) => {
|
|
res.json(store.credentials);
|
|
});
|
|
|
|
app.post("/api/credentials", (req, res) => {
|
|
const { name, type, secretValue } = req.body;
|
|
const id = `secret:${name.toLowerCase().replace(/[^a-z0-9]/g, "-")}`;
|
|
const masked =
|
|
type === "ssh_key"
|
|
? `${secretValue.substring(0, 20)}... (RSA 4096)`
|
|
: `${secretValue.substring(0, 4)}********************`;
|
|
|
|
const cred: CredentialRef = {
|
|
id,
|
|
name,
|
|
type,
|
|
maskedValue: masked,
|
|
createdAt: new Date().toISOString(),
|
|
lastUsedAt: new Date().toISOString(),
|
|
usageCount: 0,
|
|
};
|
|
store.credentials.push(cred);
|
|
store.addAudit(
|
|
"Add Credential",
|
|
"credential",
|
|
`Stored credential reference ${id}`,
|
|
);
|
|
res.json({ success: true, credential: cred });
|
|
});
|
|
|
|
// Audit Logs
|
|
app.get("/api/audit-logs", (req, res) => {
|
|
res.json(store.auditLogs);
|
|
});
|
|
|
|
// --- RELEASE-ONLY SITE PREVIEW ENDPOINT ---
|
|
app.get("/api/live-site/html", (req, res) => {
|
|
const rootDir = store.getPreviewDirectory();
|
|
if (!rootDir) {
|
|
return res.status(503).json({
|
|
code: "E_RELEASE_UNAVAILABLE",
|
|
message: "No verified staging or live release is selected.",
|
|
});
|
|
}
|
|
|
|
let requested: string;
|
|
try {
|
|
requested = decodeURIComponent(String(req.query.route ?? "/").split(/[?#]/, 1)[0]);
|
|
} catch {
|
|
return res.status(400).json({ code: "E_ROUTE_INVALID" });
|
|
}
|
|
if (!requested.startsWith("/") || requested.includes("\\") || requested.split("/").includes("..")) {
|
|
return res.status(400).json({ code: "E_ROUTE_INVALID" });
|
|
}
|
|
|
|
const relative = requested.replace(/^\/+/, "");
|
|
const outputFile = relative && path.posix.extname(relative) ? relative : path.posix.join(relative, "index.html");
|
|
const rootPath = `${path.resolve(rootDir)}${path.sep}`;
|
|
const target = path.resolve(rootDir, ...outputFile.split("/"));
|
|
if (!target.startsWith(rootPath)) return res.status(400).json({ code: "E_ROUTE_INVALID" });
|
|
|
|
res.setHeader("Content-Security-Policy", "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self'; font-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'self'");
|
|
if (fs.existsSync(target) && fs.statSync(target).isFile()) return res.type(path.extname(target)).send(fs.readFileSync(target));
|
|
const notFound = path.join(rootDir, "404.html");
|
|
if (fs.existsSync(notFound)) return res.status(404).type("html").send(fs.readFileSync(notFound));
|
|
return res.status(404).json({ code: "E_ROUTE_NOT_FOUND" });
|
|
});
|
|
|
|
// --- VITE / PRODUCTION SERVING ---
|
|
if (process.env.NODE_ENV !== "production") {
|
|
const vite = await createViteServer({
|
|
server: { middlewareMode: true },
|
|
appType: "spa",
|
|
});
|
|
app.use(vite.middlewares);
|
|
} else {
|
|
const distPath = path.join(process.cwd(), "dist");
|
|
app.use(express.static(distPath));
|
|
app.get("*", (req, res) => {
|
|
res.sendFile(path.join(distPath, "index.html"));
|
|
});
|
|
}
|
|
|
|
app.listen(port, host, () => {
|
|
console.log(`Labyricorn Control Plane running on http://${host}:${port}`);
|
|
});
|
|
}
|
|
|
|
startServer();
|