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

+108
View File
@@ -0,0 +1,108 @@
package files
import (
"context"
"strings"
"unicode"
"github.com/rs/zerolog/log"
"aether/internal/storage"
)
// maxFilenameLen begrenzt den Dateinamen so, dass der zusammengesetzte Pfad
// auf gängigen Dateisystemen (255 Bytes pro Segment) sicher passt.
const maxFilenameLen = 200
// BuildStorageKey erzeugt einen menschenlesbaren Pfad, der die Struktur der
// App spiegelt:
//
// users/{username}/home/{folderPath}/{filename}
//
// Bewusst lesbar statt UUID-basiert: fällt die Datenbank aus, sollen die
// Dateien per SFTP weiterhin auffindbar sein. Versionen (.versions/) und
// Papierkorb (.trash/) sind davon ausgenommen – das sind interne Artefakte.
// BuildStorageKey setzt den lesbaren Ablagepfad zusammen. root ist das
// Wurzelsegment – `users/<name>` für persönliche Daten, `groups/<id>` für
// Gruppenordner (siehe acl.Access.StorageRoot).
func BuildStorageKey(root, folderPath, filename string) string {
if folderPath == "" {
return root + "/home/" + filename
}
return root + "/home/" + folderPath + "/" + filename
}
// SanitizeFilename macht einen vom Client gelieferten Namen als einzelnes
// Pfadsegment sicher.
//
// Der Traversal-Schutz sitzt in storage.safePath; hier geht es darum, dass
// Name in der Datenbank und Pfad im Storage nicht auseinanderlaufen: Ein Name
// wie "a/b.txt" würde sonst still eine Ordnerebene im Storage erzeugen, die
// die Datenbank nicht kennt.
//
// Gibt "" zurück, wenn nichts Brauchbares übrig bleibt – Aufrufer müssen das
// als ungültige Eingabe behandeln.
func SanitizeFilename(name string) string {
// Nur das letzte Segment übernehmen – Clients schicken teils ganze Pfade.
if i := strings.LastIndexAny(name, `/\`); i >= 0 {
name = name[i+1:]
}
// Steuerzeichen entfernen (inkl. NUL und Zeilenumbrüche in Namen).
name = strings.Map(func(r rune) rune {
if r == unicode.ReplacementChar || unicode.IsControl(r) {
return -1
}
return r
}, name)
// Führende/abschließende Punkte und Leerraum: "." und ".." wären sonst
// Verzeichnisverweise, abschließende Punkte stören unter Windows.
name = strings.Trim(strings.TrimSpace(name), ".")
name = strings.TrimSpace(name)
if len(name) > maxFilenameLen {
// Auf Rune-Grenze kürzen, damit kein kaputtes UTF-8 entsteht.
runes := []rune(name)
for len(string(runes)) > maxFilenameLen {
runes = runes[:len(runes)-1]
}
name = strings.TrimSpace(string(runes))
}
return name
}
// ResyncBlobPaths bringt die Blobs der übergebenen Dateien auf die Pfade, die
// sich aus dem aktuellen Datenbankstand ergeben.
//
// Wird ein Ordner umbenannt oder verschoben, ändert sich der Pfad aller
// Dateien darunter. Weil die Storage-Schlüssel lesbar sind (und nicht bloß
// UUIDs), müssen die Blobs mitwandern. Aufrufen, *nachdem* die Umbenennung in
// der Datenbank committet ist – erst dann liefert GetFolderPath den neuen Pfad.
//
// Die Dateiliste muss *vorher* eingesammelt werden, damit die alten
// Storage-Schlüssel noch bekannt sind.
func ResyncBlobPaths(ctx context.Context, store *Store, st storage.Storage, root string, moved []*File) {
for _, f := range moved {
newFolderPath, err := store.GetFolderPath(ctx, f.FolderID)
if err != nil {
log.Ctx(ctx).Warn().Err(err).Str("file_id", f.ID.String()).
Msg("resync: folder path lookup failed – blob keeps old path")
continue
}
newKey := BuildStorageKey(root, newFolderPath, f.Name)
if newKey == f.StorageKey {
continue
}
if err := st.Move(ctx, f.StorageKey, newKey); err != nil {
log.Ctx(ctx).Warn().Err(err).Str("file_id", f.ID.String()).
Msg("resync: blob move failed – storage key now stale")
continue
}
if err := store.UpdateFileStorageKey(ctx, f.ID, newKey); err != nil {
log.Ctx(ctx).Error().Err(err).Str("file_id", f.ID.String()).
Msg("resync: blob moved but storage key not updated")
}
}
}