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>
281 lines
7.7 KiB
Go
281 lines
7.7 KiB
Go
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
|
||
}
|