Upload Reference

Overview

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.

Upload modes

Volume — staged to the server's disk

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.

Direct — browser uploads straight to cloud storage

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

Proxied — stream through the server with zero local disk

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.

Preview — file stays on the device

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.

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

source: livetemplate/livetemplate · path: docs/references/uploads.md · ref: v0.13.0 · commit: 4c5f1c71b2de