import { spawn } from "node:child_process"; import crypto from "node:crypto"; import { createServer } from "node:http"; import path from "node:path"; import { promises as fs } from "node:fs"; import process from "node:process"; import matter from "gray-matter"; const ROOT_DIR = process.cwd(); const DIST_DIR = path.join(ROOT_DIR, "dist"); const STORAGE_ADMIN_DIR = path.join(ROOT_DIR, "storage", "admin"); const CONFIG_JSON_PATH = path.join(STORAGE_ADMIN_DIR, "config.json"); const POSTS_DIR = path.join(ROOT_DIR, "src", "content", "posts"); const SPEC_DIR = path.join(ROOT_DIR, "src", "content", "spec"); const PUBLIC_DIR = path.join(ROOT_DIR, "public"); const PUBLIC_ADMIN_ASSETS_DIR = PUBLIC_DIR; const PUBLIC_MEDIA_ROUTE = "/admin-assets/"; const CONTENT_MEDIA_ROUTE = "/__content-media/"; const SAKURA_OVERRIDE_PATH = path.join( ROOT_DIR, "src", "admin", "overrides", "sakuraConfig.override.json", ); const FOOTER_HTML_PATH = path.join(ROOT_DIR, "src", "config", "FooterConfig.html"); const BUILD_LOG_LIMIT = 400; const SESSION_COOKIE = "firefly_admin_session"; const SESSION_TTL_MS = 1000 * 60 * 60 * 24; const MAX_JSON_BODY_BYTES = 1024 * 1024 * 40; const PORT = Number.parseInt(process.env.PORT || "7860", 10); const MIME_TYPES = { ".avif": "image/avif", ".css": "text/css; charset=utf-8", ".gif": "image/gif", ".html": "text/html; charset=utf-8", ".ico": "image/x-icon", ".jpeg": "image/jpeg", ".jpg": "image/jpeg", ".js": "text/javascript; charset=utf-8", ".json": "application/json; charset=utf-8", ".map": "application/json; charset=utf-8", ".md": "text/markdown; charset=utf-8", ".mp3": "audio/mpeg", ".png": "image/png", ".svg": "image/svg+xml", ".txt": "text/plain; charset=utf-8", ".wav": "audio/wav", ".webp": "image/webp", ".woff": "font/woff", ".woff2": "font/woff2", ".xml": "application/xml; charset=utf-8", }; const IMAGE_EXTENSIONS = new Set([ ".png", ".jpg", ".jpeg", ".webp", ".gif", ".svg", ".avif", ]); const MARKDOWN_EXTENSIONS = new Set([".md", ".mdx"]); const SECTION_ALIASES = { background: "wallpaper", ads: "ad", cover: "coverImage", }; const sessions = new Map(); const buildState = { status: "idle", startedAt: null, finishedAt: null, lastBuiltAt: null, lastDurationMs: null, lastError: "", logs: [], queueLength: 0, queuedReason: "", }; let activeBuildProcess = null; function nowIso() { return new Date().toISOString(); } function buildCommand() { return process.platform === "win32" ? "pnpm.cmd" : "pnpm"; } function normalizeSlashes(value) { return value.replace(/\\/g, "/"); } function sanitizeSegment(value) { return String(value) .trim() .replace(/[<>:"|?*\x00-\x1f]/g, "-") .replace(/\.+/g, ".") .replace(/^\.+/, "") .replace(/[\\/]+/g, "-"); } function ensureInside(baseDir, targetPath) { const resolvedBase = path.resolve(baseDir); const resolvedTarget = path.resolve(targetPath); if ( resolvedTarget !== resolvedBase && !resolvedTarget.startsWith(`${resolvedBase}${path.sep}`) ) { throw new Error("Invalid path"); } return resolvedTarget; } function normalizeSectionKey(section) { const raw = String(section || "").trim(); return SECTION_ALIASES[raw] || raw; } function respond(res, statusCode, body, headers = {}) { res.writeHead(statusCode, headers); res.end(body); } function respondJson(res, statusCode, data, headers = {}) { respond( res, statusCode, JSON.stringify(data), { "Cache-Control": "no-store", "Content-Type": "application/json; charset=utf-8", ...headers, }, ); } function respondError(res, statusCode, message, details = undefined) { respondJson(res, statusCode, { error: message, ...(details === undefined ? {} : { details }), }); } function redirect(res, location, statusCode = 302) { respond(res, statusCode, "", { "Cache-Control": "no-store", Location: location, }); } function parseCookies(req) { const header = req.headers.cookie || ""; return header .split(";") .map((item) => item.trim()) .filter(Boolean) .reduce((acc, item) => { const separatorIndex = item.indexOf("="); if (separatorIndex < 0) { return acc; } const key = item.slice(0, separatorIndex); const value = item.slice(separatorIndex + 1); acc[key] = decodeURIComponent(value); return acc; }, {}); } function setCookie(res, name, value, options = {}) { const parts = [`${name}=${encodeURIComponent(value)}`]; parts.push(`Path=${options.path || "/"}`); if (options.httpOnly !== false) { parts.push("HttpOnly"); } parts.push(`SameSite=${options.sameSite || "Lax"}`); if (options.maxAge !== undefined) { parts.push(`Max-Age=${options.maxAge}`); } const existing = res.getHeader("Set-Cookie"); const nextValue = Array.isArray(existing) ? [...existing, parts.join("; ")] : existing ? [existing, parts.join("; ")] : parts.join("; "); res.setHeader("Set-Cookie", nextValue); } function clearCookie(res, name) { setCookie(res, name, "", { maxAge: 0 }); } async function readRequestBody(req, maxBytes = MAX_JSON_BODY_BYTES) { return await new Promise((resolve, reject) => { let totalBytes = 0; const chunks = []; req.on("data", (chunk) => { totalBytes += chunk.length; if (totalBytes > maxBytes) { reject(new Error("Request body too large")); req.destroy(); return; } chunks.push(chunk); }); req.on("end", () => { resolve(Buffer.concat(chunks).toString("utf8")); }); req.on("error", reject); }); } async function readJsonBody(req) { const raw = await readRequestBody(req); return raw ? JSON.parse(raw) : {}; } async function readFormBody(req) { const raw = await readRequestBody(req); const params = new URLSearchParams(raw); return Object.fromEntries(params.entries()); } async function statSafe(filePath) { try { return await fs.stat(filePath); } catch { return null; } } async function ensureRuntimeFiles() { await fs.mkdir(STORAGE_ADMIN_DIR, { recursive: true }); await fs.mkdir(PUBLIC_ADMIN_ASSETS_DIR, { recursive: true }); await fs.mkdir(path.dirname(SAKURA_OVERRIDE_PATH), { recursive: true }); await fs.mkdir(path.dirname(FOOTER_HTML_PATH), { recursive: true }); if (!(await statSafe(CONFIG_JSON_PATH))) { await fs.writeFile(CONFIG_JSON_PATH, "{}\n", "utf8"); } if (!(await statSafe(SAKURA_OVERRIDE_PATH))) { await fs.writeFile(SAKURA_OVERRIDE_PATH, "{}\n", "utf8"); } if (!(await statSafe(FOOTER_HTML_PATH))) { await fs.writeFile(FOOTER_HTML_PATH, "", "utf8"); } } async function readJsonFile(filePath, fallback = {}) { try { const raw = await fs.readFile(filePath, "utf8"); return raw.trim() ? JSON.parse(raw) : fallback; } catch { return fallback; } } async function readAdminConfig() { const config = await readJsonFile(CONFIG_JSON_PATH, {}); return typeof config === "object" && config !== null && !Array.isArray(config) ? config : {}; } async function writeAdminConfig(config) { await fs.writeFile(CONFIG_JSON_PATH, `${JSON.stringify(config, null, 2)}\n`, "utf8"); } async function readSakuraConfig() { const sakura = await readJsonFile(SAKURA_OVERRIDE_PATH, {}); return typeof sakura === "object" && sakura !== null && !Array.isArray(sakura) ? sakura : {}; } async function writeSakuraConfig(config) { await fs.writeFile(SAKURA_OVERRIDE_PATH, `${JSON.stringify(config, null, 2)}\n`, "utf8"); } async function syncFooterHtmlFromConfig(config) { const html = String(config?.footer?.customHtml || ""); await fs.writeFile(FOOTER_HTML_PATH, html, "utf8"); } async function getEffectiveConfig() { const config = await readAdminConfig(); const sakura = await readSakuraConfig(); return { site: config.site || {}, navbar: config.navbar || { links: [] }, sidebar: config.sidebar || {}, profile: config.profile || {}, wallpaper: config.wallpaper || {}, announcement: config.announcement || {}, footer: config.footer || {}, comment: config.comment || {}, friends: config.friends || { page: { columns: 2 }, items: [] }, sponsor: config.sponsor || {}, music: config.music || {}, pio: config.pio || {}, ad: config.ad || {}, license: config.license || {}, coverImage: config.coverImage || {}, font: config.site?.font || {}, sakura, }; } async function writeConfigSection(section, data) { if (typeof data !== "object" || data === null || Array.isArray(data)) { throw new Error("Config payload must be an object"); } const normalizedSection = normalizeSectionKey(section); if (normalizedSection === "sakura") { await writeSakuraConfig(data); return; } const config = await readAdminConfig(); if (normalizedSection === "font") { config.site = typeof config.site === "object" && config.site !== null ? config.site : {}; config.site.font = data; } else { config[normalizedSection] = data; } await writeAdminConfig(config); if (normalizedSection === "footer") { await syncFooterHtmlFromConfig(config); } } function serializeBuildState() { return { status: buildState.status, startedAt: buildState.startedAt, finishedAt: buildState.finishedAt, lastBuiltAt: buildState.lastBuiltAt, lastDurationMs: buildState.lastDurationMs, lastError: buildState.lastError, queueLength: buildState.queueLength, queuedReason: buildState.queuedReason, logs: [...buildState.logs], }; } function appendBuildLog(message) { const text = String(message || "").trim(); if (!text) { return; } buildState.logs.push(`${new Date().toLocaleTimeString()} ${text}`); if (buildState.logs.length > BUILD_LOG_LIMIT) { buildState.logs = buildState.logs.slice(-BUILD_LOG_LIMIT); } } function startBuild(reason = "manual") { buildState.status = "building"; buildState.startedAt = nowIso(); buildState.finishedAt = null; buildState.lastDurationMs = null; buildState.lastError = ""; buildState.logs = []; appendBuildLog(`Build started (${reason})`); const startedAtMs = Date.now(); const child = spawn(buildCommand(), ["build"], { cwd: ROOT_DIR, env: process.env, stdio: ["ignore", "pipe", "pipe"], }); activeBuildProcess = child; child.stdout.on("data", (chunk) => { appendBuildLog(String(chunk).trimEnd()); }); child.stderr.on("data", (chunk) => { appendBuildLog(String(chunk).trimEnd()); }); child.on("error", (error) => { buildState.status = "error"; buildState.finishedAt = nowIso(); buildState.lastDurationMs = Date.now() - startedAtMs; buildState.lastError = error.message; appendBuildLog(`Build failed: ${error.message}`); activeBuildProcess = null; flushBuildQueue(); }); child.on("close", (code) => { buildState.finishedAt = nowIso(); buildState.lastDurationMs = Date.now() - startedAtMs; if (code === 0) { buildState.status = "success"; buildState.lastBuiltAt = nowIso(); appendBuildLog("Build completed successfully"); } else { buildState.status = "error"; buildState.lastError = `Build exited with code ${code}`; appendBuildLog(buildState.lastError); } activeBuildProcess = null; flushBuildQueue(); }); } function flushBuildQueue() { if (activeBuildProcess || buildState.queueLength < 1) { return; } const nextReason = buildState.queuedReason || "queued"; buildState.queueLength = 0; buildState.queuedReason = ""; startBuild(nextReason); } function scheduleBuild(reason = "manual") { if (activeBuildProcess) { buildState.queueLength = 1; buildState.queuedReason = reason; appendBuildLog(`Build queued (${reason})`); return { started: false, queued: true }; } startBuild(reason); return { started: true, queued: false }; } function createSession(username) { const token = crypto.randomBytes(24).toString("hex"); sessions.set(token, { username, expiresAt: Date.now() + SESSION_TTL_MS, }); return token; } function getSession(req) { const cookies = parseCookies(req); const token = cookies[SESSION_COOKIE]; if (!token) { return null; } const session = sessions.get(token); if (!session) { return null; } if (session.expiresAt < Date.now()) { sessions.delete(token); return null; } return { token, ...session }; } function requireAuth(req, res) { const session = getSession(req); if (!session) { respondError(res, 401, "Authentication required"); return null; } return session; } function isAdminConfigured() { return Boolean(process.env.ADMIN && process.env.PASSWORD); } function completeAdminLogin(res, username) { const token = createSession(String(username)); setCookie(res, SESSION_COOKIE, token, { maxAge: Math.floor(SESSION_TTL_MS / 1000), }); return token; } function getAdminEntryPath(pathname = "/admin/login") { return pathname.startsWith("/cmsadmin") ? "/cmsadmin/" : "/admin/"; } async function handleAdminFormLogin(req, res, url) { const entryPath = getAdminEntryPath(url.pathname); if (!isAdminConfigured()) { return redirect(res, entryPath + "?login=disabled"); } const contentType = String(req.headers["content-type"] || ""); const payload = contentType.includes("application/json") ? await readJsonBody(req) : await readFormBody(req); const username = String(payload.username || ""); const password = String(payload.password || ""); if (username !== process.env.ADMIN || password !== process.env.PASSWORD) { return redirect(res, entryPath + "?login=invalid"); } completeAdminLogin(res, username); return redirect(res, entryPath); } async function walkFiles(dirPath) { const stats = await statSafe(dirPath); if (!stats?.isDirectory()) { return []; } const items = await fs.readdir(dirPath, { withFileTypes: true }); const results = []; for (const item of items) { const absolutePath = path.join(dirPath, item.name); if (item.isDirectory()) { results.push(...(await walkFiles(absolutePath))); continue; } results.push(absolutePath); } return results; } function normalizeTags(tags) { if (Array.isArray(tags)) { return tags.map((item) => String(item).trim()).filter(Boolean); } if (typeof tags === "string") { return tags .split(",") .map((item) => item.trim()) .filter(Boolean); } return []; } function postFileToSlug(filePath) { return normalizeSlashes(path.relative(POSTS_DIR, filePath)).replace(/\.(md|mdx)$/i, ""); } function pageFileToSlug(filePath) { return normalizeSlashes(path.relative(SPEC_DIR, filePath)).replace(/\.(md|mdx)$/i, ""); } function normalizePostFrontmatter(payload) { return { title: String(payload.title || "Untitled"), published: String(payload.published || new Date().toISOString().slice(0, 10)), ...(payload.updated ? { updated: String(payload.updated) } : {}), description: String(payload.description || ""), image: String(payload.image || ""), tags: normalizeTags(payload.tags), category: String(payload.category || ""), draft: Boolean(payload.draft), lang: String(payload.lang || ""), pinned: Boolean(payload.pinned), author: String(payload.author || ""), sourceLink: String(payload.sourceLink || ""), licenseName: String(payload.licenseName || ""), licenseUrl: String(payload.licenseUrl || ""), comment: payload.comment !== false, }; } async function listPosts() { const files = await walkFiles(POSTS_DIR); const markdownFiles = files.filter((filePath) => MARKDOWN_EXTENSIONS.has(path.extname(filePath).toLowerCase()), ); const posts = await Promise.all( markdownFiles.map(async (filePath) => { const raw = await fs.readFile(filePath, "utf8"); const parsed = matter(raw); const data = parsed.data || {}; return { slug: postFileToSlug(filePath), filePath: normalizeSlashes(path.relative(ROOT_DIR, filePath)), title: String(data.title || postFileToSlug(filePath)), published: String(data.published || ""), updated: String(data.updated || ""), description: String(data.description || ""), image: String(data.image || ""), tags: normalizeTags(data.tags), category: String(data.category || ""), draft: Boolean(data.draft), pinned: Boolean(data.pinned), lang: String(data.lang || ""), comment: data.comment !== false, author: String(data.author || ""), sourceLink: String(data.sourceLink || ""), licenseName: String(data.licenseName || ""), licenseUrl: String(data.licenseUrl || ""), excerpt: parsed.content.trim().slice(0, 200), modifiedAt: (await fs.stat(filePath)).mtime.toISOString(), }; }), ); return posts.sort((left, right) => { const pinnedDiff = Number(right.pinned) - Number(left.pinned); if (pinnedDiff !== 0) { return pinnedDiff; } return `${right.published}`.localeCompare(`${left.published}`); }); } async function resolvePostFile(slug) { const safeSlug = normalizeSlashes(String(slug || "")).replace(/^\/+|\/+$/g, ""); for (const extension of ["md", "mdx"]) { const filePath = ensureInside(POSTS_DIR, path.join(POSTS_DIR, `${safeSlug}.${extension}`)); if (await statSafe(filePath)) { return filePath; } } return null; } async function readPost(slug) { const filePath = await resolvePostFile(slug); if (!filePath) { return null; } const raw = await fs.readFile(filePath, "utf8"); const parsed = matter(raw); const data = normalizePostFrontmatter(parsed.data || {}); return { slug: postFileToSlug(filePath), filePath: normalizeSlashes(path.relative(ROOT_DIR, filePath)), extension: path.extname(filePath).slice(1), ...data, body: parsed.content, }; } async function writePost(existingSlug, payload) { const nextSlug = normalizeSlashes(String(payload.slug || existingSlug || "untitled")) .replace(/^\/+|\/+$/g, "") .split("/") .map(sanitizeSegment) .filter(Boolean) .join("/"); if (!nextSlug) { throw new Error("Post slug cannot be empty"); } const previousPath = existingSlug ? await resolvePostFile(existingSlug) : null; const extension = payload.extension === "mdx" || path.extname(previousPath || "") === ".mdx" ? "mdx" : "md"; const nextPath = ensureInside(POSTS_DIR, path.join(POSTS_DIR, `${nextSlug}.${extension}`)); await fs.mkdir(path.dirname(nextPath), { recursive: true }); if (!previousPath && (await statSafe(nextPath))) { throw new Error("Another post already uses this slug"); } if (previousPath && previousPath !== nextPath && (await statSafe(nextPath))) { throw new Error("Another post already uses this slug"); } if (previousPath && previousPath !== nextPath) { await fs.rename(previousPath, nextPath); } const frontmatter = normalizePostFrontmatter(payload); const body = String(payload.body ?? payload.content ?? ""); const output = matter.stringify(body, frontmatter); await fs.writeFile(nextPath, output, "utf8"); return await readPost(nextSlug); } async function deletePost(slug) { const filePath = await resolvePostFile(slug); if (!filePath) { return false; } await fs.unlink(filePath); return true; } async function listPages() { const files = await walkFiles(SPEC_DIR); const markdownFiles = files.filter((filePath) => MARKDOWN_EXTENSIONS.has(path.extname(filePath).toLowerCase()), ); const pages = await Promise.all( markdownFiles.map(async (filePath) => ({ slug: pageFileToSlug(filePath), filePath: normalizeSlashes(path.relative(ROOT_DIR, filePath)), body: await fs.readFile(filePath, "utf8"), modifiedAt: (await fs.stat(filePath)).mtime.toISOString(), })), ); return pages.sort((left, right) => left.slug.localeCompare(right.slug)); } async function readPage(slug) { const safeSlug = normalizeSlashes(String(slug || "")).replace(/^\/+|\/+$/g, ""); for (const extension of ["md", "mdx"]) { const filePath = ensureInside(SPEC_DIR, path.join(SPEC_DIR, `${safeSlug}.${extension}`)); if (await statSafe(filePath)) { return { slug: pageFileToSlug(filePath), filePath: normalizeSlashes(path.relative(ROOT_DIR, filePath)), extension, body: await fs.readFile(filePath, "utf8"), }; } } return null; } async function writePage(slug, body) { const safeSlug = normalizeSlashes(String(slug || "")) .replace(/^\/+|\/+$/g, "") .split("/") .map(sanitizeSegment) .filter(Boolean) .join("/"); if (!safeSlug) { throw new Error("Page slug cannot be empty"); } const existing = await readPage(safeSlug); const extension = existing?.extension || "md"; const filePath = ensureInside(SPEC_DIR, path.join(SPEC_DIR, `${safeSlug}.${extension}`)); await fs.mkdir(path.dirname(filePath), { recursive: true }); await fs.writeFile(filePath, String(body || ""), "utf8"); return await readPage(safeSlug); } async function buildAssetRecord(rootKey, baseDir, filePath) { const stat = await fs.stat(filePath); const relativePath = normalizeSlashes(path.relative(baseDir, filePath)); const directory = normalizeSlashes(path.dirname(relativePath)); const previewUrl = rootKey === "public" ? `${PUBLIC_MEDIA_ROUTE}${relativePath}` : `${CONTENT_MEDIA_ROUTE}${relativePath}`; const reference = rootKey === "public" ? `/${relativePath}` : relativePath; return { root: rootKey, path: relativePath, name: path.basename(filePath), directory: directory === "." ? "" : directory, size: stat.size, modifiedAt: stat.mtime.toISOString(), previewUrl, reference, }; } async function listAssets() { const roots = [ { key: "public", dir: PUBLIC_ADMIN_ASSETS_DIR }, { key: "content", dir: POSTS_DIR }, ]; const assetGroups = await Promise.all( roots.map(async ({ key, dir }) => { const files = await walkFiles(dir); const imageFiles = files.filter((filePath) => IMAGE_EXTENSIONS.has(path.extname(filePath).toLowerCase()), ); return await Promise.all( imageFiles.map((filePath) => buildAssetRecord(key, dir, filePath)), ); }), ); return assetGroups.flat().sort((left, right) => right.modifiedAt.localeCompare(left.modifiedAt)); } function parseDataUrl(dataUrl) { const match = /^data:([^;]+);base64,(.+)$/u.exec(String(dataUrl || "")); if (!match) { throw new Error("Invalid data URL"); } return { mimeType: match[1], buffer: Buffer.from(match[2], "base64"), }; } async function writeAsset(payload) { const root = payload.root === "content" ? "content" : "public"; const folderSegments = String(payload.folder || "") .split("/") .map(sanitizeSegment) .filter(Boolean); const fileName = sanitizeSegment(payload.name || `asset-${Date.now()}`); if (!fileName) { throw new Error("Asset name cannot be empty"); } const { buffer } = parseDataUrl(payload.dataUrl); const baseDir = root === "content" ? POSTS_DIR : PUBLIC_ADMIN_ASSETS_DIR; const effectiveFolders = root === "content" && folderSegments.length === 0 ? ["images"] : folderSegments; const targetDir = ensureInside(baseDir, path.join(baseDir, ...effectiveFolders)); await fs.mkdir(targetDir, { recursive: true }); let candidateName = fileName; let candidatePath = ensureInside(targetDir, path.join(targetDir, candidateName)); let counter = 1; while (await statSafe(candidatePath)) { const extension = path.extname(fileName); const baseName = path.basename(fileName, extension); candidateName = `${baseName}-${counter}${extension}`; candidatePath = ensureInside(targetDir, path.join(targetDir, candidateName)); counter += 1; } await fs.writeFile(candidatePath, buffer); return await buildAssetRecord(root, baseDir, candidatePath); } async function deleteAsset(root, relativePath) { const baseDir = root === "content" ? POSTS_DIR : PUBLIC_ADMIN_ASSETS_DIR; const safeRelativePath = normalizeSlashes(String(relativePath || "")) .split("/") .map(sanitizeSegment) .filter(Boolean) .join(path.sep); if (!safeRelativePath) { throw new Error("Asset path cannot be empty"); } const absolutePath = ensureInside(baseDir, path.join(baseDir, safeRelativePath)); await fs.unlink(absolutePath); } async function collectStats() { const [posts, pages, assets] = await Promise.all([listPosts(), listPages(), listAssets()]); const tags = new Set(); const categories = new Set(); for (const post of posts) { for (const tag of post.tags) { tags.add(tag); } if (post.category) { categories.add(post.category); } } return { posts: posts.length, drafts: posts.filter((post) => post.draft).length, pages: pages.length, assets: assets.length, tags: tags.size, categories: categories.size, build: serializeBuildState(), }; } async function handleApiRequest(req, res, url) { const pathname = url.pathname; if (pathname === "/api/admin/session" && req.method === "GET") { const session = getSession(req); return respondJson(res, 200, { authenticated: Boolean(session), configured: isAdminConfigured(), username: session?.username || "", build: serializeBuildState(), }); } if (pathname === "/api/admin/login" && req.method === "POST") { if (!isAdminConfigured()) { return respondError( res, 500, "ADMIN and PASSWORD must be configured in environment variables", ); } const payload = await readJsonBody(req); if (payload.username !== process.env.ADMIN || payload.password !== process.env.PASSWORD) { return respondError(res, 401, "Invalid username or password"); } completeAdminLogin(res, payload.username); return respondJson(res, 200, { success: true, authenticated: true, username: payload.username, build: serializeBuildState(), }); } if (pathname === "/api/admin/logout" && req.method === "POST") { const session = getSession(req); if (session) { sessions.delete(session.token); } clearCookie(res, SESSION_COOKIE); return respondJson(res, 200, { success: true }); } const session = requireAuth(req, res); if (!session) { return; } if (pathname === "/api/admin/config" && req.method === "GET") { return respondJson(res, 200, { config: await getEffectiveConfig(), }); } if (pathname.startsWith("/api/admin/config/") && req.method === "PUT") { const section = pathname.slice("/api/admin/config/".length); const payload = await readJsonBody(req); await writeConfigSection(section, payload); const buildAction = scheduleBuild(`config:${normalizeSectionKey(section)}`); return respondJson(res, 200, { success: true, section: normalizeSectionKey(section), ...buildAction, build: serializeBuildState(), }); } if (pathname === "/api/admin/dashboard" && req.method === "GET") { return respondJson(res, 200, await collectStats()); } if (pathname === "/api/admin/build" && req.method === "GET") { return respondJson(res, 200, serializeBuildState()); } if (pathname === "/api/admin/rebuild" && req.method === "POST") { const buildAction = scheduleBuild("manual"); return respondJson(res, 200, { ...buildAction, build: serializeBuildState(), }); } if (pathname === "/api/admin/pages" && req.method === "GET") { return respondJson(res, 200, { pages: await listPages(), }); } if (pathname.startsWith("/api/admin/pages/") && req.method === "GET") { const slug = decodeURIComponent(pathname.slice("/api/admin/pages/".length)); const page = await readPage(slug); if (!page) { return respondError(res, 404, "Page not found"); } return respondJson(res, 200, page); } if (pathname.startsWith("/api/admin/pages/") && req.method === "PUT") { const slug = decodeURIComponent(pathname.slice("/api/admin/pages/".length)); const payload = await readJsonBody(req); const page = await writePage(slug, payload.body ?? payload.content ?? ""); const buildAction = payload.publish === true ? scheduleBuild(`page:${slug}`) : { started: false, queued: false }; return respondJson(res, 200, { success: true, page, ...buildAction, build: serializeBuildState(), }); } if (pathname === "/api/admin/posts" && req.method === "GET") { return respondJson(res, 200, { posts: await listPosts(), }); } if (pathname === "/api/admin/posts" && req.method === "POST") { const payload = await readJsonBody(req); const post = await writePost(null, payload); const buildAction = payload.publish === true ? scheduleBuild(`post:${post.slug}`) : { started: false, queued: false }; return respondJson(res, 200, { success: true, post, ...buildAction, build: serializeBuildState(), }); } if (pathname.startsWith("/api/admin/posts/") && req.method === "GET") { const slug = decodeURIComponent(pathname.slice("/api/admin/posts/".length)); const post = await readPost(slug); if (!post) { return respondError(res, 404, "Post not found"); } return respondJson(res, 200, post); } if (pathname.startsWith("/api/admin/posts/") && req.method === "PUT") { const slug = decodeURIComponent(pathname.slice("/api/admin/posts/".length)); const payload = await readJsonBody(req); const post = await writePost(slug, payload); const buildAction = payload.publish === true ? scheduleBuild(`post:${post.slug}`) : { started: false, queued: false }; return respondJson(res, 200, { success: true, post, ...buildAction, build: serializeBuildState(), }); } if (pathname.startsWith("/api/admin/posts/") && req.method === "DELETE") { const slug = decodeURIComponent(pathname.slice("/api/admin/posts/".length)); const deleted = await deletePost(slug); if (!deleted) { return respondError(res, 404, "Post not found"); } const buildAction = scheduleBuild(`delete-post:${slug}`); return respondJson(res, 200, { success: true, ...buildAction, build: serializeBuildState(), }); } if (pathname === "/api/admin/assets" && req.method === "GET") { return respondJson(res, 200, { assets: await listAssets(), }); } if (pathname === "/api/admin/assets" && req.method === "POST") { const payload = await readJsonBody(req); const asset = await writeAsset(payload); const buildAction = scheduleBuild(`asset:${asset.path}`); return respondJson(res, 200, { success: true, asset, ...buildAction, build: serializeBuildState(), }); } if (pathname === "/api/admin/assets" && req.method === "DELETE") { const root = url.searchParams.get("root") || "public"; const targetPath = url.searchParams.get("path") || ""; await deleteAsset(root, targetPath); const buildAction = scheduleBuild(`delete-asset:${targetPath}`); return respondJson(res, 200, { success: true, ...buildAction, build: serializeBuildState(), }); } return respondError(res, 404, "API route not found"); } async function resolveStaticFile(pathname) { const decodedPath = decodeURIComponent(pathname); const cleanPath = decodedPath.replace(/^\/+/, ""); const directPath = ensureInside(DIST_DIR, path.join(DIST_DIR, cleanPath)); const directStat = await statSafe(directPath); if (directStat?.isFile()) { return { filePath: directPath, statusCode: 200 }; } if (directStat?.isDirectory()) { const indexPath = path.join(directPath, "index.html"); if (await statSafe(indexPath)) { return { filePath: indexPath, statusCode: 200 }; } } if (!path.extname(directPath)) { const nestedIndexPath = ensureInside( DIST_DIR, path.join(DIST_DIR, cleanPath, "index.html"), ); if (await statSafe(nestedIndexPath)) { return { filePath: nestedIndexPath, statusCode: 200 }; } const htmlPath = ensureInside(DIST_DIR, path.join(DIST_DIR, `${cleanPath}.html`)); if (await statSafe(htmlPath)) { return { filePath: htmlPath, statusCode: 200 }; } } const notFoundPath = path.join(DIST_DIR, "404.html"); if (await statSafe(notFoundPath)) { return { filePath: notFoundPath, statusCode: 404 }; } return null; } async function resolveSourceStaticFile(baseDir, routePrefix, pathname) { const relativePath = decodeURIComponent(pathname.slice(routePrefix.length)); const filePath = ensureInside(baseDir, path.join(baseDir, relativePath)); const stats = await statSafe(filePath); if (!stats?.isFile()) { return null; } return filePath; } async function serveFile( res, filePath, statusCode = 200, cacheControl = "public, max-age=3600", ) { const extension = path.extname(filePath).toLowerCase(); const contentType = MIME_TYPES[extension] || "application/octet-stream"; const content = await fs.readFile(filePath); return respond(res, statusCode, content, { "Cache-Control": cacheControl, "Content-Type": contentType, }); } async function handleRequest(req, res) { try { const url = new URL(req.url || "/", `http://${req.headers.host || "localhost"}`); if (url.pathname.startsWith("/api/")) { return await handleApiRequest(req, res, url); } if ( (url.pathname === "/admin/login" || url.pathname === "/cmsadmin/login") && req.method === "POST" ) { return await handleAdminFormLogin(req, res, url); } if (url.pathname.startsWith(PUBLIC_MEDIA_ROUTE)) { const filePath = await resolveSourceStaticFile( PUBLIC_ADMIN_ASSETS_DIR, PUBLIC_MEDIA_ROUTE, url.pathname, ); if (!filePath) { return respondError(res, 404, "File not found"); } return await serveFile(res, filePath); } if (url.pathname.startsWith(CONTENT_MEDIA_ROUTE)) { const filePath = await resolveSourceStaticFile( POSTS_DIR, CONTENT_MEDIA_ROUTE, url.pathname, ); if (!filePath) { return respondError(res, 404, "File not found"); } return await serveFile(res, filePath); } const resolved = await resolveStaticFile(url.pathname); if (!resolved) { return respondError(res, 404, "File not found"); } return await serveFile( res, resolved.filePath, resolved.statusCode, path.extname(resolved.filePath).toLowerCase() === ".html" ? "no-store" : "public, max-age=31536000, immutable", ); } catch (error) { console.error(error); return respondError( res, 500, "Internal server error", error instanceof Error ? error.message : String(error), ); } } await ensureRuntimeFiles(); createServer((req, res) => { handleRequest(req, res); }).listen(PORT, "0.0.0.0", () => { console.log(`Firefly admin server listening on http://0.0.0.0:${PORT}`); });