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>
This commit is contained in:
commit
31a027c93a
225 files changed
+38042
No files matched your search
@@ -0,0 +1,165 @@
|
||||
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
|
||||
}
|
||||
Reference in new issue
Block a user