Archivos, subidas y medios
Casi toda aplicación almacena archivos de usuario — avatares, adjuntos, informes exportados,
imágenes. Rustango te ofrece un trait Storage con backends intercambiables (disco
local, almacenamiento de objetos compatible con S3, en memoria para pruebas), un ayudante de
subida multipart seguro con guardas de tamaño/tipo, y — cuando necesitas una
biblioteca de medios rastreada — un MediaManager respaldado por base de datos con URLs
prefirmadas. Escribe tu código una sola vez contra el trait; cambia de disco local a S3 con un
cambio de una línea.
¿Nuevo con algún término aquí? backend de almacenamiento, multipart, almacenamiento de objetos, URL prefirmada — ver el glosario.
Fuente:
rustango::storage(Storage,LocalStorage,InMemoryStorage,s3::S3Storage,BoxedStorage),rustango::uploads(save_uploads,UploadConfig,sanitize_filename), yrustango::media(Media,MediaManager) — tras las característicasstorage/uploads/storage-s3/media(todas activadas por defecto).Versión ejecutable: los snippets de Storage + guardas de subida se copian de
files_doc.rs(cargo test -p rustango --test files_doc); el flujo multipartsave_uploadsde extremo a extremo se somete a prueba mediante los tests internos encrates/rustango/src/uploads.rs, y la biblioteca de medios mediantemedia_sqlite_live.rs.
Tabla de contenidos
- Paso 1 — Elige un backend de almacenamiento
- Paso 2 — Guarda, carga y sirve archivos
- Paso 3 — Acepta una subida
- Nombres de archivo seguros
- Producción: almacenamiento compatible con S3
- La biblioteca de medios
- Referencia
- Véase también
Paso 1 — Elige un backend de almacenamiento
Cada backend implementa el mismo trait Storage, de modo que tu código nunca nombra el tipo
concreto — sostiene un 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 | Característica | Úsalo para |
|---|---|---|
LocalStorage | storage | despliegues de un solo servidor — archivos en disco local |
S3Storage | storage-s3 | producción — almacenamiento de objetos S3 / R2 / B2 / MinIO |
InMemoryStorage | storage | pruebas — un HashMap, nunca toca el disco |
Paso 2 — Guarda, carga y sirve archivos
El trait son cuatro métodos async, indexados por una ruta en forma de cadena. save escribe
bytes, load los vuelve a leer, más 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?;
Servir el archivo. Adjunta una URL base (tu CDN o host estático) y url(key)
construye la dirección pública que almacenas en el modelo y entregas al navegador:
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")
Sin una URL base, url() devuelve None — en su lugar transmitirías los bytes a través de un
handler. LocalStorage también protege contra el path traversal en las claves.
Paso 3 — Acepta una subida
save_uploads consume un cuerpo Multipart de axum, valida cada archivo contra un
UploadConfig, y escribe los supervivientes en tu Storage — en streaming, de modo que un archivo
sobredimensionado se rechaza a mitad de transferencia en lugar de almacenarse primero en memoria.
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))
}
Las guardas se aplican (y se verifican): allowed_extensions es insensible a mayúsculas y
minúsculas ("PNG" y "png" son lo mismo), y max_bytes aborta el stream en cuanto se
excede el tamaño. Los tests internos de uploads conducen cuerpos multipart reales y
afirman que los archivos aterrizan en el almacenamiento, que los archivos sobredimensionados se
rechazan y que las extensiones no permitidas se deniegan.
let cfg = UploadConfig::new("avatars/").allowed_extensions(&["PNG", "Jpg"]);
assert!(cfg.allowed_extensions.contains("png")); // normalized to lowercase
assert!(cfg.allowed_extensions.contains("jpg"));
Nombres de archivo seguros
Nunca confíes en un nombre de archivo suministrado por el cliente. sanitize_filename lo reduce a
un basename seguro — eliminando componentes de directorio (path traversal) y reemplazando
caracteres inseguros:
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 lo aplica por ti; llámalo directamente solo si construyes claves a mano.
Producción: almacenamiento compatible con S3
Para despliegues multi-servidor, cambia LocalStorage por S3Storage (tras la
característica storage-s3). Habla la API de S3 con un firmante SigV4 hecho a mano, de modo que
funciona con AWS S3, Cloudflare R2, Backblaze B2 y MinIO. El trait es
idéntico — solo cambia el constructor:
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
Tus handlers y modelos no cambian; solo cambia el cableado en el arranque.
La biblioteca de medios
Cuando los archivos son registros de primera clase — rastreados en la base de datos, navegables en
el admin, con miniaturas y entrega por CDN/prefirmada — recurre a rustango::media en lugar de a
Storage en bruto. MediaManager persiste una fila Media por archivo y
admite dos flujos de subida:
- Del lado del servidor:
manager.save_bytes(...)almacena los bytes y la fila en una sola llamada. - Directo al almacenamiento:
manager.begin_upload(...)devuelve una URL de PUT prefirmado a la que el navegador sube directamente (tu servidor nunca hace de proxy de los bytes), luego confirmas la fila.
use rustango::media::{Media, MediaManager};
let manager = MediaManager::new_pool(pool.clone(), registry);
// Hand the browser a short-lived download link:
// Devuelve Option<String> — None en backends que no pueden firmar (p. ej. disco local).
let Some(url) = manager.presigned_get(&media, Duration::from_secs(3600)).await else {
return Err(/* sin URL firmada para este backend */);
};
También gestiona el borrado lógico y la purga de huérfanos. El flujo completo se somete a prueba
en media_sqlite_live.rs; los métodos prefirmados/de subida directa del manager están
orientados a PostgreSQL.
Servir medios en una página pública
Una página pública no pasa por media::router. Ese router es la API de
gestión interna — subidas, borrados, etiquetas, navegación — y responde 401
a quien no haya iniciado sesión, en todas sus rutas, por diseño.
Resuelve la URL desde tu propio handler:
// tu propia ruta pública
let url = manager.public_url(media_id).await?; // Option<String>, sin firma
public_url es una consulta a la base de datos más una cadena; no firma nada
ni espera a un firmante, y por eso encaja en una página. Dos modelos de
entrega, y elegir entre ellos es la decisión real:
| bucket público / CDN | bucket privado + presignado | |
|---|---|---|
| dirección | manager.public_url(id) — estable | manager.presigned_get(&m, ttl) — caduca |
| cacheable | sí, por navegadores y CDN | no; el router envía no-store |
| quién puede descargar | cualquiera con la URL | cualquiera con la URL, hasta que caduque |
| para qué | páginas públicas, <img src> | el router de gestión, herramientas internas |
public_url te dice dónde se serviría el objeto; no lo hace legible.
Apúntalo a un bucket privado y obtendrás una URL correcta y un 403 — ese caso
quiere una URL presignada, que deliberadamente no es cacheable ni compartible.
Para archivos en disco local en lugar de un bucket, el handler de estáticos ya hace esto y no necesita ninguna fila de medios:
Cli::new(pool).with_static("/uploads", "./var/uploads")
Si estabas a punto de escribir un authorizer AllowAll para que funcione
una página pública, para. Eso abre las 16 rutas — incluidas DELETE y el
PUT presignado — a todo el mundo, que es justo el agujero que cerró 0.57.7.
El router de gestión necesita una política de autorización
media::router monta 16 rutas JSON sobre el manager, todas ellas acciones de
gestión sobre la biblioteca. Todas están protegidas y no hay un valor por
defecto permisivo. Constrúyelo con media_router_with y pasa una política:
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) — el constructor antiguo — está obsoleto y ahora
responde 403 en cada ruta. Es un cambio de comportamiento deliberado en
0.57.7: antes, esas rutas no tomaban ningún extractor de autenticación,
autorización ni tenant, así que GET /media/{id} devolvía la fila y una
URL de descarga prefirmada de S3 a cualquiera capaz de adivinar un entero,
y POST /uploads/begin emitía un PUT prefirmado para un prefijo de clave
elegido por quien llamaba.
MediaPerms (necesita la feature tenancy) comprueba los codenames de
permiso {tabla}.{acción} que el admin ya usa — rustango_media.view para
leer, rustango_media_collections.add para crear una carpeta, etc. Móntalo
dentro de require_auth, que es lo que inyecta la identidad que lee; sin
eso toda petición es un 401. Los superusuarios se saltan la comprobación.
Tres cosas que no hace:
- Decisiones a nivel de fila. Un
rustango_media.viewlee cualquier fila de medios por id.MediaManagertiene un único pool, así que un despliegue multi-tenant acota las filas por su cuenta — para eso implementasMediaAuthorizer.MediaTargetnombra la fila (Media(id),Collection(id),CollectionSubtree(id), …) precisamente para ello. - Acotar el almacén de objetos.
disklo suministra quien llama enPOST /uploads/beginy elStorageRegistryes de todo el proceso, así que un pool por inquilino aísla la base de datos y no el bucket: unrustango_media.adda secas escribe en cualquier disco que el proceso conozca. Indica cuáles conMediaPerms::new(pool).allow_disks(["user-uploads"]). Los prefijos dentro de un disco siguen necesitandoMediaAuthorizer, que recibekey_prefix. - Adivinar qué significa una ruta nueva.
MediaActionyMediaTargetson#[non_exhaustive]: termina una política propia en_ => falsey una ruta añadida más adelante llegará denegada en lugar de permitida.
Conviene saberlo al escribir la tuya: DELETE /collections/{id} borra el
subárbol completo y reasigna los medios de cada nivel, así que llega como
Delete(CollectionSubtree(id)) y no como Delete(Collection(id)) — y bajo
MediaPerms necesita rustango_media.change además de
rustango_media_collections.delete, porque escribe en la tabla de medios.
UPGRADING.md
tiene las notas de migración.
Cómo se crean las tablas de medios
Las tablas de medios (rustango_media, rustango_media_collections,
rustango_media_tags, rustango_media_tag_links) son modelos gestionados. Su
esquema se envía como una migración de sistema y se crea — por inquilino, cuando la
característica media está activada — de la misma forma que el resto de las propias tablas del
framework, cada vez que ejecutas migrate / aprovisionas un inquilino. No hay un paso perezoso de
«crear en el primer uso»; si la característica está desactivada, las tablas nunca se crean.
Actualización desde antes de 0.51. Las versiones anteriores creaban las tablas de medios de forma perezosa mediante una llamada DDL
ensure_tableen lugar de una migración. En el primermigrate(o aprovisionamiento de inquilino) tras la actualización, el framework reconcilia automáticamente: como las tablas ya existen, la migración de medios generada se registra en el libro mayor de migraciones de sistema sin volver a ejecutar suCREATE TABLE, y tus filas existentes quedan intactas. No se requiere ningún paso manual — la actualización que de otro modo fallaría conrelation already exists/table already existsahora simplemente funciona. (Introducido en 0.51.1; ver el CHANGELOG.)
Referencia
Trait Storage: save(key, &bytes) · load(key) · delete(key) ·
exists(key) · url(key) -> Option<String>.
UploadConfig: new(prefix) · .max_bytes(n) · .allowed_extensions(&[..])
(insensible a mayúsculas/minúsculas) · .randomize_filename(bool). Usado por
save_uploads(multipart, &cfg, &storage).
Backends: LocalStorage (disco) · S3Storage (almacenamiento de objetos, storage-s3)
· InMemoryStorage (pruebas). Todos devuelven un BoxedStorage.
Véase también
- El admin — los medios y los widgets de FK muestran los archivos subidos en la UI.
- Trabajos en segundo plano — redimensiona/transcodifica una subida fuera de la petición.
- Caché — el mismo patrón de trait «cambia-el-backend».
- Guía de seguridad — validar entradas de subida no confiables.
