Receiving files through links
Sometimes you need files from someone who can't sign in to your application: a customer sending photos for an insurance claim, a new hire sending their documents, a supplier sending an invoice. A signed upload link is a URL that lets whoever has it upload files, without signing in, until a time you choose. You decide where the files go, how large they may be and what types they may have; the link can't be used for anything else.
Upload links work like download links, and are set up the same way: read that page first if you haven't. Both kinds are sealed with your application's Data Protection keys, so the advice in Shared links in production applies to upload links too.
Setting it up
Upload links are part of the Resourcefull.AspNetCore package, and use the same registration as download links:
using Resourcefull.AspNetCore;
builder.Services.AddResourcefull(options => options.UseSqlServer(connectionString));
builder.Services.AddResourcefullLinks();
For a store added with AddKeyedResourcefull, pass its name, as for download links: see
Several stores.
1. Add an endpoint that creates links
This is your application's endpoint, so you decide who may ask for a link. Here, a signed-in claims handler gets a link that lets a customer upload up to 20 MiB per file, of images and PDFs, into the claim's own folder, for a week:
app.MapPost("/claims/{claimId:int}/upload-link", (int claimId, IResourceLinkSigner links) =>
{
string token = links.CreateUploadToken(new ResourceUploadLink("claims")
{
NamePrefix = $"{claimId}/",
MaxSize = ResourceSize.Megabytes(20),
AllowedContentTypes = ["image/*", "application/pdf"],
ExpiresIn = TimeSpan.FromDays(7)
});
return Results.Ok(new { url = $"https://example.com/upload/{token}" });
}).RequireAuthorization("ClaimsHandlers");
2. Add the endpoint that receives uploads
This endpoint must not require sign-in, because the people uploading can't sign in. The {**name} part of the
route is the file's name, for a link with a prefix; a link to one name leaves it off. It takes PUT, for a file sent
as the request's body, and POST, for a file sent from an HTML form:
app.MapMethods("/upload/{token}/{**name}", ["PUT", "POST"],
(string token, string? name, IResourceServiceClient resources) => Results.SharedUpload(resources, token, name));
3. Upload from the browser
The file is sent as the body of a PUT request. From a web page, fetch does that, and sends the file's type and
length for you:
const file = document.querySelector("input[type=file]").files[0];
const response = await fetch(`${uploadUrl}/${encodeURIComponent(file.name)}`, { method: "PUT", body: file });
encodeURIComponent keeps a name like scan 1.pdf intact in the URL. It also encodes /, so to upload into a folder
under the prefix, encode each part of the name and keep the slashes between them: with the prefix 1042/, sending
scans/form.pdf stores 1042/scans/form.pdf.
Serve the page that holds the link with Referrer-Policy: no-referrer, so the link isn't passed on to other websites
the page loads from or links to.
Or from a plain HTML form
A form needs no JavaScript at all. Post it to the link's URL, with no file name after the token: each file is stored under the name it has on the person's computer, after the link's prefix, with the type the browser gives it.
<form method="post" enctype="multipart/form-data" action="https://example.com/upload/{token}">
<input type="file" name="files" multiple>
<button>Send</button>
</form>
- A link with a prefix stores every file in the form. With the prefix
1042/, a form withphoto.jpgandform.pdfstores1042/photo.jpgand1042/form.pdf. - A link to one name takes a form with one file, and stores it under the link's name, whatever the file is
called. Leave
multipleoff its file input. - The form's other fields are ignored, and so is a file input left empty.
- Files are stored in the order the form sends them, and the first one refused stops the upload. The files stored before it stay, and the response says which they are, so the person can send the rest again.
Each file is checked against the link as a PUT is: its type against AllowedContentTypes, and its size against
MaxSize. A form doesn't say how large each file is, so a file is read until it reaches MaxSize, and refused if it
goes past it.
The response is JSON. When every file is stored, it's 201 Created, listing each file by the name the form sent and
its ETag:
{ "stored": [{ "name": "photo.jpg", "etag": "\"sha256:…\"" }, { "name": "form.pdf", "etag": "\"sha256:…\"" }] }
When a file is refused, the response has the status code from the table below,
with problem details that name the refused file in file and list the files
stored before it in stored. Without JavaScript, the browser shows the person this JSON as it is. To show a page of
your own instead, send the form with fetch and build the page from the response:
const response = await fetch(form.action, { method: "POST", body: new FormData(form) });
What a link can do
ResourceUploadLink describes the link you're creating:
| Property | Default | What it does |
|---|---|---|
ResourceName |
The one name uploads go to. Set this or NamePrefix, not both. |
|
NamePrefix |
What every name starts with: the person uploading picks the rest. Set this or ResourceName, not both. |
|
ExpiresIn |
one hour | How long the link works, from when it's created. |
MaxSize |
no limit | How large one upload may be, such as ResourceSize.Megabytes(20). |
AllowedContentTypes |
any type | The types an upload may have, such as application/pdf, or image/* for every image type. |
Mode |
Create |
Create refuses a name that already has a file, so the link can't overwrite anything. Replace stores the upload as the name's new version. |
A link with a prefix can upload any number of files under it until it expires, each under a name the person
uploading picks. End the prefix with /: with NamePrefix = "1042", someone could also upload
10421/photo.jpg, into claim 10421's folder.
With Create, nothing is ever overwritten. That's the safe choice for a link you hand to someone outside your
organization. Use Replace for a link to one name that should take a corrected file, such as "upload your signed
contract", and turn on versioning if you want to keep what it replaced.
Your container's policies still apply on top of the link's limits: the link can narrow what the container accepts, but never widen it.
The link's own checks come before the file is read, so an upload the link refuses is never read or stored. With
MaxSize, the request must say its length (Content-Length) up front, which browsers and most HTTP clients do for a
file. The upload is then held to that length: a body cut short stores nothing.
What people see when they upload
| The request | They get |
|---|---|
| is stored | 201 Created, or 200 OK when a Replace link replaced a file, with the file's ETag. The response has no body, so the person uploading learns nothing about your store beyond what they sent. A form gets 201 Created with a JSON body listing the files it stored, by the names it sent them under. |
| uses a link that has expired | 410 Gone |
| uses a link that was changed, made up, or sealed with keys this application no longer has, or a download link's token | 404 Not Found |
| names a file the link doesn't cover: a name for a link to one name, no name for a link with a prefix, or any name for a form | 404 Not Found |
| uses a link whose container doesn't exist, or no longer does | 404 Not Found |
has no Content-Length, for a link with MaxSize |
411 Length Required |
is larger than MaxSize |
413 Content Too Large |
| has a type the link doesn't allow | 415 Unsupported Media Type |
uses a Create link whose name already has a file |
409 Conflict |
| breaks a container policy | 413, 415, 409 or 403, the codes the exception handler gives |
names a file the name rules refuse, such as one ending in a space, or has a body shorter or longer than its Content-Length |
400 Bad Request |
| is refused by your upload inspector | 422 Unprocessable Content, with the inspector's reason |
| is a form with no file, with more than one for a link to one name, or that can't be read, such as one cut short | 400 Bad Request |
| is a form larger than your server accepts: ASP.NET Core's limit on a request's size, 30,000,000 bytes unless you change it | 413 Content Too Large |
When the store refuses an upload (the row for a missing container, and the rows from 409 Conflict down), the
response has a message of its own rather than the exception's, which would name the container and could say how
full it is. The request's preconditions (If-Match, If-None-Match) are ignored: the link's Mode decides whether
an existing file is replaced.
Large files: resumable uploads
A PUT sends the whole file in one request, so when the connection drops partway, the person uploading starts again
from zero. For large files, such as videos or a 4 GB archive sent over hotel Wi-Fi, map the
resumable upload endpoints for links too. The page then sends the file with a tus
client such as Uppy or tus-js-client, which carries on from what has arrived after a dropped connection, a reload, or
the next morning:
app.MapResourcefullUploadLinks("/upload-links");
Like the PUT endpoint, these endpoints must not require sign-in. A tus client's address for a link is the path
you mapped, followed by / and the link's token. So the endpoint that creates links hands out
https://example.com/upload-links/{token}, and the page passes that to the client as its endpoint:
import Uppy from "@uppy/core";
import Dashboard from "@uppy/dashboard";
import Tus from "@uppy/tus";
new Uppy()
.use(Dashboard, { inline: true, target: "#files" })
.use(Tus, { endpoint: uploadUrl, chunkSize: 50 * 1024 * 1024 });
Each file is checked against the link when its upload starts, before any of it is sent:
- Its name. tus clients send the file's name as
filename: Uppy does that for you, and with tus-js-client, put it inmetadata. A link with a prefix stores the file under the prefix followed by that name. A link to one name stores it under that name, whatever the client sends. - Its type. The
filetypethe client sends is checked againstAllowedContentTypes, and the file is stored with it. - Its size. The length the client declares is checked against
MaxSize. The client can't send more than it declared. Unlike aPUT, a resumable upload isn't held to ASP.NET Core's limit on how large a request can be, so for a link withoutMaxSize, only the container's policies limit it. To limit every upload through these endpoints, set their ownMaxSize, as in the example below. - Any file already under its name. A
Createlink refuses the upload, and aReplacelink replaces that file once the upload finishes.
A link can start as many uploads as it could store files with PUT: any number under a prefix. A Create link to
one name can start several, but only the first to finish is stored; the others get 409 Conflict when they finish.
A link's expiry stops new uploads, not those under way. An upload started before the link expires can be resumed
and finished after, so a link that expires an hour into a 4 GB upload doesn't waste it. With UseExpiry,
an upload nothing has been appended to for a day is deleted, as any unfinished upload is. Without it, an unfinished
upload stays until it's finished or canceled.
An upload's URL works for whoever has it, since the link is the permission: it isn't tied to a signed-in user, as
it is with MapResourcefullUploads.
When an upload starts, people see:
| The upload | They get |
|---|---|
| uses a link that has expired | 410 Gone |
| uses a link that was changed, made up, or sealed with keys this application no longer has, or a download link's token | 404 Not Found |
has no filename, for a link with a prefix, or has a filetype that isn't a media type |
400 Bad Request |
is larger than MaxSize |
413 Content Too Large |
| has a type the link doesn't allow, or none, for a link that limits types | 415 Unsupported Media Type |
uses a Create link whose name already has a file |
409 Conflict |
After that, an upload through a link works as any resumable upload does: see
When an upload is finished. What the store refuses gets
the same messages as with PUT, which don't name the container.
To run code once a file is stored, such as telling the claims handler it arrived, set OnCompletedAsync. MaxSize
limits every upload through the endpoints, whatever the link. For a store added with AddKeyedResourcefull, set
StoreName, and add its signed links with AddResourcefullLinks(storeName):
app.MapResourcefullUploadLinks("/upload-links", options =>
{
options.OnCompletedAsync = (context, version) => claims.FileReceivedAsync(version.Name);
options.MaxSize = ResourceSize.Gigabytes(10);
});
For a page on another origin, add CORS as for the other resumable upload endpoints: see Pages on other origins.
Checking a token yourself
For an endpoint of your own, such as a page that shows the person uploading which types and sizes the link accepts,
IResourceLinkSigner.CheckUpload says whether a token is Valid, Expired or Invalid, and what it allows.
SignedResourceUploadLink.NameFor gives the name an upload would be stored as.
Telemetry
Each request that uses an upload link gets resourcefull.link on its span: valid, expired or invalid, as a
download link's does. A stored file records the usual upload operation. For a resumable upload, only the request
that starts it gets resourcefull.link, since that's the one that checks the link. Every request of the upload also gets
resourcefull.tus, as described in Resumable uploads. See
Telemetry.