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>
166 lines
6.0 KiB
Go
166 lines
6.0 KiB
Go
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
|
||
}
|