Manual Recipes File Upload

File Upload

elements man recipes/file-upload Read as markdown

User picks one or more files in a form. The browser reads the bytes, an rpc inserts each file into a Postgres bytea column, and a separate route serves the bytes back so an <img src> can display them.

The <input type="file" value={files}> 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:

<input type="file" value={avatar}>            <!-- one File -->
<input type="file" multiple value={photos}>   <!-- a File[] -->
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.

elements create migration 'add images' -tables=images

app/migrations/<timestamp>-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

elements create page gallery

That writes app/pages/gallery/{index.ts, template.html, style.css, test.ts}. Add a services.ts sibling by hand for the ImageMeta interface and the upload rpc.

app/pages/gallery/services.ts:

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<ImageMeta>(
    `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:

interface PostForm {
  title: string;
  body: string;
  files: File[];
}

/** @rpc */
export function createPost(form: PostForm): Post {
  // form.files is a File[] like any other field
}
<input type="file" multiple value={form.files}>

File[] crosses the rpc boundary inside a form object exactly as it does on its own. A single File field works the same way:

interface ProfileForm {
  name: string;
  avatar: File;
}
<input type="file" value={form.avatar}>

Gallery route

app/pages/gallery/index.ts:

import { sql } from "@elements/app";
import gallery from "./template";
import { ImageMeta } from "./services";

export default function route(req, res) {
  let images = sql<ImageMeta>(
    `select id, name, contentType, size, createdAt from images order by createdAt desc`,
  ).all();

  return new gallery({ 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

app/pages/gallery/template.html:

import "./style.css";
import { File } from "@elements/app";
import { upload, ImageMeta } from "./services";

<html class="gallery"
      (images: ImageMeta[],
       private files: File[] = [])>
  <form onsubmit={() => { images = upload(files); files = []; }}>
    <input type="file" multiple accept="image/*" value={files}>
    <button type="submit">upload</button>
  </form>

  <ul class="gallery">
    <li e:for={img of images}>
      <img src={`/images/${img.id}`} alt={img.name}>
      <span>{img.name}</span>
    </li>
  </ul>
</html>

value={files} binds the file input to a File[] parameter. 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(files). The rpc's return value replaces images, which re-renders the grid. Setting files = [] after the rpc resolves clears the file input via the same binding so the form is ready for the next batch.

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.

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<Image>(
    `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. The Elements session cookie is readable from script by design, so that is not a defaced image, it is a stolen session.

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 means a hostile file cannot reach your session cookie even if everything else goes wrong.

Routes

Register both routes in 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.
  • For larger files, swap bytea storage for an S3 client (or any object store). The shape of this recipe stays the same; only the upload rpc and the serve route change.
  • 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.