Files, uploads & media
Almost every app stores user files — avatars, attachments, exported reports,
images. Rustango gives you a Storage trait with swappable backends (local
disk, S3-compatible object storage, in-memory for tests), a safe multipart
upload helper with size/type guards, and — when you need a tracked media
library — a database-backed MediaManager with presigned URLs. Write your code
once against the trait; switch from local disk to S3 with a one-line change.
New to a term here? storage backend, multipart, object storage, presigned URL — see the glossary.
Source:
rustango::storage(Storage,LocalStorage,InMemoryStorage,s3::S3Storage,BoxedStorage),rustango::uploads(save_uploads,UploadConfig,sanitize_filename), andrustango::media(Media,MediaManager) — behind thestorage/uploads/storage-s3/mediafeatures (all on by default).Runnable version: the Storage + upload-guard snippets are copied from
files_doc.rs(cargo test -p rustango --test files_doc); the end-to-end multipartsave_uploadsflow is dogfooded by the in-file tests incrates/rustango/src/uploads.rs, and the media library bymedia_sqlite_live.rs.
Table of contents
- Step 1 — Pick a storage backend
- Step 2 — Save, load, and serve files
- Step 3 — Accept an upload
- Safe filenames
- Production: S3-compatible storage
- The media library
- Reference
- See also
Step 1 — Pick a storage backend
Every backend implements the same Storage trait, so your code never names the
concrete type — it holds a BoxedStorage (Arc<dyn Storage>):
use rustango::storage::{BoxedStorage, LocalStorage};
use std::path::PathBuf;
use std::sync::Arc;
let storage: BoxedStorage = Arc::new(LocalStorage::new(PathBuf::from("./uploads")));
| Backend | Feature | Use for |
|---|---|---|
LocalStorage | storage | single-server deployments — files on local disk |
S3Storage | storage-s3 | production — S3 / R2 / B2 / MinIO object storage |
InMemoryStorage | storage | tests — a HashMap, never touches disk |
Step 2 — Save, load, and serve files
The trait is four async methods, keyed by a string path. save writes bytes,
load reads them back, plus exists / delete:
use rustango::storage::{Storage, InMemoryStorage};
let store = InMemoryStorage::new();
store.save("avatars/7.png", &png_bytes).await?;
assert!(store.exists("avatars/7.png").await?);
let bytes = store.load("avatars/7.png").await?;
store.delete("avatars/7.png").await?;
Serving the file. Attach a base URL (your CDN or static host) and url(key)
builds the public address you store on the model and hand to the browser:
let store = LocalStorage::new("./uploads".into())
.with_base_url("https://cdn.example.com/uploads");
store.url("docs/report.pdf"); // Some("https://cdn.example.com/uploads/docs/report.pdf")
Without a base URL, url() returns None — you'd stream the bytes through a
handler instead. LocalStorage also guards against path traversal in keys.
Step 3 — Accept an upload
save_uploads consumes an axum Multipart body, validates each file against an
UploadConfig, and writes the survivors to your Storage — streaming, so an
oversize file is rejected mid-transfer instead of buffering into memory first.
use rustango::uploads::{save_uploads, UploadConfig};
use axum::extract::Multipart;
async fn upload(mp: Multipart) -> Result<impl IntoResponse, UploadError> {
let cfg = UploadConfig::new("avatars/") // key prefix
.max_bytes(2 * 1024 * 1024) // reject files over 2 MiB
.allowed_extensions(&["png", "jpg", "jpeg", "webp"])
.randomize_filename(true); // avoid collisions
let saved = save_uploads(mp, &cfg, &storage).await?; // Vec<SavedUpload>
Ok(Json(saved))
}
The guards are enforced (and verified): allowed_extensions is case-insensitive
("PNG" and "png" are the same), and max_bytes aborts the stream as soon as
the size is exceeded. The in-file uploads tests drive real multipart bodies and
assert files land in storage, oversize files are rejected, and disallowed
extensions are refused.
let cfg = UploadConfig::new("avatars/").allowed_extensions(&["PNG", "Jpg"]);
assert!(cfg.allowed_extensions.contains("png")); // normalized to lowercase
assert!(cfg.allowed_extensions.contains("jpg"));
Safe filenames
Never trust a client-supplied filename. sanitize_filename reduces it to a safe
basename — stripping directory components (path traversal) and replacing unsafe
characters:
use rustango::uploads::sanitize_filename;
sanitize_filename("../../etc/passwd"); // "passwd" — no traversal
sanitize_filename("my photo!.png"); // "my_photo_.png"
sanitize_filename(""); // "upload" — never empty
save_uploads applies this for you; call it directly only if you build keys by
hand.
Production: S3-compatible storage
For multi-server deployments, swap LocalStorage for S3Storage (behind the
storage-s3 feature). It speaks the S3 API with a hand-rolled SigV4 signer, so
it works with AWS S3, Cloudflare R2, Backblaze B2, and MinIO. The trait is
identical — only the constructor changes:
use rustango::storage::s3::S3Storage; // needs the `storage-s3` feature
let storage: BoxedStorage = Arc::new(
S3Storage::new(/* bucket, region, endpoint, credentials */)
);
// save / load / delete / url — exactly the same calls as LocalStorage
Your handlers and models don't change; only the wiring at startup does.
The media library
When files are first-class records — tracked in the database, browsable in the
admin, with CDN/presigned delivery — reach for rustango::media
instead of raw Storage. MediaManager persists a Media row per file and
supports two upload flows:
- Server-side:
manager.save_bytes(...)stores the bytes and the row in one call. - Direct-to-storage:
manager.begin_upload(...)returns a presigned PUT URL the browser uploads to directly (your server never proxies the bytes), then you confirm the row. The PUT must send every header inticket.headers(Content-Type,If-None-Match: *), so the bucket's CORS rule must allow both. The URL can create the object once and never replace it. Grants3:ListBuckettoo: without it S3 answers a missing object with 403, and finalize returns 502 instead of marking the rowFailed.
use rustango::media::{Media, MediaManager};
let manager = MediaManager::new_pool(pool.clone(), registry);
// Hand the browser a short-lived download link:
// Returns Option<String> — None on backends that cannot sign (e.g. local disk).
let Some(url) = manager.presigned_get(&media, Duration::from_secs(3600)).await else {
return Err(/* no signed URL for this backend */);
};
It also handles soft-delete and orphan purging. The full flow is dogfooded in
media_sqlite_live.rs; the manager's presigned and direct-upload methods
run on all three backends.
Serving media on a public page
A public page does not go through media::router. That router is the
internal management API — uploads, deletes, tagging, browsing — and it
answers 401 to anyone not signed in, on every route, by design.
Render the URL from your own handler instead:
// your own public route
let url = manager.public_url(media_id).await?; // Option<String>, no signature
public_url is a database lookup plus a string; it mints nothing and awaits
no signer, which is why it suits a page. Two delivery models, and choosing
between them is the actual decision:
| public bucket / CDN | private bucket + presigned | |
|---|---|---|
| address | manager.public_url(id) — stable | manager.presigned_get(&m, ttl) — expires |
| cacheable | yes, by browsers and CDNs | no; the router sends no-store |
| who may fetch | anyone with the URL | anyone with the URL, until it expires |
| use for | public pages, <img src> | the management router, internal tools |
public_url tells you where the object would be served from; it does not
make the object readable. Point it at a private bucket and you get a correct
URL and a 403 — that case wants a presigned URL, which is deliberately not
cacheable and not shareable.
For files on local disk rather than a bucket, the static handler already does this and needs no media row at all:
Cli::new(pool).with_uploads("/uploads", "./var/uploads")
If you were about to write an AllowAll authorizer to make a public page
work, stop. That opens all 16 routes — including DELETE and the presigned
PUT — to everyone, which is the hole 0.57.7 closed.
The management router needs an authorization policy
media::router mounts 16 JSON routes over the manager, all of them operator
actions on the library. Every one is gated, and there is no permissive
default. Build it with media_router_with and supply a policy:
use rustango::media::router::{media_router_with, MediaPerms};
let app = axum::Router::new()
.nest("/media", media_router_with(manager, MediaPerms::new(pool)));
media_router(manager) — the old constructor — is deprecated and now
answers 403 on every route. That is a deliberate behaviour change in
0.57.7: before it, those routes took no authentication, authorization or
tenant extractor at all, so GET /media/{id} returned the row and a
presigned S3 download URL to anyone who could guess an integer, and
POST /uploads/begin minted a presigned PUT for a caller-chosen key
prefix.
MediaPerms (needs the tenancy feature) checks the {table}.{action}
permission codenames the admin already uses — rustango_media.view to read,
rustango_media_collections.add to create a folder, and so on. Mount it
inside require_auth, which is what injects the identity it reads;
without that every request is a 401. Superusers skip the codename check —
but not allow_disks, which binds them too: is_superuser elevates
inside one tenant, and the object store is shared across all of them.
Three things it does not do:
- Row-level decisions. A grant of
rustango_media.viewreads any media row by id.MediaManagerholds one pool, so a multi-tenant deployment scopes rows itself — implementMediaAuthorizerfor that.MediaTargetnames the row (Media(id),Collection(id),CollectionSubtree(id), …) precisely so a per-row policy can be written.?recursiveon a collection's contents arrives asCollectionContents { id, recursive: true }— same target, flagged — so a policy can be stricter about the wide read without losing the collection it names. - Scope the object store.
diskis caller-supplied onPOST /uploads/beginand theStorageRegistryis process-wide, so pool-per-tenant isolates the database and not the bucket: a barerustango_media.addgrant writes into any disk the process knows about. Say which ones withMediaPerms::new(pool).allow_disks(["user-uploads"]). Prefixes within a disk still needMediaAuthorizer, which is handedkey_prefix. - Guess what a new route means. Both
MediaActionandMediaTargetare#[non_exhaustive], so end a hand-written policy on_ => falseand a route added in a later release arrives denied rather than allowed.
Worth knowing when you write your own: DELETE /collections/{id} deletes the
whole subtree and re-parents the media under every level of it, so it
arrives as Delete(CollectionSubtree(id)) rather than
Delete(Collection(id)) — and under MediaPerms it needs
rustango_media.change as well as rustango_media_collections.delete,
because it writes to the media table.
UPGRADING.md has
the migration notes.
How the media tables are created
The media tables (rustango_media, rustango_media_collections,
rustango_media_tags, rustango_media_tag_links) are managed models. Their
schema ships as a system migration and is created — per tenant, when the
media feature is enabled — the same way as the rest of the framework's own
tables, whenever you run migrate / provision a tenant. There is no lazy
"create on first use" step; if the feature is off, the tables are never created.
Upgrading from before 0.51. Earlier versions created the media tables lazily via an
ensure_tableDDL call rather than a migration. On the firstmigrate(or tenant provision) after upgrading, the framework auto-reconciles: because the tables already exist, the generated media migration is recorded in the system-migration ledger without re-running itsCREATE TABLE, and your existing rows are left untouched. No manual step is required — the upgrade that would otherwise fail withrelation already exists/table already existsnow just works.Upgrade to 0.51.2 or later, not 0.51.1. 0.51.0 moved the media tables onto system migrations and 0.51.1 claimed this reconcile — cross-version testing against real 0.46–0.50 databases showed neither worked, and both are yanked. The reconcile guard demanded a migration be purely
CreateTable, while a generated initial migration is tables and indexes, so it bailed on every real one. 0.51.2 is the release where this actually fires (#1167).
Reference
Storage trait: save(key, &bytes) · load(key) · delete(key) ·
exists(key) · url(key) -> Option<String>, plus defaulted save_with_content_type,
presigned_get_url(key, ttl), presigned_put_url(key, ttl, &put) and metadata(key)
(a signing backend overrides the presign pair; the defaults return None).
UploadConfig: new(prefix) · .max_bytes(n) · .max_files(n) · .allowed_extensions(&[..])
(case-insensitive) · .randomize_filename(bool). Used by
save_uploads(multipart, &cfg, &storage).
Backends: LocalStorage (disk) · S3Storage (object storage, storage-s3)
· InMemoryStorage (tests). All return a BoxedStorage.
See also
- The admin — media and FK widgets surface uploaded files in the UI.
- Background jobs — resize/transcode an upload off the request.
- Caching — the same swap-the-backend trait pattern.
- Security guide — validating untrusted upload input.
