LiveTemplate provides a file upload system with four modes, chosen purely by
server config on an otherwise identical <input lvt-upload>:
| Mode | Bytes path | Server sees bytes? | Local disk? | Config |
|---|---|---|---|---|
| Volume (default) | browser → server → retained directory | yes | yes | Mode: UploadModeVolume, Dir: "..." |
| Direct | browser → cloud via presigned URL | no | no | Mode: UploadModeDirect, External: presigner |
| Proxied | browser → server → remote storage, streamed | yes | no | Mode: UploadModeProxied + OnUpload |
| Preview | stays on the device | metadata only | no | Mode: UploadModePreview |
livetemplate.WithUpload("avatar", livetemplate.UploadConfig{
Mode: livetemplate.UploadModeProxied, // stream to remote storage, zero disk
Accept: []string{"image/*"},
MaxFileSize: 5 << 20,
})
The mode is delivered to the client per-entry, so the same markup and the same
ctx.GetCompletedUploads(name) consumption work across every mode. See
Upload modes below and the upload-modes example.
Mode: UploadModeVolume (the default) stages bytes to the server's filesystem.
With Dir set the file is retained there and the app owns its lifecycle; with
no Dir it stages to a temp dir that is cleaned up when the connection closes
(the legacy stage-then-move pattern). Read the path from entry.TempPath.
Mode: UploadModeDirect with an External presigner has the browser PUT bytes
straight to S3/GCS/etc. via a presigned URL — they never touch the server. Read
the reference from entry.ExternalRef. (Setting External without an explicit
Mode is treated as Direct for backward compatibility.)
Mode: 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 {
// "id" must be ordered before the file input in the form (see below).
ref, err := myBackend.Put(ctx, ctx.GetString("id"), part.Filename, part)
if err != nil {
return err
}
part.SetResult(ref) // surfaced via GetCompletedUploads(...).ExternalRef
return nil
}
Inside OnUpload, ctx carries the request identity (ctx.UserID(),
ctx.GroupID()) and the form fields parsed before this file part, read via
ctx.GetString(...). Because parts stream in body order, a field is visible only
if its input precedes the file input — so order any field you need mid-stream
(e.g. the record id to route the bytes to the right destination) ahead of the
file input. Fields after the file part reach only the follow-on action.
The reader enforces MaxFileSize mid-stream, returning ErrUploadTooLarge (a
distinct sentinel, not io.EOF) so a truncated stream aborts instead of
committing a partial object. Because nothing stages to disk, a pure-Proxied app
needs no writable working directory and never creates .uploads.
Note: Adding a Proxied field routes every multipart POST to that handler through the streaming path, including requests carrying only Volume fields. Those Volume parts are staged to disk as usual (equivalent to the default path), so mixing modes on one handler is fine — just be aware the coupling exists.
Mode: UploadModePreview keeps the file in the browser; only its metadata
(name/type/size) reaches the server. Render the on-device preview with the
template helper:
<input type="file" lvt-upload="draft" accept="image/*" />
{{.lvt.UploadPreview "draft"}}
The client fills the placeholder from URL.createObjectURL and never uploads the
bytes. The server records a metadata-only entry (entry.Preview == true, no
TempPath/ExternalRef) readable via GetCompletedUploads.
Use WithUpload() to declare upload fields when creating a template:
tmpl := livetemplate.New("profile",
livetemplate.WithUpload("avatar", livetemplate.UploadConfig{
Accept: []string{"image/*"},
MaxFileSize: 5 * 1024 * 1024, // 5MB
MaxEntries: 1,
AutoUpload: true,
}),
)
handler := tmpl.Handle(&ProfileController{}, livetemplate.AsState(&ProfileState{}))
<form method="POST" enctype="multipart/form-data">
<input type="file" lvt-upload="avatar" accept="image/*" />
{{range .lvt.Uploads "avatar"}}
<div class="upload-entry">
<span>{{.ClientName}}</span>
<progress value="{{.Progress}}" max="100"></progress>
{{if .Error}}<span class="error">{{.Error}}</span>{{end}}
</div>
{{end}}
<button name="saveProfile" type="submit">Save Profile</button>
</form>
Access completed uploads and text fields via the Context. When a form with
enctype="multipart/form-data" is submitted, both file uploads and text fields
are available in the same action handler.
Note: The field name
datais reserved for the LiveTemplate client library's JSON encoding. Avoid naming a plain text fielddatain multipart forms.
func (c *ProfileController) SaveProfile(state ProfileState, ctx *livetemplate.Context) (ProfileState, error) {
// Text fields from the same form are available via ctx.GetString()
state.Name = ctx.GetString("name")
state.Email = ctx.GetString("email")
// File uploads are available via ctx.GetCompletedUploads()
for _, entry := range ctx.GetCompletedUploads("avatar") {
// entry.TempPath: server-side temporary file path
// entry.ClientName: original filename
// entry.ClientType: MIME type
// entry.ClientSize: file size in bytes
state.AvatarPath = moveToStorage(entry.TempPath)
}
return state, nil
}
Configure upload fields at template creation:
func WithUpload(name string, config UploadConfig) Option
Multiple upload fields can be configured on the same template:
tmpl := livetemplate.New("editor",
livetemplate.WithUpload("avatar", livetemplate.UploadConfig{
Accept: []string{"image/*"},
MaxFileSize: 5 << 20,
MaxEntries: 1,
}),
livetemplate.WithUpload("documents", livetemplate.UploadConfig{
Accept: []string{".pdf", ".doc", ".docx"},
MaxFileSize: 50 << 20,
MaxEntries: 10,
}),
)
Configures upload behavior for a specific field:
type UploadConfig struct {
Accept []string // Allowed MIME types or extensions (e.g., []string{"image/*", ".pdf"})
MaxEntries int // Maximum number of concurrent files (0 = unlimited)
MaxFileSize int64 // Maximum file size in bytes (0 = unlimited)
AutoUpload bool // Start upload automatically on file selection
ChunkSize int // Chunk size for WebSocket uploads in bytes (default: 256KB)
External Presigner // Optional presigner for direct-to-storage uploads
}
Represents a single uploaded file:
type UploadEntry struct {
ID string // Server-generated unique ID
ClientName string // Original filename from client
ClientType string // MIME type reported by the client
ClientSize int64 // File size in bytes
Progress int // Upload progress 0-100
Valid bool // Whether the upload passed validation
Done bool // Whether the upload has completed
Error string // Error message if validation or upload failed
TempPath string // Server-side temporary file path (server uploads only)
BytesRecv int64 // Bytes received so far (for progress tracking)
ExternalRef string // Presigned URL from Presigner (external uploads only)
CreatedAt time.Time
CompletedAt time.Time
}
| Method | Return Type | Description |
|---|---|---|
ctx.HasUploads(name) |
bool |
Check if any entries exist for a field (including in-progress) |
ctx.GetCompletedUploads(name) |
[]*UploadEntry |
Get all completed upload entries |
.lvt.Uploads "name"Iterate over upload entries for a specific field:
{{range .lvt.Uploads "avatar"}}
<div class="upload">
<span>{{.ClientName}} ({{.ClientSize}} bytes)</span>
<progress value="{{.Progress}}" max="100">{{.Progress}}%</progress>
{{if .Error}}
<div class="error">{{.Error}}</div>
{{end}}
{{if .Done}}
<span class="badge">Complete</span>
{{end}}
</div>
{{end}}
.lvt.HasUploadError "name"Check if an upload field has errors:
{{if .lvt.HasUploadError "avatar"}}
<div class="alert alert-error">
{{.lvt.UploadError "avatar"}}
</div>
{{end}}
.lvt.UploadError "name"Get the error message for an upload field:
<span class="error">{{.lvt.UploadError "documents"}}</span>
Import github.com/livetemplate/lvt/pkg/s3presigner to use the S3 presigner:
s3Config := s3presigner.S3Config{
Bucket: "my-uploads",
Region: "us-east-1",
KeyPrefix: "uploads", // Optional: organizes S3 keys
Expiry: 15 * time.Minute, // Presigned URL expiry
// Option 1: IAM role (recommended for production)
// Credentials auto-detected from environment
// Option 2: Static credentials
AccessKeyID: os.Getenv("AWS_ACCESS_KEY_ID"),
SecretAccessKey: os.Getenv("AWS_SECRET_ACCESS_KEY"),
// Option 3: Custom endpoint (MinIO, LocalStack)
Endpoint: "http://localhost:9000",
}
presigner, err := s3presigner.NewS3Presigner(s3Config)
if err != nil {
log.Fatal(err)
}
tmpl := livetemplate.New("photos",
livetemplate.WithUpload("photos", livetemplate.UploadConfig{
Accept: []string{"image/*"},
MaxFileSize: 10 << 20,
External: presigner, // Client uploads directly to S3
}),
)
Presigner.Presign(), stores the URL in UploadEntry.ExternalRef, and returns UploadMeta to the clientupload_complete - Server marks entries as donectx.GetCompletedUploads()For custom external upload providers:
type Presigner interface {
Presign(entry *UploadEntry) (UploadMeta, error)
}
type UploadMeta struct {
Uploader string // Provider name (e.g., "s3", "gcs", "azure")
URL string // Presigned upload URL
Fields map[string]string // Form fields for multipart POST providers (nil for PUT-based providers like S3)
Headers map[string]string // HTTP headers for the upload request
}
type AzurePresigner struct {
config AzureConfig
}
func (p *AzurePresigner) Presign(entry *livetemplate.UploadEntry) (livetemplate.UploadMeta, error) {
sasURL := p.generateSAS(entry.ClientName)
return livetemplate.UploadMeta{
Uploader: "azure",
URL: sasURL,
Headers: map[string]string{
"x-ms-blob-type": "BlockBlob",
},
}, nil
}
The LiveTemplate client automatically detects lvt-upload attributes on file inputs:
<input type="file" lvt-upload="avatar" accept="image/*" />
No manual initialization required.
const wrapper = document.querySelector('[data-lvt-id]');
// Progress updates
wrapper.addEventListener('lvt:upload:progress', (e) => {
const { entry } = e.detail;
console.log(`${entry.file.name}: ${entry.progress}%`);
});
// Upload complete
wrapper.addEventListener('lvt:upload:complete', (e) => {
const { uploadName, entries } = e.detail;
console.log(`Completed: ${uploadName}`, entries);
});
// Upload error
wrapper.addEventListener('lvt:upload:error', (e) => {
const { entry, error } = e.detail;
console.error(`Error uploading ${entry.file.name}:`, error);
});
Uploads are validated against UploadConfig:
livetemplate.UploadConfig{
Accept: []string{"image/jpeg", "image/png", ".jpg", ".png"},
MaxFileSize: 5 * 1024 * 1024, // 5MB
MaxEntries: 3, // Max 3 files
}
Validation checks:
Invalid files:
Valid: falseError field set with reasonGetCompletedUploads() resultsAccept: []string{
"image/*", // Any image MIME type
"image/jpeg", // Specific MIME type
".jpg", ".png", // File extensions
}
MIME types can be spoofed. Always validate file content in your action handler.
MaxFileSize: 10 * 1024 * 1024, // 10MB limit
S3 keys are sanitized using filepath.Base() to extract filename only.
Default: 256KB chunks. Tunable via ChunkSize:
ChunkSize: 512 * 1024, // 512KB chunks
lvt-upload attribute matches the field name in WithUpload()lvt-upload="fieldName" on the file inputAccept validation checks MIME type and extension — ensure both matchMaxFileSize is in bytes — use 5 << 20 for 5MB, not 5000000MaxEntries limits concurrent uploads per fieldfilepath.Base() — forward-slash paths are stripped (note: on Unix, backslash-separated paths like ..\..\..\etc\passwd are treated as literal filenames)MIME types can be spoofed. For security-critical uploads, validate actual file content:
import (
"fmt"
"io"
"net/http"
"os"
"strings"
"github.com/livetemplate/livetemplate"
)
func (c *Controller) SaveAvatar(state State, ctx *livetemplate.Context) (State, error) {
for _, entry := range ctx.GetCompletedUploads("avatar") {
detected, err := detectContentType(entry.TempPath)
if err != nil {
return state, fmt.Errorf("reading upload: %w", err)
}
if !strings.HasPrefix(detected, "image/") {
return state, fmt.Errorf("invalid file type: %s", detected)
}
}
return state, nil
}
func detectContentType(path string) (string, error) {
f, err := os.Open(path)
if err != nil {
return "", err
}
defer f.Close()
buf := make([]byte, 512)
if _, err := f.Read(buf); err != nil && err != io.EOF {
return "", err
}
return http.DetectContentType(buf), nil
}
lvt-upload attribute details