Table of Contents

Accepting uploads

Files reach an API in one of two ways: as the whole body of a PUT request, which suits scripts and apps, or as a field in an HTML form. This page covers both, how to let only one of several editors save, and how to accept files larger than the web server allows by default.

Note

This endpoint leaves out authorization to stay short. Before you use it, see Securing file endpoints.

app.MapPut("/files/{container}/{**name}", (string container, string name, HttpRequest request,
    IResourceServiceClient resources, CancellationToken cancellationToken) =>
    resources.GetContainerClient(container).UploadFromRequestAsync(name, request, null, cancellationToken));

UploadFromRequestAsync streams the request body into storage, so nothing is buffered whatever its size. It stores the request's Content-Type, and passes its Content-Length as the declared length, so a truncated body throws ResourceContentLengthMismatchException instead of storing part of a file. Like any PUT, it replaces the current version, whether or not the container keeps versions. It answers:

  • 201 Created when the name had no current version, and 200 OK when it replaced one, either way with the stored ResourceInfo as JSON, its ETag, and its URI as Location when a URI provider gives one;
  • 412 Precondition Failed, with the current version's ETag, when the request's preconditions don't hold.

The preconditions are decided by the write itself, so of clients that read the same version and upload changes, exactly one succeeds (see Updating only if unchanged):

Request header Stores the upload only if
If-Match: "<ETag from a download>" the current version still has that content
If-Match: * the name has a current version
If-None-Match: * the name has no current version
If-None-Match: "<ETag>" the current version isn't that one
If-Unmodified-Since the current version hasn't changed since then

If-Match compares tags strongly, so a weak tag (W/"…") never matches. Pass ResourceUploadOptions for metadata, other headers to store, or Conditions; when the request has preconditions, they replace the options' Conditions. The options' Mode 4.0 is ignored: a PUT replaces, unless the client sends If-None-Match: *. An endpoint that must only create files can require that header, answering 428 Precondition Required without it.

With SQL Server 4.0, a transient failure partway through an upload is retried, even though a request body can't be read twice: the content goes in about a MiB at a time, and a batch that fails is sent again from what it holds.

From an HTML form

Take an IFormFile. ASP.NET Core buffers form files, to disk above 64 KB, before your endpoint runs:

builder.Services.AddAntiforgery();
// ...
app.UseAntiforgery();

app.MapPost("/files/{container}", async (string container, IFormFile file, IResourceServiceClient resources,
    CancellationToken cancellationToken) =>
{
    // A name your application chooses: the browser's file name is whatever the uploader says.
    string name = $"uploads/{Guid.NewGuid():N}{Path.GetExtension(file.FileName)}";
    await using Stream content = file.OpenReadStream();
    ResourceInfo stored = await resources.GetContainerClient(container).UploadAsync(name, content,
        new ResourceUploadOptions
        {
            Headers = new ResourceHeaders(file.ContentType, file.Length),
            Metadata = new Dictionary<string, string> { ["fileName"] = file.FileName }
        }, cancellationToken);
    return Results.Created(stored.Uri, stored);
});

Minimal APIs check the form's antiforgery token for every endpoint that takes a form file, once UseAntiforgery is in the pipeline, so a page on another site can't make a signed-in user's browser upload for it. Render the token in your form with @Html.AntiForgeryToken() or <AntiforgeryToken />. Keep the browser's file name as metadata if you want to show it, but don't use it as the resource name: it can be anything, including a name that overwrites another file.

A form file's stream can seek, so with SQL Server a transient failure partway through retries the upload.

Large uploads

Kestrel rejects request bodies over about 30 MB with 413 Payload Too Large, forms included, and form files are also limited to 128 MB. Raise the limits for the endpoints that take large files:

var fiveGigabytes = new RequestSizeLimitAttribute(5L * 1024 * 1024 * 1024);
app.MapPut("/files/{container}/{**name}", UploadAsync).WithMetadata(fiveGigabytes);
app.MapPost("/files/{container}", UploadFormAsync).WithMetadata(fiveGigabytes).DisableAntiforgery();

builder.Services.Configure<FormOptions>(options => options.MultipartBodyLengthLimit = 5L * 1024 * 1024 * 1024);

Behind IIS or a reverse proxy, raise its limit too (maxAllowedContentLength, client_max_body_size). For files large enough that a dropped connection is likely, or behind a limit you can't raise, use resumable uploads instead: the client sends the file in chunks, and resumes where it stopped.