Tutorial 24 of 36 · for anyone with an avatar, an attachment or a CSV to take · Markdown

Accept a file

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.

Before you start

Install from crates.io. You need no Node and no package manager.

cargo install snapfire_compiler
cargo install snapfire_fsr_cli
fsr --version

Every command and screenshot on this page was captured with fsr 0.x.

Declare what the action takes#

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:

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

The action#

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#

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:

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:

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:

app/routes/page.tsx
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 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.