Emdash source with visual editor image upload fix

Fixes:
1. media.ts: wrap placeholder generation in try-catch
2. toolbar.ts: check r.ok, display error message in popover
This commit is contained in:
2026-05-03 10:44:54 +07:00
parent 78f81bebb6
commit 2d1be52177
2352 changed files with 662964 additions and 0 deletions

View File

@@ -0,0 +1,112 @@
import { CompiledQuery } from "kysely";
import { describe, it, expect, vi } from "vitest";
import { PreviewDODialect } from "../../src/db/do-dialect.js";
import type { PreviewDODialectConfig } from "../../src/db/do-dialect.js";
function createMockStub(queryFn = vi.fn()) {
return { query: queryFn };
}
function createConfig(queryFn = vi.fn()): PreviewDODialectConfig {
const stub = createMockStub(queryFn);
return { getStub: () => stub };
}
describe("PreviewDODialect", () => {
it("creates a SqliteAdapter", () => {
const dialect = new PreviewDODialect(createConfig());
const adapter = dialect.createAdapter();
expect(adapter.constructor.name).toBe("SqliteAdapter");
});
it("creates a SqliteQueryCompiler", () => {
const dialect = new PreviewDODialect(createConfig());
const compiler = dialect.createQueryCompiler();
expect(compiler.constructor.name).toBe("SqliteQueryCompiler");
});
});
describe("PreviewDODriver", () => {
it("acquires a connection", async () => {
const dialect = new PreviewDODialect(createConfig());
const driver = dialect.createDriver();
const conn = await driver.acquireConnection();
expect(conn).toBeDefined();
expect(conn.executeQuery).toBeTypeOf("function");
});
it("transaction methods are no-ops (preview is read-only)", async () => {
const dialect = new PreviewDODialect(createConfig());
const driver = dialect.createDriver();
const conn = await driver.acquireConnection();
// These should not throw
await driver.beginTransaction(conn, {});
await driver.commitTransaction(conn);
await driver.rollbackTransaction(conn);
});
});
describe("PreviewDOConnection", () => {
it("passes sql and parameters to the stub", async () => {
const queryFn = vi.fn().mockResolvedValue({ rows: [], changes: 0 });
const dialect = new PreviewDODialect(createConfig(queryFn));
const driver = dialect.createDriver();
const conn = await driver.acquireConnection();
await conn.executeQuery(CompiledQuery.raw("SELECT * FROM users WHERE id = ?", ["abc"]));
expect(queryFn).toHaveBeenCalledWith("SELECT * FROM users WHERE id = ?", ["abc"]);
});
it("returns rows from the stub result", async () => {
const rows = [
{ id: "1", name: "Alice" },
{ id: "2", name: "Bob" },
];
const queryFn = vi.fn().mockResolvedValue({ rows, changes: 0 });
const dialect = new PreviewDODialect(createConfig(queryFn));
const driver = dialect.createDriver();
const conn = await driver.acquireConnection();
const result = await conn.executeQuery(CompiledQuery.raw("SELECT * FROM users"));
expect(result.rows).toEqual(rows);
});
it("converts changes to bigint numAffectedRows", async () => {
const queryFn = vi.fn().mockResolvedValue({ rows: [], changes: 3 });
const dialect = new PreviewDODialect(createConfig(queryFn));
const driver = dialect.createDriver();
const conn = await driver.acquireConnection();
const result = await conn.executeQuery(CompiledQuery.raw("UPDATE users SET name = ?", ["x"]));
expect(result.numAffectedRows).toBe(3n);
});
it("sets numAffectedRows to undefined when changes is undefined", async () => {
const queryFn = vi.fn().mockResolvedValue({ rows: [{ count: 5 }] });
const dialect = new PreviewDODialect(createConfig(queryFn));
const driver = dialect.createDriver();
const conn = await driver.acquireConnection();
const result = await conn.executeQuery(
CompiledQuery.raw("SELECT count(*) as count FROM users"),
);
expect(result.numAffectedRows).toBeUndefined();
});
it("handles zero changes correctly", async () => {
const queryFn = vi.fn().mockResolvedValue({ rows: [], changes: 0 });
const dialect = new PreviewDODialect(createConfig(queryFn));
const driver = dialect.createDriver();
const conn = await driver.acquireConnection();
const result = await conn.executeQuery(CompiledQuery.raw("DELETE FROM users WHERE 1=0"));
expect(result.numAffectedRows).toBe(0n);
});
});

View File

@@ -0,0 +1,97 @@
import { describe, it, expect } from "vitest";
import { isBlockedInPlayground } from "../../src/db/do-playground-routes.js";
describe("isBlockedInPlayground", () => {
describe("blocked routes", () => {
it.each([
// Auth routes
"/_emdash/api/auth/",
"/_emdash/api/auth/passkey/options",
"/_emdash/api/auth/passkey/verify",
"/_emdash/api/auth/dev-bypass",
"/_emdash/api/auth/magic-link/send",
"/_emdash/api/auth/logout",
// Setup routes
"/_emdash/api/setup/",
"/_emdash/api/setup/status",
"/_emdash/api/setup/admin",
"/_emdash/api/setup/dev-bypass",
// OAuth routes
"/_emdash/api/oauth/",
"/_emdash/api/oauth/authorize",
"/_emdash/api/oauth/token",
// Token management
"/_emdash/api/tokens/",
"/_emdash/api/tokens/abc123",
// User invite
"/_emdash/api/users/invite",
// Plugin install/marketplace
"/_emdash/api/plugins/install",
"/_emdash/api/plugins/marketplace",
"/_emdash/api/plugins/marketplace/featured",
// Media upload (abuse vector)
"/_emdash/api/media/upload",
// Snapshot export
"/_emdash/api/snapshot",
"/_emdash/api/snapshot?drafts=true",
])("blocks %s", (path: string) => {
expect(isBlockedInPlayground(path)).toBe(true);
});
});
describe("allowed routes", () => {
it.each([
// Site pages
"/",
"/blog/my-post",
"/about",
"/sitemap.xml",
// Admin UI
"/_emdash/admin",
"/_emdash/admin/content",
"/_emdash/admin/content/posts",
"/_emdash/admin/settings",
"/_emdash/admin/media",
"/_emdash/admin/schema",
// Auth allowlist (admin UI needs /auth/me)
"/_emdash/api/auth/me",
// Content CRUD (the whole point of the playground)
"/_emdash/api/content/posts",
"/_emdash/api/content/posts/abc123",
// Schema editing
"/_emdash/api/schema",
"/_emdash/api/schema/collections",
"/_emdash/api/schema/collections/posts/fields",
// Taxonomies
"/_emdash/api/taxonomies",
"/_emdash/api/taxonomies/category/terms",
// Menus
"/_emdash/api/menus",
"/_emdash/api/menus/primary/items",
// Widgets
"/_emdash/api/widgets",
// Search
"/_emdash/api/search",
"/_emdash/api/search/suggest",
// Settings (read/write)
"/_emdash/api/settings",
// Dashboard
"/_emdash/api/dashboard",
// Manifest
"/_emdash/api/manifest",
// Media listing (not upload)
"/_emdash/api/media",
"/_emdash/api/media/abc123",
"/_emdash/api/media/file/image.jpg",
// Users list (not invite)
"/_emdash/api/users",
"/_emdash/api/users/abc123",
// Plugin list (not install/marketplace)
"/_emdash/api/plugins",
"/_emdash/api/plugins/my-plugin",
])("allows %s", (path: string) => {
expect(isBlockedInPlayground(path)).toBe(false);
});
});
});

View File

@@ -0,0 +1,94 @@
import { describe, it, expect } from "vitest";
import { signPreviewUrl, verifyPreviewSignature } from "../../src/db/do-preview-sign.js";
const SECRET = "test-secret-key";
const DIGITS = /^\d+$/;
const HEX_64 = /^[0-9a-f]{64}$/;
describe("signPreviewUrl", () => {
it("returns a URL with source, exp, and sig params", async () => {
const url = await signPreviewUrl("https://preview.example.com", "https://mysite.com", SECRET);
const parsed = new URL(url);
expect(parsed.origin).toBe("https://preview.example.com");
expect(parsed.searchParams.get("source")).toBe("https://mysite.com");
expect(parsed.searchParams.get("exp")).toMatch(DIGITS);
expect(parsed.searchParams.get("sig")).toMatch(HEX_64);
});
it("sets expiry based on ttl", async () => {
const before = Math.floor(Date.now() / 1000);
const url = await signPreviewUrl(
"https://preview.example.com",
"https://mysite.com",
SECRET,
7200,
);
const after = Math.floor(Date.now() / 1000);
const exp = Number(new URL(url).searchParams.get("exp"));
expect(exp).toBeGreaterThanOrEqual(before + 7200);
expect(exp).toBeLessThanOrEqual(after + 7200);
});
it("defaults to 1 hour TTL", async () => {
const before = Math.floor(Date.now() / 1000);
const url = await signPreviewUrl("https://preview.example.com", "https://mysite.com", SECRET);
const exp = Number(new URL(url).searchParams.get("exp"));
expect(exp).toBeGreaterThanOrEqual(before + 3600);
});
});
describe("verifyPreviewSignature", () => {
it("verifies a signature produced by signPreviewUrl", async () => {
const url = await signPreviewUrl("https://preview.example.com", "https://mysite.com", SECRET);
const parsed = new URL(url);
const source = parsed.searchParams.get("source")!;
const exp = Number(parsed.searchParams.get("exp"));
const sig = parsed.searchParams.get("sig")!;
expect(await verifyPreviewSignature(source, exp, sig, SECRET)).toBe(true);
});
it("rejects a wrong secret", async () => {
const url = await signPreviewUrl("https://preview.example.com", "https://mysite.com", SECRET);
const parsed = new URL(url);
const source = parsed.searchParams.get("source")!;
const exp = Number(parsed.searchParams.get("exp"));
const sig = parsed.searchParams.get("sig")!;
expect(await verifyPreviewSignature(source, exp, sig, "wrong-secret")).toBe(false);
});
it("rejects a tampered source", async () => {
const url = await signPreviewUrl("https://preview.example.com", "https://mysite.com", SECRET);
const parsed = new URL(url);
const exp = Number(parsed.searchParams.get("exp"));
const sig = parsed.searchParams.get("sig")!;
expect(await verifyPreviewSignature("https://evil.com", exp, sig, SECRET)).toBe(false);
});
it("rejects a tampered expiry", async () => {
const url = await signPreviewUrl("https://preview.example.com", "https://mysite.com", SECRET);
const parsed = new URL(url);
const source = parsed.searchParams.get("source")!;
const sig = parsed.searchParams.get("sig")!;
expect(await verifyPreviewSignature(source, 9999999999, sig, SECRET)).toBe(false);
});
it("rejects a tampered signature", async () => {
const url = await signPreviewUrl("https://preview.example.com", "https://mysite.com", SECRET);
const parsed = new URL(url);
const source = parsed.searchParams.get("source")!;
const exp = Number(parsed.searchParams.get("exp"));
expect(await verifyPreviewSignature(source, exp, "a".repeat(64), SECRET)).toBe(false);
});
it("rejects a signature with wrong length", async () => {
expect(await verifyPreviewSignature("https://x.com", 123, "tooshort", SECRET)).toBe(false);
});
});

View File

@@ -0,0 +1,61 @@
import { describe, it, expect } from "vitest";
import { isBlockedInPreview } from "../../src/db/do-preview-routes.js";
describe("isBlockedInPreview", () => {
it.each([
// Admin UI
"/_emdash/admin",
"/_emdash/admin/content",
"/_emdash/admin/settings",
// Auth endpoints
"/_emdash/api/auth/passkey/options",
"/_emdash/api/auth/passkey/verify",
"/_emdash/api/auth/dev-bypass",
"/_emdash/api/auth/magic-link/send",
"/_emdash/api/auth/oauth/github",
"/_emdash/api/auth/oauth/github/callback",
"/_emdash/api/auth/me",
"/_emdash/api/auth/logout",
// Setup endpoints
"/_emdash/api/setup/status",
"/_emdash/api/setup/dev-bypass",
"/_emdash/api/setup/dev-reset",
"/_emdash/api/setup/admin",
// Write endpoints (plugins, users, settings, imports)
"/_emdash/api/plugins/install",
"/_emdash/api/users",
"/_emdash/api/settings",
"/_emdash/api/import",
// Any unknown /_emdash/ path
"/_emdash/api/unknown-future-endpoint",
"/_emdash/anything",
])("blocks %s", (path: string) => {
expect(isBlockedInPreview(path)).toBe(true);
});
it.each([
// Site pages (not /_emdash/)
"/",
"/blog/my-post",
"/about",
"/sitemap.xml",
"/robots.txt",
// Allowlisted read-only API routes
"/_emdash/api/content/posts",
"/_emdash/api/content/posts/abc123",
"/_emdash/api/schema",
"/_emdash/api/schema/collections",
"/_emdash/api/manifest",
"/_emdash/api/dashboard",
"/_emdash/api/search",
"/_emdash/api/search/suggest",
"/_emdash/api/media",
"/_emdash/api/media/file/image.jpg",
"/_emdash/api/taxonomies",
"/_emdash/api/menus",
"/_emdash/api/snapshot",
])("allows %s", (path: string) => {
expect(isBlockedInPreview(path)).toBe(false);
});
});

View File

@@ -0,0 +1,44 @@
import { Kysely } from "kysely";
import { describe, it, expect } from "vitest";
import { PreviewDODialect } from "../../src/db/do-dialect.js";
import type { PreviewDBStub } from "../../src/db/do-dialect.js";
/**
* Recreates the playground's dummy dialect logic inline to avoid
* importing playground.ts which re-exports do-class.ts (cloudflare:workers).
*/
function createTestDialect() {
const notInitialized: PreviewDBStub = {
async query(): Promise<{ rows: Record<string, unknown>[] }> {
throw new Error(
"Playground database not initialized. " +
"Ensure the playground middleware is registered in src/middleware.ts " +
"and all requests go through it.",
);
},
};
return new PreviewDODialect({ getStub: () => notInitialized });
}
describe("playground dummy dialect", () => {
it("creates a dialect without throwing", () => {
const dialect = createTestDialect();
expect(dialect).toBeDefined();
expect(dialect.createAdapter).toBeTypeOf("function");
expect(dialect.createDriver).toBeTypeOf("function");
expect(dialect.createQueryCompiler).toBeTypeOf("function");
});
it("throws when a query is executed (no middleware ALS override)", async () => {
const dialect = createTestDialect();
const db = new Kysely<any>({ dialect });
await expect(
db
.selectFrom("users" as any)
.selectAll()
.execute(),
).rejects.toThrow("Playground database not initialized");
});
});

View File

@@ -0,0 +1,73 @@
import { describe, it, expect } from "vitest";
import { renderPlaygroundToolbar } from "../../src/db/playground-toolbar.js";
const BASE_CONFIG = {
createdAt: "2026-03-16T12:00:00.000Z",
ttl: 3600,
editMode: false,
};
describe("renderPlaygroundToolbar", () => {
it("renders HTML with data attributes", () => {
const html = renderPlaygroundToolbar(BASE_CONFIG);
expect(html).toContain('id="emdash-playground-toolbar"');
expect(html).toContain('data-created-at="2026-03-16T12:00:00.000Z"');
expect(html).toContain('data-ttl="3600"');
});
it("renders the playground badge", () => {
const html = renderPlaygroundToolbar(BASE_CONFIG);
expect(html).toContain("Playground");
});
it("renders the deploy CTA link", () => {
const html = renderPlaygroundToolbar(BASE_CONFIG);
expect(html).toContain("Deploy your own");
expect(html).toContain("github.com/emdash-cms/emdash");
});
it("renders reset and dismiss buttons", () => {
const html = renderPlaygroundToolbar(BASE_CONFIG);
expect(html).toContain('id="ec-pg-reset"');
expect(html).toContain('id="ec-pg-dismiss"');
expect(html).toContain("/_playground/reset");
});
it("escapes HTML in data attributes", () => {
const html = renderPlaygroundToolbar({
...BASE_CONFIG,
createdAt: '"<script>alert(1)</script>',
});
expect(html).toContain("&lt;script&gt;alert(1)&lt;/script&gt;");
expect(html).not.toContain('data-created-at="<script>');
});
it("includes countdown script", () => {
const html = renderPlaygroundToolbar(BASE_CONFIG);
expect(html).toContain("<script>");
expect(html).toContain("getRemaining");
expect(html).toContain("formatRemaining");
});
it("renders edit toggle unchecked when editMode is false", () => {
const html = renderPlaygroundToolbar({ ...BASE_CONFIG, editMode: false });
expect(html).toContain('id="ec-pg-edit-toggle"');
expect(html).toContain('data-edit-mode="false"');
expect(html).not.toContain('id="ec-pg-edit-toggle" checked');
});
it("renders edit toggle checked when editMode is true", () => {
const html = renderPlaygroundToolbar({ ...BASE_CONFIG, editMode: true });
expect(html).toContain('data-edit-mode="true"');
expect(html).toContain('id="ec-pg-edit-toggle" checked');
});
it("includes edit mode hover styles for data-emdash-ref elements", () => {
const html = renderPlaygroundToolbar(BASE_CONFIG);
expect(html).toContain("[data-emdash-ref]");
expect(html).toContain('data-edit-mode="true"');
});
});