diff --git a/server.js b/server.js index 57b0881..c192b76 100644 --- a/server.js +++ b/server.js @@ -1,6 +1,7 @@ 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 = { @@ -205,12 +206,43 @@ export function createServer(customSiteDir, customPostsDir, customAssetsDir) { } const cleanSlug = querySlug.toLowerCase().replace(/[^a-z0-9_-]/g, "-").replace(/^-+|-+$/g, ""); - const targetFilePath = path.join(postsDir, `${cleanSlug}.md`); + 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; diff --git a/test/ingestion-api.test.js b/test/ingestion-api.test.js index bcb6ea5..84d7fff 100644 --- a/test/ingestion-api.test.js +++ b/test/ingestion-api.test.js @@ -173,6 +173,17 @@ Here is an image: ![[camera-board.png]] const assetPath = path.join(tmpAssets, "camera-board.png"); assert.ok(fs.existsSync(assetPath), "Asset file must be saved"); + // Create simulated compiled static post files in siteDir + const compiledPostDir = path.join(tmpDir, "posts", "flock-firmware"); + fs.mkdirSync(compiledPostDir, { recursive: true }); + const compiledPostHtml = path.join(compiledPostDir, "index.html"); + fs.writeFileSync(compiledPostHtml, "Simulated compiled post"); + const standalonePostHtml = path.join(tmpDir, "posts", "flock-firmware.html"); + fs.writeFileSync(standalonePostHtml, "Simulated standalone post"); + + assert.ok(fs.existsSync(compiledPostHtml), "Simulated compiled post must exist before delete"); + assert.ok(fs.existsSync(standalonePostHtml), "Simulated standalone post must exist before delete"); + // Delete post const resDelete = await fetch(`http://127.0.0.1:${port}/api/posts?slug=flock-firmware`, { method: "DELETE", @@ -182,6 +193,8 @@ Here is an image: ![[camera-board.png]] }); assert.strictEqual(resDelete.status, 200, "Delete must return 200"); assert.ok(!fs.existsSync(writtenPath), "Post markdown file must be deleted"); + assert.ok(!fs.existsSync(compiledPostDir), "Compiled post directory must be deleted from _site"); + assert.ok(!fs.existsSync(standalonePostHtml), "Compiled standalone HTML must be deleted from _site"); } finally { await new Promise((resolve) => server.close(resolve)); fs.rmSync(tmpDir, { recursive: true, force: true }); @@ -190,7 +203,7 @@ Here is an image: ![[camera-board.png]] } }); -test("Ingestion API: adversarial protection against path traversal in slug", async () => { +test("Ingestion API: adversarial protection against path traversal in slug and DELETE validation", async () => { const tmpDir = fs.mkdtempSync(path.join(process.cwd(), "test-tmp-site-")); const tmpPosts = fs.mkdtempSync(path.join(process.cwd(), "test-tmp-posts-")); process.env.API_SECRET = TEST_SECRET; @@ -200,6 +213,7 @@ test("Ingestion API: adversarial protection against path traversal in slug", asy const port = server.address().port; try { + // 1. Path traversal in POST const resTraversal = await fetch(`http://127.0.0.1:${port}/api/posts`, { method: "POST", headers: { @@ -218,6 +232,42 @@ test("Ingestion API: adversarial protection against path traversal in slug", asy } else { assert.strictEqual(resTraversal.status, 400, "Invalid slug must return 400"); } + + // 2. DELETE without auth token + const resDeleteNoAuth = await fetch(`http://127.0.0.1:${port}/api/posts?slug=any-post`, { + method: "DELETE" + }); + assert.strictEqual(resDeleteNoAuth.status, 401, "DELETE without Bearer token must return 401"); + + // 3. DELETE with missing slug parameter + const resDeleteNoSlug = await fetch(`http://127.0.0.1:${port}/api/posts`, { + method: "DELETE", + headers: { + Authorization: `Bearer ${TEST_SECRET}` + } + }); + assert.strictEqual(resDeleteNoSlug.status, 400, "DELETE without slug parameter must return 400"); + + // 4. DELETE with invalid/empty slug + const resDeleteBadSlug = await fetch(`http://127.0.0.1:${port}/api/posts?slug=///`, { + method: "DELETE", + headers: { + Authorization: `Bearer ${TEST_SECRET}` + } + }); + assert.strictEqual(resDeleteBadSlug.status, 400, "DELETE with invalid slug must return 400"); + + // 5. DELETE non-existent post succeeds gracefully (idempotent) + const resDeleteNonExistent = await fetch(`http://127.0.0.1:${port}/api/posts?slug=non-existent-post`, { + method: "DELETE", + headers: { + Authorization: `Bearer ${TEST_SECRET}` + } + }); + assert.strictEqual(resDeleteNonExistent.status, 200, "DELETE non-existent post must return 200 idempotently"); + const jsonNonExistent = await resDeleteNonExistent.json(); + assert.strictEqual(jsonNonExistent.status, "deleted"); + assert.strictEqual(jsonNonExistent.slug, "non-existent-post"); } finally { await new Promise((resolve) => server.close(resolve)); fs.rmSync(tmpDir, { recursive: true, force: true });