Files
Aether/backend/internal/files/groupfolder.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

166 lines
6.0 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"
"errors"
"fmt"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
)
// Gruppenordner: Ordner, die keinem Konto gehören, sondern einer Gruppe.
// Sichtbar für jedes Mitglied, angelegt und entfernt ausschließlich vom Admin.
// ErrGroupRootProtected: Der Wurzelordner einer Gruppe lässt sich nicht aus der
// Dateiansicht heraus löschen oder umbenennen. Er ist kein gewöhnlicher Ordner,
// sondern der Zugang der Gruppe – wenn ein beliebiges Mitglied ihn entfernen
// könnte, wären die Daten aller anderen mit weg. Verwaltet wird er im
// Admin-Bereich; alles *innerhalb* bleibt normal löschbar.
var ErrGroupRootProtected = errors.New("group folder can only be removed by an administrator")
// ErrGroupFolderExists: Eine Gruppe hat genau einen Ordner, nicht mehrere.
// Das hält das Modell verständlich („der Ordner der Gruppe") und erlaubt es,
// beim Löschen den gesamten Speicherbereich der Gruppe in einem Zug zu
// entfernen, statt Blob für Blob – wodurch keine leeren Verzeichnisse
// zurückbleiben. Wer mehr Struktur braucht, legt Unterordner an.
var ErrGroupFolderExists = errors.New("this group already has a folder")
// GroupFolder ist ein Gruppenordner samt Gruppe – für die Admin-Übersicht.
type GroupFolder struct {
ID uuid.UUID `json:"id"`
Name string `json:"name"`
GroupID uuid.UUID `json:"group_id"`
GroupName string `json:"group_name"`
Members int `json:"members"`
UsedBytes int64 `json:"used_bytes"`
QuotaByte int64 `json:"quota_bytes"`
}
// IsGroupRoot sagt, ob ein Ordner der Wurzelordner einer Gruppe ist.
func (s *Store) IsGroupRoot(ctx context.Context, folderID uuid.UUID) (bool, error) {
var isRoot bool
err := s.db.QueryRow(ctx,
`SELECT group_id IS NOT NULL AND parent_id IS NULL FROM folders WHERE id = $1`,
folderID).Scan(&isRoot)
if errors.Is(err, pgx.ErrNoRows) {
return false, ErrNotFound
}
if err != nil {
return false, fmt.Errorf("check group root: %w", err)
}
return isRoot, nil
}
// GroupName liefert den Namen einer Gruppe – für die Beschriftung des
// Gruppenordners in WebDAV.
func (s *Store) GroupName(ctx context.Context, groupID uuid.UUID) (string, error) {
var name string
err := s.db.QueryRow(ctx, `SELECT name FROM groups WHERE id = $1`, groupID).Scan(&name)
if errors.Is(err, pgx.ErrNoRows) {
return "", ErrNotFound
}
if err != nil {
return "", fmt.Errorf("group name: %w", err)
}
return name, nil
}
// ListGroupFoldersForUser liefert die Gruppenordner, die dem Nutzer über seine
// Mitgliedschaften zustehen. Sie erscheinen in seiner Wurzel neben den eigenen
// Ordnern – ohne dass er etwas dafür tun muss.
func (s *Store) ListGroupFoldersForUser(ctx context.Context, userID uuid.UUID) ([]*Folder, error) {
rows, err := s.db.Query(ctx,
`SELECT f.id, f.owner_id, f.group_id, f.parent_id, f.name, f.created_at
FROM folders f
JOIN group_members m ON m.group_id = f.group_id
WHERE m.user_id = $1 AND f.parent_id IS NULL
ORDER BY f.name`, userID)
if err != nil {
return nil, fmt.Errorf("list group folders: %w", err)
}
defer rows.Close()
out := []*Folder{}
for rows.Next() {
var f Folder
if err := rows.Scan(&f.ID, &f.OwnerID, &f.GroupID, &f.ParentID, &f.Name, &f.CreatedAt); err != nil {
return nil, fmt.Errorf("scan group folder: %w", err)
}
out = append(out, &f)
}
return out, rows.Err()
}
// ListGroupFolders liefert alle Gruppenordner für den Admin-Bereich.
func (s *Store) ListGroupFolders(ctx context.Context) ([]*GroupFolder, error) {
rows, err := s.db.Query(ctx, `
SELECT f.id, f.name, g.id, g.name, g.quota_bytes,
(SELECT count(*) FROM group_members m WHERE m.group_id = g.id),
COALESCE((SELECT SUM(x.size) FROM files x
WHERE x.group_id = g.id AND x.is_deleted = false), 0)
FROM folders f
JOIN groups g ON g.id = f.group_id
WHERE f.parent_id IS NULL
ORDER BY g.name, f.name`)
if err != nil {
return nil, fmt.Errorf("list group folders: %w", err)
}
defer rows.Close()
out := []*GroupFolder{}
for rows.Next() {
var gf GroupFolder
if err := rows.Scan(&gf.ID, &gf.Name, &gf.GroupID, &gf.GroupName,
&gf.QuotaByte, &gf.Members, &gf.UsedBytes); err != nil {
return nil, fmt.Errorf("scan group folder: %w", err)
}
out = append(out, &gf)
}
return out, rows.Err()
}
// CreateGroupFolder legt den Wurzelordner einer Gruppe an.
func (s *Store) CreateGroupFolder(ctx context.Context, groupID uuid.UUID, name string) (*Folder, error) {
var exists bool
if err := s.db.QueryRow(ctx,
`SELECT EXISTS (SELECT 1 FROM folders WHERE group_id = $1 AND parent_id IS NULL)`,
groupID).Scan(&exists); err != nil {
return nil, fmt.Errorf("check existing group folder: %w", err)
}
if exists {
return nil, ErrGroupFolderExists
}
return s.CreateFolder(ctx, &Folder{GroupID: &groupID, Name: name})
}
// DeleteGroupFolderTree entfernt einen Gruppenordner mit allem darin und gibt
// die Gruppen-ID zurück, damit der Aufrufer deren Speicherbereich am Stück
// entfernen kann. Nur für den Admin-Bereich gedacht.
func (s *Store) DeleteGroupFolderTree(ctx context.Context, folderID uuid.UUID) (uuid.UUID, error) {
var groupID *uuid.UUID
var parentID *uuid.UUID
err := s.db.QueryRow(ctx,
`SELECT group_id, parent_id FROM folders WHERE id = $1`, folderID).Scan(&groupID, &parentID)
if errors.Is(err, pgx.ErrNoRows) {
return uuid.Nil, ErrNotFound
}
if err != nil {
return uuid.Nil, fmt.Errorf("load group folder: %w", err)
}
if groupID == nil || parentID != nil {
return uuid.Nil, ErrNotFound
}
// Dateien zuerst: Die Kaskade über folders nimmt nur mit, was unter dem
// Ordner hängt. Dateien direkt in der Wurzel hätten sonst keinen
// Elternteil mehr, der sie entfernt.
if _, err := s.db.Exec(ctx, `DELETE FROM files WHERE group_id = $1`, *groupID); err != nil {
return uuid.Nil, fmt.Errorf("delete group files: %w", err)
}
if _, err := s.db.Exec(ctx, `DELETE FROM folders WHERE group_id = $1`, *groupID); err != nil {
return uuid.Nil, fmt.Errorf("delete group folders: %w", err)
}
return *groupID, nil
}