disk, err := storage.NewLocal("storage/app/public", "/storage") // os.Root: no "..", no escaping symlinks
r.Handle("/storage/", http.StripPrefix("/storage", storage.Handler(disk, nil)))
path, err := storage.PutUploaded(ctx, disk, fileHeader, "avatars", storage.Images(2<<20))
url := disk.URL(path)Disks#
storage.Disk has Put (atomic rename on local disks), Open, Exists, Delete, List and URL; storage.Get and storage.PutBytes are helpers. storage.NewMemory(baseURL) serves tests, and S3 lives in its own module. Custom disks pass storage/storagetest (conformance and traversal suites).
Serving files#
storage.Handler(disk, signer) never serves dotfiles or directories and sends X-Content-Type-Options: nosniff. With a signed.Signer it answers only signed, unexpired URLs:
private, _ := storage.NewLocal("storage/app/private", "")
r.Handle("/files/", http.StripPrefix("/files", storage.Handler(private, signer)))
link, err := storage.TemporaryURL(signer, "/files", "invoices/42.pdf", 15*time.Minute)Public files are served by the handler instead of a storage:link symlink (which needs extra privileges on Windows).
Uploads#
PutUploaded never uses the client's file name: it stores a random name with an extension from the type detected in the content (http.DetectContentType), checks the size from the header and again while copying, and answers 422 for disallowed types or sizes.
storage.Images(2 << 20) // JPEG, PNG, GIF, WebP up to 2 MiB
storage.UploadRules{MaxSize: 10 << 20, Types: []string{"application/pdf"}}For streaming, resumable (tus), scanned and direct-to-bucket uploads, see File uploads.
S3#
go get github.com/AlexGalhardo/vukem/drivers/s3A storage.Disk on Amazon S3 or any S3-compatible service (Cloudflare R2, DigitalOcean Spaces, Backblaze B2, RustFS, SeaweedFS, Garage). There is no AWS SDK: requests are signed by a standard-library Signature Version 4 implementation verified against the AWS test suite.
cfg, err := config.Load[s3.Config](app.Source)
disk, err := s3.New(cfg) // validates the config, no I/O
err = disk.Put(ctx, "videos/intro.mp4", file) // one PUT up to S3_PART_SIZE, multipart above
info, err := disk.Stat(ctx, "videos/intro.mp4") // Size, ETag, ContentType, LastModified
up, err := disk.PresignPut("uploads/avatar.png", 5*time.Minute) // browser uploads straight to the bucket
up, err := disk.PresignPutWith("uploads/avatar.png", 5*time.Minute, s3.PresignPutOptions{
ContentType: "image/png", ContentLength: size, // signed: the upload must send exactly these
})
link, err := disk.PresignGet("invoices/42.pdf", time.Hour)| Variable | Default | Meaning |
|---|---|---|
S3_BUCKET | — | bucket name (3–63 of a-z 0-9 . -) |
S3_REGION | us-east-1 | signing region (auto for R2) |
S3_ENDPOINT | https://s3.<region>.amazonaws.com | origin of an S3-compatible service |
S3_ACCESS_KEY_ID, S3_SECRET_ACCESS_KEY | — | required; S3_SESSION_TOKEN for temporary credentials |
S3_USE_PATH_STYLE | false | endpoint/bucket/key instead of bucket.endpoint/key (most self-hosted servers) |
S3_INSECURE_HTTP | false | allow an http:// endpoint (local development only) |
S3_PART_SIZE | 8 MiB | multipart part size, 5 MiB to 5 GiB |
S3_PUBLIC_URL | — | prefix for disk.URL (CDN or public bucket); empty means private |
Putbuffers at most one part. A multipart upload is aborted on any failure (part error, reader error, cancelled context, a200carrying an<Error>), so no orphaned parts are billed. The content type comes from the extension, else from the first bytes.Openstreams the object;Listfollows continuation tokens and skips folder placeholders. Missing objects matchfs.ErrNotExist; other failures are*s3.Error(StatusCode,Code,Message,RequestID).disk.CreateBucket(ctx)creates the bucket inS3_REGION, for dev setup and tests against a fresh local S3 (RustFS, MinIO). A bucket you already own is not an error; a name taken by another account is an*s3.ErrorwithCodeBucketAlreadyExists.- Keys go through
storage.Clean: no empty key, no leading/, no.., backslash or:. - Presigned URLs expire after 1 second to 7 days.
PresignGetandPresignPutsign only thehostheader, so aPresignPutURL accepts any type and size.PresignPutWithalso signsContent-Typeand/orContent-Length(s3.PresignPutOptions): the uploader must send exactly those values or the bucket answers 403. Browsers setContent-Lengthfrom the body, but theContent-Typeheader must be sent explicitly:fetch(url, {method: "PUT", headers: {"Content-Type": type}, body: file}). A signedContent-Lengthis an exact size, not a ceiling: pass the real file size the client declared (after checking it against your maximum), never the maximum itself, or every smaller file gets 403. Credentials areconfig.Secret, so errors and%+vnever show them. - Integration tests run against RustFS (
task services:up); the driver passesstoragetest.Runandstoragetest.RunTraversal.