blog / server /index.mjs
cacode's picture
Upload 439 files
3043850 verified
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}`);
});