tinka alpha

File uploads

This guide covers accepting file uploads in Tinka. You will learn how to:

Overview

Browsers upload files in multipart/form-data forms. c.Body decodes them like any other form, and puts each file in a field of type *tinka.File, next to the form's other fields, so one type describes the whole form and one Valid method checks it.

Small files are kept in memory, and larger ones in temporary files. Either way they're removed when the request ends, so a handler saves the files it keeps.

Accept your first file

Declare a *tinka.File field with the name of the file input:

views/profile/edit.html
<form method="post" action="/profile" enctype="multipart/form-data">
  <input name="name">
  <input type="file" name="avatar" accept="image/png, image/jpeg">
  <button>Save</button>
</form>
profile.go
package main

import (
	"github.com/mucolabs/tinka"
)

type profileForm struct {
	Name   string      `form:"name"`
	Avatar *tinka.File `form:"avatar"`
}

type Profiles struct {
	Dir string // where avatars are stored
}

func (h Profiles) Update(c *tinka.Ctx) (tinka.Response, error) {
	in, err := c.Body[profileForm]()
	if err != nil {
		return nil, err
	}
	if in.Avatar != nil {
		path, err := in.Avatar.Save(h.Dir)
		if err != nil {
			return nil, err
		}
		_ = path // store the path with the user's profile
	}
	return tinka.Back("/profile"), nil
}

A file input left empty sends no file, so the field stays nil. A tinka.File describes the upload:

Filenamestring

The file's name on the client, without any directory. Show it to people, but never use it as a path.

Sizeint64

The file's size in bytes.

Typestring

The file's media type, such as image/png, read from its first bytes, so a file can't pass as an image by its name or its part's Content-Type. For formats those bytes can't tell apart, such as CSV and the zip-based office formats, it comes from the extension. A name never makes a file HTML, SVG, XML, a script, CSS or PDF: only its content can, so a script named page.html stays application/octet-stream or text/plain.

Ext()string

The extension of Filename, lowercased and without the dot: png for Photo.PNG. The client chose it, so check Type to know what the file is.

Fieldstring

The form field the file came in, such as photos[2].

Open()(multipart.File, error)

Opens the file for reading, for a handler that processes it instead of storing it.

Validate uploaded files

Check files in Valid, with the form's other fields, so every problem comes back in one 422. Check Type for the kind of file, and Size for its weight:

profile.go
func (f profileForm) Valid(ctx context.Context) map[string]string {
	problems := map[string]string{}
	if strings.TrimSpace(f.Name) == "" {
		problems["name"] = "Enter your name."
	}
	switch {
	case f.Avatar == nil:
	case f.Avatar.Type != "image/png" && f.Avatar.Type != "image/jpeg":
		problems["avatar"] = "Choose a PNG or JPEG picture."
	case f.Avatar.Size > 2<<20:
		problems["avatar"] = "Choose a picture under 2 MiB."
	}
	return problems
}

Save files

Save copies a file into a directory, creating it if needed, under a new random name, and returns the path. It never uses the client's file name, which could overwrite another file or reach outside the directory. The name's extension matches the file's Type, not its name, so a file server that picks the Content-Type by extension serves it as what it is: a PNG uploaded as page.html is saved as .png, and can't run as a page.

SaveAs copies a file to a path you choose. It fails with an error matching fs.ErrExist when the path exists, instead of overwriting it:

go
	err := in.Avatar.SaveAs(filepath.Join(h.Dir, user.ID+".png"))
	if errors.Is(err, fs.ErrExist) {
		// replace the old avatar deliberately: remove it, then save
	}

To serve saved files, send them with tinka.SendFile, or serve their directory with http.FileServerFS.

Accept several files

An input with multiple, or several inputs with the same name, sends several files. Declare a []*tinka.File field:

views/albums/new.html
<input type="file" name="photos" multiple>
albums.go
type albumForm struct {
	Title  string        `form:"title"`
	Photos []*tinka.File `form:"photos"`
}

Files nest like other form fields, so a list of photos, each with a caption, is a list of structs:

views/albums/new.html
<input name="photos[0][caption]">
<input type="file" name="photos[0][image]">
<input name="photos[1][caption]">
<input type="file" name="photos[1][image]">
albums.go
type albumForm struct {
	Title  string `form:"title"`
	Photos []struct {
		Caption string      `form:"caption"`
		Image   *tinka.File `form:"image"`
	} `form:"photos"`
}

Change the upload limit

A multipart body, files included, is at most 20 MiB, and has at most 1,000 parts. Raise the limit for the App with MaxUploadBytes, or for the routes that need it with tinka.BodyLimit:

main.go
	app.With(tinka.BodyLimit(500<<20)).Post("/videos", uploadVideo)

Up to 4 MiB of a form is kept in memory; the rest of its files go to temporary files in the system's temporary directory (TMPDIR on Unix), so a large upload costs disk, not memory.

See also: Body parser, Response, Exception handling