From d10f91815e63f169cff3d1daef5505aef077c76c Mon Sep 17 00:00:00 2001 From: Ben Holmes Date: Fri, 8 Nov 2024 17:03:57 -0500 Subject: [PATCH] Actions middleware (#12373) * add manual middleware config option with getMiddlewareContext() * refactor requestInfo to action object * set action error response status from render context * update automatic middleware to plain POST handler * fix missing Locals type * test: add separate POST and cookie forwarding tests * remove actions.middleware flag * add docs on actionResultAlreadySet * test: use Astro.rewrite instead of middleware next(). TODO: fix next() * fix type errors from rebase * test: remove middleware handler * test: use cookie forwarding for 'lots of fields' * refactor: _isPrerendered -> ctx.isPrerendered * expose getOriginPathname as middleware utility * add support for handling RPC action results from middleware * test: RPC security middleware * refactor POST route handler to use getMiddlewareContext() * remove unused actionRedirect flag * changeset * test: add expectedd rewrite failure for Ema to debug * fix e2e test * nit: form -> from Co-authored-by: Emanuele Stoppa * rename getMiddlewareContext -> getActionContext * rename form-action -> form * move /_actions/ route pattern to const * move type defs to user-accessible ActionMiddlewareContext type * export action middleware context type * strip omitted fields for Action API Context * add satisfies to type for good measure * move getOriginPathname to shared ctx.originPathname * remove `next()` rewrite because it isn't supported * fix empty forms raising a 415 * fix missing async on cookie example * nit: ctx -> context * fix json parse error when content length is 0 * refactor body parsing to function * edit: migration -> updating your HTML form actions Co-authored-by: Sarah Rainsberger * update changeset to match docs v5 guide * add absolute urls to changeset links --------- Co-authored-by: Emanuele Stoppa Co-authored-by: Sarah Rainsberger --- .changeset/tall-waves-impress.md | 62 +++++++ packages/astro/e2e/actions-blog.test.js | 4 +- .../fixtures/actions-blog/astro.config.mjs | 2 +- .../actions-blog/src/actions/index.ts | 12 +- .../fixtures/actions-blog/src/middleware.ts | 43 ++++- .../src/pages/blog/[...slug].astro | 12 +- .../src/pages/lots-of-fields.astro | 72 ++++---- .../actions-blog/src/pages/rewritten.astro | 8 +- .../fixtures/actions-blog/src/pages/sum.astro | 3 + packages/astro/src/actions/consts.ts | 3 +- packages/astro/src/actions/integration.ts | 4 +- .../astro/src/actions/runtime/middleware.ts | 169 +----------------- packages/astro/src/actions/runtime/route.ts | 35 +--- packages/astro/src/actions/runtime/utils.ts | 10 ++ .../src/actions/runtime/virtual/client.ts | 4 + .../src/actions/runtime/virtual/get-action.ts | 5 +- .../src/actions/runtime/virtual/server.ts | 129 ++++++++++++- packages/astro/src/actions/utils.ts | 3 +- packages/astro/src/core/middleware/index.ts | 6 +- packages/astro/src/core/render-context.ts | 19 +- packages/astro/src/core/routing/rewrite.ts | 4 +- packages/astro/src/types/public/context.ts | 5 + packages/astro/templates/actions.mjs | 11 +- packages/astro/test/actions.test.js | 72 +++++++- .../fixtures/actions/src/actions/index.ts | 43 +++-- .../test/fixtures/actions/src/middleware.ts | 56 +++++- 26 files changed, 484 insertions(+), 312 deletions(-) create mode 100644 .changeset/tall-waves-impress.md create mode 100644 packages/astro/e2e/fixtures/actions-blog/src/pages/sum.astro diff --git a/.changeset/tall-waves-impress.md b/.changeset/tall-waves-impress.md new file mode 100644 index 0000000000..aecf307e02 --- /dev/null +++ b/.changeset/tall-waves-impress.md @@ -0,0 +1,62 @@ +--- +'astro': minor +--- + +Changes the default behavior for Astro Action form requests to a standard POST submission. + +In Astro 4.x, actions called from an HTML form would trigger a redirect with the result forwarded using cookies. This caused issues for large form errors and return values that exceeded the 4 KB limit of cookie-based storage. + +Astro 5.0 now renders the result of an action as a POST result without any forwarding. This will introduce a "confirm form resubmission?" dialog when a user attempts to refresh the page, though it no longer imposes a 4 KB limit on action return value. + +## Customize form submission behavior + +If you prefer to address the "confirm form resubmission?" dialog on refresh, or to preserve action results across sessions, you can now [customize action result handling from middleware](https://5-0-0-beta.docs.astro.build/en/guides/actions/#advanced-persist-action-results-with-a-session). + +We recommend using a session storage provider [as described in our Netlify Blob example](https://5-0-0-beta.docs.astro.build/en/guides/actions/#advanced-persist-action-results-with-a-session). However, if you prefer the cookie forwarding behavior from 4.X and accept the 4 KB size limit, you can implement the pattern as shown in this sample snippet: + +```ts +// src/middleware.ts +import { defineMiddleware } from 'astro:middleware'; +import { getActionContext } from 'astro:actions'; + +export const onRequest = defineMiddleware(async (context, next) => { + // Skip requests for prerendered pages + if (context.isPrerendered) return next(); + + const { action, setActionResult, serializeActionResult } = getActionContext(context); + + // If an action result was forwarded as a cookie, set the result + // to be accessible from `Astro.getActionResult()` + const payload = context.cookies.get('ACTION_PAYLOAD'); + if (payload) { + const { actionName, actionResult } = payload.json(); + setActionResult(actionName, actionResult); + context.cookies.delete('ACTION_PAYLOAD'); + return next(); + } + + // If an action was called from an HTML form action, + // call the action handler and redirect with the result as a cookie. + if (action?.calledFrom === 'form') { + const actionResult = await action.handler(); + + context.cookies.set('ACTION_PAYLOAD', { + actionName: action.name, + actionResult: serializeActionResult(actionResult), + }); + + if (actionResult.error) { + // Redirect back to the previous page on error + const referer = context.request.headers.get('Referer'); + if (!referer) { + throw new Error('Internal: Referer unexpectedly missing from Action POST request.'); + } + return context.redirect(referer); + } + // Redirect to the destination page on success + return context.redirect(context.originPathname); + } + + return next(); +}) +``` diff --git a/packages/astro/e2e/actions-blog.test.js b/packages/astro/e2e/actions-blog.test.js index a8d9a7fc67..7d362334e3 100644 --- a/packages/astro/e2e/actions-blog.test.js +++ b/packages/astro/e2e/actions-blog.test.js @@ -155,14 +155,14 @@ test.describe('Astro Actions - Blog', () => { await expect(page).toHaveURL(astro.resolveUrl('/blog/')); }); - test('Should redirect to the origin pathname when there is a rewrite', async ({ + test('Should redirect to the origin pathname when there is a rewrite from an Astro page', async ({ page, astro, }) => { await page.goto(astro.resolveUrl('/sum')); const submitButton = page.getByTestId('submit'); await submitButton.click(); - await expect(page).toHaveURL(astro.resolveUrl('/sum')); + await expect(page).toHaveURL(astro.resolveUrl('/sum?_astroAction=sum')); const p = page.locator('p').nth(0); await expect(p).toContainText('Form result: {"data":3}'); }); diff --git a/packages/astro/e2e/fixtures/actions-blog/astro.config.mjs b/packages/astro/e2e/fixtures/actions-blog/astro.config.mjs index 35f7481f23..c00c4da70d 100644 --- a/packages/astro/e2e/fixtures/actions-blog/astro.config.mjs +++ b/packages/astro/e2e/fixtures/actions-blog/astro.config.mjs @@ -7,7 +7,7 @@ import node from '@astrojs/node'; export default defineConfig({ site: 'https://example.com', integrations: [db(), react()], - output: 'static', + output: 'server', adapter: node({ mode: 'standalone', }), diff --git a/packages/astro/e2e/fixtures/actions-blog/src/actions/index.ts b/packages/astro/e2e/fixtures/actions-blog/src/actions/index.ts index f3e1f248e5..01b479b2b9 100644 --- a/packages/astro/e2e/fixtures/actions-blog/src/actions/index.ts +++ b/packages/astro/e2e/fixtures/actions-blog/src/actions/index.ts @@ -68,21 +68,21 @@ export const server = { seven: z.string().min(3), eight: z.string().min(3), nine: z.string().min(3), - ten: z.string().min(3) + ten: z.string().min(3), }), handler(form) { return form; - } - }) + }, + }), }, sum: defineAction({ - accept: "form", + accept: 'form', input: z.object({ a: z.number(), b: z.number(), }), async handler({ a, b }) { - return a + b + return a + b; }, - }) + }), }; diff --git a/packages/astro/e2e/fixtures/actions-blog/src/middleware.ts b/packages/astro/e2e/fixtures/actions-blog/src/middleware.ts index 53bb8235ac..9e259f4ee9 100644 --- a/packages/astro/e2e/fixtures/actions-blog/src/middleware.ts +++ b/packages/astro/e2e/fixtures/actions-blog/src/middleware.ts @@ -1,9 +1,38 @@ -import { defineMiddleware } from "astro:middleware"; +import { defineMiddleware } from 'astro:middleware'; +import { getActionContext } from 'astro:actions'; -export const onRequest = defineMiddleware((ctx, next) => { - if (ctx.request.method === "GET" && ctx.url.pathname === "/sum") { - return next("/rewritten") +const actionCookieForwarding = defineMiddleware(async (ctx, next) => { + if (ctx.isPrerendered) return next(); + + const { action, setActionResult, serializeActionResult } = getActionContext(ctx); + + const payload = ctx.cookies.get('ACTION_PAYLOAD'); + if (payload) { + const { actionName, actionResult } = payload.json(); + setActionResult(actionName, actionResult); + ctx.cookies.delete('ACTION_PAYLOAD'); + return next(); } - - return next() -}) + + if (action?.calledFrom === 'form' && ctx.url.searchParams.has('actionCookieForwarding')) { + const actionResult = await action.handler(); + + ctx.cookies.set('ACTION_PAYLOAD', { + actionName: action.name, + actionResult: serializeActionResult(actionResult), + }); + + if (actionResult.error) { + const referer = ctx.request.headers.get('Referer'); + if (!referer) { + throw new Error('Internal: Referer unexpectedly missing from Action POST request.'); + } + return ctx.redirect(referer); + } + return ctx.redirect(ctx.originPathname); + } + + return next(); +}); + +export const onRequest = actionCookieForwarding; diff --git a/packages/astro/e2e/fixtures/actions-blog/src/pages/blog/[...slug].astro b/packages/astro/e2e/fixtures/actions-blog/src/pages/blog/[...slug].astro index ad4aea521a..9dc5404d64 100644 --- a/packages/astro/e2e/fixtures/actions-blog/src/pages/blog/[...slug].astro +++ b/packages/astro/e2e/fixtures/actions-blog/src/pages/blog/[...slug].astro @@ -1,5 +1,5 @@ --- -import { type CollectionEntry, getCollection, getEntry } from 'astro:content'; +import { type CollectionEntry, getEntry } from 'astro:content'; import BlogPost from '../../layouts/BlogPost.astro'; import { Logout } from '../../components/Logout'; import { db, eq, Comment, Likes } from 'astro:db'; @@ -8,16 +8,6 @@ import { PostComment } from '../../components/PostComment'; import { actions } from 'astro:actions'; import { isInputError } from 'astro:actions'; -export const prerender = false; - -export async function getStaticPaths() { - const posts = await getCollection('blog'); - return posts.map((post) => ({ - params: { slug: post.slug }, - props: post, - })); -} - type Props = CollectionEntry<'blog'>; const post = await getEntry('blog', Astro.params.slug)!; diff --git a/packages/astro/e2e/fixtures/actions-blog/src/pages/lots-of-fields.astro b/packages/astro/e2e/fixtures/actions-blog/src/pages/lots-of-fields.astro index 2b78aee1b8..e260e3b395 100644 --- a/packages/astro/e2e/fixtures/actions-blog/src/pages/lots-of-fields.astro +++ b/packages/astro/e2e/fixtures/actions-blog/src/pages/lots-of-fields.astro @@ -6,38 +6,42 @@ const result = Astro.getActionResult(actions.blog.lotsOfStuff); --- - - Actions - - - -
- - {result?.error?.fields.one} - - {result?.error?.fields.two} - - {result?.error?.fields.three} - - {result?.error?.fields.four} - - {result?.error?.fields.five} - - {result?.error?.fields.six} - - {result?.error?.fields.seven} - - {result?.error?.fields.eight} - - {result?.error?.fields.nine} - - {result?.error?.fields.ten} - -
- + + Actions + + + +
+ + {result?.error?.fields.one} + + {result?.error?.fields.two} + + {result?.error?.fields.three} + + {result?.error?.fields.four} + + {result?.error?.fields.five} + + {result?.error?.fields.six} + + {result?.error?.fields.seven} + + {result?.error?.fields.eight} + + {result?.error?.fields.nine} + + {result?.error?.fields.ten} + +
+ diff --git a/packages/astro/e2e/fixtures/actions-blog/src/pages/rewritten.astro b/packages/astro/e2e/fixtures/actions-blog/src/pages/rewritten.astro index 72eebf1bb0..0a41c1a8f0 100644 --- a/packages/astro/e2e/fixtures/actions-blog/src/pages/rewritten.astro +++ b/packages/astro/e2e/fixtures/actions-blog/src/pages/rewritten.astro @@ -1,8 +1,7 @@ --- -import { actions } from "astro:actions"; - -const result = Astro.getActionResult(actions.sum) +import { actions } from 'astro:actions'; +const result = Astro.getActionResult(actions.sum); --- @@ -13,6 +12,5 @@ const result = Astro.getActionResult(actions.sum)

Form result: {JSON.stringify(result)}

- - + diff --git a/packages/astro/e2e/fixtures/actions-blog/src/pages/sum.astro b/packages/astro/e2e/fixtures/actions-blog/src/pages/sum.astro new file mode 100644 index 0000000000..46856b4ff0 --- /dev/null +++ b/packages/astro/e2e/fixtures/actions-blog/src/pages/sum.astro @@ -0,0 +1,3 @@ +--- +return Astro.rewrite('/rewritten' + Astro.url.search); +--- diff --git a/packages/astro/src/actions/consts.ts b/packages/astro/src/actions/consts.ts index 6a55386d86..35137df59a 100644 --- a/packages/astro/src/actions/consts.ts +++ b/packages/astro/src/actions/consts.ts @@ -8,5 +8,6 @@ export const NOOP_ACTIONS = '\0noop-actions'; export const ACTION_QUERY_PARAMS = { actionName: '_astroAction', actionPayload: '_astroActionPayload', - actionRedirect: '_astroActionRedirect', }; + +export const ACTION_RPC_ROUTE_PATTERN = '/_actions/[...path]'; diff --git a/packages/astro/src/actions/integration.ts b/packages/astro/src/actions/integration.ts index 830420836a..23fbd904a5 100644 --- a/packages/astro/src/actions/integration.ts +++ b/packages/astro/src/actions/integration.ts @@ -3,7 +3,7 @@ import { AstroError } from '../core/errors/errors.js'; import { viteID } from '../core/util.js'; import type { AstroSettings } from '../types/astro.js'; import type { AstroIntegration } from '../types/public/integrations.js'; -import { ACTIONS_TYPES_FILE, VIRTUAL_MODULE_ID } from './consts.js'; +import { ACTIONS_TYPES_FILE, VIRTUAL_MODULE_ID, ACTION_RPC_ROUTE_PATTERN } from './consts.js'; /** * This integration is applied when the user is using Actions in their project. @@ -19,7 +19,7 @@ export default function astroIntegrationActionsRouteHandler({ hooks: { async 'astro:config:setup'(params) { params.injectRoute({ - pattern: '/_actions/[...path]', + pattern: ACTION_RPC_ROUTE_PATTERN, entrypoint: 'astro/actions/runtime/route.js', prerender: false, }); diff --git a/packages/astro/src/actions/runtime/middleware.ts b/packages/astro/src/actions/runtime/middleware.ts index 881169cb47..47adc29454 100644 --- a/packages/astro/src/actions/runtime/middleware.ts +++ b/packages/astro/src/actions/runtime/middleware.ts @@ -1,166 +1,13 @@ -import { decodeBase64, encodeBase64 } from '@oslojs/encoding'; -import { yellow } from 'kleur/colors'; -import { defineMiddleware } from '../../core/middleware/index.js'; -import { getOriginPathname } from '../../core/routing/rewrite.js'; -import type { MiddlewareNext } from '../../types/public/common.js'; -import type { APIContext } from '../../types/public/context.js'; -import { ACTION_QUERY_PARAMS } from '../consts.js'; -import { formContentTypes, hasContentType } from './utils.js'; -import { getAction } from './virtual/get-action.js'; -import { - type SafeResult, - type SerializedActionResult, - serializeActionResult, -} from './virtual/shared.js'; - -export type ActionPayload = { - actionResult: SerializedActionResult; - actionName: string; -}; - -export type Locals = { - _actionPayload: ActionPayload; -}; - -const decoder = new TextDecoder(); -const encoder = new TextEncoder(); +import { defineMiddleware } from '../../virtual-modules/middleware.js'; +import { getActionContext } from './virtual/server.js'; export const onRequest = defineMiddleware(async (context, next) => { - if (context.isPrerendered) { - if (context.request.method === 'POST') { - console.warn( - yellow('[astro:actions]'), - "POST requests should not be sent to prerendered pages. If you're using Actions, disable prerendering with `export const prerender = false`.", - ); - } - return next(); + if (context.isPrerendered) return next(); + const { action, setActionResult, serializeActionResult } = getActionContext(context); + + if (action?.calledFrom === 'form') { + const actionResult = await action.handler(); + setActionResult(action.name, serializeActionResult(actionResult)); } - - const locals = context.locals as Locals; - // Actions middleware may have run already after a path rewrite. - // See https://github.com/withastro/roadmap/blob/main/proposals/0048-rerouting.md#ctxrewrite - // `_actionPayload` is the same for every page, - // so short circuit if already defined. - if (locals._actionPayload) return next(); - - const actionPayloadCookie = context.cookies.get(ACTION_QUERY_PARAMS.actionPayload)?.value; - if (actionPayloadCookie) { - const actionPayload = JSON.parse(decoder.decode(decodeBase64(actionPayloadCookie))); - - if (!isActionPayload(actionPayload)) { - throw new Error('Internal: Invalid action payload in cookie.'); - } - return renderResult({ context, next, ...actionPayload }); - } - - const actionName = context.url.searchParams.get(ACTION_QUERY_PARAMS.actionName); - - if (context.request.method === 'POST' && actionName) { - return handlePost({ context, next, actionName }); - } - return next(); }); - -async function renderResult({ - context, - next, - actionResult, - actionName, -}: { - context: APIContext; - next: MiddlewareNext; - actionResult: SerializedActionResult; - actionName: string; -}) { - const locals = context.locals as Locals; - - locals._actionPayload = { actionResult, actionName }; - const response = await next(); - context.cookies.delete(ACTION_QUERY_PARAMS.actionPayload); - - if (actionResult.type === 'error') { - return new Response(response.body, { - status: actionResult.status, - statusText: actionResult.type, - headers: response.headers, - }); - } - return response; -} - -async function handlePost({ - context, - next, - actionName, -}: { - context: APIContext; - next: MiddlewareNext; - actionName: string; -}) { - const { request } = context; - const baseAction = await getAction(actionName); - - const contentType = request.headers.get('content-type'); - let formData: FormData | undefined; - if (contentType && hasContentType(contentType, formContentTypes)) { - formData = await request.clone().formData(); - } - const { getActionResult, callAction, props, redirect, ...actionAPIContext } = context; - const action = baseAction.bind(actionAPIContext); - const actionResult = await action(formData); - - if (context.url.searchParams.get(ACTION_QUERY_PARAMS.actionRedirect) === 'false') { - return renderResult({ - context, - next, - actionName, - actionResult: serializeActionResult(actionResult), - }); - } - - return redirectWithResult({ context, actionName, actionResult }); -} - -async function redirectWithResult({ - context, - actionName, - actionResult, -}: { - context: APIContext; - actionName: string; - actionResult: SafeResult; -}) { - const cookieValue = encodeBase64( - encoder.encode( - JSON.stringify({ - actionName: actionName, - actionResult: serializeActionResult(actionResult), - }), - ), - ); - context.cookies.set(ACTION_QUERY_PARAMS.actionPayload, cookieValue); - - if (actionResult.error) { - const referer = context.request.headers.get('Referer'); - if (!referer) { - throw new Error('Internal: Referer unexpectedly missing from Action POST request.'); - } - return context.redirect(referer); - } - - const referer = getOriginPathname(context.request); - if (referer) { - return context.redirect(referer); - } - - return context.redirect(context.url.pathname); -} - -function isActionPayload(json: unknown): json is ActionPayload { - if (typeof json !== 'object' || json == null) return false; - - if (!('actionResult' in json) || typeof json.actionResult !== 'object') return false; - if (!('actionName' in json) || typeof json.actionName !== 'string') return false; - return true; -} diff --git a/packages/astro/src/actions/runtime/route.ts b/packages/astro/src/actions/runtime/route.ts index 07e06ee9e6..c7522328d2 100644 --- a/packages/astro/src/actions/runtime/route.ts +++ b/packages/astro/src/actions/runtime/route.ts @@ -1,35 +1,14 @@ import type { APIRoute } from '../../types/public/common.js'; -import { formContentTypes, hasContentType } from './utils.js'; -import { getAction } from './virtual/get-action.js'; -import { serializeActionResult } from './virtual/shared.js'; +import { getActionContext } from './virtual/server.js'; export const POST: APIRoute = async (context) => { - const { request, url } = context; - let baseAction; - try { - baseAction = await getAction(url.pathname); - } catch (e) { - if (import.meta.env.DEV) throw e; - console.error(e); - return new Response(e instanceof Error ? e.message : null, { status: 404 }); + const { action, serializeActionResult } = getActionContext(context); + + if (action?.calledFrom !== 'rpc') { + return new Response('Not found', { status: 404 }); } - const contentType = request.headers.get('Content-Type'); - const contentLength = request.headers.get('Content-Length'); - let args: unknown; - if (!contentType || contentLength === '0') { - args = undefined; - } else if (contentType && hasContentType(contentType, formContentTypes)) { - args = await request.clone().formData(); - } else if (contentType && hasContentType(contentType, ['application/json'])) { - args = await request.clone().json(); - } else { - // 415: Unsupported media type - // https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/415 - return new Response(null, { status: 415 }); - } - const { getActionResult, callAction, props, redirect, ...actionAPIContext } = context; - const action = baseAction.bind(actionAPIContext); - const result = await action(args); + + const result = await action.handler(); const serialized = serializeActionResult(result); if (serialized.type === 'empty') { diff --git a/packages/astro/src/actions/runtime/utils.ts b/packages/astro/src/actions/runtime/utils.ts index d8b339a093..7d6a217305 100644 --- a/packages/astro/src/actions/runtime/utils.ts +++ b/packages/astro/src/actions/runtime/utils.ts @@ -1,4 +1,14 @@ import type { APIContext } from '../../types/public/context.js'; +import type { SerializedActionResult } from './virtual/shared.js'; + +export type ActionPayload = { + actionResult: SerializedActionResult; + actionName: string; +}; + +export type Locals = { + _actionPayload: ActionPayload; +}; export const formContentTypes = ['application/x-www-form-urlencoded', 'multipart/form-data']; diff --git a/packages/astro/src/actions/runtime/virtual/client.ts b/packages/astro/src/actions/runtime/virtual/client.ts index c80e6778ae..68407f4cbf 100644 --- a/packages/astro/src/actions/runtime/virtual/client.ts +++ b/packages/astro/src/actions/runtime/virtual/client.ts @@ -3,3 +3,7 @@ export * from './shared.js'; export function defineAction() { throw new Error('[astro:action] `defineAction()` unexpectedly used on the client.'); } + +export function getActionContext() { + throw new Error('[astro:action] `getActionContext()` unexpectedly used on the client.'); +} diff --git a/packages/astro/src/actions/runtime/virtual/get-action.ts b/packages/astro/src/actions/runtime/virtual/get-action.ts index 7cd260f866..a11e72fc48 100644 --- a/packages/astro/src/actions/runtime/virtual/get-action.ts +++ b/packages/astro/src/actions/runtime/virtual/get-action.ts @@ -11,10 +11,7 @@ import type { ActionAccept, ActionClient } from './server.js'; export async function getAction( path: string, ): Promise> { - const pathKeys = path - .replace(/^.*\/_actions\//, '') - .split('.') - .map((key) => decodeURIComponent(key)); + const pathKeys = path.split('.').map((key) => decodeURIComponent(key)); // @ts-expect-error virtual module let { server: actionLookup } = await import('astro:internal-actions'); diff --git a/packages/astro/src/actions/runtime/virtual/server.ts b/packages/astro/src/actions/runtime/virtual/server.ts index 8e5e6bb4f1..f8fac557a0 100644 --- a/packages/astro/src/actions/runtime/virtual/server.ts +++ b/packages/astro/src/actions/runtime/virtual/server.ts @@ -1,8 +1,27 @@ import { z } from 'zod'; import { ActionCalledFromServerError } from '../../../core/errors/errors-data.js'; import { AstroError } from '../../../core/errors/errors.js'; -import type { ActionAPIContext, ErrorInferenceObject, MaybePromise } from '../utils.js'; -import { ActionError, ActionInputError, type SafeResult, callSafely } from './shared.js'; +import { + formContentTypes, + hasContentType, + type ActionAPIContext, + type ErrorInferenceObject, + type MaybePromise, +} from '../utils.js'; +import { + ACTION_QUERY_PARAMS, + ActionError, + ActionInputError, + type SafeResult, + type SerializedActionResult, + callSafely, + deserializeActionResult, + serializeActionResult, +} from './shared.js'; +import type { Locals } from '../utils.js'; +import { getAction } from './get-action.js'; +import type { APIContext } from '../../../types/public/index.js'; +import { ACTION_RPC_ROUTE_PATTERN } from '../../consts.js'; export * from './shared.js'; @@ -212,3 +231,109 @@ function unwrapBaseObjectSchema(schema: z.ZodType, unparsedInput: FormData) { } return schema; } + +export type ActionMiddlewareContext = { + /** Information about an incoming action request. */ + action?: { + /** Whether an action was called using an RPC function or by using an HTML form action. */ + calledFrom: 'rpc' | 'form'; + /** The name of the action. Useful to track the source of an action result during a redirect. */ + name: string; + /** Programatically call the action to get the result. */ + handler: () => Promise>; + }; + /** + * Manually set the action result accessed via `getActionResult()`. + * Calling this function from middleware will disable Astro's own action result handling. + */ + setActionResult(actionName: string, actionResult: SerializedActionResult): void; + /** + * Serialize an action result to stored in a cookie or session. + * Also used to pass a result to Astro templates via `setActionResult()`. + */ + serializeActionResult: typeof serializeActionResult; + /** + * Deserialize an action result to access data and error objects. + */ + deserializeActionResult: typeof deserializeActionResult; +}; + +/** + * Access information about Action requests from middleware. + */ +export function getActionContext(context: APIContext): ActionMiddlewareContext { + const callerInfo = getCallerInfo(context); + + // Prevents action results from being handled on a rewrite. + // Also prevents our *own* fallback middleware from running + // if the user's middleware has already handled the result. + const actionResultAlreadySet = Boolean((context.locals as Locals)._actionPayload); + + let action: ActionMiddlewareContext['action'] = undefined; + + if (callerInfo && context.request.method === 'POST' && !actionResultAlreadySet) { + action = { + calledFrom: callerInfo.from, + name: callerInfo.name, + handler: async () => { + const baseAction = await getAction(callerInfo.name); + let input; + try { + input = await parseRequestBody(context.request); + } catch (e) { + if (e instanceof TypeError) { + return { data: undefined, error: new ActionError({ code: 'UNSUPPORTED_MEDIA_TYPE' }) }; + } + throw e; + } + const { + props: _props, + getActionResult: _getActionResult, + callAction: _callAction, + redirect: _redirect, + ...actionAPIContext + } = context; + const handler = baseAction.bind(actionAPIContext satisfies ActionAPIContext); + return handler(input); + }, + }; + } + + function setActionResult(actionName: string, actionResult: SerializedActionResult) { + (context.locals as Locals)._actionPayload = { + actionResult, + actionName, + }; + } + return { + action, + setActionResult, + serializeActionResult, + deserializeActionResult, + }; +} + +function getCallerInfo(ctx: APIContext) { + if (ctx.routePattern === ACTION_RPC_ROUTE_PATTERN) { + return { from: 'rpc', name: ctx.url.pathname.replace(/^.*\/_actions\//, '') } as const; + } + const queryParam = ctx.url.searchParams.get(ACTION_QUERY_PARAMS.actionName); + if (queryParam) { + return { from: 'form', name: queryParam } as const; + } + return undefined; +} + +async function parseRequestBody(request: Request) { + const contentType = request.headers.get('content-type'); + const contentLength = request.headers.get('Content-Length'); + + if (!contentType) return undefined; + if (hasContentType(contentType, formContentTypes)) { + return await request.clone().formData(); + } + if (hasContentType(contentType, ['application/json'])) { + return contentLength === '0' ? undefined : await request.clone().json(); + } + throw new TypeError('Unsupported content type'); +} diff --git a/packages/astro/src/actions/utils.ts b/packages/astro/src/actions/utils.ts index 3f2f45bfe6..dc0fa4b148 100644 --- a/packages/astro/src/actions/utils.ts +++ b/packages/astro/src/actions/utils.ts @@ -1,8 +1,7 @@ import type fsMod from 'node:fs'; import * as eslexer from 'es-module-lexer'; import type { APIContext } from '../types/public/context.js'; -import type { Locals } from './runtime/middleware.js'; -import type { ActionAPIContext } from './runtime/utils.js'; +import type { ActionAPIContext, Locals } from './runtime/utils.js'; import { deserializeActionResult, getActionQueryString } from './runtime/virtual/shared.js'; export function hasActionPayload(locals: APIContext['locals']): locals is Locals { diff --git a/packages/astro/src/core/middleware/index.ts b/packages/astro/src/core/middleware/index.ts index 9e27434dc9..c7ed6e6479 100644 --- a/packages/astro/src/core/middleware/index.ts +++ b/packages/astro/src/core/middleware/index.ts @@ -6,10 +6,11 @@ import { } from '../../i18n/utils.js'; import type { MiddlewareHandler, Params, RewritePayload } from '../../types/public/common.js'; import type { APIContext } from '../../types/public/context.js'; -import { ASTRO_VERSION, clientAddressSymbol, clientLocalsSymbol } from '../constants.js'; +import { ASTRO_VERSION, clientLocalsSymbol } from '../constants.js'; import { AstroCookies } from '../cookies/index.js'; import { AstroError, AstroErrorData } from '../errors/index.js'; import { getClientIpAddress } from '../routing/request.js'; +import { getOriginPathname } from '../routing/rewrite.js'; import { sequence } from './sequence.js'; function defineMiddleware(fn: MiddlewareHandler) { @@ -89,6 +90,9 @@ function createContext({ return (currentLocale ??= computeCurrentLocale(route, userDefinedLocales, defaultLocale)); }, url, + get originPathname() { + return getOriginPathname(request); + }, get clientAddress() { if (clientIpAddress) { return clientIpAddress; diff --git a/packages/astro/src/core/render-context.ts b/packages/astro/src/core/render-context.ts index 880d1f6187..49f174c33f 100644 --- a/packages/astro/src/core/render-context.ts +++ b/packages/astro/src/core/render-context.ts @@ -28,7 +28,7 @@ import { callMiddleware } from './middleware/callMiddleware.js'; import { sequence } from './middleware/index.js'; import { renderRedirect } from './redirects/render.js'; import { type Pipeline, Slots, getParams, getProps } from './render/index.js'; -import { copyRequest, setOriginPathname } from './routing/rewrite.js'; +import { copyRequest, getOriginPathname, setOriginPathname } from './routing/rewrite.js'; import { SERVER_ISLAND_COMPONENT } from './server-islands/endpoint.js'; export const apiContextRoutesSymbol = Symbol.for('context.routes'); @@ -299,6 +299,9 @@ export class RenderContext { request: this.request, site: pipeline.site, url, + get originPathname() { + return getOriginPathname(renderContext.request); + }, }; } @@ -311,9 +314,12 @@ export class RenderContext { (await pipeline.componentMetadata(routeData)) ?? manifest.componentMetadata; const headers = new Headers({ 'Content-Type': 'text/html' }); const partial = typeof this.partial === 'boolean' ? this.partial : Boolean(mod.partial); + const actionResult = hasActionPayload(this.locals) + ? deserializeActionResult(this.locals._actionPayload.actionResult) + : undefined; const response = { - status, - statusText: 'OK', + status: actionResult?.error ? actionResult?.error.status : status, + statusText: actionResult?.error ? actionResult?.error.type : 'OK', get headers() { return headers; }, @@ -323,10 +329,6 @@ export class RenderContext { }, } satisfies AstroGlobal['response']; - const actionResult = hasActionPayload(this.locals) - ? deserializeActionResult(this.locals._actionPayload.actionResult) - : undefined; - // Create the result object that will be passed into the renderPage function. // This object starts here as an empty shell (not yet the result) but then // calling the render() function will populate the object with scripts, styles, etc. @@ -478,6 +480,9 @@ export class RenderContext { return createCallAction(this); }, url, + get originPathname() { + return getOriginPathname(renderContext.request); + }, }; } diff --git a/packages/astro/src/core/routing/rewrite.ts b/packages/astro/src/core/routing/rewrite.ts index cb23060374..57709892ea 100644 --- a/packages/astro/src/core/routing/rewrite.ts +++ b/packages/astro/src/core/routing/rewrite.ts @@ -108,10 +108,10 @@ export function setOriginPathname(request: Request, pathname: string): void { Reflect.set(request, originPathnameSymbol, encodeURIComponent(pathname)); } -export function getOriginPathname(request: Request): string | undefined { +export function getOriginPathname(request: Request): string { const origin = Reflect.get(request, originPathnameSymbol); if (origin) { return decodeURIComponent(origin); } - return undefined; + return new URL(request.url).pathname; } diff --git a/packages/astro/src/types/public/context.ts b/packages/astro/src/types/public/context.ts index 8d7c4fb307..7a6f3b6be0 100644 --- a/packages/astro/src/types/public/context.ts +++ b/packages/astro/src/types/public/context.ts @@ -268,6 +268,11 @@ interface AstroSharedContext< * A full URL object of the request URL. */ url: URL; + /** + * The origin pathname of the request URL. + * Useful to track the original URL before rewrites were applied. + */ + originPathname: string; /** * Get action result on the server when using a form POST. */ diff --git a/packages/astro/templates/actions.mjs b/packages/astro/templates/actions.mjs index 0aa22f5084..82a287448a 100644 --- a/packages/astro/templates/actions.mjs +++ b/packages/astro/templates/actions.mjs @@ -1,9 +1,4 @@ -import { - ACTION_QUERY_PARAMS, - ActionError, - deserializeActionResult, - getActionQueryString, -} from 'astro:actions'; +import { ActionError, deserializeActionResult, getActionQueryString } from 'astro:actions'; const ENCODED_DOT = '%2E'; @@ -26,10 +21,6 @@ function toActionProxy(actionCallback = {}, aggregatedPath = '') { // Progressive enhancement info for React. $$FORM_ACTION: function () { const searchParams = new URLSearchParams(action.toString()); - // Astro will redirect with a GET request by default. - // Disable this behavior to preserve form state - // for React's progressive enhancement. - searchParams.set(ACTION_QUERY_PARAMS.actionRedirect, 'false'); return { method: 'POST', // `name` creates a hidden input. diff --git a/packages/astro/test/actions.test.js b/packages/astro/test/actions.test.js index 0ed98db935..c34b91a7d1 100644 --- a/packages/astro/test/actions.test.js +++ b/packages/astro/test/actions.test.js @@ -213,7 +213,7 @@ describe('Astro Actions', () => { assert.equal(data.isFormData, true, 'Should receive plain FormData'); }); - it('Response middleware fallback', async () => { + it('Response middleware fallback - POST', async () => { const req = new Request('http://example.com/user?_astroAction=getUser', { method: 'POST', body: new FormData(), @@ -221,6 +221,25 @@ describe('Astro Actions', () => { Referer: 'http://example.com/user', }, }); + const res = await app.render(req); + assert.equal(res.ok, true); + + const html = await res.text(); + let $ = cheerio.load(html); + assert.equal($('#user').text(), 'Houston'); + }); + + it('Response middleware fallback - cookie forwarding', async () => { + const req = new Request( + 'http://example.com/user?_astroAction=getUser&actionCookieForwarding=true', + { + method: 'POST', + body: new FormData(), + headers: { + Referer: 'http://example.com/user', + }, + }, + ); const res = await followExpectedRedirect(req, app); assert.equal(res.ok, true); @@ -229,7 +248,7 @@ describe('Astro Actions', () => { assert.equal($('#user').text(), 'Houston'); }); - it('Respects custom errors', async () => { + it('Respects custom errors - POST', async () => { const req = new Request('http://example.com/user-or-throw?_astroAction=getUserOrThrow', { method: 'POST', body: new FormData(), @@ -237,6 +256,26 @@ describe('Astro Actions', () => { Referer: 'http://example.com/user-or-throw', }, }); + const res = await app.render(req); + assert.equal(res.status, 401); + + const html = await res.text(); + let $ = cheerio.load(html); + assert.equal($('#error-message').text(), 'Not logged in'); + assert.equal($('#error-code').text(), 'UNAUTHORIZED'); + }); + + it('Respects custom errors - cookie forwarding', async () => { + const req = new Request( + 'http://example.com/user-or-throw?_astroAction=getUserOrThrow&actionCookieForwarding=true', + { + method: 'POST', + body: new FormData(), + headers: { + Referer: 'http://example.com/user-or-throw', + }, + }, + ); const res = await followExpectedRedirect(req, app); assert.equal(res.status, 401); @@ -246,6 +285,35 @@ describe('Astro Actions', () => { assert.equal($('#error-code').text(), 'UNAUTHORIZED'); }); + it('Respects RPC middleware handling - locked', async () => { + const req = new Request('http://example.com/_actions/locked', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: '{}', + }); + const res = await app.render(req); + assert.equal(res.status, 401); + }); + + it('Respects RPC middleware handling - cookie present', async () => { + const req = new Request('http://example.com/_actions/locked', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Cookie: 'actionCookie=1234', + }, + body: '{}', + }); + const res = await app.render(req); + assert.equal(res.ok, true); + assert.equal(res.headers.get('Content-Type'), 'application/json+devalue'); + + const data = devalue.parse(await res.text()); + assert.equal('safe' in data, true); + }); + it('Ignores `_astroAction` name for GET requests', async () => { const req = new Request('http://example.com/user-or-throw?_astroAction=getUserOrThrow', { method: 'GET', diff --git a/packages/astro/test/fixtures/actions/src/actions/index.ts b/packages/astro/test/fixtures/actions/src/actions/index.ts index 78cc39620b..d3cd1b1bba 100644 --- a/packages/astro/test/fixtures/actions/src/actions/index.ts +++ b/packages/astro/test/fixtures/actions/src/actions/index.ts @@ -161,28 +161,33 @@ export const server = { }; }, }), - "with.dot": defineAction({ - input: z.object({ - name: z.string(), - }), - handler: async (input) => { - return `Hello, ${input.name}!` - } - }), - "with space": defineAction({ - input: z.object({ - name: z.string(), - }), - handler: async (input) => { - return `Hello, ${input.name}!` - } - }), - "with/slash": defineAction({ + locked: defineAction({ + handler: async () => { + return { safe: true }; + }, + }), + 'with.dot': defineAction({ input: z.object({ name: z.string(), }), handler: async (input) => { - return `Hello, ${input.name}!` - } + return `Hello, ${input.name}!`; + }, + }), + 'with space': defineAction({ + input: z.object({ + name: z.string(), + }), + handler: async (input) => { + return `Hello, ${input.name}!`; + }, + }), + 'with/slash': defineAction({ + input: z.object({ + name: z.string(), + }), + handler: async (input) => { + return `Hello, ${input.name}!`; + }, }), }; diff --git a/packages/astro/test/fixtures/actions/src/middleware.ts b/packages/astro/test/fixtures/actions/src/middleware.ts index e630dfb730..0730b7e7be 100644 --- a/packages/astro/test/fixtures/actions/src/middleware.ts +++ b/packages/astro/test/fixtures/actions/src/middleware.ts @@ -1,8 +1,54 @@ -import { defineMiddleware } from 'astro:middleware'; +import { defineMiddleware, sequence } from 'astro:middleware'; +import { getActionContext } from 'astro:actions'; + +const actionCookieForwarding = defineMiddleware(async (ctx, next) => { + if (ctx.isPrerendered) return next(); + + const { action, setActionResult, serializeActionResult } = getActionContext(ctx); + + const payload = ctx.cookies.get('ACTION_PAYLOAD'); + if (payload) { + const { actionName, actionResult } = payload.json(); + setActionResult(actionName, actionResult); + ctx.cookies.delete('ACTION_PAYLOAD'); + return next(); + } + + if ( + action?.calledFrom === 'rpc' && + action.name === 'locked' && + !ctx.cookies.has('actionCookie') + ) { + return new Response('Unauthorized', { status: 401 }); + } + + if (action?.calledFrom === 'form' && ctx.url.searchParams.has('actionCookieForwarding')) { + const actionResult = await action.handler(); + + ctx.cookies.set('ACTION_PAYLOAD', { + actionName: action.name, + actionResult: serializeActionResult(actionResult), + }); + + if (actionResult.error) { + const referer = ctx.request.headers.get('Referer'); + if (!referer) { + throw new Error('Internal: Referer unexpectedly missing from Action POST request.'); + } + return ctx.redirect(referer); + } + return ctx.redirect(ctx.originPathname); + } -export const onRequest = defineMiddleware((ctx, next) => { - ctx.locals.user = { - name: 'Houston', - }; return next(); }); + +export const onRequest = sequence( + defineMiddleware((ctx, next) => { + ctx.locals.user = { + name: 'Houston', + }; + return next(); + }), + actionCookieForwarding, +);