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
|
||||
}
|
||||
Reference in new issue
Block a user