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

+93
View File
@@ -0,0 +1,93 @@
package files
import (
"context"
"errors"
"fmt"
"io"
"github.com/google/uuid"
"github.com/rs/zerolog/log"
"aether/internal/acl"
"aether/internal/storage"
)
// ReplaceContent ersetzt den Inhalt einer bestehenden Datei.
//
// Gebraucht von der Office-Anbindung: Der Office-Server schickt beim Speichern
// den neuen Inhalt, aber keinen Ordner, keinen Namen und keine Sitzung. Der
// Weg über den Upload-Handler geht deshalb nicht – der liest ein Formular und
// schreibt selbst die HTTP-Antwort.
//
// **Ein Office-Server ist kein Sonderweg an der Datenintegrität vorbei.** Es
// gelten dieselben vier Regeln wie bei jedem Upload: atomar schreiben, Prüfsumme
// neu berechnen, das Kontingent beachten, und bei eingeschalteter Versionierung
// die alte Fassung aufheben.
func ReplaceContent(
ctx context.Context,
store *Store,
blobs storage.Storage,
f *File,
owner acl.Access,
content io.Reader,
) (*File, error) {
// Die alte Fassung zählt nach dem Überschreiben nicht mehr zum Verbrauch –
// deshalb ihre Größe als Freibetrag.
remaining, unlimited, err := store.RemainingQuota(ctx, owner.OwnerID, owner.OwnerQuota, f.Size)
if err != nil {
return nil, fmt.Errorf("quota: %w", err)
}
// Nur bei eingeschalteter Versionierung wird die alte Fassung beiseite
// geschoben. Sonst ersetzt Put sie atomar und der Platz bleibt frei.
var versionKey string
if f.VersioningEnabled {
versionKey = fmt.Sprintf("%s/.versions/%s", owner.StorageRoot(), uuid.New().String())
if err := blobs.Move(ctx, f.StorageKey, versionKey); err != nil {
return nil, fmt.Errorf("keep previous version: %w", err)
}
}
if !unlimited {
content = newQuotaReader(content, remaining)
}
checksum, size, err := blobs.Put(ctx, f.StorageKey, content)
if err != nil {
// Die alte Fassung ist schon verschoben – zurückrollen, sonst
// verliert man die bestehende Datei an ein Speichern, das nie ankam.
if versionKey != "" {
if mvErr := blobs.Move(ctx, versionKey, f.StorageKey); mvErr != nil {
log.Ctx(ctx).Error().Err(mvErr).Str("file_id", f.ID.String()).
Msg("rollback of version move failed – blob orphaned")
}
}
if errors.Is(err, ErrQuotaExceeded) {
return nil, ErrQuotaExceeded
}
return nil, fmt.Errorf("write: %w", err)
}
if versionKey != "" {
if err := store.CreateVersion(ctx, f.ID, f.Version, f.Size, f.Checksum, versionKey); err != nil {
return nil, fmt.Errorf("record version: %w", err)
}
}
updated, err := store.UpdateFileContent(ctx, f.ID, size, checksum, f.StorageKey)
if err != nil {
return nil, fmt.Errorf("update file: %w", err)
}
if versionKey != "" {
// Alte Fassungen über der Grenze wegräumen – sonst wächst der Verlauf
// eines oft gespeicherten Dokuments unbegrenzt. Ein Fehler hier ist
// kein Grund, das Speichern scheitern zu lassen.
if oldKeys, err := store.PruneVersions(ctx, f.ID, maxVersionsKept); err == nil {
for _, k := range oldKeys {
_ = blobs.Delete(ctx, k)
}
}
}
return updated, nil
}