344 lines
13 KiB
JavaScript
344 lines
13 KiB
JavaScript
import http from "node:http";
|
|
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import { exec } from "node:child_process";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
const MIME_TYPES = {
|
|
".html": "text/html; charset=utf-8",
|
|
".css": "text/css; charset=utf-8",
|
|
".js": "application/javascript; charset=utf-8",
|
|
".mjs": "application/javascript; charset=utf-8",
|
|
".json": "application/json; charset=utf-8",
|
|
".xml": "application/xml; charset=utf-8",
|
|
".svg": "image/svg+xml",
|
|
".png": "image/png",
|
|
".jpg": "image/jpeg",
|
|
".jpeg": "image/jpeg",
|
|
".webp": "image/webp",
|
|
".gif": "image/gif",
|
|
".ico": "image/x-icon",
|
|
".woff": "font/woff",
|
|
".woff2": "font/woff2",
|
|
".ttf": "font/ttf",
|
|
".txt": "text/plain; charset=utf-8"
|
|
};
|
|
|
|
/**
|
|
* Normalizes Obsidian-specific syntax (wikilinks, internal attachments)
|
|
* into standard web URLs and image references.
|
|
* @param {string} content Raw Markdown content from Obsidian
|
|
* @returns {string} Normalized Markdown content
|
|
*/
|
|
export function normalizeObsidianMarkdown(content) {
|
|
if (typeof content !== "string") return "";
|
|
|
|
let normalized = content;
|
|
|
|
// Bridge created -> date so Eleventy collections sorting and filters work seamlessly
|
|
const createdMatch = normalized.match(/^created:\s*([^\r\n]+)$/m);
|
|
const hasDate = /^date:\s*.+$/m.test(normalized);
|
|
if (createdMatch && !hasDate) {
|
|
normalized = normalized.replace(/^(---\r?\n)/, `$1date: ${createdMatch[1].trim()}\n`);
|
|
}
|
|
|
|
// 1. Convert internal attachments: ![[image.png|alt]] or ![[image.png]]
|
|
normalized = normalized.replace(/!\[\[([^|\]]+)(?:\|([^\]]+))?\]\]/g, (match, filename, alt) => {
|
|
const cleanFilename = path.basename(filename.trim());
|
|
const altText = alt ? alt.trim() : cleanFilename;
|
|
return ``;
|
|
});
|
|
|
|
// 2. Convert wikilinks with label: [[target-slug|Custom Label]] -> [Custom Label](/posts/target-slug/)
|
|
normalized = normalized.replace(/\[\[([^|\]]+)\|([^\]]+)\]\]/g, (match, target, label) => {
|
|
const slug = slugify(target);
|
|
return `[${label.trim()}](/posts/${slug}/)`;
|
|
});
|
|
|
|
// 3. Convert simple wikilinks: [[target-slug]] -> [target-slug](/posts/target-slug/)
|
|
normalized = normalized.replace(/\[\[([^\]]+)\]\]/g, (match, target) => {
|
|
const trimmed = target.trim();
|
|
const slug = slugify(trimmed);
|
|
return `[${trimmed}](/posts/${slug}/)`;
|
|
});
|
|
|
|
return normalized;
|
|
}
|
|
|
|
function slugify(text) {
|
|
return text
|
|
.toLowerCase()
|
|
.trim()
|
|
.replace(/[^\w\s-]/g, "")
|
|
.replace(/[\s_-]+/g, "-")
|
|
.replace(/^-+|-+$/g, "");
|
|
}
|
|
|
|
/**
|
|
* Creates the HTTP server instance for serving static files and handling ingestion API
|
|
* @param {string} [customSiteDir] Optional custom directory for _site
|
|
* @param {string} [customPostsDir] Optional custom directory for posts
|
|
* @param {string} [customAssetsDir] Optional custom directory for uploaded post assets
|
|
* @returns {http.Server}
|
|
*/
|
|
export function createServer(customSiteDir, customPostsDir, customAssetsDir) {
|
|
const siteDir = path.resolve(customSiteDir || path.join(process.cwd(), "_site"));
|
|
const postsDir = path.resolve(customPostsDir || path.join(process.cwd(), "src/posts"));
|
|
const assetsDir = path.resolve(customAssetsDir || path.join(process.cwd(), "src/assets/images/posts"));
|
|
|
|
return http.createServer((req, res) => {
|
|
// Standard security headers
|
|
res.setHeader("X-Content-Type-Options", "nosniff");
|
|
res.setHeader("X-Frame-Options", "DENY");
|
|
res.setHeader("Referrer-Policy", "strict-origin-when-cross-origin");
|
|
|
|
const parsedUrl = new URL(req.url, "http://localhost");
|
|
|
|
// Health check endpoint for container orchestrators & telemetry
|
|
if (parsedUrl.pathname === "/healthz" || parsedUrl.pathname === "/health") {
|
|
res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" });
|
|
res.end(JSON.stringify({ status: "ok", uptime: process.uptime() }));
|
|
return;
|
|
}
|
|
|
|
// Obsidian Ingestion API: /api/posts
|
|
if (parsedUrl.pathname === "/api/posts") {
|
|
const authHeader = req.headers["authorization"] || "";
|
|
const expectedSecret = process.env.API_SECRET;
|
|
|
|
if (!expectedSecret) {
|
|
res.writeHead(500, { "Content-Type": "application/json" });
|
|
res.end(JSON.stringify({ error: "Server API_SECRET is not configured" }));
|
|
return;
|
|
}
|
|
|
|
const match = authHeader.match(/^Bearer\s+(.+)$/i);
|
|
if (!match || match[1] !== expectedSecret) {
|
|
res.writeHead(401, { "Content-Type": "application/json" });
|
|
res.end(JSON.stringify({ error: "Unauthorized: Invalid or missing Bearer token" }));
|
|
return;
|
|
}
|
|
|
|
if (req.method === "POST") {
|
|
let body = "";
|
|
req.on("data", (chunk) => {
|
|
body += chunk;
|
|
if (body.length > 50 * 1024 * 1024) {
|
|
res.writeHead(413, { "Content-Type": "application/json" });
|
|
res.end(JSON.stringify({ error: "Payload too large (50MB limit)" }));
|
|
req.destroy();
|
|
}
|
|
});
|
|
|
|
req.on("end", () => {
|
|
let payload;
|
|
try {
|
|
payload = JSON.parse(body);
|
|
} catch {
|
|
res.writeHead(400, { "Content-Type": "application/json" });
|
|
res.end(JSON.stringify({ error: "Invalid JSON payload" }));
|
|
return;
|
|
}
|
|
|
|
const { slug, content, assets } = payload;
|
|
if (!slug || typeof slug !== "string" || !content || typeof content !== "string") {
|
|
res.writeHead(400, { "Content-Type": "application/json" });
|
|
res.end(JSON.stringify({ error: "Missing required fields: 'slug' and 'content'" }));
|
|
return;
|
|
}
|
|
|
|
const cleanSlug = slug.toLowerCase().replace(/[^a-z0-9_-]/g, "-").replace(/^-+|-+$/g, "");
|
|
if (!cleanSlug) {
|
|
res.writeHead(400, { "Content-Type": "application/json" });
|
|
res.end(JSON.stringify({ error: "Invalid slug format" }));
|
|
return;
|
|
}
|
|
|
|
const hasTitle = /^title:\s*.+$/m.test(content);
|
|
const hasDate = /^date:\s*.+$/m.test(content) || /^created:\s*.+$/m.test(content);
|
|
if (!hasTitle || !hasDate) {
|
|
res.writeHead(400, { "Content-Type": "application/json" });
|
|
res.end(JSON.stringify({ error: "Missing required frontmatter: 'title' and 'created' (or 'date') are required" }));
|
|
return;
|
|
}
|
|
|
|
try {
|
|
fs.mkdirSync(postsDir, { recursive: true });
|
|
fs.mkdirSync(assetsDir, { recursive: true });
|
|
|
|
const normalizedContent = normalizeObsidianMarkdown(content);
|
|
const targetFilePath = path.join(postsDir, `${cleanSlug}.md`);
|
|
fs.writeFileSync(targetFilePath, normalizedContent, "utf-8");
|
|
|
|
if (Array.isArray(assets)) {
|
|
for (const asset of assets) {
|
|
if (asset && asset.filename && asset.data) {
|
|
const cleanName = path.basename(asset.filename).replace(/[^a-zA-Z0-9._-]/g, "");
|
|
if (cleanName) {
|
|
const assetPath = path.join(assetsDir, cleanName);
|
|
fs.writeFileSync(assetPath, Buffer.from(asset.data, "base64"));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
res.end(JSON.stringify({
|
|
status: "published",
|
|
slug: cleanSlug,
|
|
path: `/posts/${cleanSlug}/`
|
|
}));
|
|
} catch (writeErr) {
|
|
console.error("[Ingestion API] Error saving post/assets:", writeErr);
|
|
res.writeHead(500, { "Content-Type": "application/json" });
|
|
res.end(JSON.stringify({ error: `Failed to write file to disk: ${writeErr.message}` }));
|
|
}
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (req.method === "DELETE") {
|
|
const querySlug = parsedUrl.searchParams.get("slug");
|
|
if (!querySlug) {
|
|
res.writeHead(400, { "Content-Type": "application/json" });
|
|
res.end(JSON.stringify({ error: "Missing 'slug' parameter" }));
|
|
return;
|
|
}
|
|
|
|
const cleanSlug = querySlug.toLowerCase().replace(/[^a-z0-9_-]/g, "-").replace(/^-+|-+$/g, "");
|
|
if (!cleanSlug) {
|
|
res.writeHead(400, { "Content-Type": "application/json" });
|
|
res.end(JSON.stringify({ error: "Invalid slug parameter" }));
|
|
return;
|
|
}
|
|
|
|
// 1. Remove source markdown post
|
|
const targetFilePath = path.join(postsDir, `${cleanSlug}.md`);
|
|
if (fs.existsSync(targetFilePath)) {
|
|
fs.unlinkSync(targetFilePath);
|
|
}
|
|
|
|
// 2. Remove compiled static site directory and file
|
|
const compiledDir = path.join(siteDir, "posts", cleanSlug);
|
|
if (fs.existsSync(compiledDir)) {
|
|
fs.rmSync(compiledDir, { recursive: true, force: true });
|
|
}
|
|
const compiledHtml = path.join(siteDir, "posts", `${cleanSlug}.html`);
|
|
if (fs.existsSync(compiledHtml)) {
|
|
fs.unlinkSync(compiledHtml);
|
|
}
|
|
|
|
// 3. Rebuild static collections (index.html, dispatches, tags) if in full Eleventy environment
|
|
try {
|
|
if (fs.existsSync(path.join(process.cwd(), ".eleventy.js"))) {
|
|
exec("npx @11ty/eleventy", (err) => {
|
|
if (err) {
|
|
console.error("[Ingestion API] Error rebuilding site after delete:", err);
|
|
} else {
|
|
console.log(`[Ingestion API] Site rebuild complete after unpublishing '${cleanSlug}'.`);
|
|
}
|
|
});
|
|
}
|
|
} catch (rebuildErr) {
|
|
console.error("[Ingestion API] Failed to trigger Eleventy rebuild:", rebuildErr);
|
|
}
|
|
|
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
res.end(JSON.stringify({ status: "deleted", slug: cleanSlug }));
|
|
return;
|
|
}
|
|
|
|
res.writeHead(405, { "Content-Type": "application/json" });
|
|
res.end(JSON.stringify({ error: "Method Not Allowed" }));
|
|
return;
|
|
}
|
|
|
|
// Path normalization & security validation against directory traversal
|
|
let decodedPath = "";
|
|
try {
|
|
decodedPath = decodeURIComponent(parsedUrl.pathname);
|
|
} catch {
|
|
res.writeHead(400, { "Content-Type": "text/plain" });
|
|
res.end("Bad Request");
|
|
return;
|
|
}
|
|
|
|
const resolvedPath = path.resolve(siteDir, `.${decodedPath}`);
|
|
|
|
// Boundary check: ensure target path is within siteDir
|
|
if (!resolvedPath.startsWith(siteDir)) {
|
|
res.writeHead(403, { "Content-Type": "text/plain" });
|
|
res.end("Forbidden");
|
|
return;
|
|
}
|
|
|
|
let targetFile = resolvedPath;
|
|
|
|
// Directory index resolution
|
|
if (fs.existsSync(targetFile) && fs.statSync(targetFile).isDirectory()) {
|
|
targetFile = path.join(targetFile, "index.html");
|
|
}
|
|
|
|
// Clean URL extensionless fallback (/posts/my-post -> /posts/my-post.html)
|
|
if (!fs.existsSync(targetFile) && fs.existsSync(`${targetFile}.html`)) {
|
|
targetFile = `${targetFile}.html`;
|
|
}
|
|
|
|
// Check if target file exists and is a regular file
|
|
if (fs.existsSync(targetFile) && fs.statSync(targetFile).isFile()) {
|
|
const ext = path.extname(targetFile).toLowerCase();
|
|
const contentType = MIME_TYPES[ext] || "application/octet-stream";
|
|
|
|
// Cache control: assets get long-term caching, html gets short
|
|
if (decodedPath.startsWith("/assets/")) {
|
|
res.setHeader("Cache-Control", "public, max-age=2592000, immutable");
|
|
} else {
|
|
res.setHeader("Cache-Control", "public, max-age=0, must-revalidate");
|
|
}
|
|
|
|
res.writeHead(200, { "Content-Type": contentType });
|
|
const stream = fs.createReadStream(targetFile);
|
|
stream.pipe(res);
|
|
return;
|
|
}
|
|
|
|
// 404 Not Found fallback
|
|
const custom404 = path.join(siteDir, "404.html");
|
|
if (fs.existsSync(custom404)) {
|
|
res.writeHead(404, { "Content-Type": "text/html; charset=utf-8" });
|
|
fs.createReadStream(custom404).pipe(res);
|
|
} else {
|
|
res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
|
|
res.end("404 Not Found");
|
|
}
|
|
});
|
|
}
|
|
|
|
// Direct CLI execution handler
|
|
const currentFilePath = fileURLToPath(import.meta.url);
|
|
if (process.argv[1] && path.resolve(process.argv[1]) === currentFilePath) {
|
|
const port = parseInt(process.env.PORT || "8080", 10);
|
|
const host = process.env.HOST || "0.0.0.0";
|
|
const siteDir = path.resolve(process.cwd(), "_site");
|
|
|
|
if (!fs.existsSync(siteDir)) {
|
|
console.warn(`[Static Server] Warning: '${siteDir}' does not exist yet. Run 'npm run build' first.`);
|
|
}
|
|
|
|
const server = createServer();
|
|
server.listen(port, host, () => {
|
|
console.log(`[Static Server] Serving '${siteDir}' on http://${host}:${port}`);
|
|
});
|
|
|
|
// Graceful termination handling
|
|
const shutdown = (signal) => {
|
|
console.log(`\n[Static Server] Received ${signal}. Shutting down gracefully...`);
|
|
server.close(() => {
|
|
process.exit(0);
|
|
});
|
|
};
|
|
|
|
process.on("SIGINT", () => shutdown("SIGINT"));
|
|
process.on("SIGTERM", () => shutdown("SIGTERM"));
|
|
}
|