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:
HoneyandClaude Opus 5 committed 2026-08-24 09:25:36 +02:00
commit 31a027c93a
225 files changed
+38042

No files matched your search

+280
View File
@@ -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
}