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>
94 lines
2.9 KiB
Go
94 lines
2.9 KiB
Go
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
|
||
}
|