Rustango docs
← Anleitungen

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.

Dateien in Rustango: Ein Multipart-Upload wird größen- und erweiterungsgeprüft und dann durch das Storage-Trait geschrieben; dasselbe Trait unterlegt lokale Festplatte, S3 und In-Memory, und url() gibt eine öffentliche Adresse zurück

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) und rustango::media (Media, MediaManager) — hinter den Features storage / 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 in crates/rustango/src/uploads.rs selbst erprobt, und die Mediathek von media_sqlite_live.rs.

Inhaltsverzeichnis


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")));
BackendFeatureVerwenden für
LocalStoragestorageEin-Server-Deployments — Dateien auf lokaler Festplatte
S3Storagestorage-s3Produktion — S3 / R2 / B2 / MinIO Objektspeicher
InMemoryStoragestorageTests — 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 / CDNprivater Bucket + Presigned
Adressemanager.public_url(id) — stabilmanager.presigned_get(&m, ttl) — läuft ab
cachebarja, von Browsern und CDNsnein; der Router sendet no-store
wer darf ladenjede:r mit der URLjede: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.view liest jede Media-Zeile per id. MediaManager hält einen Pool, ein mandantenfähiges Deployment grenzt Zeilen also selbst ein — dafür implementierst du MediaAuthorizer. MediaTarget benennt die Zeile (Media(id), Collection(id), CollectionSubtree(id), …) genau dafür.
  • Den Objektspeicher eingrenzen. disk kommt bei POST /uploads/begin vom Aufrufer, und die StorageRegistry ist prozessweit — Pool-pro-Mandant trennt die Datenbank, nicht den Bucket: ein bloßes rustango_media.add schreibt in jede Disk, die der Prozess kennt. Grenze sie mit MediaPerms::new(pool).allow_disks(["user-uploads"]) ein. Präfixe innerhalb einer Disk brauchen weiterhin MediaAuthorizer, dem key_prefix übergeben wird.
  • Raten, was eine neue Route bedeutet. MediaAction und MediaTarget sind #[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 ersten migrate (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 ihr CREATE TABLE erneut auszuführen, und deine bestehenden Zeilen bleiben unberührt. Kein manueller Schritt ist erforderlich — das Upgrade, das andernfalls mit relation already exists / table already exists fehlschlagen 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.