Files
Aether/backend/internal/files/store_zip.go
T
HoneyandClaude Opus 5 31a027c93a 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>
2026-08-24 09:25:36 +02:00

121 lines
4.1 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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()
}