# File Upload
User picks one or more files in a form. The browser reads the bytes and an RPC
stores them. A route serves the bytes back so an `` can display them.
Store the bytes in Postgres or in S3 or any object store. Both are first-class,
and both serve from every app server: the form, the `File[]` binding and the
RPC are the same either way, and only the insert and the serve route change.
This page stores files in a Postgres `bytea` column; "Object storage" below
shows the other path.
The `` binding does the read for you. The
form's submit handler waits for any in-flight file reads to finish before it
runs, so there is no race between picking a file and submitting.
## One file or many
The `multiple` attribute decides the type the input binds:
```ehtml Inputs
```
```ts Bound fields
private avatar: File; // no multiple
private photos: File[] = []; // multiple
```
Mismatch them and the build says so. `File[]` on an input without `multiple`
is an error, and so is a single `File` on one with it.
The rest of this page builds a gallery, so everything below uses `multiple` and
`File[]`. For a single attachment, drop `multiple` and use `File` wherever you
see `File[]`.
## Migration
Scaffold with `elements create migration`, then add the columns specific to file
storage.
```bash Terminal
elements create migration 'add images' -tables=images
```
```sql app/migrations/-add-images.migration.sql
-- add images
-- Auto-update updatedAt on row changes.
create or replace function touchUpdatedAt()
returns trigger
language plpgsql
as $$
begin
new.updatedAt = now();
return new;
end;
$$;
create table images (
id uuid primary key default uuidGenerateV7(),
createdAt timestamptz not null default now(),
updatedAt timestamptz not null default now(),
name text not null,
contentType text not null,
size integer not null,
data bytea not null
);
create trigger imagesTouchUpdatedAt
before update on images
for each row execute function touchUpdatedAt();
```
The four columns added on top of the scaffold are `name`, `contentType`, `size`,
and `data`. The `data` column is `bytea`. `File.data` (a `Uint8Array`)
interpolates into a `bytea` parameter directly.
## Gallery services
```bash Terminal
elements create page gallery
```
That writes
`app/pages/gallery/{index.ts, template.ehtml, style.css, test.ts}`. The
`ImageMeta` interface and the `upload` rpc go in `template.ehtml`, and the route
imports them from there.
`app/pages/gallery/template.ehtml`, above the markup:
```ehtml app/pages/gallery/template.ehtml
import "./style.css";
import { sql, tx, File, ValidationError } from "@elements/app";
// The types this gallery is willing to store and later serve inline. Anything
// not on this list is rejected. See "Never trust the uploaded content type".
const ALLOWED = new Set(["image/png", "image/jpeg", "image/gif", "image/webp"]);
export interface ImageMeta {
id: string;
name: string;
contentType: string;
size: number;
createdAt: Date;
}
/** @rpc */
export function upload(files: File[]): ImageMeta[] {
for (let f of files) {
if (!ALLOWED.has(f.contentType)) {
throw new ValidationError(`${f.name} is not an image`);
}
}
tx(() => {
for (let f of files) {
sql(
`
insert into images (
name,
contentType,
size,
data
) values (
${f.name},
${f.contentType},
${f.size},
${f.data}
)
`,
);
}
});
return sql(`
select
id,
name,
contentType,
size,
createdAt
from images
order by createdAt desc
`).all();
}
```
The allowlist runs before `tx()` so a rejected file fails the whole batch before
anything is written. The bulk insert then runs inside `tx()` so the batch is
atomic: if one row fails, none commit. The RPC returns the refreshed metadata
list; the template reassigns its parameter and the grid re-renders.
This gallery uploads files only, so `File[]` is its own parameter. A form that
also collects text (a post with a title, a body, and an attachment) puts the
`File[]` in the same form object as the other fields and passes that one object
to a single `@rpc`, rather than splitting files into a separate parameter:
```ts PostForm
interface PostForm {
title: string;
body: string;
files: File[];
}
/** @rpc */
export function createPost(form: PostForm): Post {
// form.files is a File[] like any other field
}
```
```ehtml PostForm input
```
`File[]` crosses the RPC boundary inside a form object exactly as it does on its
own. A single `File` field works the same way:
```ts ProfileForm
interface ProfileForm {
name: string;
avatar: File;
}
```
```ehtml ProfileForm input
```
## Gallery route
```ts app/pages/gallery/index.ts
import { Request, Response, sql } from "@elements/app";
import gallery, { ImageMeta } from "./template";
export default function route(req: Request, res: Response) {
let images = sql(`
select
id,
name,
contentType,
size,
createdAt
from images
order by createdAt desc
`).all();
return new gallery({ initial: images });
}
```
The `select` lists every column except `data` so the metadata stays small.
Pulling every image's bytes into the HTML render would put megabytes of base64
in the response. The bytes are served separately, on demand, by the route below.
## Gallery template
The rest of `app/pages/gallery/template.ehtml`, under the code above:
```ehtml app/pages/gallery/template.ehtml
interface Gallery {
images: ImageMeta[];
files: File[];
}
function onSubmit(gallery: Gallery) {
gallery.images = upload(gallery.files);
gallery.files = [];
}
{img.name}
```
`value={gallery.files}` binds the file input to a `File[]` field. When the user
picks files, the runtime reads the bytes asynchronously and assigns the array.
The form's submit handler waits for the read to finish, then calls
`upload(...)`. The RPC's return value replaces `gallery.images`, which
re-renders the grid. Setting `gallery.files = []` after the RPC resolves clears
the file input via the same binding so the form is ready for the next batch.
The two fields are one object because `onSubmit` reassigns both. A handler can
mutate an object's fields and the change is live; reassigning a bare `images`
attribute from inside a function would update nothing
(`elements man html/reactivity`). The route's rows arrive as `initial` and seed
the object.
## Serving the bytes
The serve route returns binary bytes, not HTML, so it isn't a page. Create the
file by hand alongside the pages.
```ts app/routes/images.ts
import { sql } from "@elements/app";
export interface Image {
id: string;
name: string;
contentType: string;
data: Buffer;
}
const INLINE = new Set(["image/png", "image/jpeg", "image/gif", "image/webp"]);
export default function serveImage(req, res) {
let img = sql(`
select
id,
name,
contentType,
data
from images
where id = ${req.params.id}
`).firstOrThrow();
if (INLINE.has(img.contentType)) {
res.setHeader("Content-Type", img.contentType);
} else {
// Not a type we are willing to render on our own origin. Send it as an
// opaque download instead of letting the browser decide what it is.
res.setHeader("Content-Type", "application/octet-stream");
res.setHeader("Content-Disposition", "attachment");
}
return img.data;
}
```
`firstOrThrow()` returns a safe 404 if the id doesn't exist. Returning a `Buffer`
directly sends raw bytes; the Elements response layer recognizes `Buffer` and
skips the EJSON serializer.
## Never trust the uploaded content type
The allowlist appears twice on purpose, and the second one is the one that
matters.
`f.contentType` is supplied by whoever uploaded the file. It is not derived from
the bytes. If you store it and echo it back, someone uploads a file declaring
`text/html`, your serve route answers with `Content-Type: text/html`, and the
browser runs their markup **on your origin**, with your origin's full
privileges, including the signed-in user's session. The serve route decides
the content type, never the uploader.
Checking on upload is necessary but not sufficient: rows can predate the check,
arrive from a migration, or be written by another code path. The serve route is
the only place that decides what the browser is told, so the guard belongs
there too.
Two more things make this solid:
- Elements sends `X-Content-Type-Options: nosniff` on every response, so a file
whose declared type is `image/png` but whose bytes are HTML is not
reinterpreted. The allowlist and the header work together: the allowlist
controls what you declare, `nosniff` stops the browser second-guessing it.
- For anything beyond images, serve user content from a different origin. A
separate hostname keeps user content away from your session cookie.
## Object storage
For video, large media, or a bucket you already run, install an S3 client with
`elements install` and keep the `images` table as metadata plus the object key:
- Replace the `data bytea` column with `objectKey text not null`.
- In `upload`, write each `f.data` to the bucket under a new key, then insert
the row with that key.
- In the serve route, read the row, fetch the object by its key, and return
its bytes with the same `INLINE` check.
The allowlist, the gallery route and the template stay exactly as they are.
## Routes
Register both routes in `index.ts`:
```ts index.ts
import gallery from "#app/pages/gallery";
import serveImage from "#app/routes/images";
// ...
app.route("/gallery", gallery);
app.route("/images/:id", serveImage);
```
## Notes
- `File.data` is a `Uint8Array` on the browser. After the RPC, the server sees
the same bytes via the Elements JSON deserializer. The `${f.data}`
interpolation goes straight into the `bytea` parameter slot.
- The metadata query in the route handler omits `data` on purpose. Selecting `*`
would pull every image's bytes into the HTML render, which is usually
megabytes of base64 you don't need.
- `accept="image/*"` is a hint to the browser's file picker, not a server-side
check, and not a security boundary. The allowlist in the RPC is the check.