-
Notifications
You must be signed in to change notification settings - Fork 917
fix(forms): render public form embeds via SSR plugin routes #985
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,6 @@ | ||
| --- | ||
| "emdash": patch | ||
| "@emdash-cms/plugin-forms": patch | ||
| --- | ||
|
|
||
| Fixes public form embeds during SSR by allowing frontend plugin components to call public plugin routes without self-fetching. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,41 @@ | ||
| import type { HandlerResponse } from "./types.js"; | ||
|
|
||
| export type PublicPluginApiRouteHandler = ( | ||
| pluginId: string, | ||
| method: string, | ||
| path: string, | ||
| request: Request, | ||
| ) => Promise<HandlerResponse>; | ||
|
|
||
| interface PublicPluginApiRouteRuntime { | ||
| getPluginRouteMeta(pluginId: string, path: string): { public: boolean } | null; | ||
| handlePluginApiRoute( | ||
| pluginId: string, | ||
| method: string, | ||
| path: string, | ||
| request: Request, | ||
| ): Promise<HandlerResponse>; | ||
| } | ||
|
|
||
| function pluginRouteNotFound(): HandlerResponse { | ||
| return { | ||
| success: false, | ||
| error: { | ||
| code: "NOT_FOUND", | ||
| message: "Plugin route not found", | ||
| }, | ||
| }; | ||
| } | ||
|
|
||
| export function createPublicPluginApiRouteHandler( | ||
| runtime: PublicPluginApiRouteRuntime, | ||
| ): PublicPluginApiRouteHandler { | ||
| return async (pluginId, method, path, request) => { | ||
| const meta = runtime.getPluginRouteMeta(pluginId, path); | ||
| if (meta?.public !== true) { | ||
| return pluginRouteNotFound(); | ||
| } | ||
|
|
||
| return runtime.handlePluginApiRoute(pluginId, method, path, request); | ||
| }; | ||
| } |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -11,6 +11,29 @@ const { DB_CONFIG_MARKER } = vi.hoisted(() => ({ | |||||||||||||||||||
| DB_CONFIG_MARKER: { binding: "DB", session: "auto" }, | ||||||||||||||||||||
| })); | ||||||||||||||||||||
|
|
||||||||||||||||||||
| const { MOCK_RUNTIME } = vi.hoisted(() => ({ | ||||||||||||||||||||
| MOCK_RUNTIME: new Proxy( | ||||||||||||||||||||
| { | ||||||||||||||||||||
| storage: null, | ||||||||||||||||||||
| db: {}, | ||||||||||||||||||||
| hooks: {}, | ||||||||||||||||||||
| email: null, | ||||||||||||||||||||
| configuredPlugins: [], | ||||||||||||||||||||
| }, | ||||||||||||||||||||
| { | ||||||||||||||||||||
| get(target, prop) { | ||||||||||||||||||||
| if (prop === "then") return undefined; | ||||||||||||||||||||
| if (prop in target) return target[prop as keyof typeof target]; | ||||||||||||||||||||
| if (prop === "getPluginRouteMeta") return () => ({ public: true }); | ||||||||||||||||||||
| if (prop === "handlePluginApiRoute") return async () => ({ success: true, data: {} }); | ||||||||||||||||||||
| if (prop === "collectPageMetadata") return async () => []; | ||||||||||||||||||||
| if (prop === "collectPageFragments") return async () => []; | ||||||||||||||||||||
| return async () => ({ success: true }); | ||||||||||||||||||||
| }, | ||||||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Minor: this
Suggested change
|
||||||||||||||||||||
| }, | ||||||||||||||||||||
| ), | ||||||||||||||||||||
| })); | ||||||||||||||||||||
|
|
||||||||||||||||||||
| vi.mock( | ||||||||||||||||||||
| "virtual:emdash/config", | ||||||||||||||||||||
| () => ({ | ||||||||||||||||||||
|
|
@@ -45,6 +68,12 @@ vi.mock("virtual:emdash/sandboxed-plugins", () => ({ sandboxedPlugins: [] }), { | |||||||||||||||||||
| vi.mock("virtual:emdash/storage", () => ({ createStorage: null }), { virtual: true }); | ||||||||||||||||||||
| vi.mock("virtual:emdash/wait-until", () => ({ waitUntil: undefined }), { virtual: true }); | ||||||||||||||||||||
|
|
||||||||||||||||||||
| vi.mock("../../../src/emdash-runtime.js", () => ({ | ||||||||||||||||||||
| EmDashRuntime: { | ||||||||||||||||||||
| create: async () => MOCK_RUNTIME, | ||||||||||||||||||||
| }, | ||||||||||||||||||||
| })); | ||||||||||||||||||||
|
|
||||||||||||||||||||
| vi.mock("../../../src/loader.js", () => ({ | ||||||||||||||||||||
| getDb: vi.fn(async () => ({ | ||||||||||||||||||||
| selectFrom: () => ({ | ||||||||||||||||||||
|
|
@@ -167,6 +196,45 @@ describe("astro middleware anonymous session reads", () => { | |||||||||||||||||||
| expect(sessionGet).not.toHaveBeenCalled(); | ||||||||||||||||||||
| }); | ||||||||||||||||||||
|
|
||||||||||||||||||||
| it("exposes only restricted public runtime helpers to anonymous public pages", async () => { | ||||||||||||||||||||
| const cookies = { | ||||||||||||||||||||
| get: vi.fn((name: string) => { | ||||||||||||||||||||
| if (name === "astro-session") return undefined; | ||||||||||||||||||||
| return undefined; | ||||||||||||||||||||
| }), | ||||||||||||||||||||
| set: vi.fn(), | ||||||||||||||||||||
| }; | ||||||||||||||||||||
| const sessionGet = vi.fn(async () => null); | ||||||||||||||||||||
| const astroSession = { get: sessionGet }; | ||||||||||||||||||||
| const locals: Record<string, unknown> = {}; | ||||||||||||||||||||
|
|
||||||||||||||||||||
| const context: Record<string, unknown> = { | ||||||||||||||||||||
| request: new Request("https://example.com/contact"), | ||||||||||||||||||||
| url: new URL("https://example.com/contact"), | ||||||||||||||||||||
| cookies, | ||||||||||||||||||||
| locals, | ||||||||||||||||||||
| redirect: vi.fn(), | ||||||||||||||||||||
| isPrerendered: false, | ||||||||||||||||||||
| session: astroSession, | ||||||||||||||||||||
| }; | ||||||||||||||||||||
|
|
||||||||||||||||||||
| const response = await onRequest( | ||||||||||||||||||||
| context as Parameters<typeof onRequest>[0], | ||||||||||||||||||||
| async () => new Response("ok"), | ||||||||||||||||||||
| ); | ||||||||||||||||||||
|
|
||||||||||||||||||||
| expect(response.status).toBe(200); | ||||||||||||||||||||
| expect(sessionGet).not.toHaveBeenCalled(); | ||||||||||||||||||||
| const emdash = locals.emdash as Record<string, unknown>; | ||||||||||||||||||||
| expect(typeof emdash.handlePluginApiRoute).toBe("function"); | ||||||||||||||||||||
| expect(typeof emdash.collectPageMetadata).toBe("function"); | ||||||||||||||||||||
| expect(typeof emdash.collectPageFragments).toBe("function"); | ||||||||||||||||||||
| expect("getPluginRouteMeta" in emdash).toBe(false); | ||||||||||||||||||||
| expect("handleContentList" in emdash).toBe(false); | ||||||||||||||||||||
| expect("db" in emdash).toBe(false); | ||||||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Two small gaps in the assertions here:
|
||||||||||||||||||||
| expect("config" in emdash).toBe(false); | ||||||||||||||||||||
| }); | ||||||||||||||||||||
|
|
||||||||||||||||||||
| it("reads the Astro session when an astro-session cookie is present", async () => { | ||||||||||||||||||||
| const cookies = { | ||||||||||||||||||||
| get: vi.fn((name: string) => { | ||||||||||||||||||||
|
|
||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,74 @@ | ||
| import { describe, expect, it, vi } from "vitest"; | ||
|
|
||
| import { createPublicPluginApiRouteHandler } from "../../../src/astro/public-plugin-api-routes.js"; | ||
|
|
||
| function createRuntime(meta: { public: boolean } | null) { | ||
| const result = { success: true, data: { ok: true } }; | ||
| const handlePluginApiRoute = vi.fn(async () => result); | ||
| const getPluginRouteMeta = vi.fn(() => meta); | ||
|
|
||
| return { | ||
| runtime: { | ||
| getPluginRouteMeta, | ||
| handlePluginApiRoute, | ||
| }, | ||
| getPluginRouteMeta, | ||
| handlePluginApiRoute, | ||
| result, | ||
| }; | ||
| } | ||
|
|
||
| describe("createPublicPluginApiRouteHandler", () => { | ||
| it("delegates to the runtime when the plugin route is public", async () => { | ||
| const { runtime, getPluginRouteMeta, handlePluginApiRoute, result } = createRuntime({ | ||
| public: true, | ||
| }); | ||
| const request = new Request("https://example.com/_emdash/api/plugins/demo/definition", { | ||
| method: "POST", | ||
| body: "{}", | ||
| }); | ||
|
|
||
| const handler = createPublicPluginApiRouteHandler(runtime); | ||
| const actual = await handler("demo", "POST", "/definition", request); | ||
|
|
||
| expect(getPluginRouteMeta).toHaveBeenCalledWith("demo", "/definition"); | ||
| expect(handlePluginApiRoute).toHaveBeenCalledWith("demo", "POST", "/definition", request); | ||
| expect(actual).toBe(result); | ||
| }); | ||
|
|
||
| it("returns not found without invoking private plugin routes", async () => { | ||
| const { runtime, handlePluginApiRoute } = createRuntime({ public: false }); | ||
| const handler = createPublicPluginApiRouteHandler(runtime); | ||
|
|
||
| const result = await handler( | ||
| "demo", | ||
| "POST", | ||
| "/admin", | ||
| new Request("https://example.com/_emdash/api/plugins/demo/admin"), | ||
| ); | ||
|
|
||
| expect(handlePluginApiRoute).not.toHaveBeenCalled(); | ||
| expect(result).toEqual({ | ||
| success: false, | ||
| error: { | ||
| code: "NOT_FOUND", | ||
| message: "Plugin route not found", | ||
| }, | ||
| }); | ||
| }); | ||
|
|
||
| it("returns not found without invoking missing plugin routes", async () => { | ||
| const { runtime, handlePluginApiRoute } = createRuntime(null); | ||
| const handler = createPublicPluginApiRouteHandler(runtime); | ||
|
|
||
| const result = await handler( | ||
| "demo", | ||
| "POST", | ||
| "/missing", | ||
| new Request("https://example.com/_emdash/api/plugins/demo/missing"), | ||
| ); | ||
|
|
||
| expect(handlePluginApiRoute).not.toHaveBeenCalled(); | ||
| expect(result.error?.code).toBe("NOT_FOUND"); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -6,7 +6,10 @@ | |
| * Without JavaScript, all pages are visible as one long form. | ||
| * The client-side script enhances with multi-page navigation, AJAX, etc. | ||
| */ | ||
| import { parsePublicFormDefinitionResponse } from "../public-definition.js"; | ||
| import { | ||
| loadPublicFormDefinition, | ||
| type PublicPluginApiRouteHandler, | ||
| } from "../public-definition.js"; | ||
| import type { FormField, FormPage } from "../types.js"; | ||
|
|
||
| interface Props { | ||
|
|
@@ -16,17 +19,15 @@ interface Props { | |
| const { node } = Astro.props; | ||
| const formId = node.formId; | ||
|
|
||
| // Fetch form definition server-side | ||
| const response = await fetch( | ||
| new URL("/_emdash/api/plugins/emdash-forms/definition", Astro.url), | ||
| { | ||
| method: "POST", | ||
| headers: { "Content-Type": "application/json" }, | ||
| body: JSON.stringify({ id: formId }), | ||
| } | ||
| ); | ||
| const handlePluginApiRoute = (Astro.locals.emdash as | ||
| | { handlePluginApiRoute?: PublicPluginApiRouteHandler } | ||
| | undefined)?.handlePluginApiRoute; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Minor type-safety note: this cast ( Consider exporting a typed accessor from |
||
|
|
||
| const form = await parsePublicFormDefinitionResponse(response); | ||
| const form = await loadPublicFormDefinition({ | ||
| formId, | ||
| baseUrl: Astro.url, | ||
| handlePluginApiRoute, | ||
| }); | ||
| if (!form) return; | ||
|
|
||
| const submitUrl = `/_emdash/api/plugins/emdash-forms/submit`; | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -14,6 +14,47 @@ export interface PublicFormDefinition { | |||||||||||||||
| _turnstileSiteKey?: string | null; | ||||||||||||||||
| } | ||||||||||||||||
|
|
||||||||||||||||
| export type PublicPluginApiRouteHandler = ( | ||||||||||||||||
| pluginId: string, | ||||||||||||||||
| method: string, | ||||||||||||||||
| path: string, | ||||||||||||||||
| request: Request, | ||||||||||||||||
| ) => Promise<unknown>; | ||||||||||||||||
|
|
||||||||||||||||
| interface LoadPublicFormDefinitionOptions { | ||||||||||||||||
| formId: string; | ||||||||||||||||
| baseUrl: URL; | ||||||||||||||||
| handlePluginApiRoute?: PublicPluginApiRouteHandler; | ||||||||||||||||
| fetch?: (input: Request) => Promise<Response>; | ||||||||||||||||
| } | ||||||||||||||||
|
|
||||||||||||||||
| function isObject(value: unknown): value is Record<string, unknown> { | ||||||||||||||||
| return typeof value === "object" && value !== null; | ||||||||||||||||
| } | ||||||||||||||||
|
|
||||||||||||||||
| export function parsePublicFormDefinitionPayload(payload: unknown): PublicFormDefinition | null { | ||||||||||||||||
| if (!isObject(payload)) { | ||||||||||||||||
| return null; | ||||||||||||||||
| } | ||||||||||||||||
|
|
||||||||||||||||
| if ("success" in payload) { | ||||||||||||||||
| if (payload.success !== true) { | ||||||||||||||||
| return null; | ||||||||||||||||
| } | ||||||||||||||||
| return parsePublicFormDefinitionPayload(payload.data); | ||||||||||||||||
| } | ||||||||||||||||
|
|
||||||||||||||||
| if ("data" in payload) { | ||||||||||||||||
| return parsePublicFormDefinitionPayload(payload.data); | ||||||||||||||||
| } | ||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Minor: the |
||||||||||||||||
|
|
||||||||||||||||
| if (payload.status !== "active") { | ||||||||||||||||
| return null; | ||||||||||||||||
| } | ||||||||||||||||
|
|
||||||||||||||||
| return payload as unknown as PublicFormDefinition; | ||||||||||||||||
| } | ||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Not introduced by this PR (the old code did the same), but you're now reaching this path from a new entry point ( |
||||||||||||||||
|
|
||||||||||||||||
| export async function parsePublicFormDefinitionResponse( | ||||||||||||||||
| response: Response, | ||||||||||||||||
| ): Promise<PublicFormDefinition | null> { | ||||||||||||||||
|
|
@@ -22,9 +63,39 @@ export async function parsePublicFormDefinitionResponse( | |||||||||||||||
| } | ||||||||||||||||
|
|
||||||||||||||||
| const form = await parseApiResponse<PublicFormDefinition | undefined>(response); | ||||||||||||||||
| if (!form || form.status !== "active") { | ||||||||||||||||
| return null; | ||||||||||||||||
| return parsePublicFormDefinitionPayload(form); | ||||||||||||||||
| } | ||||||||||||||||
|
|
||||||||||||||||
| function createPublicFormDefinitionRequest(formId: string, baseUrl: URL): Request { | ||||||||||||||||
| return new Request(new URL("/_emdash/api/plugins/emdash-forms/definition", baseUrl), { | ||||||||||||||||
| method: "POST", | ||||||||||||||||
| headers: { "Content-Type": "application/json" }, | ||||||||||||||||
| body: JSON.stringify({ id: formId }), | ||||||||||||||||
| }); | ||||||||||||||||
| } | ||||||||||||||||
|
|
||||||||||||||||
| export async function loadPublicFormDefinition({ | ||||||||||||||||
| formId, | ||||||||||||||||
| baseUrl, | ||||||||||||||||
| handlePluginApiRoute, | ||||||||||||||||
| fetch: fetchImpl = fetch, | ||||||||||||||||
| }: LoadPublicFormDefinitionOptions): Promise<PublicFormDefinition | null> { | ||||||||||||||||
| if (handlePluginApiRoute) { | ||||||||||||||||
| try { | ||||||||||||||||
| return parsePublicFormDefinitionPayload( | ||||||||||||||||
| await handlePluginApiRoute( | ||||||||||||||||
| "emdash-forms", | ||||||||||||||||
| "POST", | ||||||||||||||||
| "/definition", | ||||||||||||||||
| createPublicFormDefinitionRequest(formId, baseUrl), | ||||||||||||||||
| ), | ||||||||||||||||
| ); | ||||||||||||||||
| } catch { | ||||||||||||||||
| // Fall back to HTTP fetch for older runtimes or unexpected dispatcher failures. | ||||||||||||||||
| } | ||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The silent At minimum, log the error so it's grep-able in production. Consider also distinguishing "genuine fallback for older runtimes" (no dispatcher present at all, handled by the outer
Suggested change
|
||||||||||||||||
| } | ||||||||||||||||
|
|
||||||||||||||||
| return form; | ||||||||||||||||
| return parsePublicFormDefinitionResponse( | ||||||||||||||||
| await fetchImpl(createPublicFormDefinitionRequest(formId, baseUrl)), | ||||||||||||||||
| ); | ||||||||||||||||
| } | ||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
After this PR,
Astro.locals.emdash.handlePluginApiRoutemeans two different things depending on which branch of the middleware runs:getPluginRouteMeta(...).public === true.runtime.handlePluginApiRoute.bind(runtime)— the raw method, which will happily invoke private plugin routes with no auth / CSRF / scope check.That's a meaningful difference. A plugin author calling this from a public Astro page is going to discover the difference the hard way when an editor visits the same page and suddenly more routes are reachable. The PR description claims "the core helper gates every internal dispatch on
getPluginRouteMeta(...).public === true" — that's only true on the anonymous path.A couple of options, roughly in order of cost:
FormEmbed, which only needs public routes anyway). That makes the property uniformly safe.dispatchPublicPluginRouteso the asymmetry is explicit in the type.Option 1 seems cleanest unless there's a known consumer of the raw method on the full path.