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/.
| 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.
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.
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.
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.
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>
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.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.
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.
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).
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.
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.