Dateien, Uploads & Medien
Fast jede App speichert Nutzerdateien — Avatare, Anhänge, exportierte Berichte,
Bilder. Rustango gibt dir ein Storage-Trait mit austauschbaren Backends (lokale
Festplatte, S3-kompatibler Objektspeicher, In-Memory für Tests), einen sicheren Multipart-Upload-Helfer
mit Größen-/Typ-Schutzvorkehrungen und — wenn du eine nachverfolgte Mediathek brauchst — einen
datenbankgestützten MediaManager mit vorsignierten URLs. Schreibe deinen Code
einmal gegen das Trait; wechsle von lokaler Festplatte zu S3 mit einer einzeiligen Änderung.
Neu bei einem Begriff hier? Storage-Backend, Multipart, Objektspeicher, vorsignierte URL — siehe das Glossar.
Quelle:
rustango::storage(Storage,LocalStorage,InMemoryStorage,s3::S3Storage,BoxedStorage),rustango::uploads(save_uploads,UploadConfig,sanitize_filename) undrustango::media(Media,MediaManager) — hinter den Featuresstorage/uploads/storage-s3/media(alle standardmäßig aktiviert).Lauffähige Version: Die Storage- + Upload-Schutzvorkehrungs-Snippets sind kopiert aus
files_doc.rs(cargo test -p rustango --test files_doc); der End-to-End-Multipart-save_uploads-Ablauf wird von den In-File-Tests incrates/rustango/src/uploads.rsselbst erprobt, und die Mediathek vonmedia_sqlite_live.rs.
Inhaltsverzeichnis
- Schritt 1 — Ein Storage-Backend wählen
- Schritt 2 — Dateien speichern, laden und ausliefern
- Schritt 3 — Einen Upload annehmen
- Sichere Dateinamen
- Produktion: S3-kompatibler Speicher
- Die Mediathek
- Referenz
- Siehe auch
Schritt 1 — Ein Storage-Backend wählen
Jedes Backend implementiert dasselbe Storage-Trait, sodass dein Code den konkreten Typ nie
benennt — er hält ein 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 | Verwenden für |
|---|---|---|
LocalStorage | storage | Ein-Server-Deployments — Dateien auf lokaler Festplatte |
S3Storage | storage-s3 | Produktion — S3 / R2 / B2 / MinIO Objektspeicher |
InMemoryStorage | storage | Tests — eine HashMap, berührt nie die Festplatte |
Schritt 2 — Dateien speichern, laden und ausliefern
Das Trait besteht aus vier async-Methoden, indiziert durch einen String-Pfad. save schreibt
Bytes, load liest sie zurück, dazu 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?;
Die Datei ausliefern. Hänge eine Basis-URL an (dein CDN oder statischer Host) und url(key)
baut die öffentliche Adresse, die du am Modell speicherst und dem Browser übergibst:
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")
Ohne Basis-URL gibt url() None zurück — du würdest die Bytes stattdessen durch einen
Handler streamen. LocalStorage schützt außerdem vor Path-Traversal in Keys.
Schritt 3 — Einen Upload annehmen
save_uploads konsumiert einen axum-Multipart-Body, validiert jede Datei gegen eine
UploadConfig und schreibt die Überlebenden in dein Storage — als Stream, sodass eine
überdimensionierte Datei mitten in der Übertragung abgelehnt wird, statt zuerst in den Speicher
gepuffert zu werden.
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))
}
Die Schutzvorkehrungen werden erzwungen (und verifiziert): allowed_extensions ist
case-insensitiv ("PNG" und "png" sind dasselbe), und max_bytes bricht den Stream ab,
sobald die Größe überschritten wird. Die In-File-uploads-Tests treiben echte Multipart-Bodies an
und behaupten, dass Dateien im Speicher landen, überdimensionierte Dateien abgelehnt und
nicht erlaubte Erweiterungen verweigert werden.
let cfg = UploadConfig::new("avatars/").allowed_extensions(&["PNG", "Jpg"]);
assert!(cfg.allowed_extensions.contains("png")); // normalized to lowercase
assert!(cfg.allowed_extensions.contains("jpg"));
Sichere Dateinamen
Vertraue nie einem vom Client gelieferten Dateinamen. sanitize_filename reduziert ihn auf einen
sicheren Basenamen — es entfernt Verzeichniskomponenten (Path-Traversal) und ersetzt unsichere
Zeichen:
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 wendet dies für dich an; rufe es nur direkt auf, wenn du Keys von Hand baust.
Produktion: S3-kompatibler Speicher
Für Multi-Server-Deployments tausche LocalStorage gegen S3Storage (hinter dem
storage-s3-Feature). Es spricht die S3-API mit einem selbstgebauten SigV4-Signierer, sodass es
mit AWS S3, Cloudflare R2, Backblaze B2 und MinIO funktioniert. Das Trait ist
identisch — nur der Konstruktor ändert sich:
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
Deine Handler und Modelle ändern sich nicht; nur die Verdrahtung beim Start.
Die Mediathek
Wenn Dateien erstklassige Datensätze sind — in der Datenbank nachverfolgt, im Admin durchstöberbar,
mit Thumbnails und CDN-/vorsignierter Auslieferung — greife zu rustango::media statt zu rohem
Storage. MediaManager persistiert eine Media-Zeile pro Datei und
unterstützt zwei Upload-Abläufe:
- Serverseitig:
manager.save_bytes(...)speichert die Bytes und die Zeile in einem Aufruf. - Direkt-in-den-Speicher:
manager.begin_upload(...)gibt eine vorsignierte PUT-URL zurück, zu der der Browser direkt hochlädt (dein Server proxyt die Bytes nie), dann bestätigst du die Zeile.
use rustango::media::{Media, MediaManager};
let manager = MediaManager::new_pool(pool.clone(), registry);
// Hand the browser a short-lived download link:
// Liefert Option<String> — None bei Backends, die nicht signieren können (z. B. lokale Platte).
let Some(url) = manager.presigned_get(&media, Duration::from_secs(3600)).await else {
return Err(/* keine signierte URL für dieses Backend */);
};
Er kümmert sich außerdem um Soft-Delete und das Bereinigen von Waisen. Der vollständige Ablauf wird
in media_sqlite_live.rs erprobt; die vorsignierten/Direct-Upload-Methoden des Managers sind
PostgreSQL-orientiert.
Medien auf einer öffentlichen Seite ausliefern
Eine öffentliche Seite geht nicht über media::router. Dieser Router ist
die interne Verwaltungs-API — Uploads, Löschungen, Tags, Blättern — und er
antwortet allen, die nicht angemeldet sind, auf jeder Route absichtlich mit
401.
Ermittle die URL stattdessen im eigenen Handler:
// deine eigene öffentliche Route
let url = manager.public_url(media_id).await?; // Option<String>, ohne Signatur
public_url ist ein Datenbank-Lookup plus ein String; es signiert nichts und
wartet auf keinen Signierer — deshalb passt es in eine Seite. Zwei
Auslieferungsmodelle, und die Wahl dazwischen ist die eigentliche Entscheidung:
| öffentlicher Bucket / CDN | privater Bucket + Presigned | |
|---|---|---|
| Adresse | manager.public_url(id) — stabil | manager.presigned_get(&m, ttl) — läuft ab |
| cachebar | ja, von Browsern und CDNs | nein; der Router sendet no-store |
| wer darf laden | jede:r mit der URL | jede:r mit der URL, bis sie abläuft |
| wofür | öffentliche Seiten, <img src> | der Verwaltungs-Router, interne Tools |
public_url sagt dir, wo das Objekt ausgeliefert würde; es macht das
Objekt nicht lesbar. Zeigt es auf einen privaten Bucket, bekommst du eine
korrekte URL und einen 403 — dieser Fall will eine Presigned-URL, die bewusst
weder cachebar noch teilbar ist.
Für Dateien auf der lokalen Platte statt in einem Bucket erledigt das der Static-Handler bereits, ganz ohne Media-Zeile:
Cli::new(pool).with_static("/uploads", "./var/uploads")
Wenn du gerade einen AllowAll-Authorizer schreiben wolltest, damit eine
öffentliche Seite funktioniert: nicht tun. Das öffnet alle 16 Routen — auch
DELETE und das Presigned PUT — für alle, und genau dieses Loch hat 0.57.7
geschlossen.
Der Verwaltungs-Router braucht eine Autorisierungs-Policy
media::router hängt 16 JSON-Routen über den Manager, allesamt
Verwaltungs-Aktionen auf der Mediathek. Jede einzelne davon ist abgesichert,
und es gibt keinen freizügigen Standard. Baue ihn mit media_router_with
und übergib eine 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) — der alte Konstruktor — ist deprecated und antwortet
auf jeder Route mit 403. Das ist eine bewusste Verhaltensänderung in
0.57.7: davor nahmen diese Routen überhaupt keinen Authentifizierungs-,
Autorisierungs- oder Tenant-Extractor entgegen, also lieferte
GET /media/{id} die Zeile und eine vorsignierte S3-Download-URL an
jeden, der eine Ganzzahl raten konnte, und POST /uploads/begin erzeugte ein
vorsigniertes PUT für ein vom Aufrufer gewähltes Key-Präfix.
MediaPerms (braucht das tenancy-Feature) prüft die
{tabelle}.{aktion}-Permission-Codenamen, die der Admin ohnehin verwendet —
rustango_media.view zum Lesen, rustango_media_collections.add zum Anlegen
eines Ordners und so weiter. Hänge es innerhalb von require_auth ein,
das die Identität injiziert, die es liest; ohne das ist jede Anfrage ein
401. Superuser überspringen die Prüfung.
Drei Dinge, die es nicht leistet:
- Entscheidungen auf Zeilenebene. Ein
rustango_media.viewliest jede Media-Zeile per id.MediaManagerhält einen Pool, ein mandantenfähiges Deployment grenzt Zeilen also selbst ein — dafür implementierst duMediaAuthorizer.MediaTargetbenennt die Zeile (Media(id),Collection(id),CollectionSubtree(id), …) genau dafür. - Den Objektspeicher eingrenzen.
diskkommt beiPOST /uploads/beginvom Aufrufer, und dieStorageRegistryist prozessweit — Pool-pro-Mandant trennt die Datenbank, nicht den Bucket: ein bloßesrustango_media.addschreibt in jede Disk, die der Prozess kennt. Grenze sie mitMediaPerms::new(pool).allow_disks(["user-uploads"])ein. Präfixe innerhalb einer Disk brauchen weiterhinMediaAuthorizer, demkey_prefixübergeben wird. - Raten, was eine neue Route bedeutet.
MediaActionundMediaTargetsind#[non_exhaustive]; beende eine selbst geschriebene Policy mit_ => false, dann kommt eine später ergänzte Route abgelehnt statt erlaubt an.
Wichtig für eine eigene Policy: DELETE /collections/{id} löscht den ganzen
Teilbaum und hängt die Medien auf jeder Ebene um, kommt also als
Delete(CollectionSubtree(id)) an, nicht als Delete(Collection(id)) — und
unter MediaPerms braucht es zusätzlich rustango_media.change neben
rustango_media_collections.delete, weil es in die Medientabelle schreibt.
UPGRADING.md
enthält die Migrationshinweise.
Wie die Medientabellen angelegt werden
Die Medientabellen (rustango_media, rustango_media_collections,
rustango_media_tags, rustango_media_tag_links) sind verwaltete Modelle. Ihr
Schema wird als System-Migration ausgeliefert und angelegt — pro Tenant, wenn das
media-Feature aktiviert ist — auf dieselbe Weise wie die übrigen frameworkeigenen
Tabellen, immer wenn du migrate ausführst / einen Tenant provisionierst. Es gibt keinen faulen
Schritt „beim ersten Gebrauch anlegen"; ist das Feature aus, werden die Tabellen nie angelegt.
Upgrade von vor 0.51. Frühere Versionen legten die Medientabellen faul über einen
ensure_table-DDL-Aufruf statt über eine Migration an. Beim erstenmigrate(oder Tenant-Provisionierung) nach dem Upgrade rekonziliert das Framework automatisch: Weil die Tabellen bereits existieren, wird die generierte Medien-Migration im System-Migrations-Ledger verzeichnet, ohne ihrCREATE TABLEerneut auszuführen, und deine bestehenden Zeilen bleiben unberührt. Kein manueller Schritt ist erforderlich — das Upgrade, das andernfalls mitrelation already exists/table already existsfehlschlagen würde, funktioniert jetzt einfach. (Eingeführt in 0.51.1; siehe das CHANGELOG.)
Referenz
Storage-Trait: save(key, &bytes) · load(key) · delete(key) ·
exists(key) · url(key) -> Option<String>.
UploadConfig: new(prefix) · .max_bytes(n) · .allowed_extensions(&[..])
(case-insensitiv) · .randomize_filename(bool). Verwendet von
save_uploads(multipart, &cfg, &storage).
Backends: LocalStorage (Festplatte) · S3Storage (Objektspeicher, storage-s3)
· InMemoryStorage (Tests). Alle geben ein BoxedStorage zurück.
Siehe auch
- Der Admin — Medien- und FK-Widgets bringen hochgeladene Dateien in der UI zur Anzeige.
- Hintergrundjobs — skaliere/transcodiere einen Upload außerhalb des Requests.
- Caching — dasselbe Muster „Backend-austauschen" per Trait.
- Sicherheitsleitfaden — Validieren nicht vertrauenswürdiger Upload-Eingaben.
