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() }