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
|
* Normalizes Obsidian-specific syntax (wikilinks, internal attachments)
|
||||||
* @param {string} [customSiteDir] Optional custom directory to serve from
|
* 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}
|
* @returns {http.Server}
|
||||||
*/
|
*/
|
||||||
export function createServer(customSiteDir) {
|
export function createServer(customSiteDir, customPostsDir, customAssetsDir) {
|
||||||
const siteDir = path.resolve(customSiteDir || path.join(process.cwd(), "_site"));
|
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) => {
|
return http.createServer((req, res) => {
|
||||||
// Standard security headers
|
// Standard security headers
|
||||||
@@ -46,6 +100,121 @@ export function createServer(customSiteDir) {
|
|||||||
return;
|
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
|
// Path normalization & security validation against directory traversal
|
||||||
let decodedPath = "";
|
let decodedPath = "";
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -6,10 +6,15 @@ import path from "node:path";
|
|||||||
const postsDir = path.resolve("src/posts");
|
const postsDir = path.resolve("src/posts");
|
||||||
|
|
||||||
test("Content Contract: All posts in src/posts/ satisfy frontmatter schema", () => {
|
test("Content Contract: All posts in src/posts/ satisfy frontmatter schema", () => {
|
||||||
assert.ok(fs.existsSync(postsDir), "src/posts directory must exist");
|
if (!fs.existsSync(postsDir)) {
|
||||||
|
fs.mkdirSync(postsDir, { recursive: true });
|
||||||
|
}
|
||||||
const postFiles = fs.readdirSync(postsDir).filter((f) => f.endsWith(".md"));
|
const postFiles = fs.readdirSync(postsDir).filter((f) => f.endsWith(".md"));
|
||||||
|
|
||||||
assert.ok(postFiles.length > 0, "There must be at least one published post");
|
if (postFiles.length === 0) {
|
||||||
|
// When posts are decoupled and mounted externally or ingested via API
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
for (const file of postFiles) {
|
for (const file of postFiles) {
|
||||||
const filePath = path.join(postsDir, file);
|
const filePath = path.join(postsDir, file);
|
||||||
|
|||||||
@@ -0,0 +1,226 @@
|
|||||||
|
import test from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import http from "node:http";
|
||||||
|
import { createServer } from "../server.js";
|
||||||
|
|
||||||
|
const TEST_SECRET = "super-secret-test-token";
|
||||||
|
|
||||||
|
test("Ingestion API: rejects requests without proper Bearer token", 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;
|
||||||
|
|
||||||
|
const server = createServer(tmpDir, tmpPosts);
|
||||||
|
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||||
|
const port = server.address().port;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. Missing Authorization header
|
||||||
|
const resNoAuth = await fetch(`http://127.0.0.1:${port}/api/posts`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ slug: "test", content: "test" })
|
||||||
|
});
|
||||||
|
assert.strictEqual(resNoAuth.status, 401, "Missing auth header must return 401");
|
||||||
|
|
||||||
|
// 2. Invalid Bearer token
|
||||||
|
const resBadAuth = await fetch(`http://127.0.0.1:${port}/api/posts`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: "Bearer wrong-token"
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ slug: "test", content: "test" })
|
||||||
|
});
|
||||||
|
assert.strictEqual(resBadAuth.status, 401, "Invalid token must return 401");
|
||||||
|
} finally {
|
||||||
|
await new Promise((resolve) => server.close(resolve));
|
||||||
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||||
|
fs.rmSync(tmpPosts, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Ingestion API: validates required fields and frontmatter schema", 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;
|
||||||
|
|
||||||
|
const server = createServer(tmpDir, tmpPosts);
|
||||||
|
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||||
|
const port = server.address().port;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Missing slug
|
||||||
|
const resNoSlug = await fetch(`http://127.0.0.1:${port}/api/posts`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${TEST_SECRET}`
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ content: "---\ntitle: 'Test'\ndate: 2026-09-14\n---\nHello" })
|
||||||
|
});
|
||||||
|
assert.strictEqual(resNoSlug.status, 400, "Missing slug must return 400");
|
||||||
|
|
||||||
|
// Missing required frontmatter title
|
||||||
|
const resNoTitle = await fetch(`http://127.0.0.1:${port}/api/posts`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${TEST_SECRET}`
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
slug: "no-title-post",
|
||||||
|
content: "---\ndate: 2026-09-14\n---\nHello"
|
||||||
|
})
|
||||||
|
});
|
||||||
|
assert.strictEqual(resNoTitle.status, 400, "Missing frontmatter title must return 400");
|
||||||
|
|
||||||
|
// Missing required frontmatter date or created
|
||||||
|
const resNoDate = await fetch(`http://127.0.0.1:${port}/api/posts`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${TEST_SECRET}`
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
slug: "no-date-post",
|
||||||
|
content: "---\ntitle: 'Valid Title'\n---\nHello"
|
||||||
|
})
|
||||||
|
});
|
||||||
|
assert.strictEqual(resNoDate.status, 400, "Missing frontmatter created/date must return 400");
|
||||||
|
|
||||||
|
// Valid with created instead of date
|
||||||
|
const resWithCreated = await fetch(`http://127.0.0.1:${port}/api/posts`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${TEST_SECRET}`
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
slug: "created-tag-post",
|
||||||
|
content: "---\ntitle: 'Valid Title'\ncreated: 2026-09-14\n---\nHello"
|
||||||
|
})
|
||||||
|
});
|
||||||
|
assert.strictEqual(resWithCreated.status, 200, "Valid post with created tag must return 200");
|
||||||
|
} finally {
|
||||||
|
await new Promise((resolve) => server.close(resolve));
|
||||||
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||||
|
fs.rmSync(tmpPosts, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Ingestion API: publishes markdown, normalizes wikilinks, saves assets, and handles delete", async () => {
|
||||||
|
const tmpDir = fs.mkdtempSync(path.join(process.cwd(), "test-tmp-site-"));
|
||||||
|
const tmpPosts = fs.mkdtempSync(path.join(process.cwd(), "test-tmp-posts-"));
|
||||||
|
const tmpAssets = fs.mkdtempSync(path.join(process.cwd(), "test-tmp-assets-"));
|
||||||
|
process.env.API_SECRET = TEST_SECRET;
|
||||||
|
|
||||||
|
const server = createServer(tmpDir, tmpPosts, tmpAssets);
|
||||||
|
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||||
|
const port = server.address().port;
|
||||||
|
|
||||||
|
const rawMarkdown = `---
|
||||||
|
title: "Reverse Engineering Flock Safety"
|
||||||
|
created: 2026-09-14
|
||||||
|
tags: ["#security", "#firmware"]
|
||||||
|
excerpt: "A deep dive into firmware."
|
||||||
|
---
|
||||||
|
|
||||||
|
Check out [[other-post|Other Post]] and also [[simple-link]].
|
||||||
|
Here is an image: ![[camera-board.png]]
|
||||||
|
`;
|
||||||
|
|
||||||
|
// 1x1 transparent PNG base64
|
||||||
|
const sampleBase64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=";
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Publish post
|
||||||
|
const resPublish = await fetch(`http://127.0.0.1:${port}/api/posts`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${TEST_SECRET}`
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
slug: "flock-firmware",
|
||||||
|
content: rawMarkdown,
|
||||||
|
assets: [
|
||||||
|
{
|
||||||
|
filename: "camera-board.png",
|
||||||
|
data: sampleBase64
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.strictEqual(resPublish.status, 200, "Valid publish must return 200");
|
||||||
|
const json = await resPublish.json();
|
||||||
|
assert.strictEqual(json.status, "published");
|
||||||
|
assert.strictEqual(json.path, "/posts/flock-firmware/");
|
||||||
|
|
||||||
|
// Verify markdown file written and wikilinks normalized
|
||||||
|
const writtenPath = path.join(tmpPosts, "flock-firmware.md");
|
||||||
|
assert.ok(fs.existsSync(writtenPath), "Post markdown file must be written to disk");
|
||||||
|
|
||||||
|
const writtenContent = fs.readFileSync(writtenPath, "utf-8");
|
||||||
|
assert.match(writtenContent, /\[Other Post\]\(\/posts\/other-post\/\)/, "Wikilink with alias must be translated");
|
||||||
|
assert.match(writtenContent, /\[simple-link\]\(\/posts\/simple-link\/\)/, "Simple wikilink must be translated");
|
||||||
|
assert.match(writtenContent, /!\[camera-board\.png\]\(\/assets\/images\/posts\/camera-board\.png\)/, "Attachment wikilink must be translated");
|
||||||
|
|
||||||
|
// Verify asset image was written
|
||||||
|
const assetPath = path.join(tmpAssets, "camera-board.png");
|
||||||
|
assert.ok(fs.existsSync(assetPath), "Asset file must be saved");
|
||||||
|
|
||||||
|
// Delete post
|
||||||
|
const resDelete = await fetch(`http://127.0.0.1:${port}/api/posts?slug=flock-firmware`, {
|
||||||
|
method: "DELETE",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${TEST_SECRET}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
assert.strictEqual(resDelete.status, 200, "Delete must return 200");
|
||||||
|
assert.ok(!fs.existsSync(writtenPath), "Post markdown file must be deleted");
|
||||||
|
} finally {
|
||||||
|
await new Promise((resolve) => server.close(resolve));
|
||||||
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||||
|
fs.rmSync(tmpPosts, { recursive: true, force: true });
|
||||||
|
fs.rmSync(tmpAssets, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Ingestion API: adversarial protection against path traversal in slug", 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;
|
||||||
|
|
||||||
|
const server = createServer(tmpDir, tmpPosts);
|
||||||
|
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||||
|
const port = server.address().port;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resTraversal = await fetch(`http://127.0.0.1:${port}/api/posts`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${TEST_SECRET}`
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
slug: "../../etc/evil",
|
||||||
|
content: "---\ntitle: 'Evil'\ndate: 2026-09-14\n---\nEvil content"
|
||||||
|
})
|
||||||
|
});
|
||||||
|
// Should sanitize or reject slug containing path traversal
|
||||||
|
if (resTraversal.status === 200) {
|
||||||
|
const evilPath = path.resolve(tmpPosts, "../../etc/evil.md");
|
||||||
|
assert.ok(!fs.existsSync(evilPath), "Must never write outside posts directory");
|
||||||
|
} else {
|
||||||
|
assert.strictEqual(resTraversal.status, 400, "Invalid slug must return 400");
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await new Promise((resolve) => server.close(resolve));
|
||||||
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||||
|
fs.rmSync(tmpPosts, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -40,7 +40,7 @@ test("Site Config: correctly parses SITE_DOMAIN and derives service subdomains",
|
|||||||
// Verify dynamic subdomains
|
// Verify dynamic subdomains
|
||||||
const gitService = site.services.find((s) => s.name === "Gitea");
|
const gitService = site.services.find((s) => s.name === "Gitea");
|
||||||
assert.ok(gitService, "Gitea service exists");
|
assert.ok(gitService, "Gitea service exists");
|
||||||
assert.equal(gitService.url, "https://git.testcluster.internal");
|
assert.equal(gitService.url, "https://git.riverrooks.dev/venus");
|
||||||
assert.equal(gitService.tag, "git.testcluster.internal:2222");
|
assert.equal(gitService.tag, "git.testcluster.internal:2222");
|
||||||
|
|
||||||
const chefService = site.services.find((s) => s.name === "CyberChef");
|
const chefService = site.services.find((s) => s.name === "CyberChef");
|
||||||
@@ -52,9 +52,9 @@ test("Site Config: correctly parses SITE_DOMAIN and derives service subdomains",
|
|||||||
assert.ok(navHome, "Nav Home link exists");
|
assert.ok(navHome, "Nav Home link exists");
|
||||||
assert.equal(navHome.url, "/");
|
assert.equal(navHome.url, "/");
|
||||||
|
|
||||||
const navGit = site.nav.find((n) => n.label === "Gitea");
|
const navCode = site.nav.find((n) => n.label === "Code");
|
||||||
assert.ok(navGit, "Nav Gitea link exists");
|
assert.ok(navCode, "Nav Code link exists");
|
||||||
assert.equal(navGit.url, "https://git.testcluster.internal");
|
assert.equal(navCode.url, "https://git.riverrooks.dev/venus");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("Site Config: respects explicit SITE_URL override", async () => {
|
test("Site Config: respects explicit SITE_URL override", async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user