0
Fork 0
mirror of https://github.com/withastro/astro.git synced 2024-12-30 22:03:56 -05:00

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 <my.burning@gmail.com>

* 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 <sarah@rainsberger.ca>

* update changeset to match docs v5 guide

* add absolute urls to changeset links

---------

Co-authored-by: Emanuele Stoppa <my.burning@gmail.com>
Co-authored-by: Sarah Rainsberger <sarah@rainsberger.ca>
This commit is contained in:
Ben Holmes 2024-11-08 17:03:57 -05:00 committed by GitHub
parent d63d87dcae
commit d10f91815e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
26 changed files with 484 additions and 312 deletions

View file

@ -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();
})
```

View file

@ -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}');
});

View file

@ -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',
}),

View file

@ -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;
},
})
}),
};

View file

@ -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;

View file

@ -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)!;

View file

@ -6,38 +6,42 @@ const result = Astro.getActionResult(actions.blog.lotsOfStuff);
---
<html>
<head>
<title>Actions</title>
<style>
form {
display: grid;
grid-row-gap: 10px;
}
</style>
</head>
<body>
<form method="POST" action={actions.blog.lotsOfStuff} data-testid="lots">
<input type="text" name="one" value="">
<span class="one error">{result?.error?.fields.one}</span>
<input type="text" name="two" value="">
<span class="two error">{result?.error?.fields.two}</span>
<input type="text" name="three" value="">
<span class="three error">{result?.error?.fields.three}</span>
<input type="text" name="four" value="">
<span class="four error">{result?.error?.fields.four}</span>
<input type="text" name="five" value="">
<span class="five error">{result?.error?.fields.five}</span>
<input type="text" name="six" value="">
<span class="six error">{result?.error?.fields.six}</span>
<input type="text" name="seven" value="">
<span class="seven error">{result?.error?.fields.seven}</span>
<input type="text" name="eight" value="">
<span class="eight error">{result?.error?.fields.eight}</span>
<input type="text" name="nine" value="">
<span class="nine error">{result?.error?.fields.nine}</span>
<input type="text" name="ten" value="">
<span class="ten error">{result?.error?.fields.ten}</span>
<button type="submit">Submit</button>
</form>
</body>
<head>
<title>Actions</title>
<style>
form {
display: grid;
grid-row-gap: 10px;
}
</style>
</head>
<body>
<form
method="POST"
action={actions.blog.lotsOfStuff + '&actionCookieForwarding=true'}
data-testid="lots"
>
<input type="text" name="one" value="" />
<span class="one error">{result?.error?.fields.one}</span>
<input type="text" name="two" value="" />
<span class="two error">{result?.error?.fields.two}</span>
<input type="text" name="three" value="" />
<span class="three error">{result?.error?.fields.three}</span>
<input type="text" name="four" value="" />
<span class="four error">{result?.error?.fields.four}</span>
<input type="text" name="five" value="" />
<span class="five error">{result?.error?.fields.five}</span>
<input type="text" name="six" value="" />
<span class="six error">{result?.error?.fields.six}</span>
<input type="text" name="seven" value="" />
<span class="seven error">{result?.error?.fields.seven}</span>
<input type="text" name="eight" value="" />
<span class="eight error">{result?.error?.fields.eight}</span>
<input type="text" name="nine" value="" />
<span class="nine error">{result?.error?.fields.nine}</span>
<input type="text" name="ten" value="" />
<span class="ten error">{result?.error?.fields.ten}</span>
<button type="submit">Submit</button>
</form>
</body>
</html>

View file

@ -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);
---
<html>
@ -13,6 +12,5 @@ const result = Astro.getActionResult(actions.sum)
<button data-testid="submit" type="submit">Sum</button>
</form>
<p>Form result: {JSON.stringify(result)}</p>
<body>
</body>
</html>

View file

@ -0,0 +1,3 @@
---
return Astro.rewrite('/rewritten' + Astro.url.search);
---

View file

@ -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]';

View file

@ -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,
});

View file

@ -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<any, any>;
}) {
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;
}

View file

@ -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') {

View file

@ -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'];

View file

@ -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.');
}

View file

@ -11,10 +11,7 @@ import type { ActionAccept, ActionClient } from './server.js';
export async function getAction(
path: string,
): Promise<ActionClient<unknown, ActionAccept, ZodType>> {
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');

View file

@ -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<SafeResult<any, any>>;
};
/**
* 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');
}

View file

@ -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 {

View file

@ -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;

View file

@ -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);
},
};
}

View file

@ -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;
}

View file

@ -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.
*/

View file

@ -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.

View file

@ -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',

View file

@ -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}!`;
},
}),
};

View file

@ -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,
);