You are reading the 0.3.0 documentation. The current release is 0.3.1. Read this page for it →

Files, Streams and Redirects

A handler's return type is encoded as JSON unless it is one of the types that say otherwise. Muzak recognises them when the route is registered, writes each the way it needs to be written, and describes each in the OpenAPI document as what it actually sends.

ReturnSendsDocumented as
muzak.HTMLa page, under text/htmltext/html
muzak.Bytesa body already in memory, under the media type it namesbinary content of the types Produces lists
muzak.Streama body read as it is sent, closed on every paththe same
muzak.FileResponseone file from an fs.FS, with ranges and conditional requeststhe same
muzak.Redirecta Location and no bodythe redirect status and a Location header

Everything else about the route is unchanged: guards, providers, the rate limit, errors and the access log work exactly as they do for a JSON route.

Bytes

r.Get("/reports/{id}.csv", func(ctx *muzak.Context, in ReportIn) (muzak.Bytes, error) {
    csv, err := reports.Render(ctx.Context(), in.ID)
    if err != nil {
        return muzak.Bytes{}, err
    }
    return muzak.Bytes{
        ContentType: "text/csv; charset=utf-8",
        Data:        csv,
        Filename:    "report-" + in.ID + ".csv",
        Download:    true,
    }, nil
}, muzak.Produces("text/csv"))

The body is written as given, with a Content-Length. The status follows muzak.Status and ctx.SetStatus as it does for JSON, and 204 and 304 send no body at all.

ContentType is the media type the body is sent as. Left empty it is application/octet-stream, which a browser saves rather than renders. It must be a media type as RFC 9110 spells it, a type and a subtype with optional parameters; anything else, a line break or any other control character included, fails the request with a 500 and the cause is logged, rather than reaching the header. Every one of these responses carries X-Content-Type-Options: nosniff, even with the security headers turned off, so a browser believes the type it is given instead of guessing from the bytes.

Filename and Download build the Content-Disposition header for you; see Offering a download.

An error renderer may answer with Bytes too, which is how an application sends its failures in a format of its own.

Stream

For a body too large to hold, or one produced as it is sent:

r.Get("/exports/{id}", func(ctx *muzak.Context, in ExportIn) (muzak.Stream, error) {
    object, err := bucket.Open(ctx.Context(), in.ID)
    if err != nil {
        return muzak.Stream{}, muzak.NotFound("").Wrap(err)
    }
    return muzak.Stream{
        ContentType: "application/zip",
        Body:        object,
        Length:      object.Size(),
    }, nil
}, muzak.Produces("application/zip"))

Length is sent as Content-Length, so a client can show progress and tell a truncated body from a complete one. Zero or less means it is not known, and the body is sent chunked.

Muzak owns Body from the moment the handler returns it. When it is an io.Closer it is closed exactly once, on every path:

The request......and the body is
is answeredcopied, then closed
is a HEADclosed without being read
answers 204 or 304closed without being read
failed in the handler, which returned an error beside the streamclosed without being read, and the error rendered
failed while the body was readclosed, and the connection aborted
lost its client part wayclosed, and the connection aborted
panicked while the body was readclosed, and the panic recovered

Do not close it yourself. The copy goes through a pooled 32 KiB buffer, so a server streaming many bodies does not allocate one per response. A Stream with no Body fails with a 500; return Bytes for a body that is empty.

Once the header is sent the response cannot become an error, so a body that fails part way, ends before the Length it declared or runs past it, is logged and the connection aborted: the client sees a failed transfer rather than a body that ends cleanly and looks complete. The body is held to its Length by Muzak itself, so this holds under Compression too, which takes the Content-Length off.

The route's Acquire releases run before the header is sent, so that a commit that fails can still answer with an error, and therefore before Body is read at all. A Body must not read from anything an Acquire provider handed out, such as rows of the request's Transaction, which by then has been committed or rolled back. It opens what it reads itself, and closes it in its own Close. See Dependencies.

Muzak cannot interrupt a Read that blocks. A body whose Read can wait, such as an io.Pipe fed by a goroutine, should end when ctx.Context() is done; a body from an outbound request made with that context already does.

reader, writer := io.Pipe()
// The goroutine keeps the request's context, never ctx itself: the Context is
// pooled and handed to another request once the handler returns.
requestCtx := ctx.Context()
go func() {
    // CloseWithError ends the copy with the producer's failure, and a write
    // after the response is closed fails, which is what stops this goroutine
    // when the client goes away.
    writer.CloseWithError(export.WriteCSV(requestCtx, writer))
}()
return muzak.Stream{ContentType: "text/csv", Body: reader}, nil

For a sequence of events rather than one body, use Server-Sent Events, which keeps the typed contract.

Serving a file

var uploads, _ = os.OpenRoot("/var/lib/app/uploads")

r.Get("/files/{name...}", func(ctx *muzak.Context, in FileIn) (muzak.FileResponse, error) {
    return muzak.FileResponse{FS: uploads.FS(), Name: in.Name, Download: true}, nil
})

The file is served with http.ServeContent, so a Range request is answered with the ranges asked for, and If-Modified-Since, If-Unmodified-Since and If-Range are answered from the file's modification time. A HEAD request gets the headers and no body, and the file is always closed. A FileResponse with no FS fails with a 500.

For a whole directory of assets rather than one file a handler chose, mount it with Router.Static instead; see Static Files and Frontends.

The name is checked before anything is opened

Name is very often what a client asked for, so it is checked first, and a name that fails is answered 404, exactly as a file that is not there would be, without the filesystem being asked to open it.

A name that......is refused because
leaves the filesystem (../secret, /etc/passwd, a//b, a trailing /)fs.ValidPath refuses it
names a dotfile or anything inside a dot directory (.env, .git/config, .well-known/x)a deployment directory is where those end up by accident
holds a backslash, NUL or any other control charactera filesystem may read it as a separator or the end of the name
on Windows, names a drive or a stream (C:x, file.txt:$DATA)it opens something other than what it spells
on Windows, names a device (CON, nul.txt, COM1, LPT1)Windows opens the device whatever the extension
on Windows, ends a segment with a dot or a space (secret.txt.)Windows drops it, so the name opens another file
on Windows, is shaped like an 8.3 short name (GIT~1)it opens the long name it abbreviates, which no check has seen

A directory, a device, a pipe and anything else that is not a regular file is answered 404 as well: a link to /dev/zero would otherwise be read for as long as the client cared to wait. A file that does not exist logs nothing. Any other failure to open it, a permission or a link the root refused to follow, is a 404 to the client and logged for you.

The check covers the name and nothing else. os.DirFS follows a symbolic link wherever it points, so a link planted inside the directory serves whatever file it names. Serve a directory on disk through the FS of an os.Root, opened with os.OpenRoot, which refuses to leave it. An embed.FS has no links.

What a file is served as

The type comes from the extension, with the same table Router.Static uses, and never from the content, so a file with no extension is application/octet-stream. Set ContentType to name one yourself; it is checked as Bytes.ContentType is.

An HTML, SVG or other XML file is sent under Content-Security-Policy: sandbox, so a file a client uploaded cannot run script as your application when it is opened directly. Set AllowActiveContent for a file that is part of your site.

A file from a filesystem whose files cannot seek, such as a zip archive, is buffered up to 8 MiB so ranges keep working, and streamed without them past that.

The status follows muzak.Status and ctx.SetStatus. 200 is answered by http.ServeContent; 204 and 304 send no body; any other status, a custom 404 page say, sends the whole file without reading the range or conditional headers. When http.ServeContent answers a range it cannot serve with 416, it clears Cache-Control; a response that carries a session cookie is kept private regardless.

Offering a download

Bytes, Stream and FileResponse share two fields:

FieldsHeader
neithernone
Filename: "notes.txt"Content-Disposition: inline; filename="notes.txt"
Download: trueattachment, with the file's own name for a FileResponse
bothattachment; filename="..."

Never build the header by hand from a name a client chose. The filename is cleaned before it is sent: control characters (CR, LF and NUL among them), double quotes, the Unicode controls that reorder text (which would let invoice + U+202E + fdp.exe display as invoiceexe.pdf), and line and paragraph separators are removed; a slash or a backslash becomes _; and the name is cut to 255 bytes. A name with characters outside ASCII is sent twice, as RFC 6266 asks: an ASCII stand-in in filename and the name in full in filename*, encoded as RFC 8187 describes.

Content-Disposition: attachment; filename="r_sum_ _.pdf"; filename*=UTF-8''r%C3%A9sum%C3%A9%20%E2%82%AC.pdf

Redirects

r.Post("/login", func(ctx *muzak.Context, in LoginIn) (muzak.Redirect, error) {
    if err := accounts.SignIn(ctx, in); err != nil {
        return muzak.Redirect{}, err
    }
    return muzak.Redirect{To: "/dashboard"}, nil
})

A redirect is sent with a Location and no body. Its status is Redirect.Status when set, otherwise the route's own when that is a redirect status, and otherwise 302 Found for GET and HEAD and 303 See Other for every other method. After a form is posted, 303 is the one status every client follows with a GET, which is what keeps a refresh from posting the form again; for a GET, 302 is the conventional "look over there".

StatusUse it forThe client repeats
301a move that is permanentas a GET
302a move that is notas a GET
303"the answer is over there", after a POSTas a GET
307a temporary move that must keep the method and bodythe same request
308a permanent move that must keep the method and bodythe same request

Any other Status fails the request with a 500 and a logged cause, and declaring a status that is not a redirect on a route returning Redirect is a build error.

Open redirects are refused

A redirect to a place a client named is how a phishing link borrows your domain: https://api.example.com/login?next=//evil.com looks like yours until the browser lands on evil.com. So by default a Redirect only goes to a path on your own origin, and the check is made on every response:

A target that......is
begins with exactly one / (/dashboard, /items/42?tab=history#top)sent
begins with // or /\ (//evil.com, /\evil.com)refused: a browser reads it as another host
holds a backslash, a space, a tab or a line break anywhererefused: a browser strips or rewrites them before it reads the URL
holds any other control character, or a character outside ASCIIrefused: percent-encode it, as url.URL.String does
holds a % that does not begin an escape (/50%off)refused: write it %25
decodes, once or up to three times, to // or /\ (/%2F/evil.com, /%252F%252Fevil.com)refused: a proxy or a framework that decodes again reads another host
has a path that decodes to a control character (/%0d%0aSet-Cookie:x)refused
reaches // once dot segments are resolved (/.//evil.com, /%2e%2e//evil.com)refused
is relative without a leading / (evil.com, ../x)refused
uses a scheme other than http or https (javascript:, data:)refused
is an absolute URL to a host nothing listsrefused
is empty, or longer than 8 KiBrefused

A query and a fragment may hold anything a URI may, so /login?next=%2F%2Fevil.com is fine: it is your own login page that has to check next before it follows it.

A refusal fails the request with a 500, and the log names the reason but not the target, which may carry a token in its query.

Absolute URLs

An absolute http or https URL is sent only when its host is listed with RedirectHosts, on the route, a router above it or the application:

app := muzak.New(opts, muzak.RedirectHosts("accounts.example.com", "localhost:5173"))

A listed host is compared without regard to case, and must otherwise be exactly the one listed:

TargetWith RedirectHosts("accounts.example.com")
https://accounts.example.com/cbsent
https://ACCOUNTS.example.com/cbsent
https://accounts.example.com:443/cbsent: the scheme's default port
https://accounts.example.com:8443/cbrefused: another port is another service
https://accounts.example.com./cbrefused: a name with a trailing dot is not the name listed
https://accounts.example.com.evil.com/refused
https://sub.accounts.example.com/refused: there are no wildcards
https://accounts.example.com@evil.com/refused: user information disguises the real host
https://accounts%2Eexample.com/refused: a host is never percent-encoded
https:accounts.example.comrefused: an absolute URL names its host after //

An entry is a host name or an IP address with an optional port, an IPv6 address in brackets. An entry without a port admits the scheme's default port only, written or not; one with a port admits that port only. Anything else, a scheme, a path, user information, a wildcard or a trailing dot, is a build error, reported for each route that can answer with a Redirect. A list is read only by such routes, so a mistake in one declared on the application is not reported until a route returns Redirect. Lists add up: a route may send a client to any host it or a router above it lists.

For a target your handler built from its own configuration rather than from the request, such as an identity provider's authorization endpoint, set External:

return muzak.Redirect{To: oauth.AuthCodeURL(state), External: true}, nil

External vouches for the host and nothing else: the target must still be a well-formed http or https URL with a host and no user information. Never set it for a URL taken from the request.

An error renderer may answer with a Redirect too, which is how a missing session sends a browser to the login page. It is checked the same way, against the hosts of the route that failed, and only a route that itself returns Redirect reads its hosts. A path on your own origin is always fine; a renderer sending a client to another host sets External for the URL it built.

Entity tags

app := muzak.New(opts, muzak.AutoETag())

AutoETag tags a response with an entity tag computed from its body, and answers a request that already holds that body with 304 Not Modified, so the client keeps its copy and the bytes are not sent again. It is a shared option: declare it on the application (muzak.New or app.Options), a router, an Include or a single route. Once on, it stays on for everything beneath: a tag only ever saves sending a body the client already has, so there is nothing a route below gains by turning it back off.

It applies to a GET or HEAD answered 200 with a body Muzak holds in full: JSON, HTML and Bytes. The tag is strong, the first 128 bits of the body's SHA-256 in unpadded base64url, quoted, so it changes whenever a byte of the body does and only then. JSON is encoded with map keys sorted on a tagged route, so the same value always produces the same tag. A handler that sets its own ETag keeps it, and the request is answered against that one.

GET /items/42 HTTP/1.1
If-None-Match: "q1Hs3cWc9mJfV8x1Zr0b2A"

HTTP/1.1 304 Not Modified
ETag: "q1Hs3cWc9mJfV8x1Zr0b2A"

If-None-Match is compared weakly, as RFC 9110 requires, so W/"..." matches too, and * matches any body. It is read as a list, in a single pass, so a tag holding a comma is found where it is; a field that is not a well-formed list is ignored and the full response sent, because a full response is always correct and a 304 sent on a misreading is not.

The 304 keeps what RFC 9110 asks for, ETag, Cache-Control, Content-Location, Date, Expires and Vary, and drops what describes the body it does not send: Content-Type, Content-Length, Content-Encoding, Content-Language, Content-Disposition, Last-Modified and the rest. Headers about the exchange rather than the body, such as Set-Cookie, the CORS answer and the request identifier, are kept.

It composes with what surrounds it:

  • Compression weakens the tag of a body it compresses, "v1" to W/"v1", and of the 304 it answers for one, so a cache can use the 304 to refresh what it stored. The weak tag the client sends back still matches. See Compression.
  • A guarded route keeps Cache-Control: private, no-cache on its 304, so a shared cache never stores it. A guard still runs first, so a client it refuses is refused whatever tag it holds, and a different user with someone else's tag is sent their own body.
  • A Stream has no body to hash until it is sent, and a FileResponse is answered by http.ServeContent, which reads the conditional headers itself, so neither is tagged.
  • Anything else is left alone: a POST, a 201, an error, a redirect.

The body is hashed on every tagged request, which costs time linear in its size: the 304 saves the bytes and the client's work, not the handler's. A route that does not declare it pays nothing, not even a look at the request's headers.

In the document

Bytes, Stream and FileResponse are described as binary content, under application/octet-stream unless the route lists its types with Produces:

r.Get("/avatars/{id}", handlers.Avatar, muzak.Produces("image/png", "image/webp"))
"200": {
  "description": "OK",
  "content": {
    "image/png": {"schema": {"type": "string", "format": "binary", "contentMediaType": "image/png"}},
    "image/webp": {"schema": {"type": "string", "format": "binary", "contentMediaType": "image/webp"}}
  }
}

format: binary is what client generators read as "a file, not text to parse", and contentMediaType says the same thing in JSON Schema's terms. A range such as image/* is accepted and carries no contentMediaType. Produces only describes: the type a response is sent with is the one the value names. It is a route option, declared on the route it describes. Using it on a route returning anything else, JSON included, giving it no types, or giving it something that is not a media type is a build error.

A Redirect is described with its status, no content and a Location header:

"303": {
  "description": "See Other",
  "headers": {
    "Location": {
      "description": "Where the client is sent: a path on this origin, or an absolute URL to a host the route allows.",
      "required": true,
      "schema": {"type": "string", "format": "uri-reference"}
    }
  }
}

WithResponseModel[muzak.Bytes] (or Stream, or FileResponse) describes another status as binary content under application/octet-stream, and WithResponseModel[muzak.Redirect] describes one as a redirect with its Location; see Response Models. Returning a pointer to any of these types is a build error, since it would be encoded as JSON. OpenAPI covers the rest of the document.

Limits

  • Bytes and Stream are sent under the type you name, unsandboxed. Only a FileResponse adds Content-Security-Policy: sandbox to HTML, SVG and XML. A Bytes or Stream sent as text/html is a page of your origin, so never send a body a client wrote that way; send it as text/plain or application/octet-stream, or as a download.
  • A file a client uploaded is served under its real type. An uploaded .js or .css file is sent as JavaScript or CSS, which a page of your origin may then load, and a Content-Security-Policy of script-src 'self' does not stop it, since the file is your origin's. Serve uploads from another origin, or set ContentType to application/octet-stream, which nosniff keeps a browser from running as a script or applying as a stylesheet. Download alone is not enough: it changes what a browser does with the file opened directly, and nothing about a page that loads it.

Safe Defaults lists the headers every response carries, nosniff among them.

Where to go next

Responses covers the return type, status codes and headers, Static Files and Frontends covers mounting a whole directory, Compression covers how compressed bodies and entity tags interact, and Typed Endpoints covers how a Go caller receives Bytes, Stream and Redirect back as themselves.

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