Upload Reference

Overview

LiveTemplate provides a file upload system with support for:

Quick Start

1. Configure Uploads on Template

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{}))

2. Add Upload Input to Template

<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>

3. Process Uploads in Action Handler

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 data is reserved for the LiveTemplate client library's JSON encoding. Avoid naming a plain text field data in 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
}

Server API

WithUpload Option

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,
    }),
)

UploadConfig

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
}

UploadEntry

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
}

Context Upload Methods

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

Template Helpers

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

S3 / External Uploads

Setup S3 Presigner

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
    }),
)

S3 Upload Flow

  1. Client selects file - Sends file metadata to server
  2. Server generates presigned URL - Calls Presigner.Presign(), stores the URL in UploadEntry.ExternalRef, and returns UploadMeta to the client
  3. Client uploads directly to S3 - No server bandwidth used
  4. Client sends upload_complete - Server marks entries as done
  5. Action handler processes - Access via ctx.GetCompletedUploads()

Presigner Interface

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
}

Custom External Uploader

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
}

Client Library

The LiveTemplate client automatically detects lvt-upload attributes on file inputs:

<input type="file" lvt-upload="avatar" accept="image/*" />

No manual initialization required.

Upload Events

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);
});

Validation

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:

Security

File Type Validation

Accept: []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.

File Size Limits

MaxFileSize: 10 * 1024 * 1024, // 10MB limit

Path Traversal Prevention

S3 keys are sanitized using filepath.Base() to extract filename only.

Temporary File Security

Performance

Chunked Upload Sizes

Default: 256KB chunks. Tunable via ChunkSize:

ChunkSize: 512 * 1024, // 512KB chunks

Memory Usage

Troubleshooting

Upload Not Starting

Progress Not Updating

File Rejected by Validation

Temporary Files Not Cleaned Up

External Upload (S3) Errors

Content Validation

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
}

See Also