Package upload (stdlib only) goes beyond storage.PutUploaded: it never buffers a whole file.
rules := upload.Images(5 << 20) // MaxFileSize and Types are required: zero Rules reject everything
rules.MaxFiles, rules.Dir = 3, "photos"
rules.Scanner = upload.ClamAV("127.0.0.1:3310") // optional
form, err := upload.Receive(c, disk, rules) // form.Values, form.Files[i].Path/Name/Type/Size/SHA256Streaming multipart#
Receivereadsr.MultipartReader()part by part and streams each file to thestorage.Disk: a 64 MiB upload allocates about 100 KiB.Rulesbound the file size, the whole request (MaxSize, defaultMaxFiles*MaxFileSize + 1 MiB, which replaces the router-wide body limit for that route), the file count, and field count and size. Sizes fail with 413; types, extensions and count with 422. On any error every file already written is deleted.- Type checks:
http.DetectContentTypeon the first 512 bytes must be inTypes, and the client's extension must be one known for that type (upload.Extensions("image/jpeg")→.jpg,.jpeg). The client'sContent-Typeis ignored. Stored names are random with the canonical extension; the sanitized client name (upload.SanitizeName) is metadata only. upload.Save(ctx, disk, rules, field, filename, r)applies the same checks to any reader (finished tus uploads, imports).- Image dimensions: a 50 KB file can declare a 100 000 × 100 000 canvas that takes gigabytes to decode (a decompression bomb).
upload.ImagessetsMaxPixelsto 25 megapixels: after storing a JPEG, PNG, GIF or WebP, only its header is read, and the file is deleted with 422 when the declared width × height is larger, or when the header cannot be read. Raise it for photo apps (rules.MaxPixels = 50_000_000);0turns the check off. Direct uploads get the same check inVerify.
Virus scanning#
With Rules.Scanner, files are written to QuarantineDir/.<random> (dot-named, so storage.Handler never serves them), scanned, then copied to Dir. ErrInfected deletes the file and answers 422; a scanner error deletes it and answers 503 (fail closed). upload.ClamAV(addr) speaks clamd's INSTREAM over TCP with per-operation timeouts.
Resumable uploads (tus)#
upload.Tus implements tus 1.0 core with the creation, expiration and termination extensions:
tus, err := upload.Tus(upload.TusConfig{
Dir: "storage/app/tus", BasePath: "/uploads/tus/", MaxSize: 2 << 30,
Authorize: func(r *http.Request) error { /* session or token check */ return nil },
OnComplete: func(ctx context.Context, info upload.TusInfo, open func() (io.ReadCloser, error)) error {
rc, err := open()
if err != nil {
return err
}
defer rc.Close()
_, err = upload.Save(ctx, disk, rules, "file", info.Filename(), rc)
return err
},
})
r.Handle("/uploads/tus/", tus)
app.OnShutdown(func(context.Context) error { return tus.Close() })POSTchecksUpload-LengthandUpload-Metadataand answers 201 with a randomLocation;HEADreturns the offset;PATCHneedsTus-Resumable: 1.0.0,Content-Type: application/offset+octet-streamand the currentUpload-Offset, and appends the body as it streams. An interrupted request keeps what arrived. ConcurrentPATCHes on one upload get 423.- The upload id is the capability, so
Authorizeis required (boot error when nil; returnnilexplicitly to allow everyone). - Abandoned uploads expire after
Expiry(24h); scheduletus.Cleanup(ctx)to delete them. When the last byte arrivesOnCompleteruns; its error goes to the client (422 for validation) and the partial data is deleted either way. Validation means*errs.ValidationError,z.FieldErrororz.Issues(the first message is the response text; a plainRefineerror reads "The value field is invalid."); anAuthorizeerror keeps its 4xx status (422 for z errors, anything else 403) but never its text.
Direct-to-S3 uploads#
Browsers can upload straight to the bucket. upload.Direct takes any upload.Presigner:
type Presigner interface {
PresignPut(ctx context.Context, key, contentType string, maxSize int64, ttl time.Duration) (url string, headers http.Header, err error)
}
direct, err := upload.Direct(upload.DirectConfig{Presigner: presigner, Rules: rules, Authorize: authorize})
r.Handle("/uploads/presign", direct)
// later, when the client reports the upload done:
file, err := upload.Verify(ctx, disk, key, rules)Direct answers POST {"filename","content_type","size"} with {"key","url","method":"PUT","headers","expires_at"} after checking the declared type, extension and size, under a random key in Rules.Dir (TTL 15 minutes, at most 1 hour). The declared type cannot be trusted, so upload.Verify sniffs the stored object and deletes it when it breaks the rules.
Thumbnails (contrib/imaging)#
Product photos, avatars and galleries need smaller copies. The optional module contrib/imaging makes them:
go get github.com/AlexGalhardo/vukem/contrib/imagingform, err := upload.Receive(c, disk, upload.Images(10<<20))
if err != nil {
return err
}
photo := form.Files[0]
// One size:
thumb, err := imaging.Thumbnail(c.Context(), disk, photo.Path, "products/"+id+"/thumb",
imaging.Options{MaxWidth: 400, MaxHeight: 400})
// thumb.Path = "products/<id>/thumb.jpg", thumb.Width, thumb.Height
// Several sizes from one decode:
sizes, err := imaging.Thumbnails(c.Context(), disk, photo.Path, "products/"+id, map[string]imaging.Options{
"sm": {MaxWidth: 320, MaxHeight: 320},
"lg": {MaxWidth: 1200, MaxHeight: 1200, Quality: 88},
})
// sizes["sm"].Path = "products/<id>-sm.jpg"What it does, step by step:
- Reads the file from the disk (at most
MaxBytes, 25 MiB by default) and picks the decoder from the content (JPEG, PNG, GIF, WebP), never the file name. - Reads only the header and refuses images that declare more than
MaxPixels(upload.DefaultMaxPixels, 25 megapixels) before decoding: a decompression bomb never gets decoded. - Scales down to fit
MaxWidth×MaxHeight, keeping the aspect ratio (CatmullRom filter). Small images are never upscaled. - Writes a JPEG (
Quality, default 82), or a PNG when the image has transparency, replacing a file already at that path. Re-encoding removes all metadata (EXIF, GPS location). EXIF orientation is not applied: photos taken sideways stay sideways. Animated GIFs become a still of their first frame.
| Error | When |
|---|---|
imaging.ErrTooLarge | the file is bigger than MaxBytes or declares more than MaxPixels |
imaging.ErrUnsupported | not a readable JPEG, PNG, GIF or WebP |
imaging.ErrInvalidOptions | MaxWidth/MaxHeight missing or above 16384, Quality outside 0–100, a Thumbnails size name that is not letters, digits, - or _ |
On failure the output is removed. Thumbnails applies the strictest MaxPixels/MaxBytes of all sizes (defaults included) to its single decode.
Memory: one call holds the source file plus its decoded pixels, up to about 8 bytes per pixel (16-bit PNG), so a 25-megapixel image can take ~200 MB. Resizing also takes CPU time. For big uploads, store the original in the request and make thumbnails in a queue job with limited concurrency.
HTMX progress component#
The HTMX starter ships resources/views/components/upload.html (a data-upload form with a <progress>) and a /uploads demo page. resources/js/app.ts swaps htmx's fetch for XMLHttpRequest on those forms to report progress, without inline scripts, so the CSP holds. The demo stores files under UPLOADS_DIR (default storage/app/uploads); UPLOADS_DISK=memory swaps in storage.NewMemory for tests (refused in production), so the generated TestUploads never writes to the filesystem: on Windows, antivirus filters briefly recreate just-deleted files, which can make t.TempDir cleanup fail with "directory is not empty". Use a memory disk the same way in your own upload tests. Validate files received through c.Input() with z.File() (validation).