Rask
Rask
v0.23.1-alpha.0.523
You're reading the Rask v0.23.1-alpha.0.523 guides. Updated .View source
All guides

Rask.Storage — uploaded files on disk, S3, Azure or in the database

In practice: the file picker in HTTP & files · the dashboard's Storage tab · deployment.

Rask.Storage keeps the files your users upload. A saved file is two things: its bytes in a store — a directory, an S3-compatible bucket, an Azure Blob container or a table in the app's own database — and a StoredFile row on the app's own database that says what the bytes are. You call Files.Save, handing it the IRaskFile a file picker gives you, keep the returned id on your own entity, and later hand the file back as a public URL, a temporary URL, or a download behind your own authorization check. No cloud SDK is referenced: S3 requests are signed in-process with SigV4, Azure requests with Shared Key or a service SAS.

Included in Rask.Server — nothing to install. It is on; an app that does without it says so:

app.Configure(c => c.Storage.Off());

Why a row per file

Getting bytes from the browser to the server is the easy half, and Rask already does it (HTTP & files). The hard half is everything after: where the bytes go, what name they are kept under, who may fetch them later, what Content-Type they are served with, and how to find out what is stored at all. Written by hand, each of those is a decision that fails quietly — an uploaded HTML page served inline from your own origin, a storage path built from a name the user typed, a directory that fills with files nothing refers to any more.

A row beside the bytes answers them in one place. The row holds the type sniffed from the content, the size, the hash and the visibility, so serving a file never has to trust the browser that sent it or the store that holds it. Your entity refers to a file by id, the way it refers to any other row. And the bytes can live in a local directory today and a bucket tomorrow without a line of your code changing — only configuration.

Use

In an app built on RaskApp there is nothing to write: the service, the table mapping and the routes are wired for you. Configure it only where your app differs:

app.Configure(c => c.Storage.Configure(o => o.MaxFileSize = 100 * 1024 * 1024));

Turning it off keeps the table mapped, like every database-backed battery, so switching it back on later needs no migration.

A hand-wired host takes three lines. Register the service:

// Program.cs
builder.Services.AddRaskStorage<AppDbContext>();
builder.Services.AddDbContextFactory<AppDbContext>(o => o.UseSqlite("Data Source=app.db"));

Map the table:

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.ApplyConfigurationsFromAssembly(typeof(AppDbContext).Assembly);
    modelBuilder.AddRaskStorage();   // maps StoredFile, and StoredFileBytes for the Database provider
}

And map the routes that serve public and temporary links — after MapRask, because the routes live under the path base it sets:

app.MapRask<App>();
app.MapRaskStorage();

Add a migration for the new table before running — rask db add AddStorage && rask db update.

rask new MyApp scaffolds all of it — on the server and wasm-hosted templates alike; --no-storage leaves it out. The wiring mistakes are loud rather than silent: a missing modelBuilder.AddRaskStorage() stops the boot and names the line to add, a configuration value that can't work (a negative size, a storage directory inside wwwroot) stops the boot and names what to change, and a host that never calls MapRaskStorage() logs a warning at startup — its public and temporary links would otherwise 404 with nothing to say why.

Saving an upload

A file picker hands its handler a list of IRaskFiles (uploading files). Pass one's opener, name and size to Files.Save — nothing is injected, because it reaches the store of the work it runs in:

public sealed partial class AvatarPicker : Component
{
    private string? _avatarUrl;
    private string? _error;

    private async Task OnFiles(IReadOnlyList<IRaskFile> picked)
    {
        if (picked.Count == 0)
        {
            return;   // the reader cancelled the picker
        }

        try
        {
            var file = picked[0];
            var saved = await Files.Save(file.OpenReadStream, file.Name, file.Size).Public();
            _avatarUrl = Files.Url(saved.Id);
            _error = null;
        }
        catch (FileRejectedException e)
        {
            _avatarUrl = null;
            _error = e.Reason == FileRejection.TooLarge
                ? $"That file is larger than {e.Limit / (1024 * 1024)} MB."
                : "That kind of file isn't accepted here.";
        }
    }

    protected override Component? Render() =>
        Div[
            Ui.FileUpload.Label("Avatar").Accept("image/*").OnFiles(OnFiles)[
                Ui.FileUploadDropzone.Heading("Drop a picture or click to browse").Text("PNG or JPG").Inline()
            ],
            _avatarUrl is null
                ? (Component)P.Class("text-sm")[_error ?? "No avatar yet."]
                : Img.Src(_avatarUrl).Alt("Your avatar")
        ];
}

Three things about that handler are load-bearing:

  • Save before the handler returns. A IRaskFile is only readable while its handler is on the stack, so the await belongs inside it.
  • Pass the opener, don't call it. file.OpenReadStream goes in as a method group, and Save calls it with the storage size limit. Calling OpenReadStream() yourself gets its 512 KB default cap, which is not the limit you configured.
  • Accept is a hint to the dialog, not a check. The browser's filter and the browser's claimed type are both the client's word. What is enforced is what Save sniffs from the bytes — see what is accepted.

Bytes that don't come from a picker — a generated report, a file fetched from elsewhere — take the stream overload. The stream is read to its end and not disposed:

await using var pdf = await invoices.RenderPdf(invoiceId, ct);
var saved = await Files.Save(pdf, "invoice.pdf");

Keeping the id

The returned StoredFile carries Id, the display Name (reduced to a safe leaf), the sniffed ContentType, Size, the Sha256 of the bytes, the Provider and object Key, Public and CreatedAt (UTC). Keep the Id on your own entity; every other call is addressed by it.

StoredFile is a Rask.Data entity with a read face, so listing what has been uploaded is a query like any other, with no context of your own: StoredFile.OrderByDescending(f => f.CreatedAt).Take(50).

Saving inside Db.Transaction

A save inside Db.Transaction takes part in it — nothing to add to the call:

await Db.Transaction(async () =>
{
    var saved = await Files.Save(xml, "bevallas.xml");
    await Return.Create(new() { FileId = saved.Id, Period = period });
});
  • Database provider: the StoredFile and StoredFileBytes rows are written on the transaction's connection. Nobody sees the file before the commit, and a rollback leaves nothing behind.
  • Disk, S3, Azure: the bytes cannot join a database transaction, so they are written at once and the StoredFile row joins. If the work throws and the transaction rolls back, Rask deletes the stored object again. A delete that fails is logged as a warning, never thrown over the error that caused the rollback, and the sweep collects what it left.
  • A delete (Files.Delete) inside the transaction removes the row with the work, and the bytes only once it has committed. A rollback keeps both.
  • The storage table must be on the transaction's database. If the context AddRaskStorage<TContext>() names is on another connection string, the save is refused before a byte is written. Jobs and mail follow the same rule.

Outside a Db.Transaction, each save and delete commits on its own DbContext. The bytes are written first and the row second, so a save that fails half way leaves bytes with no row (the sweep removes them) and never a row with no bytes. Once the save returns, the file exists whatever your code does next. Keep the save and the entity that refers to it in one Db.Transaction, and a failed save of the entity takes the file with it.

Size limits along the way

MaxFileSize (50 MB by default) is checked twice: before reading, when the upload declared its size, and again while copying — so a client that under-reports its size is still stopped at the limit. The transport has limits of its own that apply first, and raising one without the other just moves the refusal:

Handing a file back

Works for Reaches
Files.Url(id) files saved with .Public() anyone with the link, for as long as the file exists
await Files.Share(id).For(15.Minutes) any file anyone with the link, until it expires
Files.Download(id) any file whoever your own endpoint lets through

Public URLs

Url does no I/O — it is built from the id — so it is safe to call inside Render(), as the avatar picker above does. With nothing configured it returns {PathBase}/_rask/files/public/{id}, a route the app serves itself with Cache-Control: public, max-age=31536000, immutable. That header is safe because the bytes under an id never change: saving again always creates a new id. The route answers 404 for a file that is not public, exactly as it does for one that does not exist. The bucket behind it stays private — the app reads the object and streams it, so no object ACL is ever set. Because that response may be cached for a year, deleting a public file does not take it back from a browser or a CDN that already holds it — anything you may need to withdraw belongs in a private file handed out with temporary URLs.

Set PublicBaseUrl to serve public files from a CDN or a public bucket domain instead. Url(id) then returns that URL joined with the file's key, and the request never reaches the app. Prefer an origin other than the app's own.

PublicBaseUrl moves the check out of the app, so grant public read on {Prefix}public/ only. The Public flag is enforced by the app's route; a CDN or a publicly readable bucket serves whatever key it is asked for. Rask keeps public files under {Prefix}public/ and private ones under {Prefix}private/ for exactly this reason: a provider-signed temporary URL carries the file's key in plain sight, and a bucket policy that also covered private/ would turn a five-minute link into a permanent one.

Temporary URLs

var link = await Files.Share(invoice.PdfId).For(15.Minutes);

Any file, public or not. The lifetime must be greater than zero and at most seven days; the result is null when there is no such file. What the link is depends on where the bytes are:

Store The link The download Withdrawn by deleting the file
S3; Azure with an account key the provider's own signed URL goes straight from the bucket no — it cannot be revoked before it expires
Disk; Azure with a SAS-only connection string a signed app route, /_rask/files/{token} passes through the app yes, the moment the file is deleted

A provider-signed link costs the app nothing to serve, and the price is that the app can't take it back. Choose the lifetime for the worst case of the link being forwarded.

The app-route token is sealed with ASP.NET Core Data Protection and carries only the file id. An expired token, a tampered one, one for an unknown file and one for a deleted file all answer the same 404, so a link reveals nothing about whether a file ever existed. On more than one instance every host must share the key ring; on a rask deploy box Rask already persists it to /data/keys on the volume (see deployment).

Downloads behind your own check

For files only some users may see, serve them from an endpoint of your own that checks first, then hands the response to Download:

app.MapEndpoints(e => e.MapGet("/invoices/{id:guid}/pdf",
    async (Guid id, ClaimsPrincipal user, Invoices invoices) =>
        await invoices.PdfFileIdFor(user, id) is Guid fileId
            ? Files.Download(fileId)
            : Results.NotFound()));

Download streams the file and answers 404 when it is missing. It supports Range requests (resumed downloads, seeking in a video), If-None-Match and HEAD. Answering 404 rather than 403 to someone who may not see a file, as above, also keeps from confirming that it exists.

What every response carries

Whichever of the three serves it, a file goes out with:

  • X-Content-Type-Options: nosniff — the browser uses the type Rask sniffed, and does not guess another;
  • Content-Security-Policy: default-src 'none'; style-src 'unsafe-inline'; sandbox — should a browser render a file as a document anyway, no script runs and it gets no access to your origin;
  • Referrer-Policy: no-referrer — a signed link is not leaked onward in a Referer header;
  • a strong ETag that is the file's SHA-256, so a repeat request can be answered 304.

What is accepted, and how it is served

The content type is sniffed

The type recorded for a file comes from its bytes — magic numbers, the WHATWG MIME Sniffing patterns for markup, and a UTF-8 text check. The type the browser claimed is ignored. The file's extension may narrow a sniffed family, never promote it: text named .csv, .md or .json becomes text/csv, text/markdown or application/json, and a zip named .docx, .xlsx or .pptx becomes that Office type — but a text file named photo.png is still text.

AllowedTypes is matched against that sniffed type, as type/subtype or type/*. Empty, the default, accepts anything:

builder.Services.AddRaskStorage<AppDbContext>(o =>
{
    o.AllowedTypes.Add("image/*");
    o.AllowedTypes.Add("application/pdf");
});

An executable renamed invoice.pdf is refused by that list, because its bytes don't say PDF. A family never admits a type that can run script: image/* does not let SVG in — name image/svg+xml if you mean it.

Stating the type yourself

Bytes your own code made — an XML return, a generated report — can carry the type you know they are. .As(…) records it in place of the sniffed one:

var saved = await Files.Save(xml, "bevallas.xml").As("application/xml");

The value must be a plain media type, type/subtype: no parameters, spaces or line breaks, because it reaches a response header. Anything else throws ArgumentException before a byte is read. The stated type is still matched against AllowedTypes, and Download and temporary URLs serve it under the same rules as a sniffed one — so application/xml, like HTML and SVG, still goes out as an application/octet-stream attachment (below). Leave .As out and the bytes decide, as before. Under Files.Fake() the row carries the stated type too.

Inline or attachment

Only types a browser displays without running anything are served inline:

Family Types served inline
Raster images image/png, image/jpeg, image/gif, image/webp, image/avif
Audio audio/mpeg, audio/aac, audio/ogg, audio/wav, audio/flac, audio/mp4
Video video/mp4, video/quicktime, video/webm

Everything else is sent as an attachment, so it downloads rather than opens. HTML, SVG and XML are sent as application/octet-stream as well: each can carry script, and a file a user uploaded must never run as a page on your origin. That is why SVG, which is an image, is not on the inline list.

Names

The name a file was uploaded with is kept only as a display name, reduced to a safe leaf: no directories, no control characters, no quotes and no bidirectional-override characters. It never reaches a storage path — keys are built from the id alone, as {Prefix}public/ or {Prefix}private/, then {two hex characters}/{id} — so a name cannot choose where the bytes land or collide with another file.

Rejections

A file that is too large or of a type not allowed throws FileRejectedException (an InvalidOperationException), and nothing is stored:

Member Meaning
Reason FileRejection.TooLarge or FileRejection.TypeNotAllowed
Size, Limit for TooLarge: the bytes read when the limit was passed, and the limit
ContentType for TypeNotAllowed: the sniffed type

The message is written for the developer — it names the option to change — and is safe to log: it never repeats the uploaded file name, which is text a stranger chose. Show the user your own wording, as the avatar picker does.

Configuration

Every option can come from configuration under Rask:Storage, like every other Rask area, which is how a deployed app picks its store without a code change. In appsettings.json that is "Rask": { "Storage": { "Provider": "S3", … } }; in the environment, Rask__Storage__Provider. Code in the configure delegate wins over configuration. A top-level Storage section is no longer read — see migrating from the old keys.

Key Default Meaning
Rask__Storage__Provider Disk Disk, S3, Azure or Database.
Rask__Storage__MaxFileSize 52428800 (50 MB) The largest file accepted, in bytes.
Rask__Storage__PublicBaseUrl — An absolute https URL (a CDN or a public bucket domain) that Url(id) joins with the file's key. http is accepted only for localhost. Unset, the app serves public files itself.
Rask__Storage__Prefix — A key prefix, so one bucket can hold several apps (myapp/). The sweep never looks outside it.
Rask__Storage__Disk__Root /data/files when the /data volume exists, else storage/ under the content root Where the disk provider writes. A relative path resolves against the content root; a path inside wwwroot is refused.
Rask__Storage__S3__ServiceUrl — The S3-compatible endpoint — see providers.
Rask__Storage__S3__Bucket — The bucket.
Rask__Storage__S3__Region us-east-1 The signing region. Cloudflare R2 wants auto.
Rask__Storage__S3__AccessKeyId, Rask__Storage__S3__SecretAccessKey — The access key pair.
Rask__Storage__S3__SessionToken — Only for temporary credentials.
Rask__Storage__S3__UsePathStyle true Put the bucket in the path rather than the host name. R2 and MinIO need it.
Rask__Storage__Azure__ConnectionString — See Azure Blob.
Rask__Storage__Azure__Container — The container.

The rest of StorageOptions binds the same way — AllowedTypes (above), OrphanGracePeriod and SweepInterval (see the sweep) — or is set in code. The options are validated when the app starts, so a bad value stops the boot, naming Rask:Storage — not the first upload in production.

Secrets go the way every other secret does: rask deploy --env Rask:Storage:S3:SecretAccessKey=…, remembered by name and never by value (see secrets).

Providers

Disk

The default, and the right one for development and for a single box you back up yourself. With no Rask__Storage__Disk__Root set, files go to /data/files when the /data deploy volume exists — so on a rask deploy box they survive a redeploy the way the database does — and to storage/ under the content root otherwise.

A root inside wwwroot stops the boot: the static-file middleware would serve those files directly, with none of the checks above, and an uploaded HTML page would be served as HTML from your own origin.

Know the two limits before you rely on it: files on disk are backed up only by rask db backup (not by Litestream or snapshots), and the directory is node-local. Both are covered below.

S3-compatible storage

One provider covers every store that speaks the S3 API. Requests are signed with SigV4 in-process:

Service ServiceUrl Notes
AWS S3 https://s3.<region>.amazonaws.com Region is the bucket's region.
Cloudflare R2 https://<account-id>.r2.cloudflarestorage.com Region is auto; path-style (the default).
Backblaze B2 https://s3.<region>.backblazeb2.com An application key with S3 access.
MinIO your server's URL Path-style (the default).
DigitalOcean Spaces https://<region>.digitaloceanspaces.com
Google Cloud Storage https://storage.googleapis.com Interoperability HMAC keys, not a service-account key file.
rask deploy --env Rask:Storage:Provider=S3 \
            --env Rask:Storage:S3:ServiceUrl=https://<account-id>.r2.cloudflarestorage.com \
            --env Rask:Storage:S3:Bucket=shop-files \
            --env Rask:Storage:S3:Region=auto \
            --env Rask:Storage:S3:AccessKeyId=… \
            --env Rask:Storage:S3:SecretAccessKey=…

Temporary URLs here are presigned by the provider, so downloads go straight from the bucket.

Azure Blob Storage

Set Rask__Storage__Azure__ConnectionString and Rask__Storage__Azure__Container. The connection string's form decides how temporary URLs work:

Connection string Temporary URLs
With AccountName and AccountKey Signed by Azure as a service SAS; downloads go straight from the container.
BlobEndpoint=…;SharedAccessSignature=… Through the app's own signed route — without the account key there is nothing to sign a new SAS with.
UseDevelopmentStorage=true Azurite, for local development.

Requests are signed with Shared Key, or carry the configured SAS.

Database

Rask__Storage__Provider=Database keeps each file's bytes in a StoredFileBytes table beside StoredFile, one row per file under the same id. It is for documents and images of a few megabytes — invoices, scans, photos — in an app that would rather have one thing to back up: the database backup is the file backup, on every host, with nothing node-local. SQL Server, PostgreSQL and SQLite all work.

  • One transaction. A save writes the bytes and the StoredFile row together, so there is never a row without bytes, or bytes a save is still about to claim. Inside Db.Transaction that transaction is the work's own (above).
  • Streamed. The upload is spooled to a temp file (sniffed, hashed and size-checked as for every store), then streamed into the row on SQL Server and PostgreSQL; SQLite reads the spooled file into memory once to bind it. Reads stream out of the data reader. Listing files, the dashboard and the sweep only ever touch StoredFile, never a byte of the other table.
  • Served by the app, through the same /_rask/files/… routes as the disk store — Url(id), Share(id).For(…) and Download(id), ranges, ETag (the SHA-256) and 304s included. PublicBaseUrl cannot apply (no CDN reads a row), so setting it with this provider stops the boot.
  • MaxFileSize still applies (50 MB by default), and is capped at 1 GB — the most SQLite and PostgreSQL keep in one value.

modelBuilder.AddRaskStorage() maps both tables, so an app on Rask's migrations gets the new table from rask db add AddStoredFileBytes && rask db update and nothing else. With the provider on and StoredFileBytes missing from the model, the boot stops and names the line to add.

An adopted database: the tables by hand

An app that keeps its own schema and runs no Rask migrations creates the two tables itself. On SQL Server, in dbo with plain names — for an app whose tenants are numbered (PerTenant<int>):

CREATE TABLE dbo.StoredFile (
    Id uniqueidentifier NOT NULL CONSTRAINT PK_StoredFile PRIMARY KEY,
    Name nvarchar(255) NOT NULL,
    ContentType nvarchar(127) NOT NULL,
    Size bigint NOT NULL,
    Sha256 nvarchar(64) NOT NULL,
    Provider nvarchar(16) NOT NULL,
    [Key] nvarchar(512) NOT NULL,
    [Public] bit NOT NULL,
    TenantId int NULL,
    CreatedAt datetime2 NOT NULL,
    UpdatedAt datetime2 NOT NULL
);
CREATE INDEX IX_StoredFile_CreatedAt ON dbo.StoredFile (CreatedAt);

CREATE TABLE dbo.StoredFileBytes (
    Id uniqueidentifier NOT NULL CONSTRAINT PK_StoredFileBytes PRIMARY KEY,
    Bytes varbinary(max) NOT NULL
);

A foreign key from StoredFile.TenantId to your own tenants table is yours to add — Rask does not model it and never writes a tenant that is not the one in flight:

ALTER TABLE dbo.StoredFile ADD CONSTRAINT FK_StoredFile_Tenants FOREIGN KEY (TenantId) REFERENCES dbo.Tenants (Id);

For an app whose tenants are Guids (or that has none), the same tables with a uniqueidentifier tenant — exactly the StoredFile table Rask has always mapped:

CREATE TABLE dbo.StoredFile (
    Id uniqueidentifier NOT NULL CONSTRAINT PK_StoredFile PRIMARY KEY,
    Name nvarchar(255) NOT NULL,
    ContentType nvarchar(127) NOT NULL,
    Size bigint NOT NULL,
    Sha256 nvarchar(64) NOT NULL,
    Provider nvarchar(16) NOT NULL,
    [Key] nvarchar(512) NOT NULL,
    [Public] bit NOT NULL,
    TenantId uniqueidentifier NULL,
    CreatedAt datetime2 NOT NULL,
    UpdatedAt datetime2 NOT NULL
);
CREATE INDEX IX_StoredFile_CreatedAt ON dbo.StoredFile (CreatedAt);

CREATE TABLE dbo.StoredFileBytes (
    Id uniqueidentifier NOT NULL CONSTRAINT PK_StoredFileBytes PRIMARY KEY,
    Bytes varbinary(max) NOT NULL
);

There is deliberately no foreign key between the two tables: deleting a file removes its row, then its bytes, and bytes left behind by a failure are what the sweep removes.

Changing provider

Changing Rask__Storage__Provider does not move existing files. Each row records the provider its bytes were written to, so a file is never looked for in the wrong store: a call for a row from the old provider throws, naming both providers, and the file routes answer 404. There is no migration tool — pick the production store before the first upload you intend to keep.

Deleting

var deleted = await Files.Delete(fileId);   // false when there was no such file

The row goes first, then the bytes. Once the row is gone nothing will serve the file, and app-signed temporary links stop working at that moment; if removing the bytes then fails, they are orphans the sweep collects. A provider-signed temporary URL is the exception described above. Inside Db.Transaction the bytes wait for the commit (saving inside Db.Transaction).

Files and tenants

In an app with multi-tenancy, a file belongs to the tenant that saved it. The row records the tenant in flight at the save — the signed-in user's, or a Tenant.Use scope — and the places a file is reached by its id are scoped to it: looking the row up, opening its bytes, handing out a temporary URL and deleting it. One tenant holding another tenant's file id gets the same answer as for an id that does not exist. A file saved by the host itself, with no tenant in flight, belongs to nobody, and is reached only with no tenant in flight.

The table is deliberately not partitioned with a query filter, the way an app's own tenant-scoped tables are: the orphan sweep has to see every tenant's rows to decide which bytes are unreferenced, and a filter would hide them — leaving other tenants' files on disk for ever while the sweep reported nothing to do.

The tenant column follows your tenants. StoredFile.TenantId is a Guid? in C#, like Current.Tenant. Its column is a uniqueidentifier (a uuid, SQLite text) — unless every tenant-scoped table in the model numbers its tenants (PerTenant<int>, or a declared int? TenantId): then it is an int column holding the number itself, 42 for Tenant.Use(42) — or a bigint for PerTenant<long>. A model with no tenant-scoped table, or with tables of more than one kind, keeps the Guid column. modelBuilder.AddRaskStorage() reads this off the model, before or after ApplyRaskConventions. In a numbered app, a tenant in flight that is not a number is refused rather than compared.

The orphan sweep

A hosted service runs about a minute after the app starts and then every SweepInterval (24 hours by default), and removes bytes that have no row and are older than OrphanGracePeriod (24 hours by default, at least 5 minutes — longer than any save could still be in flight between writing the bytes and the row). It is built to do nothing rather than the wrong thing:

  • It fails closed. Any database error ends the sweep with nothing deleted.
  • It only touches its own keys — the {prefix}{public|private}/{two hex}/{id} layout under the configured prefix. Other objects in the same bucket are never considered.
  • It refuses a mass delete. When a sweep would remove more than 100 objects and more than 10% of what it looked at, it deletes nothing and logs an error. That shape is the signature of the wrong database (an empty or restored app.db) or of two environments sharing one bucket and prefix — give each environment its own Prefix.
  • It deletes nothing against an empty table, whatever the numbers — an app pointed at a fresh database sees every object as an orphan.
  • On the Database provider it removes StoredFileBytes rows whose StoredFile row is gone, with no grace period to wait out: a save writes both in one transaction, so there are no bytes in flight.
  • On S3 or Azure it needs a Prefix to delete anything. Without one it only logs what it found, and the app warns at startup: a bucket is easily shared by two environments, and a key's shape alone cannot tell this app's orphans from another app's files. Set Rask__Storage__Prefix for each environment.

Backups, and more than one instance

rask db backup takes the files with the database; nothing else does. The StoredFile rows live in app.db and the bytes live in the disk root, so a backup of one without the other restores rows pointing at files that are no longer there. rask db backup (locally and --remote) archives the disk root beside the database copy — shop-20260805-081500.db comes with shop-20260805-081500.files.tgz — and rask db restore puts both back when it finds the archive beside the file you name (Backup and restore). The files are archived after the database is copied: a save writes its bytes before its row, so every row in the copy has its bytes in the archive, and bytes saved in between are orphans the sweep removes.

Litestream and SQLite snapshots copy app.db alone. An app that relies on them for disaster recovery, or that uploads files it can't afford to lose between backups, should use S3 or Azure, where the bytes' durability is the provider's — or the Database provider, where the bytes are in app.db too.

A database restored without its files says so. The storage sweep also looks for disk rows older than the grace period whose bytes are gone, and logs a warning with their count and the first few ids; rask db restore prints the same check when it finishes. Rows are reported, never deleted — the fix is to restore the files.

A remote backup archives /data/files, where the disk provider writes on a rask deploy box. An app that sets Rask__Storage__Disk__Root somewhere else on the host keeps its files outside what the backup can see.

The disk store is node-local. A file saved on one host does not exist on another, and routing a visitor back to the same host doesn't help, because a file is read by people other than the one who uploaded it. Run more than one instance only with S3, Azure or the Database provider — and, for app-signed temporary links, a shared Data Protection key ring. See scaling.

The operator console

The dashboard at /_rask gains a read-only Storage tab at /_rask/storage: how many files and bytes are stored, how many are public, usage per provider, and a searchable list of recent files. When files are on disk it says so, because that is the one state in which no backup covers them. The /_rask/files/… prefix is reserved for the file routes.

Security notes

  • The browser's word is never used. The content type is sniffed, the name is reduced to a display leaf, and the size is enforced while copying.
  • An upload can't become a page on your origin. Only raster images, audio and video are served inline; HTML, SVG and XML go out as application/octet-stream; every response carries nosniff and a sandboxing Content-Security-Policy; and a disk root inside wwwroot is refused.
  • The file routes allow anonymous requests. The public flag or the signed token is the authorization — a fallback authorization policy would otherwise break every <img> pointing at them. On a hand-wired host you can chain your own conventions onto them, such as a rate limit: app.MapRaskStorage().RequireRateLimiting("files").
  • Private files go through your code. Use Download from an endpoint that has already checked the user, or a short-lived Share(id).For(…). Remember that a provider-signed link can't be withdrawn early, and that PublicBaseUrl exposes every key the bucket serves.
  • Failures say little to strangers. Every "no" from the file routes is the same 404, and FileRejectedException's message never repeats the uploaded name.

Limits

  • Server-only. Rask.Storage runs in the ASP.NET host, not in the browser: a WebAssembly page uploads to the server, which saves the file. That host can be the server template's or the wasm-hosted one's.
  • One object per file, no multipart upload. A single object is at most 5 GiB on S3, 5000 MiB on Azure and 1 GB in the database, so a MaxFileSize above the chosen provider's ceiling stops the boot.
  • No image processing. No resizing, no thumbnails, no format conversion.
  • Disk files are not backed up and don't span instances — see above.
  • One store per app, and changing it doesn't move what is already stored.

See also

  • HTTP & files — the file picker and IRaskFile, and Download.File for bytes you generate on the fly.
  • Forms — file inputs inside a form.
  • Configuration — the server's own upload limits.
  • Deployment — the deploy volume, and passing storage settings.
  • Scaling — what else changes on more than one host.
  • Dashboard — the Storage tab.
  • Looking for localStorage in the browser? That is LocalStorage in Rask.Web.

Confirm