fix(ingestion): file deletion of old articles now syncs correctly

This commit is contained in:
venus
2026-09-14 01:17:19 -05:00
parent cf16e1f60d
commit f30d636bba
2 changed files with 84 additions and 2 deletions
+33 -1
View File
@@ -1,6 +1,7 @@
import http from "node:http"; import http from "node:http";
import fs from "node:fs"; import fs from "node:fs";
import path from "node:path"; import path from "node:path";
import { exec } from "node:child_process";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
const MIME_TYPES = { 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 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)) { if (fs.existsSync(targetFilePath)) {
fs.unlinkSync(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.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ status: "deleted", slug: cleanSlug })); res.end(JSON.stringify({ status: "deleted", slug: cleanSlug }));
return; return;
+51 -1
View File
@@ -173,6 +173,17 @@ Here is an image: ![[camera-board.png]]
const assetPath = path.join(tmpAssets, "camera-board.png"); const assetPath = path.join(tmpAssets, "camera-board.png");
assert.ok(fs.existsSync(assetPath), "Asset file must be saved"); 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, "<html><body>Simulated compiled post</body></html>");
const standalonePostHtml = path.join(tmpDir, "posts", "flock-firmware.html");
fs.writeFileSync(standalonePostHtml, "<html><body>Simulated standalone post</body></html>");
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 // Delete post
const resDelete = await fetch(`http://127.0.0.1:${port}/api/posts?slug=flock-firmware`, { const resDelete = await fetch(`http://127.0.0.1:${port}/api/posts?slug=flock-firmware`, {
method: "DELETE", method: "DELETE",
@@ -182,6 +193,8 @@ Here is an image: ![[camera-board.png]]
}); });
assert.strictEqual(resDelete.status, 200, "Delete must return 200"); assert.strictEqual(resDelete.status, 200, "Delete must return 200");
assert.ok(!fs.existsSync(writtenPath), "Post markdown file must be deleted"); 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 { } finally {
await new Promise((resolve) => server.close(resolve)); await new Promise((resolve) => server.close(resolve));
fs.rmSync(tmpDir, { recursive: true, force: true }); 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 tmpDir = fs.mkdtempSync(path.join(process.cwd(), "test-tmp-site-"));
const tmpPosts = fs.mkdtempSync(path.join(process.cwd(), "test-tmp-posts-")); const tmpPosts = fs.mkdtempSync(path.join(process.cwd(), "test-tmp-posts-"));
process.env.API_SECRET = TEST_SECRET; 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; const port = server.address().port;
try { try {
// 1. Path traversal in POST
const resTraversal = await fetch(`http://127.0.0.1:${port}/api/posts`, { const resTraversal = await fetch(`http://127.0.0.1:${port}/api/posts`, {
method: "POST", method: "POST",
headers: { headers: {
@@ -218,6 +232,42 @@ test("Ingestion API: adversarial protection against path traversal in slug", asy
} else { } else {
assert.strictEqual(resTraversal.status, 400, "Invalid slug must return 400"); 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 { } finally {
await new Promise((resolve) => server.close(resolve)); await new Promise((resolve) => server.close(resolve));
fs.rmSync(tmpDir, { recursive: true, force: true }); fs.rmSync(tmpDir, { recursive: true, force: true });