Forms and HTML

A field tagged form:"name" is bound from a form body, converted by the same setters that convert a query parameter. A route that binds form values and no files at all is not a special kind of route: it validates, documents and fails exactly like any other.

Binding a form

// LoginIn is the sign-in form.
//
// The two fields carry no location tag beyond `form`, so this route reads a
// form body rather than JSON and accepts both encodings a browser can produce:
// application/x-www-form-urlencoded, which a plain HTML form posts, and
// multipart/form-data, which one with an enctype does.
type LoginIn struct {
    Username string `form:"username" doc:"The account to sign in to"`
    Password string `form:"password" doc:"The account's password"`

    // Next is where to send the browser afterwards. A form value is required
    // like the body is, so an optional one says so.
    Next string `form:"next" required:"false" doc:"Where to redirect after signing in"`
}

A form value is body content, so it is required by default, unlike a query parameter. Mark an optional one required:"false" or give it a default.

type SearchForm struct {
    Query string `form:"q"`
    Page  int    `form:"page" default:"1"`
    Safe  bool   `form:"safe" default:"true"`
}

What a form route accepts

The input bindsAccepted media types
form fields onlyapplication/x-www-form-urlencoded and multipart/form-data
any file fieldmultipart/form-data only, because urlencoded cannot carry a file

That is why a plain HTML form with no enctype posts to a form route without being told to.

An input that binds form or file fields cannot also declare JSON members. A field with no location tag on such an input is a build error telling you to tag it with form or move it to the path, query, header or cookie.

Validating a form

// Validate bounds the credentials before any comparison is attempted.
//
// The rules are deliberately shape-only. A password that is too short is worth
// rejecting outright, but nothing here may hint at whether the account exists;
// that answer belongs to the handler, which gives the same one either way.
func (in *LoginIn) Validate(v *muzak.Validation) {
    v.String(&in.Username).Trim().Lower().MinLen(2).MaxLen(32)
    v.String(&in.Password).MinLen(8).MaxLen(128)
}

Failures are reported with "location": "body", because a form value is body content.

The handler

// Login establishes a session from a submitted form.
//
// By the time this runs the form has been read, the username trimmed and
// lower-cased and both lengths checked, so the handler is left with the one
// decision that is actually its own.
func Login(ctx *muzak.Context, in schemas.LoginIn) (schemas.LoginOut, error) {
    expected, known := accounts[in.Username]

    // The comparison runs even for an unknown account, and the same answer is
    // given either way. Returning "no such user" would turn this endpoint into
    // a way to enumerate accounts, and returning early would let its timing do
    // the same thing more quietly.
    matches := subtle.ConstantTimeCompare([]byte(expected), []byte(in.Password)) == 1
    if !known || !matches {
        return schemas.LoginOut{}, muzak.Unauthorized("the username or password is incorrect")
    }

    session := ctx.Session()
    // Regenerating comes first, before anything that says who the user is, so
    // a session someone else planted in the browser is worthless at the moment
    // it would have become valuable.
    if err := session.Regenerate(); err != nil {
        return schemas.LoginOut{}, err
    }
    if err := session.Set("user", in.Username); err != nil {
        return schemas.LoginOut{}, err
    }

    return schemas.LoginOut{
        Username: in.Username,
        Next:     in.Next,
    }, nil
}

// Logout ends the session: the cookie is removed from the browser.
func Logout(ctx *muzak.Context, _ muzak.Empty) (muzak.Empty, error) {
    ctx.Session().Destroy()
    return muzak.Empty{}, nil
}

The rate limit on a sign-in route is not optional in practice. See Rate Limiting for why the count belongs before the guards.

The cookie the session travels in is named __Host-session and is sent HttpOnly, Secure and SameSite=Lax without being asked; see Sessions. A form is the request another site can make a browser send, which is why sessions require AppOptions.CrossOriginProtection: a form posted from another origin is refused with 403 before the handler runs. See Cross-Origin Requests.

Returning HTML

A handler returning muzak.HTML bypasses JSON encoding entirely: the string is written verbatim under text/html, and the generated document describes the response as text/html rather than as a JSON schema.

// LoginForm serves the page that posts to Login.
//
// It is a plain form with no enctype, so the browser posts it as
// application/x-www-form-urlencoded. The route accepts that without being told
// to, because it binds form values and no files.
func LoginForm(ctx *muzak.Context, _ muzak.Empty) (muzak.HTML, error) {
    return muzak.HTML(`<body>
<form action="/login/" method="post">
<label>Username <input name="username" autocomplete="username"></label>
<label>Password <input name="password" type="password" autocomplete="current-password"></label>
<input type="hidden" name="next" value="/feed">
<input type="submit" value="Sign in">
</form>
</body>`), nil
}

Everything else about the route is unchanged: status, headers, cookies and errors work exactly as they do for a JSON route.

Escaping

The value is written as given. Muzak does not escape it, because it cannot tell markup the handler meant from text it did not. Anything a client supplied has to be escaped by the handler.

func Greeting(ctx *muzak.Context, in GreetIn) (muzak.HTML, error) {
    return muzak.HTML("<p>Hello, " + html.EscapeString(in.Name) + "</p>"), nil
}

For anything larger than a fragment, use html/template, which escapes by construction:

var page = template.Must(template.ParseFiles("templates/profile.html"))

func Profile(ctx *muzak.Context, in ProfileIn) (muzak.HTML, error) {
    var buf strings.Builder
    if err := page.Execute(&buf, in); err != nil {
        return "", err
    }
    return muzak.HTML(buf.String()), nil
}

Parse the templates once and publish the set with muzak.WithSingleton or muzak.Singleton rather than parsing per request. See Dependencies.

Redirecting after a post

func Login(ctx *muzak.Context, in schemas.LoginIn) (muzak.Empty, error) {
    // ... establish the session ...
    ctx.SetHeader("Location", in.Next)
    ctx.SetStatus(http.StatusSeeOther)
    return muzak.Empty{}, nil
}

Next came from the client, so treat it as input: validate it against a set of known destinations, or require it to be a relative path, before putting it in a Location header. A handler that returns muzak.Redirect{To: in.Next} has the second check made for it: a target that is not a path on this origin is refused with a 500 and never sent, and a redirect answering a POST is a 303 by default. See Files, Streams and Redirects.

Body limits

What bounds a form body depends on what the route binds:

The input bindsBounded byDefault
a file fieldMaxUploadSize32 MiB
form fields only, urlencodedMaxBodySize1 MiB
form fields only, multipartMaxBodySize1 MiB

A login or sign-up form that binds only form: values used to take its multipart/form-data body under the 32 MiB upload limit, so net/http held up to 20 MiB of text values (about 49 MiB of heap per request in flight) before any rule could run, and a file sent to it, which nothing reads, was spooled to disk first. A form-only route that legitimately receives more than MaxBodySize as multipart must raise MaxBodySize for it:

r.Post("/import", handlers.Import, muzak.MaxBodySize(8<<20))

A body that declares a Content-Length over the limit is refused before any of it is read. A route that declares a file: field keeps MaxUploadSize, declared once on the router that holds those routes:

r := muzak.NewRouter(
    muzak.WithTags("uploads"),
    muzak.MaxUploadSize(32<<20),
    muzak.MaxFileSize(10<<20),
)

A malformed form body is answered with a 422 and is not written to the error log: the parse error quoted the bad part header whole, so a 1 MiB request wrote a 5 MB log line.

Testing a form route

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

    form := url.Values{"username": {"muzak"}, "password": {"correct-horse-battery"}}
    res := client.Post("/login/", testclient.Body(
        "application/x-www-form-urlencoded", strings.NewReader(form.Encode())))

    res.AssertStatus(http.StatusOK)
    isSession := func(c *http.Cookie) bool { return c.Name == "__Host-session" }
    if !slices.ContainsFunc(res.Cookies, isSession) {
        t.Error("want the session cookie")
    }
}

Where to go next

File Uploads covers the other half of a multipart body, and Sessions covers the session a sign-in establishes.

Open source under MIT / Apache-2.0.Built on net/http, and nothing else.