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 binds | Accepted media types |
|---|---|
form fields only | application/x-www-form-urlencoded and multipart/form-data |
any file field | multipart/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 binds | Bounded by | Default |
|---|---|---|
a file field | MaxUploadSize | 32 MiB |
form fields only, urlencoded | MaxBodySize | 1 MiB |
form fields only, multipart | MaxBodySize | 1 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.