NE.Standard

Files

File transfer runs beside the live connection, not on it: upload is a plain multipart/form-data POST and download a GET, both authenticated from the same session cookie the page render writes. Every operation is scoped to the session, so a file id alone is never enough to read a file.

Uploading

FileInputComponent and ImageInputComponent upload as soon as the user picks a file — the picker sends it immediately, and the returned selection id lands in the component's own bound value. The upload, download and content endpoints all authenticate from the session cookie and honour UISecurityOptions.DefaultPolicy: under Authenticated, an anonymous session gets 401 from every one of them, even on a page marked [UIAllowAnonymous] — a public upload form on a closed site needs the policy left open or the visitor signed in.

new FileInputComponent()
    .BindSelectionId(nameof(ProfileController.AvatarSelectionId))
    .SetAccept("image/png,image/jpeg")
    .SetMaxFileSize(2 * 1024 * 1024)
    .SetMultiple(false)

SelectionId is a string? with UIBindingMode.TwoWay — bind it to read what was uploaded; Value only controls what the field displays. MaxFileSize is picker chrome: a client can simply not send it, so the real limit is enforced on the server (see below). ImageInputComponent carries the same SelectionId, plus Shape, Fit, PlaceholderIcon and a Value that holds the picture actually shown — a URL the controller owns, shown as a local preview until the controller answers with one of its own.

A controller reads the selection through Context.Uploads (IUIUploadService):

[UICommand]
public async Task SaveAvatarAsync(CancellationToken cancellationToken)
{
    UIUploadSelection selection = await Context.Uploads
        .GetSelectionAsync(Context.Handle, AvatarSelectionId, cancellationToken)
        .ConfigureAwait(false);

    if (selection.Files.Length == 0)
        return;

    UIUploadedFile file = await Context.Uploads
        .OpenAsync(Context.Handle, selection.Files[0].FileId, cancellationToken: cancellationToken)
        .ConfigureAwait(false);

    await using (file.ConfigureAwait(false))
    {
        // file.Content is the readable stream; file.Metadata has FileName/ContentType/Size.
    }
}

GetSelectionAsync(handle, selectionId) returns a UIUploadSelectionFiles (UIUploadFile[]), FileIds, and the convenience members SingleFile, HasFiles, IsSingle. OpenAsync/OpenManyAsync open one or more uploaded files as a UIUploadedFile (Metadata + Content, disposable). CopyToAsync/CopyManyToAsync copy straight to a destination stream instead of handing one back. All four take an optional IProgress<double> — a real server-side copy loop over already-staged content, so the number means what it says.

Every UIUploadFile.FileId is issued by the store, never taken from the client — a client-chosen id could collide with, or guess at, another client's file.

Limits

UIFileOptions, configured on the application builder:

builder.ConfigureFiles(files =>
{
    files.MaxFileSize = 10 * 1024 * 1024;
    files.MaxFilesPerSelection = 8;
    files.UploadRetention = TimeSpan.FromHours(1);
    files.DownloadRetention = TimeSpan.FromMinutes(5);
    files.StorageRoot = "/var/ne-uploads";
});

MaxFileSize is enforced at the upload endpoint while the part streams, not after buffering it. UploadRetention and DownloadRetention say how long staged content survives before the sweep removes it; StorageRoot is where the default store keeps content — unset, it uses a folder under the system temp directory.

Storage itself is IUIFileStore. The framework registers FileSystemUIFileStore by default (services.TryAddSingleton<IUIFileStore, FileSystemUIFileStore>()); a host that needs blob storage or anything else registers its own IUIFileStore before that call runs. Every method on the store takes the session id — that is the security boundary an implementation must not drop.

Downloading

A controller stages a download through Context.Downloads (IUIDownloadService):

[UICommand]
public async Task DownloadReportAsync(CancellationToken cancellationToken)
{
    byte[] content = Encoding.UTF8.GetBytes("stage,seconds\nresolve,0.8\n");

    _ = await Context.Downloads
        .DownloadAsync(Context.Handle, "deploy-report.csv", "text/csv", content, cancellationToken)
        .ConfigureAwait(false);
}

DownloadAsync takes a Stream or a byte[], stages it, and returns a UITransferResult (Success, Cancelled, Error — mutually exclusive, with Ok()/Cancel()/Fail(error) factories). There is no IProgress<double> here: the server hands the browser a URL and the browser does the fetching, so the server cannot observe it. Staging pushes a DownloadFileEffect (RequestPath, FileName) to the client, which turns it into a browser download; the path is single-use and the token behind it is dropped once read — a second fetch, or a fetch from another session, is 404, never 403, so the token tells nothing about what it named.

Protected content

A staged download is fetched once and gone. Content the application keeps around — an avatar, a chat background — is fetched by an <img> or a link whenever the browser likes, and it is scoped to the user rather than to one connection's session. That is IUIContentProvider:

public sealed class MediaService(AppDatabase database, IUIContentAddressResolver content) : IUIContentProvider
{
    public string AddressOf(string mediaId) => content.AddressOf(mediaId);

    public Task<UIContent?> ResolveAsync(UIContentRequest request, CancellationToken cancellationToken = default)
    {
        // Decide from request.Session and request.Key; answer null for anything this session may not read.
    }
}

Register it as a service — services.AddSingleton<IUIContentProvider>(...) — and name a stored item's address with the injected IUIContentAddressResolver.AddressOf(key) (a controller reaches the same resolver through Context.Content), bound to a component's Source. The host serves it at GET /_ne/content/{key}, resolving the session from the same cookie the file endpoints use. The provider is the whole of the authorization: it sees who asks (UIContentRequest.Session) and what for (UIContentRequest.Key), and answers null for anything that session may not have — the host turns null into 404, never 403, so a key's existence is not revealed to someone who may not read it. No session at all answers 401 before the provider is asked.

UIContent carries Content, ContentType, an optional FileName (set it to make the browser save rather than show the content inline), and Immutable — set it only when the key always answers the same bytes, which is what a content-addressed key buys; the response is then cached for a year, otherwise private, no-cache.

A controller does not need any of this to read the content itself — it already has the application's services. The endpoint exists for the browser's own fetches.