Upload Modes — one input, four destinations

A file upload has to decide where the bytes go: staged on the server, sent straight to cloud storage, streamed through the server with nothing kept on disk, or never uploaded at all. LiveTemplate makes that a server config choice, not a markup or client-code choice. The HTML is the same plain <input lvt-upload> in every case; only UploadConfig.Mode differs. The full source is examples/upload-modes/.

The four modes

Mode Bytes path Server sees bytes? Local disk?
Volume (default) browser → server → retained directory yes yes
Direct browser → storage via presigned URL no no
Proxied browser → server → storage (streamed) yes no
Preview stays on the device metadata only no

Leave Mode out and it defaults to Volume (server-side staging). For backward compatibility, a config that sets External without an explicit LiveTemplate treats Mode as Direct.

One declaration per mode

Each field is the same WithUpload call with a different Mode — the example wires all four on one template:

livetemplate.WithUpload("volume",  livetemplate.UploadConfig{Mode: livetemplate.UploadModeVolume,  Dir: "storage/volume"}),
livetemplate.WithUpload("direct",  livetemplate.UploadConfig{Mode: livetemplate.UploadModeDirect,  External: presigner}),
livetemplate.WithUpload("proxied", livetemplate.UploadConfig{Mode: livetemplate.UploadModeProxied}), // controller implements OnUpload
livetemplate.WithUpload("preview", livetemplate.UploadConfig{Mode: livetemplate.UploadModePreview}),

The markup is identical across all four — the mode is invisible to the template:

<input type="file" lvt-upload="volume"  accept="image/*" />
<input type="file" lvt-upload="direct"  accept="image/*" />
<input type="file" lvt-upload="proxied" accept="image/*" />
<input type="file" lvt-upload="preview" accept="image/*" />

And consumption is uniform too — every mode surfaces its result through ctx.GetCompletedUploads(name), whatever path the bytes took.

Volume — staged on the server

The default. Bytes land on the server's disk; with Dir set they are retained there and your app owns the path (read it from entry.TempPath). This is the Avatar Upload recipe's mode. Use it when the server needs to see and keep the bytes.

Direct — browser uploads straight to storage

With an External presigner, the browser PUTs bytes straight to S3/GCS/etc. via a presigned URL — they never touch the server. Read the stored reference from entry.ExternalRef. To keep the example self-contained, its presigner points at the server's own /sink route, so it needs no real cloud.

Proxied — stream through the server, zero local disk

UploadModeProxied streams the in-flight bytes straight to a handler with no local-disk staging — ideal for forwarding to remote object storage. The controller implements UploadStreamer:

func (c *Controller) OnUpload(part *livetemplate.UploadPart, ctx *livetemplate.Context) error {
    recordID := filepath.Base(ctx.GetString("record_id")) // a field ordered before the file input
    dst := filepath.Join("storage/proxied", recordID, filepath.Base(part.Filename))
    // ... os.MkdirAll + os.Create ...
    if _, err := io.Copy(f, part); err != nil {
        return err
    }
    part.SetResult("/files/proxied/" + recordID + "/" + filepath.Base(part.Filename))
    return nil
}

Two conditions have to hold for record_id to reach that handler, and the markup carries both:

<form>
    <input type="text" name="record_id" lvt-upload-with />
    <input type="file" lvt-upload="proxied" accept="image/*" />
</form>
  1. The field carries lvt-upload-with. Since @livetemplate/client v0.19.1 nothing from the enclosing form travels with an upload unless it is marked. The old behaviour serialized every co-located field — CSRF tokens, hidden secrets — to the upload endpoint, and a Proxied upload auto-fires on file selection, so there was no submit-time moment for the user to notice. The denylist failed open; the opt-in fails closed. An unmarked field now shows up as a missing value in the handler rather than as a silent leak.
  2. It precedes the file input. Multipart parts stream in body order, so a field is only readable mid-stream via ctx.GetString if its input comes first — which is how the example routes each upload to its record's folder.

The result recorded with part.SetResult is later read back from entry.ExternalRef.

Marked fields are multipart-only

lvt-upload-with fields ride the multipart request, so whether they arrive depends on the transport the mode uses:

Mode Transport Marked fields arrive?
Proxied Always multipart Yes, always
Volume Chunked over WebSocket; multipart when the socket is down Only on the multipart fallback
Direct Presigned PUT, then a metadata-only completion No, on any path

The chunked transport sends bytes and entry ids, and Direct completes with a metadata-only message, so in both cases the server builds the completion action's context from an empty map. Since client v0.20.0 the client warns in the console when it finds marked fields it cannot deliver, naming the fields and the remedy — Volume delivers them on its multipart fallback, while Direct never does, so for Direct the record id belongs in controller state instead. Requires @livetemplate/client v0.20.0 or newer.

Preview — the file never leaves the device

UploadModePreview keeps the file in the browser; only its metadata (name/type/size) reaches the server. Render the on-device preview with a template helper:

<input type="file" lvt-upload="preview" accept="image/*" />
{{.lvt.UploadPreview "preview"}}

The client fills the placeholder from a local URL.createObjectURL and never uploads the bytes. The server records a metadata-only entry (entry.Preview == true, no TempPath / ExternalRef).

Works with the WebSocket disabled

Every mode completes over plain HTTP when the socket is down. Volume falls back to a single multipart POST that the server stages to Dir (#449); Direct presigns over HTTP, the browser PUTs, then the client re-sends the entry metadata over an HTTP completion handshake so upload_<field>_complete still runs (#448). Proxied and Preview are single requests and were already WS-independent. No app code changes — the same controller works on either transport.

Run it

cd examples/upload-modes
GOWORK=off ./run.sh

Open http://localhost:8087 and upload into each of the four cards in turn — each one stores (or previews) the file a different way while the markup stays identical. The end-to-end test drives all four modes in a real browser and asserts the Proxied upload stages zero files on local disk.

See also

source: livetemplate/docs · path: content/recipes/apps/upload-modes.md