Autumn ships a pluggable file-storage abstraction so apps that accept user-uploaded files (avatars, attachments, generated reports) don't have to pick an SDK, design a key scheme, or hand-roll URL signing every time.
This is the layer that turns the
Multipart extractor's "stream-to-disk"
primitive into something that survives container restarts and works
across replicas.
When you need it
Reach for BlobStore if you answer "yes" to any of:
- Does my app accept user-uploaded files?
- Will I run more than one web replica?
- Do I redeploy on a schedule, where local disk is wiped between containers?
If "no" to all three, the existing MultipartField::save_to(path)
primitive is fine.
Quick start
Enable the storage feature on autumn-web (for the Local backend and the
BlobStore trait). For S3-compatible production storage also add
autumn-storage-s3:
[dependencies]
autumn-web = { version = "0.5", features = ["storage", "multipart"] }
autumn-storage-s3 = "0.5" # only needed when storage.backend = "s3"
The framework gives you a working Local backend in dev out of the
box — bytes land under target/blobs/ and signed URLs are served
from /_blobs/.... No further config required.
use autumn_web::extract::{Multipart, State};
use autumn_web::prelude::*;
use autumn_web::storage::BlobStoreState;
#[post("/avatar")]
async fn upload(
State(state): State<AppState>,
mut form: Multipart,
) -> AutumnResult<String> {
let blobs = state
.extension::<BlobStoreState>()
.ok_or_else(|| AutumnError::internal_server_error_msg("storage not configured"))?;
let store = blobs.store().clone();
while let Some(field) = form.next_field().await? {
if field.name() == Some("avatar") {
let blob = field
.save_to_blob_store(&*store, "avatars/me.png")
.await?;
return Ok(blob.key);
}
}
Err(AutumnError::bad_request_msg("missing 'avatar' field"))
}
The full working version — Maud-rendered upload form, presigned-URL
<img> rendering, and a Blob column on a #[model] user — lives
in examples/reddit-clone. See
src/routes/avatars.rs for the upload handler,
src/routes/auth.rs::profile for the rendering, and
migrations/20260427000000_add_user_avatar/up.sql for the migration
generated by add_blob_column!.
Configuration
[storage]
backend = "local" # "local" | "s3" | "disabled"
default_provider = "default"
allow_local_in_production = false
[storage.local]
root = "target/blobs"
mount_path = "/_blobs"
default_url_expiry_secs = 900
# signing_key = "..." # optional; falls back to AUTUMN_STORAGE__LOCAL__SIGNING_KEY
[storage.s3]
bucket = "my-app-uploads"
region = "us-east-1"
endpoint = "https://s3.amazonaws.com" # optional; required for R2/MinIO/Spaces/Wasabi
access_key_id_env = "AWS_ACCESS_KEY_ID"
secret_access_key_env = "AWS_SECRET_ACCESS_KEY"
force_path_style = false
Every field is overridable via AUTUMN_STORAGE__* env vars (see the
in-source config docs for the canonical
list).
Profile-aware defaults
| Profile | [storage].backend | Notes |
|---|---|---|
dev | disabled | Opt in by setting backend = "local" |
prod | disabled | backend = "local" fails fast unless storage.allow_local_in_production = true |
The fail-fast in prod is intentional: a single-replica Local
deployment is fine, but it has to be explicitly acknowledged. Apps
that scale beyond one replica should select s3.
The Blob column story
Apps store Blob columns; the BlobStore owns the bytes; the
database owns lifecycle.
use autumn_web::model;
use autumn_web::storage::Blob;
#[model]
pub struct User {
pub id: i64,
pub name: String,
pub avatar: Option<Blob>,
}
Blob is Serialize + Deserialize and (when the db feature is on)
implements AsExpression / FromSqlRow for Postgres JSONB, so the
default #[model] derives Just Work.
Blob column migrations
Adding a blob column to an existing table is one macro call:
use autumn_web::storage::migrations::add_blob_column;
let (up, down) = add_blob_column!("users", "avatar");
// up = "ALTER TABLE users ADD COLUMN avatar JSONB NULL"
// down = "ALTER TABLE users DROP COLUMN avatar"
For Diesel file-based migrations, paste the strings into
migrations/<name>/{up,down}.sql; for runtime migrations, hand them
to diesel::sql_query(...). The macro deliberately accepts only
string literals — runtime-derived identifiers can't be safely
interpolated without a quoting layer, so passing them through here
would be an injection footgun.
Reddit-clone's migrations/20260427000000_add_user_avatar/ is the
worked example.
Presigned-URL semantics
| Backend | URL shape | Signing |
|---|---|---|
Local | /{mount_path}/{key}?exp=…&sig=… | HMAC-SHA256 over {key}:{exp}, verified by the mounted serving route |
S3 | Real S3 presigned URL | AWS SigV4 (or your provider's equivalent) |
Both expire. Both are tamper-resistant. Both are safe to embed in templates and emails.
For the local backend, set [storage.local].signing_key (or the
AUTUMN_STORAGE__LOCAL__SIGNING_KEY env var) so URLs survive a
process restart and replicas agree on signatures. Without it the
framework generates a random key per process — fine for dev, never
for prod.
Multi-replica safety
The local backend writes to a single host's disk. That's broken across replicas:
- replica A serves the upload, the bytes land on A's disk
- replica B serves the next request, can't see A's bytes
The framework doesn't try to paper over this. It surfaces the constraint:
prod+localwithoutallow_local_in_productionfails fast at startup.prod+local+ acknowledgement logs a warning explaining the replicas can't see each other's bytes.
Multi-replica production should choose backend = "s3".
Production checklist
Before flipping a real app to backend = "s3":
- [ ] Bucket exists and is private (no public-read policy unless you really mean it).
- [ ] Bucket policy permits
PutObject,GetObject,DeleteObject, and (forhead)HeadObjectfrom the credentials your app uses. - [ ] CORS is configured if you'll ever generate browser-served presigned URLs across origins.
- [ ] Lifecycle rules are in place to expire orphaned blobs (the
framework's first slice deliberately does not garbage-collect
for you —
deletethe row anddeletethe blob in a transaction-bracketed pattern). - [ ] Credentials come from your secrets manager via
access_key_id_env/secret_access_key_env, not committedautumn.toml. - [ ] You're using a region that's geographically near your app
tier (latency on every
put/get).
Using the S3 backend
Add autumn-storage-s3 to your Cargo.toml and wire it up in main:
[dependencies]
autumn-web = { version = "0.5", features = ["storage", "multipart"] }
autumn-storage-s3 = "0.5"
use autumn_storage_s3::S3BlobStore;
#[tokio::main]
async fn main() {
let config = autumn_web::config::TomlEnvConfigLoader::new()
.load()
.await
.expect("config");
let store = S3BlobStore::from_config(&config.storage.s3)
.await
.expect("S3 store");
autumn_web::app()
.routes(routes![...])
.with_blob_store(store)
.run()
.await;
}
And in autumn.toml:
[storage]
backend = "s3"
[storage.s3]
bucket = "my-app-uploads"
region = "us-east-1"
access_key_id_env = "AWS_ACCESS_KEY_ID"
secret_access_key_env = "AWS_SECRET_ACCESS_KEY"
S3BlobStore::from_config resolves credentials from the named
environment variables, or falls back to the AWS default chain
(~/.aws/credentials, instance-metadata, ECS task role, etc.) when
neither access_key_id_env nor secret_access_key_env is set.
The S3 plugin lives in its own crate (autumn-storage-s3) so apps
that don't need S3 don't pull in the AWS SDK tree. Peer plugins for
other providers (GCS, Azure, B2) follow the same pattern.
Direct uploads
Large files (video, high-res images, bulk CSV) can bypass the Autumn process entirely and go straight from the browser to the storage backend. The pattern:
- Presign — your CSRF-protected endpoint calls
store.presign_put(key, content_type, expires_in)and returns thePresignPutResultto the browser. - Upload — browser
PUTs directly to the presigned URL. - Confirm — browser calls your completion endpoint; the endpoint calls
autumn_web::storage::complete_direct_upload(&*store, key).await?which does aHEADto verify the object exists, then returns aBlobthat your handler can store in the database.
Security model
The presigned PUT URL authorises only the upload byte-transfer — it does not
grant any application privilege. Binding the blob to a model (inserting the Blob
JSONB into your database) requires passing through your completion endpoint, which
should be protected with CSRF and session checks as usual. A user who intercepts a
presigned URL gains nothing except the ability to overwrite the object at that key
during the expiry window.
Upload tokens (signed by sign_upload) are distinct from download tokens (signed
by sign_download) — the local backend verifies them separately, so a valid
download token cannot be replayed as an upload token.
Local backend
The local backend exposes PUT /_blobs/{key}?upload=1&ct=...&exp=...&sig=....
Everything works in dev with no extra config.
S3 backend
The S3 backend issues real AWS SigV4 presigned PUT URLs. Ensure your bucket CORS
policy allows PUT from your app's origin if you call from browser JavaScript:
[{
"AllowedHeaders": ["Content-Type"],
"AllowedMethods": ["PUT"],
"AllowedOrigins": ["https://your-app.example.com"],
"MaxAgeSeconds": 3600
}]
Worked example
use std::time::Duration;
use autumn_web::security::CsrfToken;
use autumn_web::storage::{BlobStoreState, PresignPutResult, complete_direct_upload};
use autumn_web::{AutumnResult, Json, secured, State, post};
use serde::{Deserialize, Serialize};
#[derive(Deserialize)]
struct PresignRequest {
content_type: String,
key: String,
}
#[derive(Serialize)]
struct PresignResponse {
url: String,
method: String,
headers: std::collections::HashMap<String, String>,
expires_in_secs: u64,
}
/// CSRF-protected endpoint that hands out a presigned PUT URL.
#[secured]
#[post("/uploads/presign")]
async fn presign(
_csrf: CsrfToken, // ensures caller has a valid session
State(blobs): State<BlobStoreState>,
Json(req): Json<PresignRequest>,
) -> AutumnResult<Json<PresignResponse>> {
let store = blobs.store();
let result: PresignPutResult = store
.presign_put(&req.key, &req.content_type, Duration::from_secs(300))
.await?;
Ok(Json(PresignResponse {
url: result.url,
method: result.method,
headers: result.headers,
expires_in_secs: result.expires_in.as_secs(),
}))
}
#[derive(Deserialize)]
struct CompleteRequest { key: String }
/// CSRF-protected endpoint that confirms the upload and records the Blob.
#[secured]
#[post("/uploads/complete")]
async fn complete(
_csrf: CsrfToken,
State(blobs): State<BlobStoreState>,
mut db: Db,
Json(req): Json<CompleteRequest>,
) -> AutumnResult<Json<serde_json::Value>> {
let store = blobs.store();
// HEAD-checks the object; returns NotFound if the browser didn't
// actually upload yet or if the upload expired.
let blob = complete_direct_upload(&**store, &req.key).await?;
// Bind the blob to the user's record (app's own CSRF already enforced above).
diesel::update(users::table.find(current_user_id))
.set(users::avatar.eq(Some(blob)))
.execute(&mut *db)
.await?;
Ok(Json(serde_json::json!({ "status": "ok" })))
}
Maud form helper
If your Maud templates use a standard <input type="file"> with htmx for
progressive enhancement, the autumn_web::storage::form_helper module (feature
maud) provides a pre-wired input:
use autumn_web::storage::form_helper::direct_upload_input;
html! {
(direct_upload_input("avatar", &presign_url, Some("image/*")))
}
Renders a file input with data-controller="direct-upload" for Stimulus-style JS
enhancement and an accessible progress bar placeholder. When JavaScript is unavailable,
users see a notice explaining the dependency. To provide a fallback upload path for
no-JS users, wrap the component in a <noscript> block with an alternative
enctype="multipart/form-data" form and handler.
Orphan handling
Blobs are "orphaned" when the user uploads a file but never completes the confirmation step. The framework uses the bind-step-promotes model:
- The blob exists in storage as soon as the browser's PUT succeeds.
- It becomes owned when your completion handler calls
complete_direct_uploadand records the returnedBlobJSONB value in the database. - Unbound blobs (started upload, never completed) remain in storage until your lifecycle / expiry policy removes them.
Recommended: set a bucket lifecycle rule (S3) or a cron sweeper (local) to
delete objects with a well-known "orphan" prefix after a short window (e.g.,
tmp/ prefix, 24 h expiry).
Image variants
Resize, rotate, and strip metadata from stored images — lazily, with
content-addressed caching — via the variants feature. See the
Image Variants guide for the full walkthrough.
What's out of scope (for now)
- Native non-S3 backends (GCS, Azure Blob, B2 native). Anyone
whose object store speaks S3 is covered by
autumn-storage-s3. Native backends are a future plugin-crate extension. - Antivirus / content moderation. Compose a Tower middleware on
top of
BlobStorefor this. - Orphan-blob garbage collection. Document: lifecycle is the
application's job (delete the row, then delete the blob). A
harvest-backed sweeper can come later. - Migration tooling for moving data between backends. Not framework's job today.