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>
121 lines
4.1 KiB
Go
121 lines
4.1 KiB
Go
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()
|
||
}
|