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

+120
View File
@@ -0,0 +1,120 @@
package files
import (
"context"
"fmt"
"github.com/google/uuid"
)
// ZipEntry ist eine Datei samt ihrem Pfad **innerhalb** des Archivs.
type ZipEntry struct {
ID uuid.UUID
Name string
Path string // z. B. "Belege/2026/rechnung.pdf"
Size int64
StorageKey string
MimeType string
}
// maxZipEntries begrenzt, wie viele Dateien in ein Archiv gehen.
//
// Nicht wegen des Speichers – das Archiv wird gestreamt und liegt nie ganz
// irgendwo –, sondern wegen der Zeit: Bei zehntausend Dateien liefe die
// Anfrage minutenlang, und ein Abbruch mittendrin hinterließe ein halbes
// Archiv, dem man das nicht ansieht.
const MaxZipEntries = 2000
// FilesInFolderTree liefert alle Dateien unterhalb eines Ordners **mit ihrem
// relativen Pfad**, damit das Archiv die Ordnerstruktur behält.
//
// Der Pfad entsteht in der Abfrage selbst: Ihn nachträglich in Go
// zusammenzusetzen hieße, für jeden Ordner die Kette nach oben einzeln
// abzufragen – bei fünfzig Unterordnern fünfzig Rundreisen.
func (s *Store) FilesInFolderTree(ctx context.Context, principalID, rootFolderID uuid.UUID) ([]ZipEntry, error) {
const q = `
WITH RECURSIVE tree AS (
-- Die Wurzel selbst trägt den leeren Pfad: Ihr eigener Name wird zum Namen
-- des Archivs, nicht zu einem Ordner darin.
SELECT id, ''::text AS path
FROM folders
WHERE id = $1 AND (owner_id = $2 OR group_id = $2)
UNION ALL
SELECT f.id, CASE WHEN t.path = '' THEN f.name ELSE t.path || '/' || f.name END
FROM folders f
JOIN tree t ON f.parent_id = t.id
WHERE (f.owner_id = $2 OR f.group_id = $2)
)
SELECT fi.id, fi.name,
CASE WHEN t.path = '' THEN fi.name ELSE t.path || '/' || fi.name END AS path,
fi.size, fi.storage_key, fi.mime_type
FROM files fi
JOIN tree t ON t.id = fi.folder_id
WHERE fi.is_deleted = false
AND (fi.owner_id = $2 OR fi.group_id = $2)
ORDER BY path
LIMIT $3`
return s.scanZipEntries(ctx, q, rootFolderID, principalID, MaxZipEntries+1)
}
// FilesForZipByIDs liefert genau die genannten Dateien – aber nur die, die der
// Anfragende auch erreichen darf.
//
// Der Umfang steckt wie bei der Suche in der Abfrage, nicht in einer Prüfung
// danach. Eine erratene Kennung in der Liste fällt hier still heraus, statt
// im Archiv zu landen.
//
// Die Pfade sind flach: Eine Auswahl kommt aus einem Ordner oder aus einer
// Etikettenliste, wo eine nachgebaute Ordnerstruktur nur im Weg wäre.
func (s *Store) FilesForZipByIDs(ctx context.Context, userID uuid.UUID, ids []uuid.UUID) ([]ZipEntry, error) {
const q = `
WITH RECURSIVE my_groups AS (
SELECT group_id FROM group_members WHERE user_id = $2
),
granted_roots AS (
SELECT resource_folder_id AS id FROM acl_entries
WHERE resource_folder_id IS NOT NULL
AND (subject_user_id = $2 OR subject_group_id IN (SELECT group_id FROM my_groups))
),
shared_tree AS (
SELECT id FROM granted_roots
UNION
SELECT f.id FROM folders f JOIN shared_tree s ON f.parent_id = s.id
),
granted_files AS (
SELECT resource_file_id AS id FROM acl_entries
WHERE resource_file_id IS NOT NULL
AND (subject_user_id = $2 OR subject_group_id IN (SELECT group_id FROM my_groups))
)
SELECT fi.id, fi.name, fi.name AS path, fi.size, fi.storage_key, fi.mime_type
FROM files fi
WHERE fi.id = ANY($1)
AND fi.is_deleted = false
AND (fi.owner_id = $2
OR fi.group_id IN (SELECT group_id FROM my_groups)
OR fi.folder_id IN (SELECT id FROM shared_tree)
OR fi.id IN (SELECT id FROM granted_files))
ORDER BY fi.name
LIMIT $3`
return s.scanZipEntries(ctx, q, ids, userID, MaxZipEntries+1)
}
func (s *Store) scanZipEntries(ctx context.Context, q string, args ...any) ([]ZipEntry, error) {
rows, err := s.db.Query(ctx, q, args...)
if err != nil {
return nil, fmt.Errorf("zip entries: %w", err)
}
defer rows.Close()
out := []ZipEntry{}
for rows.Next() {
var e ZipEntry
if err := rows.Scan(&e.ID, &e.Name, &e.Path, &e.Size, &e.StorageKey, &e.MimeType); err != nil {
return nil, fmt.Errorf("scan zip entry: %w", err)
}
out = append(out, e)
}
return out, rows.Err()
}