File Uploads

A field tagged file:"name" is bound from the part the client sent under that name, and its Go type decides what the handler is handed.

Field typeWhat the handler gets
[]byteThe content read straight into memory
muzak.FileThe metadata, with the content left where the parser put it
[]muzak.FileEvery file sent under the name
[][]byteThe content of every file sent under the name
// FileBytesIn binds one upload straight into memory, which suits a file small
// enough that holding all of it at once is not a decision worth thinking
// about.
type FileBytesIn struct {
    File []byte `file:"file" doc:"A file read as bytes"`
}

// UploadFileIn binds one upload alongside a form value, which is what an HTML
// form with a file input and a text input sends.
type UploadFileIn struct {
    File muzak.File `file:"file" doc:"A file read as an upload"`
    Note string      `form:"note" doc:"An optional note filed with the upload" required:"false"`
}

// MultiUploadIn binds every file sent under one name.
type MultiUploadIn struct {
    Files []muzak.File `file:"files" doc:"One or more files"`
}

A file is body content, so it is required by default. Mark an optional one required:"false".

Handlers

// FileSize reports the size of a file read into memory. Binding it as []byte is
// what makes that the whole handler.
func FileSize(ctx *muzak.Context, in schemas.FileBytesIn) (schemas.FileOut, error) {
    return schemas.FileOut{FileSize: len(in.File)}, nil
}

// UploadFile reports what a client sent with one file. Nothing is read here:
// muzak.File carries the metadata and leaves the content where the parser put
// it, so a handler that only needs the name never touches the bytes.
func UploadFile(ctx *muzak.Context, in schemas.UploadFileIn) (schemas.UploadFileOut, error) {
    return schemas.UploadFileOut{
        Filename:    in.File.Filename,
        ContentType: in.File.ContentType,
        Size:        in.File.Size,
        Note:        in.Note,
    }, nil
}

// UploadFiles reports every file sent under one name.
func UploadFiles(ctx *muzak.Context, in schemas.MultiUploadIn) (schemas.MultiUploadOut, error) {
    out := schemas.MultiUploadOut{Filenames: make([]string, len(in.Files))}
    for i, file := range in.Files {
        out.Filenames[i] = file.Filename
        out.TotalSize += file.Size
    }
    return out, nil
}

Working with muzak.File

type File struct {
    // Filename is the name the client reported for the file. It is arbitrary
    // client-supplied text and must never be used as a path, a database key or
    // anything else with meaning on the server without being checked first.
    Filename string
    // ContentType is the media type declared for the part, which is likewise
    // what the client claimed rather than what the bytes contain.
    ContentType string
    // Size is the number of bytes the file holds.
    Size int64
}
MethodWhat it does
Open()A reader over the content, positioned at the start. The caller owns and must close it. Opening more than once is allowed, and each reader has its own position
Bytes()The whole file in memory, as a fresh slice that stays valid after the request ends
Save(path)Copies the file to a path, creating or truncating it, and reports the bytes written
Header()The MIME headers of the part, for the occasional client that sends more than a filename and a content type
Present()Whether a file was uploaded at all. Only ever false for a field marked required:"false"

Open and Bytes report muzak.ErrNoFile when nothing was uploaded.

func StoreAvatar(ctx *muzak.Context, in AvatarIn) (AvatarOut, error) {
    user := muzak.From[core.CurrentUser](ctx)

    source, err := in.Avatar.Open()
    if err != nil {
        return AvatarOut{}, err
    }
    defer source.Close()

    // The destination is built from a directory the server controls and a name
    // the server generates. in.Avatar.Filename is never part of a path.
    name := uuid.NewV4().String() + extensionFor(in.Avatar.ContentType)
    destination := filepath.Join(storageDir, user.Username, name)

    written, err := in.Avatar.Save(destination)
    if err != nil {
        return AvatarOut{}, err
    }
    return AvatarOut{Name: name, Size: written}, nil
}

Lifetime

A part larger than 10 mebibytes spills to a temporary file while the body is parsed, and every temporary file is removed once the handler returns. Open and Bytes are therefore only valid while the handler runs: anything that must outlive the request has to be copied out of it first, with Save or otherwise.

That threshold is deliberately well below the default upload limit, so a handful of concurrent large uploads cannot be turned into memory pressure.

Nothing the client sends is trusted

Filename and ContentType are text the client chose. A client may send ../../etc/passwd, a name that means something to the local filesystem, or a Content-Type that has nothing to do with the bytes behind it.

  • Build a destination from a directory the server controls and a name the server generates.
  • Sniff the content if the type matters, rather than believing the declaration.
  • Echo the filename back if you like, but never route on it.

Limits

Two limits bound what a route accepts, and both are declared rather than remembered.

// Uploads returns the router for the endpoints that accept files.
//
// The two limits are declared once for the whole router, so a route added here
// later cannot forget them: MaxUploadSize bounds what the server will read at
// all, and MaxFileSize bounds any single file inside it.
func Uploads() *muzak.Router {
    r := muzak.NewRouter(
        muzak.WithTags("uploads"),
        muzak.MaxUploadSize(32<<20),
        muzak.MaxFileSize(10<<20),
    )

    r.Get("/upload", handlers.UploadForm,
        muzak.Summary("Serve a form that posts files"))

    r.Post("/files/", handlers.FileSize,
        muzak.Summary("Report the size of a file read as bytes"))

    r.Post("/uploadfile/", handlers.UploadFile,
        muzak.Summary("Report what was sent with one file"))

    r.Post("/uploadfiles/", handlers.UploadFiles,
        muzak.Summary("Report what was sent with several files"))

    return r
}
LimitApplies toDefaultBehaviour when exceeded
MaxUploadSizeThe whole form bodyDefaultMaxUploadSize, 32 MiB413 while the body is being read, so the server never buffers more than the limit
MaxFileSizeAny single file in itunset, so each file is bounded only by the upload limit413 before the handler runs

Both are settable application-wide through AppOptions.MaxUploadSize and AppOptions.MaxFileSize, and both are shared options, so a router or a single route can narrow them. A route that binds form or file fields uses MaxUploadSize in place of MaxBodySize, because an upload is expected to be larger than a JSON document and the two limits should not have to be traded off against each other.

The refusal names the field rather than the client's filename, which is text the client chose and would otherwise be echoed straight back:

{
  "error": {
    "code": "payload_too_large",
    "message": "a file uploaded as \"file\" exceeds the 10485760 byte limit for a single file on this route",
    "status": 413
  },
  "request_id": "0611f4b2-2f0a-4b57-9c1a-6e6a2e2f9b31"
}

A form that posts them

// UploadForm serves the page that posts to UploadFiles. Returning muzak.HTML
// is what bypasses JSON encoding; the document is written as it stands.
func UploadForm(ctx *muzak.Context, _ muzak.Empty) (muzak.HTML, error) {
    return muzak.HTML(`<body>
<form action="/uploadfiles/" enctype="multipart/form-data" method="post">
<input name="files" type="file" multiple>
<input type="submit">
</form>
</body>`), nil
}

A route that binds any file accepts multipart/form-data only, which is why the form above carries an enctype. A route binding form values and no files also accepts application/x-www-form-urlencoded. See Forms and HTML.

Validating an upload

Form values alongside a file validate like anything else, and a rule on the file itself is a Value rule.

func (in *UploadFileIn) Validate(v *muzak.Validation) {
    v.String(&in.Note).Trim().MaxLen(280)
    v.Value(&in.File).Must(func(f muzak.File) error {
        switch f.ContentType {
        case "image/png", "image/jpeg", "image/webp":
            return nil
        default:
            return errors.New("must be a PNG, JPEG or WebP image")
        }
    })
}

A declared content type is a hint, not proof. Where it matters, read the first bytes and check them.

Testing an upload

func TestUploadFile(t *testing.T) {
    client := testclient.New(t, buildApp())

    var body bytes.Buffer
    form := multipart.NewWriter(&body)
    part, err := form.CreateFormFile("file", "notes.txt")
    if err != nil {
        t.Fatalf("CreateFormFile = %v", err)
    }
    if _, err := part.Write([]byte("hello")); err != nil {
        t.Fatalf("Write = %v", err)
    }
    if err := form.Close(); err != nil {
        t.Fatalf("Close = %v", err)
    }

    res := client.Post("/uploadfile/", testclient.Body(form.FormDataContentType(), &body))

    res.AssertStatus(http.StatusOK)
    res.AssertJSON(`{"filename":"notes.txt","content_type":"application/octet-stream","size":5,"note":""}`)
}

Where to go next

Forms and HTML covers the rest of a multipart body, and Static Files and Frontends covers serving files back out.

Open source under MIT / Apache-2.0 · sustained by the people who ship on it.Built on net/http, and nothing else.