Skip to content
Closed
Show file tree
Hide file tree
Changes from all 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
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,24 @@ follows [Semantic Versioning](https://semver.org).
## [Unreleased]


### Fixed

- **Staging publish and the page-comment routes were unreachable.** The manifest generator registered a bare
wildcard (`/api/staging/:path*`) before routes that extend it (`/api/staging/:path*/publish`), so the
wildcard swallowed them: publishing a staged draft ran the upsert handler and 500ed on an empty body. The
same shadowing hit `/api/pages/:path*/comments`, `.../comments/:id` and `.../comments/:id/resolve`. The
generator now registers the route with more segments after a shared wildcard first.
`tests/admin/staging_publish_test.ts` publishes a staged draft over HTTP and checks the page file.

- **Every route that writes a page now announces it (#97).** Whoever writes a page fires its event; core
does not. Six admin paths wrote pages and fired nothing, so search indexes, webhooks and audit plugins
missed them: restoring a revision (`onPageUpdate`), staging publish (`onPageUpdate`), the two translate
routes (`onPageCreate`), the dev apply endpoint (create / update / delete), reorder (new `onPageMove`,
which lists every renamed folder) and collab auto-save (`onPageUpdate`, only when the save changed the file).
The routes share `announcePageEvent()`, which fires the hook and the configured content webhooks, so
`onPageMove` is also a valid webhook event. Needs `@dune/core` with `onPageMove` (0.36).
`tests/admin/page_events_test.ts` drives create, update, reorder, delete and restore through the real routes.

### Added

- **Runs the admin conformance suite from `@dune/testing`** (`tests/admin/conformance_test.ts`): the checks
Expand Down
1 change: 1 addition & 0 deletions mod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,7 @@ export function createAdminPlugin(
history: bootstrap.history,
contentDir: config.system.content.dir,
security: services.security,
hooks: bootstrap.hooks,
});

// Core accepts and stores public form submissions; the admin only
Expand Down
8 changes: 7 additions & 1 deletion scripts/generate-admin-manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,13 @@ function comparePatterns(a: string, b: string): number {
const y = sb[i];
if (x === undefined) return -1;
if (y === undefined) return 1;
if (x === y) continue;
if (x === y) {
// A shared wildcard swallows everything after it, so the pattern with
// more segments following it (`/:path*/publish`) is the narrower one
// and must be registered first, or the bare `/:path*` shadows it.
if (x.endsWith("*") && sa.length !== sb.length) return sb.length - sa.length;
continue;
}
const rank = (s: string) => s.endsWith("*") ? 2 : s.startsWith(":") ? 1 : 0;
const rx = rank(x);
const ry = rank(y);
Expand Down
24 changes: 12 additions & 12 deletions src/admin/manifest.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,10 +40,10 @@ import * as r33 from "./routes/api/page-source.ts";
import * as r34 from "./routes/api/pages/index.ts";
import * as r35 from "./routes/api/pages/reorder.ts";
import * as r36 from "./routes/api/pages/translate.ts";
import * as r37 from "./routes/api/pages/[...path]/index.ts";
import * as r38 from "./routes/api/pages/[...path]/comments/index.ts";
import * as r39 from "./routes/api/pages/[...path]/comments/[id]/index.ts";
import * as r40 from "./routes/api/pages/[...path]/comments/[id]/resolve.ts";
import * as r37 from "./routes/api/pages/[...path]/comments/[id]/resolve.ts";
import * as r38 from "./routes/api/pages/[...path]/comments/[id]/index.ts";
import * as r39 from "./routes/api/pages/[...path]/comments/index.ts";
import * as r40 from "./routes/api/pages/[...path]/index.ts";
import * as r41 from "./routes/api/plugins/index.ts";
import * as r42 from "./routes/api/plugins/install.ts";
import * as r43 from "./routes/api/plugins/[name]/config.ts";
Expand All @@ -54,8 +54,8 @@ import * as r47 from "./routes/api/render-markdown.ts";
import * as r48 from "./routes/api/search/analytics.ts";
import * as r49 from "./routes/api/search/engines.ts";
import * as r50 from "./routes/api/sections.ts";
import * as r51 from "./routes/api/staging/[...path]/index.ts";
import * as r52 from "./routes/api/staging/[...path]/publish.ts";
import * as r51 from "./routes/api/staging/[...path]/publish.ts";
import * as r52 from "./routes/api/staging/[...path]/index.ts";
import * as r53 from "./routes/api/theme-preview.ts";
import * as r54 from "./routes/api/themes/install.ts";
import * as r55 from "./routes/api/users/index.ts";
Expand Down Expand Up @@ -154,10 +154,10 @@ export const adminRoutes: AdminRouteEntry[] = [
{ pattern: "/api/pages", mod: r34 },
{ pattern: "/api/pages/reorder", mod: r35 },
{ pattern: "/api/pages/translate", mod: r36 },
{ pattern: "/api/pages/:path*", mod: r37 },
{ pattern: "/api/pages/:path*/comments", mod: r38 },
{ pattern: "/api/pages/:path*/comments/:id", mod: r39 },
{ pattern: "/api/pages/:path*/comments/:id/resolve", mod: r40 },
{ pattern: "/api/pages/:path*/comments/:id/resolve", mod: r37 },
{ pattern: "/api/pages/:path*/comments/:id", mod: r38 },
{ pattern: "/api/pages/:path*/comments", mod: r39 },
{ pattern: "/api/pages/:path*", mod: r40 },
{ pattern: "/api/plugins", mod: r41 },
{ pattern: "/api/plugins/install", mod: r42 },
{ pattern: "/api/plugins/:name/config", mod: r43 },
Expand All @@ -168,8 +168,8 @@ export const adminRoutes: AdminRouteEntry[] = [
{ pattern: "/api/search/analytics", mod: r48 },
{ pattern: "/api/search/engines", mod: r49 },
{ pattern: "/api/sections", mod: r50 },
{ pattern: "/api/staging/:path*", mod: r51 },
{ pattern: "/api/staging/:path*/publish", mod: r52 },
{ pattern: "/api/staging/:path*/publish", mod: r51 },
{ pattern: "/api/staging/:path*", mod: r52 },
{ pattern: "/api/theme-preview", mod: r53 },
{ pattern: "/api/themes/install", mod: r54 },
{ pattern: "/api/users", mod: r55 },
Expand Down
39 changes: 39 additions & 0 deletions src/admin/page-events.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
/**
* Announce a page write to plugins and content webhooks.
*
* Whoever writes a page fires its event: core never does it for the writer, and
* search indexes, webhooks and audit plugins only learn of a write through it.
* Every admin route that creates, rewrites, deletes or moves a page calls this
* after the file is on disk. Fire-and-forget: a failing handler or an
* unreachable webhook never fails the request.
*
* @module
*/

import type { FreshContext } from "fresh";
import type { AdminState } from "./types.ts";
import { fireContentWebhooks } from "./webhooks.ts";

/** Paths are relative to the content directory. */
export type PageEvent =
| { event: "onPageCreate"; payload: { sourcePath: string; title: string } }
| { event: "onPageUpdate"; payload: { sourcePath: string } }
| { event: "onPageDelete"; payload: { sourcePath: string } }
| { event: "onPageMove"; payload: { moves: Array<{ from: string; to: string }> } };

/** Fire a page event to hook handlers and to the configured content webhooks. */
export function announcePageEvent(ctx: FreshContext<AdminState>, e: PageEvent): void {
const { hooks, config, services } = ctx.state.adminContext;
if (hooks) {
hooks.fire(e.event, e.payload).catch((err: unknown) =>
console.warn(`[dune] ${e.event} hook failed: ${err instanceof Error ? err.message : err}`)
);
}
fireContentWebhooks(
config.admin?.webhooks ?? [],
e.event,
e.payload,
config.admin?.runtime_dir ?? ".dune/admin",
services.security,
);
}
10 changes: 10 additions & 0 deletions src/admin/routes/api/dev/apply.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ import type { FreshContext } from "fresh";
import type { AdminState } from "../../../types.ts";
import { json, requirePermission, requireTsxWrite, requireOwnedPage, csrfCheck, validatePagePath } from "../_utils.ts";
import { ValidationError } from "../../../http.ts";
import { announcePageEvent } from "../../../page-events.ts";
import { join } from "@std/path";
import { parse as parseYaml, stringify as stringifyYaml } from "@std/yaml";

Expand Down Expand Up @@ -482,10 +483,19 @@ export const handler = {
try {
if (change.op === "delete") {
await Deno.remove(absPath);
announcePageEvent(ctx, { event: "onPageDelete", payload: { sourcePath: change.path ?? "" } });
results.push({ op: change.op, path: change.path, status: "deleted", errors: [] });
} else {
const existed = await Deno.stat(absPath).then(() => true).catch(() => false);
await applyWrite(contentDir, change as Change & { op: "write" | "frontmatter"; path: string });
const sourcePath = change.path ?? "";
const title = change.op === "frontmatter" ? change.patch?.title : undefined;
announcePageEvent(
ctx,
existed
? { event: "onPageUpdate", payload: { sourcePath } }
: { event: "onPageCreate", payload: { sourcePath, title: typeof title === "string" ? title : "" } },
);
results.push({
op: change.op,
path: change.path,
Expand Down
2 changes: 2 additions & 0 deletions src/admin/routes/api/history/[...rest].ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
import type { AdminState } from "../../../types.ts";
import { requirePermission, requireTsxWrite, requireOwnedPage, json, serverError, csrfCheck, validatePagePath } from "../_utils.ts";
import { stringify as stringifyYaml } from "@std/yaml";
import { announcePageEvent } from "../../../page-events.ts";
import type { FreshContext } from "fresh";

export const handler = {
Expand Down Expand Up @@ -108,6 +109,7 @@ export const handler = {

await storage.write(filePath, new TextEncoder().encode(fullContent));
await engine.rebuild();
announcePageEvent(ctx, { event: "onPageUpdate", payload: { sourcePath: pageIndex.sourcePath } });

return json({ restored: true, revision: revNum });
} catch (err) {
Expand Down
2 changes: 2 additions & 0 deletions src/admin/routes/api/i18n/translate-page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import type { AdminState } from "../../../types.ts";
import { json, serverError, csrfCheck, requirePermission, requireTsxWrite, requireOwnedPage, validatePagePath } from "../_utils.ts";
import { announcePageEvent } from "../../../page-events.ts";
import type { FreshContext } from "fresh";

function splitFrontmatter(content: string): { fm: string; body: string } {
Expand Down Expand Up @@ -95,6 +96,7 @@ export const handler = {
}

await storage.write(`${contentDir}/${targetPath}`, new TextEncoder().encode(translatedFm + translatedBody));
announcePageEvent(ctx, { event: "onPageCreate", payload: { sourcePath: targetPath, title: pageIndex.title ?? "" } });
engine.rebuild().catch((err: unknown) => console.error("[dune] MT translate-page rebuild error:", err));

return json({ ok: true, targetPath });
Expand Down
7 changes: 7 additions & 0 deletions src/admin/routes/api/pages/reorder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import type { AdminState } from "../../../types.ts";
import { requirePermission, requireEditorOrAdmin, json, serverError, validatePagePath, csrfCheck } from "../_utils.ts";
import { dirname, basename } from "@std/path";
import { announcePageEvent } from "../../../page-events.ts";
import type { FreshContext } from "fresh";

export const handler = {
Expand Down Expand Up @@ -83,6 +84,12 @@ export const handler = {
}

await engine.rebuild();
// Content-relative folders that were renamed; every page below `from` moved below `to`.
const rel = (p: string) => p.startsWith(`${contentDir}/`) ? p.slice(contentDir.length + 1) : p;
announcePageEvent(ctx, {
event: "onPageMove",
payload: { moves: renames.map((r) => ({ from: rel(r.oldDir), to: rel(r.newDir) })) },
});
return json({ reordered: true });
} catch (err) {
return serverError(err);
Expand Down
2 changes: 2 additions & 0 deletions src/admin/routes/api/pages/translate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { AdminState } from "../../../types.ts";
import { requirePermission, requireTsxWrite, requireOwnedPage, json, serverError, csrfCheck, validatePagePath } from "../_utils.ts";
import { dirname, basename } from "@std/path";
import { parseContentFilename } from "@dune/core/content/path-utils";
import { announcePageEvent } from "../../../page-events.ts";
import type { FreshContext } from "fresh";

export const handler = {
Expand Down Expand Up @@ -48,6 +49,7 @@ export const handler = {

const sourceBytes = await storage.read(`${contentDir}/${pageIndex.sourcePath}`);
await storage.write(`${contentDir}/${targetPath}`, sourceBytes);
announcePageEvent(ctx, { event: "onPageCreate", payload: { sourcePath: targetPath, title: pageIndex.title ?? "" } });
await engine.rebuild();

return json({ created: true, path: targetPath });
Expand Down
2 changes: 2 additions & 0 deletions src/admin/routes/api/staging/[...path]/publish.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import type { AdminState } from "../../../../types.ts";
import { requirePermission, requireTsxWrite, requireOwnedPage, json, serverError, csrfCheck, validatePagePath } from "../../_utils.ts";
import { stringify as stringifyYaml } from "@std/yaml";
import { announcePageEvent } from "../../../../page-events.ts";
import type { FreshContext } from "fresh";

async function maybeGitCommit(
Expand Down Expand Up @@ -68,6 +69,7 @@ export const handler = {
await maybeGitCommit(filePath, pagePath, authResult.user?.name, config.admin?.git_commit);
await staging.discard(pagePath);
await engine.rebuild();
announcePageEvent(ctx, { event: "onPageUpdate", payload: { sourcePath: pageIndex.sourcePath } });

return json({ published: true, sourcePath: pagePath });
} catch (err) {
Expand Down
9 changes: 8 additions & 1 deletion src/collab/manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ function takeToken(rate: ConnectionRate): boolean {
}

export function createCollabManager(options: CollabManagerOptions): CollabManager {
const { storage, engine, history, contentDir, security } = options;
const { storage, engine, history, contentDir, security, hooks } = options;

// docId → CollabSessionState
const sessions = new Map<string, CollabSessionState>();
Expand Down Expand Up @@ -194,6 +194,13 @@ export function createCollabManager(options: CollabManagerOptions): CollabManage
return;
}

// Whoever writes a page fires its event. Skip a save that changed nothing.
if (hooks && updated !== raw) {
hooks.fire("onPageUpdate", { sourcePath: session.docId }).catch((err: unknown) =>
console.warn(`[collab] onPageUpdate hook failed for ${session.docId}:`, err)
);
}

if (history && session.lastEditor) {
const fm = parseFrontmatter(raw, security.parseUserYaml);
history.record({
Expand Down
5 changes: 5 additions & 0 deletions src/collab/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,11 @@ export interface CollabManagerOptions {
contentDir: string;
/** Core's security service (safe YAML parsing of frontmatter). */
security: Pick<import("@dune/core/plugin-api").SecurityService, "parseUserYaml">;
/**
* Hook registry. An auto-save that changes the file fires `onPageUpdate`
* (the save is already debounced, so this is not per keystroke).
*/
hooks?: Pick<import("@dune/core/hooks").HookRegistry, "fire">;
}

/** The CollabManager public interface exposed to the admin server. */
Expand Down
20 changes: 20 additions & 0 deletions tests/admin/conformance_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,4 +95,24 @@ runAdminConformance({
});
if (res.status !== 200) throw new Error(`plugin-admin edit failed: ${res.status} ${await res.text()}`);
},

async createPage(app, cookie, { sourcePath, title }) {
// The route takes the page folder and adds default.md itself.
const folder = sourcePath.replace(/^content\//, "").replace(/\/default\.md$/, "");
const res = await app.request("/admin/api/pages", {
method: "POST",
headers: { cookie, origin: ORIGIN, "content-type": "application/json" },
body: JSON.stringify({ path: folder, title }),
});
if (!res.ok) throw new Error(`plugin-admin create failed: ${res.status} ${await res.text()}`);
},

async deletePage(app, cookie, { sourcePath }) {
const relative = sourcePath.replace(/^content\//, "");
const res = await app.request(`/admin/api/pages/${relative}`, {
method: "DELETE",
headers: { cookie, origin: ORIGIN },
});
if (res.status !== 200) throw new Error(`plugin-admin delete failed: ${res.status} ${await res.text()}`);
},
});
15 changes: 15 additions & 0 deletions tests/admin/manifest_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,21 @@ Deno.test("staging and workflow status/scheduled routes are wildcard, not single
}
});

Deno.test("admin manifest registers a wildcard's longer sub-routes before the bare wildcard", () => {
// `/x/:path*` also matches `/x/a/b/publish`, so it has to come after
// `/x/:path*/publish` or the sub-route is unreachable.
const patterns = adminRoutes.map((r) => r.pattern);
const order: Array<[string, string]> = [
["/api/staging/:path*/publish", "/api/staging/:path*"],
["/api/pages/:path*/comments", "/api/pages/:path*"],
["/api/pages/:path*/comments/:id", "/api/pages/:path*/comments"],
["/api/pages/:path*/comments/:id/resolve", "/api/pages/:path*/comments/:id"],
];
for (const [first, second] of order) {
assertEquals(patterns.indexOf(first) < patterns.indexOf(second), true, `${first} must precede ${second}`);
}
});

Deno.test("admin manifest orders static segments before dynamic ones", () => {
const patterns = adminRoutes.map((r) => r.pattern);
// /api/pages/reorder (static) must be registered before /api/pages/:path*
Expand Down
Loading
Loading