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
IRaskFileis only readable while its handler is on the stack, so theawaitbelongs inside it. - Pass the opener, don't call it.
file.OpenReadStreamgoes in as a method group, andSavecalls it with the storage size limit. CallingOpenReadStream()yourself gets its 512 KB default cap, which is not the limit you configured. Acceptis 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 whatSavesniffs 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
StoredFileandStoredFileBytesrows 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
StoredFilerow 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:
- the server's upload limit,
RaskUploadOptions.MaxFileSize— 50 MB; - a remote CQRS upload's
MaxUploadBytes— 32 MB.
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.
PublicBaseUrlmoves the check out of the app, so grant public read on{Prefix}public/only. ThePublicflag 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 coveredprivate/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 aRefererheader;- a strong
ETagthat is the file's SHA-256, so a repeat request can be answered304.
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
StoredFilerow together, so there is never a row without bytes, or bytes a save is still about to claim. InsideDb.Transactionthat 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(…)andDownload(id), ranges,ETag(the SHA-256) and304s included.PublicBaseUrlcannot apply (no CDN reads a row), so setting it with this provider stops the boot. MaxFileSizestill 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 ownPrefix. - 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
StoredFileBytesrows whoseStoredFilerow 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
Prefixto 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. SetRask__Storage__Prefixfor 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 carriesnosniffand a sandboxing Content-Security-Policy; and a disk root insidewwwrootis 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
Downloadfrom an endpoint that has already checked the user, or a short-livedShare(id).For(…). Remember that a provider-signed link can't be withdrawn early, and thatPublicBaseUrlexposes every key the bucket serves. - Failures say little to strangers. Every "no" from the file routes is the same
404, andFileRejectedException's message never repeats the uploaded name.
Limits
- Server-only.
Rask.Storageruns in the ASP.NET host, not in the browser: a WebAssembly page uploads to the server, which saves the file. That host can be theservertemplate's or thewasm-hostedone'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
MaxFileSizeabove 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, andDownload.Filefor 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
localStoragein the browser? That isLocalStorageinRask.Web.