| 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}`); |
| });
|
|
|
|
|