Zum Hauptinhalt springen
Schneespur
Dokumentation durchblättern

Module entwickeln

Speicher & Backup

Wie Module über StorageBackendRegistry und BackupTargetRegistry zusätzliche Speicher- und Backup-Ziele anmelden — mit Fallback-Verhalten, Beispielen und Regeln für sicheren Dateizugriff.

Schneespur speichert Dateien — PDF-Einsatznachweise, Fotos, Dokumente — über austauschbare Speicher-Backends. Der Kern bringt ein lokales Dateisystem-Backend mit; Module ergänzen weitere Ziele wie S3 oder SFTP, ohne dass aufrufender Code sich ändern muss. Genau dasselbe Muster gilt für Backup-Ziele.

Speicher-Backends

Die StorageBackendRegistry verwaltet die Dateiablage. Ein Modul registriert dort ein zusätzliches Backend, und der restliche Code spricht weiterhin nur die Registry an — nicht das konkrete Backend. Dadurch lässt sich der Speicherort wechseln, ohne jeden Aufrufer anzufassen.

Geändert in 1.2.0 — gespeicherte Dateien sind standardmäßig privat

Dieser Abschnitt ist die wichtigste Änderung der Version für Modul-Entwickler. Wer sie überliest, baut ein Modul, dessen Dateien niemandem mehr ausgeliefert werden — oder, schlimmer, dessen Altbestand öffentlich erreichbar bleibt.

  • Das lokale Backend schreibt auf die private Ablage (storage/app/private/), nicht mehr nach storage/app/public/. Nichts, was Sie speichern, ist noch durch Erraten einer Adresse erreichbar.
  • url() liefert keine dauerhaft öffentliche Adresse mehr, sondern eine signierte, auf Berechtigung geprüfte Adresse mit 24 Stunden Gültigkeit, die auf die Core-Route media.show zeigt.
  • Jedes Modul, das Dateien speichert, muss sein Pfad-Präfix bei der MediaAccessRegistry anmelden (siehe unten). Das ist keine Kür und keine Härtungs-Feinheit: Ein nicht angemeldetes Präfix wird an niemanden ausgeliefert und für niemanden migriert.
  • Dateien aus früheren Versionen bleiben lesbar — das lokale Backend greift beim Lesen auf die öffentliche Ablage zurück —, aber sie bleiben eben auch öffentlich lesbar, solange das Präfix nicht angemeldet ist.

Jedes Backend erfüllt dasselbe Interface:

interface StorageBackendInterface
{
    public function slug(): string;
    public function label(): string;
    public function store(string $relativePath, string $contents): void;
    public function retrieve(string $relativePath): ?string;
    public function delete(string $relativePath): bool;
    public function exists(string $relativePath): bool;
    public function url(string $relativePath): string;
    public function isConfigured(): bool;
}

isConfigured() ist bewusst Teil des Vertrags: Ein S3-Backend ohne Zugangsdaten ist nicht einsatzbereit, und die Registry kann das prüfen, bevor sie es als aktives Backend verwendet.

url() verdient seit 1.2.0 besondere Aufmerksamkeit. Die Methode liefert eine Adresse, die die Browser-Sitzung eines berechtigten Betrachters öffnen kann. Sie ist weder öffentlich noch dauerhaft: Das lokale Backend signiert sie für 24 Stunden, und der Core prüft bei jedem Abruf zusätzlich die MediaAccessRegistry.

Daraus folgt eine Regel, die in der Praxis mehr Ärger verhindert als jede andere auf dieser Seite: Verschicken Sie eine solche Adresse nicht per E-Mail, speichern Sie sie nicht in der Datenbank, und geben Sie sie an keinen Fremddienst weiter, der sie serverseitig abholt — an keine Chat-Schnittstelle, keinen Webhook-Empfänger, keinen externen PDF-Dienst. Ein solcher Abruf bringt keine Sitzung mit und bekommt 403. Schicken Sie stattdessen die Bytes, die Sie über retrieveWithFallback() bekommen.

Genau an dieser Stelle ist das Telegram-Modul mit 1.2.0 aufgelaufen: Es legte eine Adresse in die Warteschlange, und Telegrams Server holten die Datei damit selbst ab. Seit 1.0.5 verschickt es die Bytes.

Speicher im eigenen Modul nutzen

Sie lösen das aktive Backend über die Registry auf und schreiben oder lesen relative Pfade:

$storage = app(StorageBackendRegistry::class);

// Write a file
$backend = $storage->resolve(); // active backend
$backend->store('documents/contract-123.pdf', $pdfContent);

// Read with automatic fallback to local
$content = $storage->retrieveWithFallback('documents/contract-123.pdf');

// Get URL with fallback — signed, 24 h, checked against MediaAccessRegistry
// on every request. Only useful for a logged-in viewer's own browser.
$url = $storage->urlWithFallback('photos/job-42.jpg');

Das Präfix documents/ oben wird erst ausgeliefert, wenn Ihr Modul es angemeldet hat. Das ist der nächste Abschnitt.

Medienzugriff — Ihr Präfix anmelden (Pflicht)

Die MediaAccessRegistry ordnet einem Pfad-Präfix eine Prüf-Funktion zu. Sie arbeitet default-deny: Ein Präfix, das niemand für sich beansprucht hat, wird abgelehnt, egal wer fragt.

Dieselbe Präfix-Liste steuert drei verschiedene Dinge gleichzeitig. Deshalb ist eine vergessene Anmeldung kein fehlendes Feature, sondern ein stiller Fehler:

Präfix angemeldetPräfix nicht angemeldet
media.show liefert die Datei an die Betrachter aus, die Ihre Prüf-Funktion durchlässtmedia.show antwortet allen mit 403, auch Administratoren
Die Rückfall-Route /storage/… weist das Präfix rundheraus abDie Rückfall-Route liefert Ihre Altbestände weiter an jeden aus, der den Pfad kennt
Der PublicMediaMigrator schiebt Ihre Dateien von vor 1.2.0 auf die private AblageIhre Dateien von vor 1.2.0 bleiben für immer auf der öffentlichen Ablage

Angemeldet wird im boot() Ihres Service Providers, neben den übrigen Registry-Aufrufen:

use App\Services\Storage\MediaAccessRegistry;
use Illuminate\Contracts\Auth\Authenticatable;

app(MediaAccessRegistry::class)->register(
    'dokumente/',
    function (string $path, ?Authenticatable $viewer): bool {
        if ($viewer === null) {
            return false;
        }

        $document = Document::where('storage_path', $path)->first();

        // A path under our prefix with no row behind it is not ours to serve.
        if ($document === null) {
            return false;
        }

        // One branch per guard — never a ?-> chain. User ids and customer ids
        // are separate spaces that collide: customer #7 must never be compared
        // against a User-side id 7.
        if ($viewer instanceof \App\Models\User) {
            return $viewer->isAdmin();
        }

        if ($viewer instanceof \App\Models\Customer) {
            return $viewer->id === $document->customer_id;
        }

        // A guard we do not know about is not a reason to hand out bytes.
        return false;
    }
);

Vier Regeln für die Prüf-Funktion:

  • Das Präfix muss zu dem passen, was Sie tatsächlich schreiben. dokumente/ deckt dokumente/{kunde}/{id}/{uuid}.pdf ab. Verglichen wird mit einem schlichten str_starts_with, deshalb gehört der abschließende Schrägstrich dazu: doku würde auch dokumentation/ für sich beanspruchen.
  • Geben Sie bei allem Unerwarteten false zurück. Eine Prüf-Funktion, die eine Ausnahme wirft, wird zwar abgefangen, protokolliert und als Ablehnung behandelt — verlassen Sie sich aber nicht darauf als Ablaufsteuerung.
  • Fragen Sie weder Sitzung noch auth() noch den Request ab. Das Argument $viewer ist die vollständige Eingabe; es kann ein User sein, ein Customer oder null für einen Gast. Der Grund ist handfest: Die Funktion läuft auch aus dem Migrator heraus, und dort gibt es überhaupt keine Sitzung.
  • Melden Sie beim Boot an, nicht bei Gelegenheit. Der Migrator und die Rückfall-Route lesen nur prefixes() und rufen allows() nie auf. Eine Anmeldung, die erst in einer Download-Route passiert, lässt diese beiden Wege also ungeschützt.

Die Anmeldung regelt ausschließlich den Dateizugriff. Eine eigene authentifizierte Download-Route (weiter unten) bleibt für Dokumente trotzdem das richtige Muster — die Registry ist das, was die Bytes schützt, wenn jemand direkt danach greift.

Fallback-Verhalten

Die StorageBackendRegistry bringt eine eingebaute Rückfall-Logik mit:

  • retrieveWithFallback() — versucht zuerst das aktive Backend und greift auf das lokale zurück, wenn die Datei dort nicht liegt.
  • urlWithFallback() — dasselbe für die URL-Erzeugung.

Das ist vor allem während einer Migration nützlich: Wer von lokalem Speicher auf Cloud-Speicher umstellt, hat eine Übergangszeit, in der ältere Dateien noch lokal liegen und neue bereits in der Cloud. Der Fallback überbrückt diese Phase, ohne dass Sie alle Bestände vorab umkopieren müssen.

Eine Ebene tiefer gibt es seit 1.2.0 einen zweiten Rückfall, den man leicht übersieht: Das lokale Backend liest zuerst von der privaten und danach von der öffentlichen Ablage, damit Dateien von vor 1.2.0 weiter funktionieren. Die retrieveWithFallback() der Registry kann das nicht selbst leisten — sie fällt auf das lokale Backend zurück, und bei einer Standard-Installation ist das lokale Backend bereits das aktive. Wenn Sie ein eigenes Backend schreiben, bauen Sie das also nicht nach: Rufen Sie retrieveWithFallback() auf und lassen Sie das lokale Backend seinen eigenen Altbestand regeln.

Ein Speicher-Backend-Modul bauen

Ein eigenes Backend implementiert das Interface und liest seine Konfiguration aus den Modul-Einstellungen. Das folgende Beispiel zeigt die tragenden Methoden für ein S3-Backend; die übrigen folgen demselben Muster:

namespace Schneespur\Module\S3Storage\Storage;

use App\Models\Setting;
use App\Services\Storage\StorageBackendInterface;
use Aws\S3\S3Client;

class S3StorageBackend implements StorageBackendInterface
{
    public function slug(): string { return 's3'; }
    public function label(): string { return 'Amazon S3'; }

    public function store(string $relativePath, string $contents): void
    {
        $this->client()->putObject([
            'Bucket' => Setting::get('s3.bucket'),
            'Key' => $relativePath,
            'Body' => $contents,
        ]);
    }

    public function retrieve(string $relativePath): ?string
    {
        try {
            $result = $this->client()->getObject([
                'Bucket' => Setting::get('s3.bucket'),
                'Key' => $relativePath,
            ]);
            return (string) $result['Body'];
        } catch (\Throwable) {
            return null;
        }
    }

    public function delete(string $relativePath): bool { /* ... */ }
    public function exists(string $relativePath): bool { /* ... */ }
    public function url(string $relativePath): string { /* ... */ }

    public function isConfigured(): bool
    {
        return !empty(Setting::get('s3.bucket'))
            && !empty(Setting::get('s3.key'))
            && !empty(Setting::get('s3.secret'));
    }

    private function client(): S3Client { /* ... */ }
}

Beachten Sie, dass retrieve() bei einem Fehler null zurückgibt statt eine Ausnahme durchzureichen. Das ist die Voraussetzung dafür, dass retrieveWithFallback() sauber auf das lokale Backend ausweichen kann, statt am Cloud-Fehler abzubrechen.

Die Konfiguration liest das Backend über Setting::get() mit dem Modul-Slug als Präfix (s3.bucket, s3.key, s3.secret). Wie Module Einstellungen registrieren, steht unter ServiceProvider.

Registriert wird das Backend im boot() des ServiceProviders:

app(StorageBackendRegistry::class)->register('s3', S3StorageBackend::class);

Backup-Ziele

Die BackupTargetRegistry bestimmt, wohin Backups geschrieben werden. Der Kern bietet ein lokales Backup-Ziel; Module ergänzen Cloud-Ziele. Das Prinzip ist dasselbe wie bei den Speicher-Backends, der Vertrag ist aber schlanker — ein Backup-Ziel muss nur ablegen und zurückspielen können:

interface BackupTargetInterface
{
    public function slug(): string;
    public function label(): string;
    public function store(string $sourcePath): bool;        // store a backup file
    public function restore(string $identifier, string $destinationPath): bool; // restore from backup
    public function isConfigured(): bool;
}

Ein Backup-Ziel-Modul bauen

namespace Schneespur\Module\CloudBackup\Backup;

use App\Models\Setting;
use App\Services\Backup\BackupTargetInterface;

class S3BackupTarget implements BackupTargetInterface
{
    public function slug(): string { return 's3-backup'; }
    public function label(): string { return 'Amazon S3 Backup'; }

    public function store(string $sourcePath): bool
    {
        // Upload the backup file to S3
        $key = 'backups/' . basename($sourcePath);
        // ... S3 upload logic
        return true;
    }

    public function restore(string $identifier, string $destinationPath): bool
    {
        // Download backup from S3 and write to $destinationPath
        return true;
    }

    public function isConfigured(): bool
    {
        return !empty(Setting::get('s3-backup.bucket'));
    }
}

Registriert wird das Ziel wie gewohnt im boot():

app(BackupTargetRegistry::class)->register('s3-backup', S3BackupTarget::class);

Aktives Ziel

Welches Backup-Ziel aktiv ist, steht in der Einstellung backup_target. Die Administration wählt es auf der Backup-Einstellungsseite aus; die verfügbaren Ziele liefert availableTargets(). So entscheidet der Betrieb über die Oberfläche, wohin Backups gehen — das Modul stellt die Möglichkeit bereit, erzwingt sie aber nicht.

Empfehlungen für den Umgang mit Dateien

Diese Regeln halten Datei-Handling sicher und gegenüber einem Backend-Wechsel robust:

  1. Dateien nie in public/ ablegen — immer über das Speicher-Backend. Was in public/ liegt, ist ohne Zugriffsprüfung im Web erreichbar.
  2. Relative Pfade verwenden — das Backend kümmert sich um absolute Pfade. So bleibt der Code unabhängig davon, ob lokal oder in der Cloud gespeichert wird.
  3. Pfade mit dem Modul-Slug präfixen — z. B. documents/, invoices/ — und dieses Präfix bei der MediaAccessRegistry anmelden. Das hält die Ablage übersichtlich, vermeidet Kollisionen zwischen Modulen und ist seit 1.2.0 die Voraussetzung dafür, dass Ihre Dateien überhaupt ausgeliefert werden.
  4. Dateien über authentifizierte Routen ausliefern — vor dem Download die Berechtigung prüfen.
  5. Metadaten in der Datenbank halten — file_path, mime_type, file_size. Der Speicher hält die Bytes, die Datenbank das Wissen darüber.

Authentifizierte Download-Route

Eine Datei sollte nie direkt aus dem Speicher ins Web durchgereicht werden, ohne dass geprüft wird, wer sie abrufen darf. Die Route prüft erst die Berechtigung über Gate und liefert den Inhalt dann mit passenden Headern aus:

Route::get('documents/{document}/download', function (Document $document) {
    Gate::authorize('documents.view');

    $storage = app(StorageBackendRegistry::class);
    $content = $storage->retrieveWithFallback($document->file_path);

    if ($content === null) {
        abort(404);
    }

    return response($content)
        ->header('Content-Type', $document->mime_type)
        ->header('Content-Disposition', 'attachment; filename="' . $document->title . '"');
})->name('admin.documents.download');

Wie Module Routen und Berechtigungen anmelden, steht unter Routen & APIs und Berechtigungen & Rollen.