Rustango docs
← Guías

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.

Archivos en Rustango: una subida multipart se comprueba en tamaño y extensión y luego se escribe a través del trait Storage; el mismo trait respalda disco local, S3 y memoria, y url() devuelve una dirección pública

¿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), y rustango::media (Media, MediaManager) — tras las características storage / 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 multipart save_uploads de extremo a extremo se somete a prueba mediante los tests internos en crates/rustango/src/uploads.rs, y la biblioteca de medios mediante media_sqlite_live.rs.

Tabla de contenidos


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")));
BackendCaracterísticaÚsalo para
LocalStoragestoragedespliegues de un solo servidor — archivos en disco local
S3Storagestorage-s3producción — almacenamiento de objetos S3 / R2 / B2 / MinIO
InMemoryStoragestoragepruebas — 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 / CDNbucket privado + presignado
direcciónmanager.public_url(id) — establemanager.presigned_get(&m, ttl) — caduca
cacheablesí, por navegadores y CDNno; el router envía no-store
quién puede descargarcualquiera con la URLcualquiera 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.view lee cualquier fila de medios por id. MediaManager tiene un único pool, así que un despliegue multi-tenant acota las filas por su cuenta — para eso implementas MediaAuthorizer. MediaTarget nombra la fila (Media(id), Collection(id), CollectionSubtree(id), …) precisamente para ello.
  • Acotar el almacén de objetos. disk lo suministra quien llama en POST /uploads/begin y el StorageRegistry es de todo el proceso, así que un pool por inquilino aísla la base de datos y no el bucket: un rustango_media.add a secas escribe en cualquier disco que el proceso conozca. Indica cuáles con MediaPerms::new(pool).allow_disks(["user-uploads"]). Los prefijos dentro de un disco siguen necesitando MediaAuthorizer, que recibe key_prefix.
  • Adivinar qué significa una ruta nueva. MediaAction y MediaTarget son #[non_exhaustive]: termina una política propia en _ => false y 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_table en lugar de una migración. En el primer migrate (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 su CREATE TABLE, y tus filas existentes quedan intactas. No se requiere ningún paso manual — la actualización que de otro modo fallaría con relation already exists / table already exists ahora 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.