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 |
When Mode is omitted it defaults to Volume (server-side staging). For
backward compatibility, a config that sets External without an explicit
Mode is treated 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 no real cloud is needed.
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
}
Because multipart parts stream in body order, a form field is readable
mid-stream via ctx.GetString only if its input precedes the file input —
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.
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.