added an api for obsidian integration
This commit is contained in:
@@ -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 ``;
|
||||
});
|
||||
|
||||
// 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 {
|
||||
|
||||
Reference in New Issue
Block a user