Skip to content
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

Add 5.0 Actions Middleware change #9943

Merged
merged 37 commits into from
Nov 11, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
e3668b1
replace cookie redirect notices with a custom session guide
bholmesdev Nov 7, 2024
fbb6a08
add since 5.0
bholmesdev Nov 7, 2024
81937f1
add detail to guide code snippets
bholmesdev Nov 7, 2024
63ac703
add `getActionContext()` reference
bholmesdev Nov 7, 2024
4f54aec
add 5.0 change
bholmesdev Nov 7, 2024
7c5336c
nit: session -> sessions
bholmesdev Nov 7, 2024
b154039
fix missing async on cookie example
bholmesdev Nov 7, 2024
8810baf
add security section
bholmesdev Nov 7, 2024
693c088
add Since badge for 'gate actions from middleware'
bholmesdev Nov 7, 2024
dbe5a15
add code formatting
bholmesdev Nov 7, 2024
3b3c48f
replace build_hash example with session token exampel
bholmesdev Nov 7, 2024
2a172c4
nit: remove unused hash variable
bholmesdev Nov 7, 2024
8f287e5
change list of examples to simpler sentence
bholmesdev Nov 8, 2024
d5df652
remove stray absolute link
bholmesdev Nov 8, 2024
fa270ef
remove stray absolute link 2
bholmesdev Nov 8, 2024
9723a27
remove stray absolute link 3
bholmesdev Nov 8, 2024
d41bbfd
move Since to after the type def
bholmesdev Nov 8, 2024
1581ab8
clarify how the redirect removes the resubmission dialog
bholmesdev Nov 8, 2024
f74350a
reword the action return value section
bholmesdev Nov 8, 2024
bc12d81
fix grammar
bholmesdev Nov 8, 2024
c1a8688
remove misleading "reintroduces
bholmesdev Nov 8, 2024
509b916
add SourcePR label
bholmesdev Nov 8, 2024
d0db878
nit: still -> however
bholmesdev Nov 8, 2024
79789b7
remove needless example of actions.blog.like
bholmesdev Nov 8, 2024
e9a3fae
rework netlify note into paragraph
bholmesdev Nov 8, 2024
2571cb3
stop telling people it's easy
bholmesdev Nov 8, 2024
4c76da6
reword 'Forbidden' to be near the example explanation
bholmesdev Nov 8, 2024
09f6632
rephrase 'what should I do?' to recommend sessions
bholmesdev Nov 8, 2024
410466e
ugh
bholmesdev Nov 8, 2024
f12ed6b
HOWEVER
bholmesdev Nov 8, 2024
3cff78a
move SourcePR to top of change section
bholmesdev Nov 8, 2024
dd74770
typo fix
sarah11918 Nov 11, 2024
eb43dac
make reference more boring
bholmesdev Nov 11, 2024
8446d8b
break "call this function..." to new line
bholmesdev Nov 11, 2024
4cc9367
make action reference more boring
bholmesdev Nov 11, 2024
beab04a
add setActionResult example
bholmesdev Nov 11, 2024
83ff5a6
add deserialize use case
bholmesdev Nov 11, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
165 changes: 143 additions & 22 deletions src/content/docs/en/guides/actions.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -524,10 +524,6 @@ const inputErrors = isInputError(result?.error) ? result.error.fields : {};
</form>
```

:::note
Astro persists action `data` and `error` with a single-use cookie. This means `getActionResult()` will return a result on the first request _only_, and `undefined` when revisiting the page.
:::

#### Preserve input values on error

Inputs will be cleared whenever a form is submitted. To persist input values, you can [enable view transitions](/en/guides/view-transitions/#adding-view-transitions-to-a-page) on the page and apply the `transition:persist` directive to each input:
Expand All @@ -538,13 +534,9 @@ Inputs will be cleared whenever a form is submitted. To persist input values, yo

### Update the UI with a form action result

The result returned by `Astro.getActionResult()` is single-use, and will reset to `undefined` whenever the page is refreshed. This is ideal for [displaying input errors](#handle-form-action-errors) and showing temporary notifications to the user on success.

:::tip
If you need a result to be displayed across page refreshes, consider storing the result in a database or [in a cookie](/en/reference/api-reference/#astrocookies).
:::
To use an action's return value to display a notification to the user on success, pass the action to `Astro.getActionResult()`. Use the returned `data` property to render the UI you want to display.

Pass an action to `Astro.getActionResult()` and use the returned `data` property to render any temporary UI you want to display. This example uses the `productName` property returned by an `addToCart` action to show a success message:
This example uses the `productName` property returned by an `addToCart` action to show a success message.

```astro title="src/pages/products/[slug].astro"
---
Expand All @@ -560,24 +552,153 @@ const result = Astro.getActionResult(actions.addToCart);
<!--...-->
```

:::caution
Action data is passed using a persisted cookie. **This cookie is not encrypted** and is limited to 4 KB in size, although the exact limit may vary between browsers.
### Advanced: Persist action results with a session

In general, we recommend returning the minimum information required from your action `handler` to avoid vulnerabilities, and persist other sensitive information in a database.
<p><Since v="5.0.0" /></p>

For example, you might return the name of a product in an `addToCart` action, rather than returning the entire `product` object:
Action results are displayed as a POST submission. This means that the result will be reset to `undefined` when a user closes and revisits the page. The user will also see a "confirm form resubmission?" dialog if they attempt to refresh the page.

```ts title="src/actions/index.ts" del={7} ins={8}
import { defineAction } from 'astro:actions';
To customize this behavior, you can add middleware to handle the result of the action manually. You may choose to persist the action result using a cookie or session storage.

Start by [creating a middleware file](/en/guides/middleware/) and importing [the `getActionContext()` utility](/en/reference/modules/astro-actions/#getactioncontext) from `astro:actions`. This function returns an `action` object with information about the incoming action request, including the action handler and whether the action was called from an HTML form. `getActionContext()` also returns the `setActionResult()` and `serializeActionResult()` functions to programmatically set the value returned by `Astro.getActionResult()`:

```ts title="src/middleware.ts" {2,5}
import { defineMiddleware } from 'astro:middleware';
import { getActionContext } from 'astro:actions';

export const onRequest = defineMiddleware(async (context, next) => {
const { action, setActionResult, serializeActionResult } = getActionContext(context);
if (action?.calledFrom === 'form') {
const result = await action.handler();
// ... handle the action result
setActionResult(action.name, serializeActionResult(result));
}
return next();
});
```

A common practice to persist HTML form results is the [POST / Redirect / GET pattern](https://en.wikipedia.org/wiki/Post/Redirect/Get). This redirect removes the "confirm form resubmission?" dialog when the page is refreshed, and allows action results to be persisted throughout the user's session.

This example applies the POST / Redirect / GET pattern to all form submissions using session storage with the [Netlify server adapter](/en/guides/integrations-guide/netlify/) installed. Action results are written to a session store using [Netlify Blob](https://docs.netlify.com/blobs/overview/), and retrieved after a redirect using a session ID:

```ts title="src/middleware.ts"
import { defineMiddleware } from 'astro:middleware';
import { getActionContext } from 'astro:actions';
import { randomUUID } from "node:crypto";
import { getStore } from "@netlify/blobs";

export const onRequest = defineMiddleware(async (context, next) => {
// Skip requests for prerendered pages
if (context.isPrerendered) return next();

const { action, setActionResult, serializeActionResult } =
getActionContext(context);
// Create a Blob store to persist action results with Netlify Blob
const actionStore = getStore("action-session");

// If an action result was forwarded as a cookie, set the result
// to be accessible from `Astro.getActionResult()`
const sessionId = context.cookies.get("action-session-id")?.value;
const session = sessionId
? await actionStore.get(sessionId, {
type: "json",
})
: undefined;

if (session) {
setActionResult(session.actionName, session.actionResult);

// Optional: delete the session after the page is rendered.
// Feel free to implement your own persistence strategy
await actionStore.delete(sessionId);
ctx.cookies.delete("action-session-id");
return next();
}

// If an action was called from an HTML form action,
// call the action handler and redirect to the destination page
if (action?.calledFrom === "form") {
const actionResult = await action.handler();

// Persist the action result using session storage
const sessionId = randomUUID();
await actionStore.setJSON(sessionId, {
actionName: action.name,
actionResult: serializeActionResult(actionResult),
});

// Pass the session ID as a cookie
// to be retrieved after redirecting to the page
context.cookies.set("action-session-id", sessionId);

// Redirect back to the previous page on error
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);
}
// Redirect to the destination page on success
return context.redirect(context.originPathname);
}

return next();
});
```

## Security when using actions

Actions are accessible as public endpoints based on the name of the action. For example, the action `blog.like()` will be accessible from `/_actions/blog.like`. This is useful for unit testing action results and debugging production errors. However, this means you **must** use same authorization checks that you would consider for API endpoints and on-demand rendered pages.

### Authorize users from an action handler

To authorize action requests, add an authentication check to your action handler. You may want to use [an authentication library](/en/guides/authentication/) to handle session management and user information.

Actions expose the full `APIContext` object to access properties passed from middleware using `context.locals`. When a user is not authorized, you can raise an `ActionError` with the `UNAUTHORIZED` code:

```ts title="src/actions/index.ts" {6-8}
import { defineAction, ActionError } from 'astro:actions';

export const server = {
addToCart: defineAction({
handler: async () => {
/* ... */
return product;
return { productName: product.name };
getUserSettings: defineAction({
handler: async (_input, context) => {
if (!context.locals.user) {
throw new ActionError({ code: 'UNAUTHORIZED' });
}
return { /* data on success */ };
}
})
}
```
:::

### Gate actions from middleware

<p><Since v="5.0.0" /></p>

Astro recommends authorizing user sessions from your action handler to respect permission levels and rate-limiting on a per-action basis. However, you can also gate requests to all actions (or a subset of actions) from middleware.

Use the `getActionContext()` function from your middleware to retrieve information about any inbound action requests. This includes the action name and whether that action was called using a client-side remote procedure call (RPC) function (e.g. `actions.blog.like()`) or an HTML form.

The following example rejects all action requests that do not have a valid session token. If the check fails, a "Forbidden" response is returned. Note: this method ensures that actions are only accessible when a session is present, but is _not_ a substitute for secure authorization.

```ts title="src/middleware.ts"
import { defineMiddleware } from 'astro:middleware';
import { getActionContext } from 'astro:actions';

export const onRequest = defineMiddleware(async (context, next) => {
const { action } = getActionContext(context);
// Check if the action was called from a client-side function
if (action?.calledFrom === 'rpc') {
// If so, check for a user session token
if (context.cookies.has('user-session')) {
return new Response('Forbidden', { status: 403 });
}
}

context.cookies.set('user-session', /* session token */);
return next();
});
```
60 changes: 60 additions & 0 deletions src/content/docs/en/guides/upgrade-to/v5.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -662,6 +662,66 @@ Note that `src/env.d.ts` is only necessary if you have added custom configuratio

<ReadMore>Read more about [TypeScript configuration in Astro](/en/guides/typescript/#setup).</ReadMore>

### Changed: Actions submitted by HTML forms no longer use cookies

<SourcePR number="12373" title="Actions middleware"/>

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.

#### What should I do?

bholmesdev marked this conversation as resolved.
Show resolved Hide resolved
You can use the new default without any changes to your code. 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](/en/guides/actions/#advanced-persist-action-results-with-a-session).

We recommend using a session storage provider [as described in our Netlify Blob example](/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 title="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();
})
```

bholmesdev marked this conversation as resolved.
Show resolved Hide resolved
### Changed: `compiledContent()` is now an async function

<SourcePR number="11782" title="Remove TLA by making compiledContent async"/>
Expand Down
115 changes: 114 additions & 1 deletion src/content/docs/en/reference/modules/astro-actions.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -173,4 +173,117 @@ The `code` property accepts human-readable versions of all HTTP status codes. Th
<Since v="4.15.0" />
</p>

The `message` property accepts a string. (e.g. "User must be logged in.")
The `message` property accepts a string. (e.g. "User must be logged in.")

### `getActionContext()`

<p>

**Type:** `(context: APIContext) => ActionMiddlewareContext`
bholmesdev marked this conversation as resolved.
Show resolved Hide resolved
<Since v="5.0.0" />
</p>

`getActionContext()` is a function called from your middleware handler to retrieve information about inbound action requests.

This function returns an `action` object with information about the request, and the `setActionResult()` and `serializeActionResult()` functions to programmatically set the value returned by `Astro.getActionResult()`.

`getActionContext()` lets you programmatically get and set action results using middleware, allowing you to persist action results from HTML forms, gate action requests with added security checks, and more.

```ts title="src/middleware.ts" {5}
import { defineMiddleware } from 'astro:middleware';
import { getActionContext } from 'astro:actions';

export const onRequest = defineMiddleware(async (context, next) => {
const { action, setActionResult, serializeActionResult } = getActionContext(context);
if (action?.calledFrom === 'form') {
const result = await action.handler();
setActionResult(action.name, serializeActionResult(result));
}
return next();
});
```

#### `action`

<p>

**Type:** `{ calledFrom: 'rpc' | 'form', name: string, handler: () => Promise<SafeResult<any, any>> } | undefined`
</p>

`action` is an object containing information about an inbound action request.

It is available from `getActionContext()`, and provides the action name, handler, and whether the action was called from an client-side RPC function (e.g. `actions.newsletter()`) or an HTML form action.

```ts title="src/middleware.ts" {6}
import { defineMiddleware } from 'astro:middleware';
import { getActionContext } from 'astro:actions';

export const onRequest = defineMiddleware(async (context, next) => {
const { action, setActionResult, serializeActionResult } = getActionContext(context);
if (action?.calledFrom === 'rpc' && action.name.startsWith('private')) {
// Check for a valid session token
}
// ...
});
```

#### `setActionResult()`

<p>

**Type:** `(actionName: string, actionResult: SerializedActionResult) => void`
</p>

`setActionResult()` is a function to programmatically set the value returned by `Astro.getActionResult()` in middleware. It is passed the action name and an action result serialized by [`serializeActionResult()`](#serializeactionresult).

This is useful when calling actions from an HTML form to persist and load results from a session.

```ts title="src/middleware.ts" {8}
import { defineMiddleware } from 'astro:middleware';
import { getActionContext } from 'astro:actions';
export const onRequest = defineMiddleware(async (context, next) => {
const { action, setActionResult, serializeActionResult } = getActionContext(context);
if (action?.calledFrom === 'form') {
const result = await action.handler();
// ... handle the action result
setActionResult(action.name, serializeActionResult(result));
}
return next();
});
```

<ReadMore>See the [advanced sessions guide](/en/guides/actions/#advanced-persist-action-results-with-a-session) for a sample implementation using Netlify Blob.</ReadMore>

#### `serializeActionResult()`

<p>

**Type:** `(result: SafeResult<any, any>) => SerializedActionResult`
</p>

`serializeActionResult()` will serialize an action result to JSON for persistence. This is required to properly handle non-JSON return values like `Map` or `Date` as well as the `ActionError` object.

Call this function when serializing an action result to be passed to `setActionResult()`:

```ts title="src/middleware.ts" {8}
import { defineMiddleware } from 'astro:middleware';
import { getActionContext } from 'astro:actions';

export const onRequest = defineMiddleware(async (context, next) => {
const { action, setActionResult, serializeActionResult } = getActionContext(context);
if (action) {
const result = await action.handler();
setActionResult(action.name, serializeActionResult(result));
}
// ...
});
```

#### `deserializeActionResult()`

<p>

**Type:** `(result: SerializedActionResult) => SafeResult<any, any>`
</p>

`deserializeActionResult()` will reverse the effect of `serializeActionResult()` and return an action result to its original state. This is useful to access the `data` and `error` objects on a serialized action result.
Loading