fix(ingestion): file deletion of old articles now syncs correctly
This commit is contained in:
@@ -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;
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
Reference in New Issue
Block a user