LiveTemplate provides a file upload system with support for:
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