Rustango docs
← Guides

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.

Files in Rustango: a multipart upload is size- and extension-checked then written through the Storage trait; the same trait backs local disk, S3, and in-memory, and url() returns a public address

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), and rustango::media (Media, MediaManager) — behind the storage / uploads / storage-s3 / media features (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 multipart save_uploads flow is dogfooded by the in-file tests in crates/rustango/src/uploads.rs, and the media library by media_sqlite_live.rs.

Table of contents


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")));
BackendFeatureUse for
LocalStoragestoragesingle-server deployments — files on local disk
S3Storagestorage-s3production — S3 / R2 / B2 / MinIO object storage
InMemoryStoragestoragetests — 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 in ticket.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. Grant s3:ListBucket too: without it S3 answers a missing object with 403, and finalize returns 502 instead of marking the row Failed.
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 / CDNprivate bucket + presigned
addressmanager.public_url(id) — stablemanager.presigned_get(&m, ttl) — expires
cacheableyes, by browsers and CDNsno; the router sends no-store
who may fetchanyone with the URLanyone with the URL, until it expires
use forpublic 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.view reads any media row by id. MediaManager holds one pool, so a multi-tenant deployment scopes rows itself — implement MediaAuthorizer for that. MediaTarget names the row (Media(id), Collection(id), CollectionSubtree(id), …) precisely so a per-row policy can be written. ?recursive on a collection's contents arrives as CollectionContents { 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. disk is caller-supplied on POST /uploads/begin and the StorageRegistry is process-wide, so pool-per-tenant isolates the database and not the bucket: a bare rustango_media.add grant writes into any disk the process knows about. Say which ones with MediaPerms::new(pool).allow_disks(["user-uploads"]). Prefixes within a disk still need MediaAuthorizer, which is handed key_prefix.
  • Guess what a new route means. Both MediaAction and MediaTarget are #[non_exhaustive], so end a hand-written policy on _ => false and 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_table DDL call rather than a migration. On the first migrate (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 its CREATE TABLE, and your existing rows are left untouched. No manual step is required — the upgrade that would otherwise fail with relation already exists / table already exists now 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.