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:
HoneyandClaude Opus 5 committed 2026-08-24 09:25:36 +02:00
commit 31a027c93a
225 files changed
+38042

No files matched your search

+165
View File
@@ -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
}