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.
| Return | Sends | Documented as |
|---|---|---|
muzak.HTML | a page, under text/html | text/html |
muzak.Bytes | a body already in memory, under the media type it names | binary content of the types Produces lists |
muzak.Stream | a body read as it is sent, closed on every path | the same |
muzak.FileResponse | one file from an fs.FS, with ranges and conditional requests | the same |
muzak.Redirect | a Location and no body | the 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 answered | copied, then closed |
is a HEAD | closed without being read |
answers 204 or 304 | closed without being read |
| failed in the handler, which returned an error beside the stream | closed without being read, and the error rendered |
| failed while the body was read | closed, and the connection aborted |
| lost its client part way | closed, and the connection aborted |
| panicked while the body was read | closed, 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 character | a 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.
Symbolic links
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:
| Fields | Header |
|---|---|
| neither | none |
Filename: "notes.txt" | Content-Disposition: inline; filename="notes.txt" |
Download: true | attachment, with the file's own name for a FileResponse |
| both | attachment; 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".
| Status | Use it for | The client repeats |
|---|---|---|
301 | a move that is permanent | as a GET |
302 | a move that is not | as a GET |
303 | "the answer is over there", after a POST | as a GET |
307 | a temporary move that must keep the method and body | the same request |
308 | a permanent move that must keep the method and body | the 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 anywhere | refused: a browser strips or rewrites them before it reads the URL |
| holds any other control character, or a character outside ASCII | refused: 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 lists | refused |
| is empty, or longer than 8 KiB | refused |
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:
| Target | With RedirectHosts("accounts.example.com") |
|---|---|
https://accounts.example.com/cb | sent |
https://ACCOUNTS.example.com/cb | sent |
https://accounts.example.com:443/cb | sent: the scheme's default port |
https://accounts.example.com:8443/cb | refused: another port is another service |
https://accounts.example.com./cb | refused: 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.com | refused: 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"toW/"v1", and of the304it answers for one, so a cache can use the304to refresh what it stored. The weak tag the client sends back still matches. See Compression. - A guarded route keeps
Cache-Control: private, no-cacheon its304, 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
Streamhas no body to hash until it is sent, and aFileResponseis answered byhttp.ServeContent, which reads the conditional headers itself, so neither is tagged. - Anything else is left alone: a
POST, a201, 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
BytesandStreamare sent under the type you name, unsandboxed. Only aFileResponseaddsContent-Security-Policy: sandboxto HTML, SVG and XML. ABytesorStreamsent astext/htmlis a page of your origin, so never send a body a client wrote that way; send it astext/plainorapplication/octet-stream, or as a download.- A file a client uploaded is served under its real type. An uploaded
.jsor.cssfile is sent as JavaScript or CSS, which a page of your origin may then load, and aContent-Security-Policyofscript-src 'self'does not stop it, since the file is your origin's. Serve uploads from another origin, or setContentTypetoapplication/octet-stream, whichnosniffkeeps a browser from running as a script or applying as a stylesheet.Downloadalone 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.