Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 58 additions & 1 deletion packages/core/src/content.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { parseFrontmatter, scanContent } from "./content";
import { parseFrontmatter, renderMarkdown, scanContent } from "./content";

const FIXTURE_DIR = join(import.meta.dir, "__fixtures__/content");

Expand Down Expand Up @@ -79,3 +79,60 @@ describe("scanContent", () => {
expect(entries.some((e) => e.filePath.endsWith("plain.txt"))).toBe(false);
});
});

describe("renderMarkdown", () => {
test("escapes raw HTML in prose so scripts cannot execute", () => {
const html = renderMarkdown("# Hello\n\n<script>alert('xss')</script>\n\nSafe.");
expect(html).not.toContain("<script>");
expect(html).toContain("&lt;script&gt;");
expect(html).toContain("<h1>Hello</h1>");
});

test("escapes raw HTML in fenced code blocks without double-escaping", () => {
const html = renderMarkdown("```html\n<div>hi</div>\n```");
expect(html).toContain("<pre><code>&lt;div&gt;hi&lt;/div&gt;</code></pre>");
expect(html).not.toContain("&amp;lt;");
});

test("escapes content inside inline code spans", () => {
const html = renderMarkdown("Use `\u003cscript\u003e` inline.");
expect(html).toContain("<code>&lt;script&gt;</code>");
expect(html).not.toContain("<script>");
});

test("escapes entity-like text without double-encoding ampersands", () => {
const html = renderMarkdown("a & b");
expect(html).toContain("a &amp; b");
expect(html).not.toContain("&amp;amp;");
});

test("renders unordered lists from '- item' lines", () => {
const html = renderMarkdown("- one\n- two\n- three");
expect(html).toContain("<ul>");
expect(html).toContain("<li>one</li>");
expect(html).toContain("<li>two</li>");
expect(html).toContain("<li>three</li>");
expect(html).toContain("</ul>");
});

test("renders ordered lists from '1. item' lines", () => {
const html = renderMarkdown("1. first\n2. second\n3. third");
expect(html).toContain("<ol>");
expect(html).toContain("<li>first</li>");
expect(html).toContain("<li>second</li>");
expect(html).toContain("</ol>");
});

test("applies inline formatting inside list items", () => {
const html = renderMarkdown("- **bold** and `code`");
expect(html).toContain("<ul>");
expect(html).toContain("<li><strong>bold</strong> and <code>code</code></li>");
});

test("escapes markup in list items to prevent injection", () => {
const html = renderMarkdown("- \u003cscript\u003ealert(1)\u003c/script\u003e");
expect(html).toContain("<ul>");
expect(html).not.toContain("<script>");
expect(html).toContain("&lt;script&gt;");
});
});
32 changes: 30 additions & 2 deletions packages/core/src/content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,15 +105,41 @@ export function escapeHtml(s: string): string {
.replace(/"/g, "&quot;");
}

/**
* Converts a contiguous block of `- item` / `1. item` lines into a `<ul>` /
* `<ol>` element. Returns the block untouched when it isn't a list.
*/
function renderListBlock(block: string): string {
const lines = block.split("\n");
const isUl = lines.every((l) => /^\s*[-*]\s+/.test(l));
const isOl = lines.every((l) => /^\s*\d+\.\s+/.test(l));
if (!isUl && !isOl) return block;
const tag = isUl ? "ul" : "ol";
const items = lines
.map((l) => `<li>${l.replace(/^\s*[-*]\s+/, "").replace(/^\s*\d+\.\s+/, "")}</li>`)
.join("");
return `<${tag}>${items}</${tag}>`;
}

export function renderMarkdown(md: string): string {
const inlineCodes: string[] = [];
let html = md
// Escape the entire source before any markup pass runs. `escapeHtml` only
// touches `&`, `<`, `>`, `"` so every markdown construct (headings, code
// fences, backticks, brackets) survives untouched, but raw `<script>` or
// HTML tags in prose, fenced code, and inline code all arrive pre-escaped.
// The tags we emit below are introduced after escaping, so they stay real
// HTML while user content can never execute. Without this, markdown bodies
// were injected via dangerouslySetInnerHTML with unescaped prose, so a
// `<script>` in a .md/.mdx file ran in the visitor's browser.
let html = escapeHtml(md)
.replace(/^### (.+)$/gm, "<h3>$1</h3>")
.replace(/^## (.+)$/gm, "<h2>$1</h2>")
.replace(/^# (.+)$/gm, "<h1>$1</h1>")
// The fence body was already escaped up front — re-escaping here would
// double-encode the `&` → `&amp;` entities just produced.
.replace(
/`{3}(\w*)\n([\s\S]*?)`{3}/gm,
(_m, _lang, code) => `<pre><code>${escapeHtml(code.trim())}</code></pre>`,
(_m, _lang, code) => `<pre><code>${code.trim()}</code></pre>`,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
)
.replace(/`([^`]+)`/g, (_m, code) => {
inlineCodes.push(code);
Expand All @@ -129,6 +155,8 @@ export function renderMarkdown(md: string): string {
.map((b) => {
const t = b.trim();
if (!t) return "";
const listBlock = renderListBlock(t);
if (listBlock !== t) return listBlock;
if (t.startsWith("<h") || t.startsWith("<pre") || t.startsWith("<ul") || t.startsWith("<ol"))
return t;
return `<p>${t}</p>`;
Expand Down
129 changes: 129 additions & 0 deletions packages/core/src/data/migrate.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import { Database } from "bun:sqlite";
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { runSQLiteMigrations } from "./migrate";

const FIXTURE_DIR = join(import.meta.dir, "__fixtures__/migrations");

function resetFixtures() {
rmSync(FIXTURE_DIR, { recursive: true, force: true });
mkdirSync(FIXTURE_DIR, { recursive: true });
}

function writeMigration(name: string, sql: string) {
writeFileSync(join(FIXTURE_DIR, name), sql);
}

function tableExists(db: Database, name: string): boolean {
const row = db
.query("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?1")
.get(name) as { name: string } | undefined;
return row !== undefined;
}

function appliedNames(db: Database): string[] {
const rows = db.query("SELECT name FROM _x_migrations ORDER BY name").all() as {
name: string;
}[];
return rows.map((r) => r.name);
}

beforeAll(() => {
resetFixtures();
});

afterAll(() => {
rmSync(FIXTURE_DIR, { recursive: true, force: true });
});

describe("runSQLiteMigrations", () => {
test("applies migrations in filename order", () => {
resetFixtures();
writeMigration(
"001_create_users.sql",
"CREATE TABLE users (id INTEGER PRIMARY KEY, email TEXT NOT NULL);",
);
writeMigration(
"002_add_profiles.sql",
"CREATE TABLE profiles (id INTEGER PRIMARY KEY, user_id INTEGER REFERENCES users(id));",
);

const db = new Database(":memory:");
const result = runSQLiteMigrations(db, FIXTURE_DIR);

expect(result.applied).toEqual(["001_create_users.sql", "002_add_profiles.sql"]);
expect(result.skipped).toEqual([]);
expect(tableExists(db, "users")).toBe(true);
expect(tableExists(db, "profiles")).toBe(true);
expect(appliedNames(db)).toEqual(["001_create_users.sql", "002_add_profiles.sql"]);
db.close();
});

test("skips migrations that were already applied", () => {
resetFixtures();
writeMigration("001_create_users.sql", "CREATE TABLE users (id INTEGER PRIMARY KEY);");

const db = new Database(":memory:");
const first = runSQLiteMigrations(db, FIXTURE_DIR);
expect(first.applied).toEqual(["001_create_users.sql"]);

const second = runSQLiteMigrations(db, FIXTURE_DIR);
expect(second.applied).toEqual([]);
expect(second.skipped).toEqual(["001_create_users.sql"]);
db.close();
});

test("rolls back a migration that fails partway", () => {
resetFixtures();
writeMigration(
"001_create_items.sql",
"CREATE TABLE items (id INTEGER PRIMARY KEY, name TEXT NOT NULL);",
);
// The second statement violates the primary key — the whole file must
// fail as one unit inside its transaction: no partial rows and no
// bookkeeping record, so a retry replays it against a clean schema.
writeMigration(
"002_broken.sql",
"INSERT INTO items (id, name) VALUES (1, 'first');\nINSERT INTO items (id, name) VALUES (1, 'duplicate');",
);

const db = new Database(":memory:");
expect(() => runSQLiteMigrations(db, FIXTURE_DIR)).toThrow();

// The earlier migration is still applied and its data is intact...
expect(appliedNames(db)).toEqual(["001_create_items.sql"]);
// ...but the failed migration left no trace: rows it inserted were rolled
// back and it was never recorded as applied.
const rows = db.query("SELECT COUNT(*) AS c FROM items").get() as { c: number };
expect(rows.c).toBe(0);
db.close();
});

test("retries a previously failed migration after a fix", () => {
resetFixtures();
writeMigration(
"001_create_items.sql",
"CREATE TABLE items (id INTEGER PRIMARY KEY, name TEXT NOT NULL);",
);
writeMigration(
"002_broken.sql",
"INSERT INTO items (id, name) VALUES (1, 'first');\nINSERT INTO items (id, name) VALUES (1, 'duplicate');",
);

const db = new Database(":memory:");
expect(() => runSQLiteMigrations(db, FIXTURE_DIR)).toThrow();

writeMigration(
"002_broken.sql",
"INSERT INTO items (id, name) VALUES (1, 'first');\nINSERT INTO items (id, name) VALUES (2, 'second');",
);

const retry = runSQLiteMigrations(db, FIXTURE_DIR);
expect(retry.applied).toEqual(["002_broken.sql"]);
expect(appliedNames(db)).toEqual(["001_create_items.sql", "002_broken.sql"]);
const rows = db.query("SELECT name FROM items ORDER BY id").all() as { name: string }[];
expect(rows.map((r) => r.name)).toEqual(["first", "second"]);
db.close();
});
});
22 changes: 18 additions & 4 deletions packages/core/src/data/migrate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,15 @@ export function runSQLiteMigrations(db: Database, migrationsDir: string): Migrat
continue;
}
const sql = readFileSync(join(migrationsDir, file), "utf-8");
db.run(sql);
db.run("INSERT INTO _x_migrations (name) VALUES (?1)", [file]);
// Run the migration SQL and its bookkeeping insert in a single
// transaction (auto-commit on success, rollback if anything throws). A
// migration that fails partway otherwise leaves the schema half-applied
// with no record it was attempted, so a retry replays broken statements
// against the already-mutated schema.
db.transaction(() => {
db.run(sql);
db.run("INSERT INTO _x_migrations (name) VALUES (?1)", [file]);
})();
console.log(`[x] migration applied: ${file}`);
result.applied.push(file);
}
Expand Down Expand Up @@ -79,8 +86,15 @@ export async function runPostgresMigrations(
continue;
}
const raw = readFileSync(join(migrationsDir, file), "utf-8");
await client.unsafe(raw);
await client.unsafe(`INSERT INTO _x_migrations (name) VALUES ('${file}')`);
// Same all-or-nothing guarantee as the SQLite runner: migration SQL +
// bookkeeping insert run inside one transaction, so a failing migration
// rolls back cleanly and can be retried after a fix. The insert is
// parameterized ($1) instead of string-interpolated, so a migration
// filename can't inject SQL into the bookkeeping statement.
await client.begin(async (tx) => {
await tx.unsafe(raw);
await tx.unsafe("INSERT INTO _x_migrations (name) VALUES ($1)", [file]);
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
console.log(`[x] migration applied: ${file}`);
result.applied.push(file);
}
Expand Down
20 changes: 18 additions & 2 deletions packages/core/src/data/postgres.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,18 @@
/** A Postgres client scoped to a single transaction (auto-commit/rollback). */
export interface PostgresTransactionClient {
unsafe(query: string, params?: unknown[]): Promise<unknown>;
(strings: TemplateStringsArray, ...values: unknown[]): Promise<unknown>;
}

export interface PostgresClient {
unsafe(query: string): Promise<unknown>;
unsafe(query: string, params?: unknown[]): Promise<unknown>;
(strings: TemplateStringsArray, ...values: unknown[]): Promise<unknown>;
/**
* Runs `fn` inside a transaction. The callback receives a transaction-scoped
* client; when it resolves the transaction commits, when it throws it rolls
* back — so a group of statements either all apply or none do.
*/
begin<T>(fn: (tx: PostgresTransactionClient) => Promise<T>): Promise<T>;
}

export type PostgresSslMode = "disable" | "prefer" | "require" | "verify-ca" | "verify-full";
Expand Down Expand Up @@ -125,7 +137,11 @@ export function connectPostgres(options: PostgresOptions = {}): PostgresClient {
return new Proxy(sql, {
get(target, prop, receiver) {
if (prop === "unsafe") {
return (query: string) => run(() => client.unsafe(query));
return (query: string, params?: unknown[]) => run(() => client.unsafe(query, params));
}
if (prop === "begin") {
return <T>(fn: (tx: PostgresTransactionClient) => Promise<T>) =>
run(() => client.begin(async (tx) => fn(tx as unknown as PostgresTransactionClient)));
}
const value = Reflect.get(target, prop, receiver);
return typeof value === "function" ? value.bind(target) : value;
Expand Down
Loading