Serving downloads
An endpoint that serves files should do more than copy bytes: send the length, type and file name, let browsers
reuse what hasn't changed without letting shared caches keep anyone's files, and let video players and download
managers ask for part of a file. Results.Resource
does all of that from a download.
Note
This endpoint leaves out authorization to stay short. Before you use it, see Securing file endpoints.
using Resourcefull.AspNetCore;
app.MapMethods("/files/{container}/{**name}", ["GET", "HEAD"], async (string container, string name,
IResourceServiceClient resources, CancellationToken cancellationToken) =>
{
ResourceDownloadResult? download = await resources.GetContainerClient(container)
.DownloadStreamingAsync(name, cancellationToken: cancellationToken);
return download is null ? Results.NotFound() : Results.Resource(download);
});
Results.Resource (or TypedResults.Resource, for typed results) sends the download with:
Content-Length, measured from the stored content, so the response isn't chunked;Content-Type, orapplication/octet-streamwhen none was stored;- a strong
ETagfrom the content hash, andLast-Modified; Content-Disposition: attachment, with the part of the name after its last/as the file name, encoded correctly whatever characters it contains. Control characters and the characters that change text direction are left out of it and of aFileNameyou give 4.0, so a right-to-left override can't makereport.exelook like a document, and a line break can't break the header. Passnew ResourceResultOptions { Inline = true }to let browsers display the file, andFileNameto offer another name. Only types that are safe to display are shown inline 4.0: common image formats (PNG, JPEG, GIF, WebP, AVIF and BMP; not SVG), PDF, audio, video and plain text (ResourceResultOptions.DefaultInlineContentTypes). Anything else is still offered as a download, because a file uploaded astext/htmlorimage/svg+xmland shown inline would run as a page of your site.InlineContentTypeschanges the list;Cache-Control: private, no-cache4.0: the user's browser may keep a copy, but checks with your application before reusing it, which theETagmakes cheap, and shared caches such as CDNs and proxies don't keep it, so one user's file is never served to another. For public files whose URL names a version, so their content never changes, setCacheControlto something likepublic, max-age=31536000, immutable. Either way, a copy a browser already holds keeps its old type if you later change the content type 4.0, since the ETag doesn't change;X-Content-Type-Options: nosniff4.0, so browsers take the type as given rather than guessing from the content.
It answers conditional requests without reading the content:
- 304 Not Modified for an
If-None-Matchthat holds the ETag, or anIf-Modified-Sinceno earlier than the last change; - 412 Precondition Failed for an
If-MatchorIf-Unmodified-Sincethat doesn't hold.
A HEAD request gets the headers alone. The download stream is disposed once the response is sent, so don't
dispose the download in the endpoint: the response is sent after the endpoint returns. The request's cancellation
token stops the read when the client disconnects. Range requests get the whole file (200
rather than 206), because the download is already open; to answer them, see below.
404 comes from the endpoint: DownloadStreamingAsync returns null when the container, resource or version
doesn't exist. The catch-all {**name} takes names with / in them, such as 2026/10/report.pdf.
Range requests
Video players seek, and download managers resume, by asking for a range of bytes. To answer them, pass the container and the name instead of a download, and the result looks the resource up itself:
app.MapMethods("/files/{container}/{**name}", ["GET", "HEAD"], (string container, string name,
IResourceServiceClient resources) =>
Results.Resource(resources.GetContainerClient(container), name));
It sends the same headers and answers the same conditional requests as above, and also:
- 206 Partial Content with
Content-Rangefor oneRange: bytes=…, reading only that range from storage. The formsbytes=0-499,bytes=500-andbytes=-500(the last 500 bytes) all work, and a range that runs past the end is cut there. If-Rangewith the ETag orLast-Modifiedof the version sent keeps the range; otherwise the response is the whole file (200), so a client resuming a download never joins parts of two versions.- 416 Range Not Satisfiable, with
Content-Range: bytes */length, for a range that starts past the end. - The whole file (200) for a request with several ranges, which HTTP allows. Players and download managers ask for one range at a time.
Accept-Ranges: byteson every response, so clients know they can ask.- 404 when the resource doesn't exist.
To serve an older version with ranges, pass its ID as versionId, for example from the query string; a version that
doesn't exist gets 404:
app.MapMethods("/files/{container}/{**name}", ["GET", "HEAD"], (string container, string name, string? versionId,
IResourceServiceClient resources) =>
Results.Resource(resources.GetContainerClient(container), name, versionId));
Conditional headers are evaluated first, so a matching If-None-Match gets 304 even with a Range. TypedResults.Resource
takes the same arguments.