Files
HoneyandClaude Opus 5 31a027c93a 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>
2026-08-24 09:25:36 +02:00

281 lines
7.7 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}