added an api for obsidian integration

This commit is contained in:
venus
2026-09-14 00:47:08 -05:00
parent 9aa0ada81d
commit 3a21998d60
4 changed files with 409 additions and 9 deletions
+172 -3
View File
@@ -24,12 +24,66 @@ const MIME_TYPES = {
};
/**
* Creates the HTTP server instance for serving static files from _site
* @param {string} [customSiteDir] Optional custom directory to serve from
* 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 `![${altText}](/assets/images/posts/${cleanFilename})`;
});
// 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) {
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
@@ -46,6 +100,121 @@ export function createServer(customSiteDir) {
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;
}
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}/`
}));
});
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, "");
const targetFilePath = path.join(postsDir, `${cleanSlug}.md`);
if (fs.existsSync(targetFilePath)) {
fs.unlinkSync(targetFilePath);
}
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 {