---
title: "Accept a file"
audience: "anyone with an avatar, an attachment or a CSV to take"
description: "You'll take a file from a form, check it before it goes anywhere and hand the bytes on. It works with JavaScript switched off."
snippet_caption: "app/src/ui/DepositForm.tsx"
snippet: "import { upload } from \"@snapfire/fsr-client\";\n\nexport function DepositForm({ token }: { token: string }) {\n  async function send(event: React.FormEvent<HTMLFormElement>) {\n    event.preventDefault();\n    const data = new FormData(event.currentTarget);\n    data.set(\"_csrf\", token);\n    await upload(\"$root.deposit\", data);\n  }\n\n  return (\n    <form method=\"post\" action=\"/_sf/action/$root.deposit\" encType=\"multipart/form-data\" onSubmit={send}>\n      <input type=\"hidden\" name=\"_csrf\" value={token} />\n      <label>\n        Caption\n        <input name=\"caption\" />\n      </label>\n      <label>\n        File\n        <input type=\"file\" name=\"file\" />\n      </label>\n      <button type=\"submit\">Deposit</button>\n    </form>\n  );\n}"
---

import { Install } from "@src/ui/Install";

You'll take a file from a form, check it before it goes anywhere and hand the bytes on. It works with JavaScript switched off.

<Install captured="0.x" />

## Declare what the action takes

```ts title="app/schemas/deposit.ts"
export interface Deposit {
  caption: string;
  file: Upload;
}
```

That is `app/schemas/deposit.ts`. `Upload` is the host's own type, so you name it without declaring it, the way you name `Uint8Array`. The build refuses an application that declares its own `Upload`, so it can't disagree with the host about the shape.

An `Upload` has these fields:

```ts title="app/generated/services.d.ts"
interface Upload {
  filename: string;
  content_type: string;
  size: bigint;
  bytes: Uint8Array;
}
```

## The action

```ts title="app/routes/actions.ts"
import { action, fail } from "@snapfire/fsr";
import type { ActionCtx } from "@snapfire/fsr";
import type { Deposit } from "@schemas/deposit";

const ACCEPTED = ["image/png", "image/jpeg", "image/webp"];

export const deposit = action(async ({ input, services }: ActionCtx<Deposit>) => {
  if (!ACCEPTED.includes(input.file.content_type)) {
    fail("invalid", `${input.file.content_type} is not a type we take`);
  }
  return await services.files.store({
    name: input.file.filename,
    body: input.file.bytes,
  });
});
```

A body has no filesystem, so `bytes` goes to a service method or to your own Rust through `ctx.native`. Writing a file involves I/O that can fail, a path policy and cleanup, none of which belong in a lowered body.

## Which fields to trust

`filename` and `content_type` are strings the client chose. A file called `holiday.png` claiming `image/png` can be anything at all, so don't trust either of them. Use `content_type` to reject the obviously wrong thing early and sniff the real type where it matters, in the service that stores it.

`size` is measured by the host, so you can rely on it.

## Limit the size

```toml title="config/app.toml"
[server]
max_body = 4194304
max_upload = 1048576
```

`max_upload` caps one part. A part over it is refused with 413 and a message naming the field, before the action's body runs and before the CSRF token is read. `max_body` caps the request whole. It has to be the larger of the two, since it covers every part of the request.

Both matter because the request is buffered before anything parses it. An upload in flight takes as much memory as its size, so this is meant for the file a person picks in a form and not for large transfers. Raising `max_body` to take a 200MB video means a handful of those can be in memory at once.

## The form, with no JavaScript

The form goes on the page at `/`, which reads `csrf_token` like any other prop:

```tsx title="app/routes/page.tsx"
export default function Index({ csrf_token }: { csrf_token?: string }) {
  return (
    <form method="post" action="/_sf/action/$root.deposit" encType="multipart/form-data">
      <input type="hidden" name="_csrf" value={csrf_token ?? ""} />
      <label>
        Caption
        <input name="caption" />
      </label>
      <label>
        File
        <input type="file" name="file" />
      </label>
      <button type="submit">Deposit</button>
    </form>
  );
}
```

`encType` is the only change from an ordinary form. A part with a filename becomes the `Upload`. A part without one is a text field coerced against the schema exactly as an ordinary form field is, so `caption` arrives as a `string`.

The host answers a form post with a 303 back to the page that posted. The browser lands on the page, the loader reads whatever the action wrote and the visitor sees the result.

> `_csrf` is required, because a form post is verified where a JSON call is not. A form that anonymous visitors post needs `[session] csrf = "always"` for a token to exist at all.

## The same action from the page

To post without a page load, the submit has to be handled in the browser. Move the form into a component with an `onSubmit` that calls `upload`:

```tsx title="app/src/ui/DepositForm.tsx"
import { upload } from "@snapfire/fsr-client";

export function DepositForm({ token }: { token: string }) {
  async function send(event: React.FormEvent<HTMLFormElement>) {
    event.preventDefault();
    const data = new FormData(event.currentTarget);
    data.set("_csrf", token);
    await upload("$root.deposit", data);
  }

  return (
    <form method="post" action="/_sf/action/$root.deposit" encType="multipart/form-data" onSubmit={send}>
      <input type="hidden" name="_csrf" value={token} />
      <label>
        Caption
        <input name="caption" />
      </label>
      <label>
        File
        <input type="file" name="file" />
      </label>
      <button type="submit">Deposit</button>
    </form>
  );
}
```

and place it on the page as a browser island. The server still renders the form, so it posts natively until the module loads:

```tsx title="app/routes/page.tsx" {1-2,6-8}
import { Island } from "@snapfire/fsr-client/react";
import { DepositForm } from "@src/ui/DepositForm";

export default function Index({ csrf_token }: { csrf_token?: string }) {
  return (
    <Island when="load">
      <DepositForm token={csrf_token ?? ""} />
    </Island>
  );
}
```

`upload` posts the `FormData` and names JSON in `Accept`, which is what makes the host answer the action's value instead of the redirect. It revalidates on success like any action call, so the page updates in place.

## When it fails

A failure comes back as an `ActionFailure` with the kind and message, the same as any other action, so the JavaScript path can show it beside the field.

A browser that posted the form natively is redirected back to the page instead, with the failure waiting as the `action_failure` prop, so a rejected upload lands on the form rather than on a page of JSON. Chapter [130](https://snapfirers.com/fsr/learn/130-validate-a-form) covers when to `fail` and when to write the rejection to the session yourself: one failure carries one kind and one message, which is enough for a wrong content type and not enough for three bad fields.

Use `fail` for a request that should never have been posted. When a person mistyped something alongside other fields, write the rejection to the session.

Next up: [200. Write your islands in Vue](https://snapfirers.com/fsr/learn/200-write-your-islands-in-vue).
