feat(aether): initial commit – self-hosted Cloud, Phasen 1–7
Go-Backend (chi, pgx, golang-migrate) + React/TS-Frontend als PWA. Enthalten: Auth inkl. OIDC, Dateien mit Versionen und Papierkorb, Freigaben (Links + intern), WebDAV, Gruppenordner, Admin/Theming, Volltextsuche mit OCR, eigener PDF-Betrachter und WOPI-Office (Collabora/OnlyOffice). Nicht im Repo: deploy/.env (Sitzungsschlüssel, DB- und Admin-Passwort) sowie alle Daten- und Build-Verzeichnisse – siehe .gitignore. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
commit
31a027c93a
225 files changed
+38042
No files matched your search
@@ -0,0 +1,280 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// LocalFS stores blobs on the local filesystem.
|
||||
// Every write is atomic: temp file → fsync → rename.
|
||||
type LocalFS struct {
|
||||
root string // absolute, canonical path
|
||||
tmpRoot string // where temp files land during upload
|
||||
}
|
||||
|
||||
func NewLocalFS(root, tmpRoot string) (*LocalFS, error) {
|
||||
root, err := filepath.Abs(root)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("abs root: %w", err)
|
||||
}
|
||||
tmpRoot, err = filepath.Abs(tmpRoot)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("abs tmpRoot: %w", err)
|
||||
}
|
||||
for _, dir := range []string{root, tmpRoot} {
|
||||
if err := os.MkdirAll(dir, 0750); err != nil {
|
||||
return nil, fmt.Errorf("mkdir %s: %w", dir, err)
|
||||
}
|
||||
}
|
||||
return &LocalFS{root: root, tmpRoot: tmpRoot}, nil
|
||||
}
|
||||
|
||||
// safePath returns the absolute path for key and ensures it stays under root.
|
||||
func (fs *LocalFS) safePath(key string) (string, error) {
|
||||
// Reject keys that try to escape with .. or absolute paths.
|
||||
if filepath.IsAbs(key) || strings.Contains(key, "..") {
|
||||
return "", fmt.Errorf("invalid storage key: %q", key)
|
||||
}
|
||||
full := filepath.Join(fs.root, filepath.FromSlash(key))
|
||||
// Canonical check: the resolved path must still be under root.
|
||||
canonical, err := filepath.Abs(full)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("abs path: %w", err)
|
||||
}
|
||||
if !strings.HasPrefix(canonical+string(filepath.Separator), fs.root+string(filepath.Separator)) {
|
||||
return "", fmt.Errorf("path traversal detected: %q", key)
|
||||
}
|
||||
return canonical, nil
|
||||
}
|
||||
|
||||
func (fs *LocalFS) Put(ctx context.Context, key string, r io.Reader) (string, int64, error) {
|
||||
dest, err := fs.safePath(key)
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(dest), 0750); err != nil {
|
||||
return "", 0, fmt.Errorf("mkdir parent: %w", err)
|
||||
}
|
||||
|
||||
// Write to temp file in tmpRoot (same filesystem for atomic rename).
|
||||
tmp, err := os.CreateTemp(fs.tmpRoot, "upload-*")
|
||||
if err != nil {
|
||||
return "", 0, fmt.Errorf("create temp: %w", err)
|
||||
}
|
||||
tmpName := tmp.Name()
|
||||
committed := false
|
||||
defer func() {
|
||||
if !committed {
|
||||
tmp.Close()
|
||||
os.Remove(tmpName)
|
||||
}
|
||||
}()
|
||||
|
||||
h := sha256.New()
|
||||
multi := io.MultiWriter(tmp, h)
|
||||
|
||||
size, err := io.Copy(multi, r)
|
||||
if err != nil {
|
||||
return "", 0, fmt.Errorf("write temp: %w", err)
|
||||
}
|
||||
|
||||
// fsync: flush to physical storage before rename.
|
||||
if err := tmp.Sync(); err != nil {
|
||||
return "", 0, fmt.Errorf("fsync: %w", err)
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return "", 0, fmt.Errorf("close temp: %w", err)
|
||||
}
|
||||
|
||||
// Atomic rename: either fully committed or not.
|
||||
if err := os.Rename(tmpName, dest); err != nil {
|
||||
return "", 0, fmt.Errorf("rename to final: %w", err)
|
||||
}
|
||||
committed = true
|
||||
|
||||
checksum := hex.EncodeToString(h.Sum(nil))
|
||||
return checksum, size, nil
|
||||
}
|
||||
|
||||
func (fs *LocalFS) Get(ctx context.Context, key string) (io.ReadCloser, error) {
|
||||
path, err := fs.safePath(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, fmt.Errorf("blob not found: %q", key)
|
||||
}
|
||||
return nil, fmt.Errorf("open blob: %w", err)
|
||||
}
|
||||
return f, nil
|
||||
}
|
||||
|
||||
func (fs *LocalFS) Delete(ctx context.Context, key string) error {
|
||||
path, err := fs.safePath(key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
|
||||
return fmt.Errorf("delete blob: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (fs *LocalFS) Stat(ctx context.Context, key string) (FileInfo, error) {
|
||||
path, err := fs.safePath(key)
|
||||
if err != nil {
|
||||
return FileInfo{}, err
|
||||
}
|
||||
fi, err := os.Stat(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return FileInfo{}, fmt.Errorf("blob not found: %q", key)
|
||||
}
|
||||
return FileInfo{}, fmt.Errorf("stat blob: %w", err)
|
||||
}
|
||||
// Recompute checksum on stat (used for integrity verification).
|
||||
checksum, err := fileChecksum(path)
|
||||
if err != nil {
|
||||
return FileInfo{}, err
|
||||
}
|
||||
return FileInfo{
|
||||
Size: fi.Size(),
|
||||
Checksum: checksum,
|
||||
ModTime: fi.ModTime().UTC().Truncate(time.Second),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// CreateTemp legt eine Datei im Staging-Verzeichnis an. Gedacht für Uploads,
|
||||
// deren Größe erst beim Schreiben feststeht: Der Inhalt landet auf derselben
|
||||
// Platte wie die späteren Blobs, nicht im Arbeitsspeicher. Der Aufrufer ist für
|
||||
// Schließen und Entfernen zuständig.
|
||||
func (fs *LocalFS) CreateTemp(pattern string) (*os.File, error) {
|
||||
f, err := os.CreateTemp(fs.tmpRoot, pattern)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create temp file: %w", err)
|
||||
}
|
||||
return f, nil
|
||||
}
|
||||
|
||||
// LocalPath gibt den Pfad im Dateisystem heraus (siehe LocalPather).
|
||||
//
|
||||
// Die Prüfung auf Ausbruch aus dem Wurzelverzeichnis ist dieselbe wie bei jedem
|
||||
// anderen Zugriff – ein Schlüssel mit ".." kommt hier so wenig durch wie bei
|
||||
// Get oder Delete. Der Rückgabewert ist zum **Lesen** gedacht; wer über diesen
|
||||
// Pfad schreibt, umgeht das atomare Schreiben und hat die Garantien verspielt.
|
||||
func (fs *LocalFS) LocalPath(key string) (string, error) {
|
||||
path, err := fs.safePath(key)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
return "", fmt.Errorf("stat %s: %w", key, err)
|
||||
}
|
||||
return path, nil
|
||||
}
|
||||
|
||||
// Walk läuft den Baum unterhalb von prefix ab und meldet jede Datei.
|
||||
// Verzeichnisse werden übersprungen; die Prüfsumme bleibt leer, weil das
|
||||
// Nachrechnen über den gesamten Speicher unverhältnismäßig teuer wäre – wer
|
||||
// sie braucht, holt sie über Stat.
|
||||
func (fs *LocalFS) Walk(ctx context.Context, prefix string, fn func(key string, info FileInfo) error) error {
|
||||
prefix = strings.Trim(prefix, "/")
|
||||
base := fs.root
|
||||
if prefix != "" {
|
||||
var err error
|
||||
base, err = fs.safePath(prefix)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat(base); os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
|
||||
return filepath.WalkDir(base, func(path string, d os.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
if d.IsDir() {
|
||||
return nil
|
||||
}
|
||||
info, err := d.Info()
|
||||
if err != nil {
|
||||
// Datei ist zwischen Auflisten und Abfragen verschwunden – das ist
|
||||
// im laufenden Betrieb normal und kein Grund abzubrechen.
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
rel, err := filepath.Rel(fs.root, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return fn(filepath.ToSlash(rel), FileInfo{Size: info.Size(), ModTime: info.ModTime()})
|
||||
})
|
||||
}
|
||||
|
||||
// DeletePrefix entfernt ein ganzes Unterverzeichnis des Speichers.
|
||||
//
|
||||
// Absichtlich streng: ein leerer Präfix würde den gesamten Speicher löschen,
|
||||
// deshalb wird er abgelehnt. Der aufgelöste Pfad muss ausserdem echt unterhalb
|
||||
// der Wurzel liegen – safePath prüft das bereits, der zusätzliche Vergleich
|
||||
// schützt davor, versehentlich die Wurzel selbst zu treffen.
|
||||
func (fs *LocalFS) DeletePrefix(ctx context.Context, prefix string) error {
|
||||
prefix = strings.Trim(prefix, "/")
|
||||
if prefix == "" {
|
||||
return fmt.Errorf("refusing to delete empty prefix")
|
||||
}
|
||||
dir, err := fs.safePath(prefix)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if dir == fs.root {
|
||||
return fmt.Errorf("refusing to delete storage root")
|
||||
}
|
||||
if err := os.RemoveAll(dir); err != nil {
|
||||
return fmt.Errorf("delete prefix %q: %w", prefix, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (fs *LocalFS) Move(ctx context.Context, oldKey, newKey string) error {
|
||||
oldPath, err := fs.safePath(oldKey)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
newPath, err := fs.safePath(newKey)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(newPath), 0750); err != nil {
|
||||
return fmt.Errorf("mkdir parent: %w", err)
|
||||
}
|
||||
return os.Rename(oldPath, newPath)
|
||||
}
|
||||
|
||||
func fileChecksum(path string) (string, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("open for checksum: %w", err)
|
||||
}
|
||||
defer f.Close()
|
||||
h := sha256.New()
|
||||
if _, err := io.Copy(h, f); err != nil {
|
||||
return "", fmt.Errorf("hash file: %w", err)
|
||||
}
|
||||
return hex.EncodeToString(h.Sum(nil)), nil
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func newTestFS(t *testing.T) *LocalFS {
|
||||
t.Helper()
|
||||
root := t.TempDir()
|
||||
tmp := t.TempDir()
|
||||
fs, err := NewLocalFS(root, tmp)
|
||||
if err != nil {
|
||||
t.Fatalf("NewLocalFS: %v", err)
|
||||
}
|
||||
return fs
|
||||
}
|
||||
|
||||
func TestPutAndGet(t *testing.T) {
|
||||
fs := newTestFS(t)
|
||||
ctx := context.Background()
|
||||
content := []byte("hello aether")
|
||||
|
||||
checksum, size, err := fs.Put(ctx, "testfile", bytes.NewReader(content))
|
||||
if err != nil {
|
||||
t.Fatalf("Put: %v", err)
|
||||
}
|
||||
if size != int64(len(content)) {
|
||||
t.Errorf("size: got %d want %d", size, len(content))
|
||||
}
|
||||
|
||||
// verify checksum
|
||||
h := sha256.Sum256(content)
|
||||
expected := hex.EncodeToString(h[:])
|
||||
if checksum != expected {
|
||||
t.Errorf("checksum: got %s want %s", checksum, expected)
|
||||
}
|
||||
|
||||
// get and read back
|
||||
rc, err := fs.Get(ctx, "testfile")
|
||||
if err != nil {
|
||||
t.Fatalf("Get: %v", err)
|
||||
}
|
||||
defer rc.Close()
|
||||
got, err := io.ReadAll(rc)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadAll: %v", err)
|
||||
}
|
||||
if !bytes.Equal(got, content) {
|
||||
t.Errorf("content mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
func TestChecksumVerification(t *testing.T) {
|
||||
fs := newTestFS(t)
|
||||
ctx := context.Background()
|
||||
content := []byte("important data")
|
||||
|
||||
storedChecksum, _, err := fs.Put(ctx, "file", bytes.NewReader(content))
|
||||
if err != nil {
|
||||
t.Fatalf("Put: %v", err)
|
||||
}
|
||||
|
||||
info, err := fs.Stat(ctx, "file")
|
||||
if err != nil {
|
||||
t.Fatalf("Stat: %v", err)
|
||||
}
|
||||
if info.Checksum != storedChecksum {
|
||||
t.Errorf("checksum mismatch after stat: got %s want %s", info.Checksum, storedChecksum)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPathTraversal(t *testing.T) {
|
||||
fs := newTestFS(t)
|
||||
ctx := context.Background()
|
||||
|
||||
cases := []string{
|
||||
"../escape",
|
||||
"../../etc/passwd",
|
||||
"/absolute/path",
|
||||
"a/../../b",
|
||||
"foo/../../../bar",
|
||||
}
|
||||
for _, key := range cases {
|
||||
_, _, err := fs.Put(ctx, key, strings.NewReader("data"))
|
||||
if err == nil {
|
||||
t.Errorf("expected error for traversal key %q but got none", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAtomicWrite_NoPartialFile(t *testing.T) {
|
||||
fs := newTestFS(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// Simulate an interrupted reader (fails mid-stream).
|
||||
bad := &failReader{data: []byte("partial"), failAt: 3}
|
||||
_, _, err := fs.Put(ctx, "partial", bad)
|
||||
if err == nil {
|
||||
t.Fatal("expected error from failing reader")
|
||||
}
|
||||
|
||||
// The destination file must NOT exist.
|
||||
dest := fs.root + "/partial"
|
||||
if _, statErr := os.Stat(dest); !os.IsNotExist(statErr) {
|
||||
t.Error("partial file must not exist after failed write")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDelete(t *testing.T) {
|
||||
fs := newTestFS(t)
|
||||
ctx := context.Background()
|
||||
|
||||
if _, _, err := fs.Put(ctx, "todelete", strings.NewReader("bye")); err != nil {
|
||||
t.Fatalf("Put: %v", err)
|
||||
}
|
||||
if err := fs.Delete(ctx, "todelete"); err != nil {
|
||||
t.Fatalf("Delete: %v", err)
|
||||
}
|
||||
if _, err := fs.Get(ctx, "todelete"); err == nil {
|
||||
t.Error("expected error getting deleted file")
|
||||
}
|
||||
// Double-delete should be a no-op.
|
||||
if err := fs.Delete(ctx, "todelete"); err != nil {
|
||||
t.Errorf("second Delete should be no-op, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// failReader returns data normally up to failAt bytes, then errors.
|
||||
type failReader struct {
|
||||
data []byte
|
||||
pos int
|
||||
failAt int
|
||||
}
|
||||
|
||||
func (r *failReader) Read(p []byte) (int, error) {
|
||||
if r.pos >= r.failAt {
|
||||
return 0, io.ErrUnexpectedEOF
|
||||
}
|
||||
n := copy(p, r.data[r.pos:min(r.failAt, len(r.data))])
|
||||
r.pos += n
|
||||
return n, nil
|
||||
}
|
||||
|
||||
func min(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// Walk ist die Grundlage der Blob-Bereinigung: Übersieht es Dateien, bleiben
|
||||
// Waisen liegen; meldet es Schlüssel in einer anderen Form als die Datenbank
|
||||
// sie speichert, hält der GC gesunde Blobs für verwaist und löscht sie.
|
||||
func TestWalkReportsEveryBlobWithDBStyleKeys(t *testing.T) {
|
||||
fs := newTestFS(t)
|
||||
ctx := context.Background()
|
||||
|
||||
want := map[string]bool{
|
||||
"users/alice/home/notiz.txt": true,
|
||||
"users/alice/home/Unterordner/bild.png": true,
|
||||
"users/alice/.versions/abc-123": true,
|
||||
"users/alice/.trash/deadbeef_alt.txt": true,
|
||||
}
|
||||
for key := range want {
|
||||
if _, _, err := fs.Put(ctx, key, strings.NewReader("inhalt")); err != nil {
|
||||
t.Fatalf("Put(%q): %v", key, err)
|
||||
}
|
||||
}
|
||||
// Außerhalb des Präfixes – darf nicht mitkommen.
|
||||
if _, _, err := fs.Put(ctx, "thumbs/xyz.jpg", strings.NewReader("x")); err != nil {
|
||||
t.Fatalf("Put thumb: %v", err)
|
||||
}
|
||||
|
||||
got := map[string]bool{}
|
||||
if err := fs.Walk(ctx, "users", func(key string, info FileInfo) error {
|
||||
got[key] = true
|
||||
if info.Size != int64(len("inhalt")) {
|
||||
t.Errorf("Walk reported size %d for %q, want %d", info.Size, key, len("inhalt"))
|
||||
}
|
||||
if info.ModTime.IsZero() {
|
||||
t.Errorf("Walk reported zero ModTime for %q – the GC grace period relies on it", key)
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
t.Fatalf("Walk: %v", err)
|
||||
}
|
||||
|
||||
for key := range want {
|
||||
if !got[key] {
|
||||
t.Errorf("Walk missed %q – the GC would never reclaim it", key)
|
||||
}
|
||||
}
|
||||
if got["thumbs/xyz.jpg"] {
|
||||
t.Error("Walk crossed the prefix boundary")
|
||||
}
|
||||
if len(got) != len(want) {
|
||||
t.Errorf("Walk returned %d keys, want %d: %v", len(got), len(want), got)
|
||||
}
|
||||
}
|
||||
|
||||
// Ein fehlender Präfix ist kein Fehler – sonst bräche der GC auf einer frischen
|
||||
// Installation ab, bevor er irgendetwas tut.
|
||||
func TestWalkMissingPrefixIsNotAnError(t *testing.T) {
|
||||
fs := newTestFS(t)
|
||||
|
||||
called := false
|
||||
if err := fs.Walk(context.Background(), "gibtesnicht", func(string, FileInfo) error {
|
||||
called = true
|
||||
return nil
|
||||
}); err != nil {
|
||||
t.Fatalf("Walk on missing prefix: %v", err)
|
||||
}
|
||||
if called {
|
||||
t.Error("callback ran although the prefix does not exist")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"os"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TempFiler kann Uploads unbekannter Größe auf der Platte zwischenlagern,
|
||||
// statt sie im Arbeitsspeicher zu halten. Bewusst eine eigene, optionale
|
||||
// Schnittstelle: *os.File gehört nicht in den allgemeinen Storage-Vertrag, und
|
||||
// eine Implementierung ohne lokales Dateisystem kann sie schlicht weglassen.
|
||||
type TempFiler interface {
|
||||
CreateTemp(pattern string) (*os.File, error)
|
||||
}
|
||||
|
||||
// LocalPather gibt den echten Pfad eines Blobs heraus – nur lesend gedacht.
|
||||
//
|
||||
// Gebraucht von der Texterkennung: ocrmypdf und tesseract sind externe
|
||||
// Programme und wollen einen Pfad, keinen Datenstrom. Ohne diese Schnittstelle
|
||||
// müsste jede Datei erst in eine vollständige Kopie geschrieben werden, bevor
|
||||
// sie erkannt werden kann – bei einem 300-MB-Scan also 300 MB zusätzlich auf
|
||||
// einer Platte, die ohnehin knapp sein kann.
|
||||
//
|
||||
// Wie TempFiler bewusst optional: Eine Ablage ohne lokales Dateisystem (S3)
|
||||
// lässt sie weg, und der Aufrufer fällt dann auf die Kopie zurück.
|
||||
type LocalPather interface {
|
||||
LocalPath(key string) (string, error)
|
||||
}
|
||||
|
||||
// FileInfo holds metadata about a stored blob.
|
||||
type FileInfo struct {
|
||||
Size int64
|
||||
Checksum string // SHA-256 hex
|
||||
ModTime time.Time
|
||||
}
|
||||
|
||||
// Storage is the blob storage abstraction.
|
||||
// All implementations must guarantee atomic writes and never corrupt existing data.
|
||||
type Storage interface {
|
||||
// Put streams r to the given key atomically (temp → fsync → rename).
|
||||
// Returns the SHA-256 checksum and byte count written.
|
||||
Put(ctx context.Context, key string, r io.Reader) (checksum string, size int64, err error)
|
||||
|
||||
// Get returns a reader for the blob at key. Caller must close it.
|
||||
Get(ctx context.Context, key string) (io.ReadCloser, error)
|
||||
|
||||
// Delete removes a blob. In Phase 1 this is a hard delete used only by
|
||||
// cleanup/GC paths; user-facing deletes are soft-deletes in the DB.
|
||||
Delete(ctx context.Context, key string) error
|
||||
|
||||
// Stat returns metadata for a blob.
|
||||
Stat(ctx context.Context, key string) (FileInfo, error)
|
||||
|
||||
// Move atomically moves a blob from oldKey to newKey within the same storage root.
|
||||
Move(ctx context.Context, oldKey, newKey string) error
|
||||
|
||||
// Walk ruft fn für jeden Blob unterhalb von prefix auf. Existiert der
|
||||
// Präfix nicht, ist das kein Fehler – nur nichts zu tun. Gedacht für die
|
||||
// Bereinigung verwaister Blobs, die den Bestand auf der Platte gegen die
|
||||
// Datenbank abgleichen muss.
|
||||
Walk(ctx context.Context, prefix string, fn func(key string, info FileInfo) error) error
|
||||
|
||||
// DeletePrefix entfernt alles unterhalb eines Präfixes, etwa den kompletten
|
||||
// Bereich eines Nutzers ("users/alice/"). Gedacht für das endgültige Löschen
|
||||
// eines Kontos: danach existiert keine Datenbankzeile mehr, die noch auf die
|
||||
// einzelnen Blobs zeigen könnte.
|
||||
DeletePrefix(ctx context.Context, prefix string) error
|
||||
}
|
||||
Reference in new issue
Block a user