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:
commit
31a027c93a
225 files changed
+38042
No files matched your search
@@ -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()
|
||||
}
|
||||
Reference in new issue
Block a user