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,351 @@
|
||||
// Package acl regelt interne Freigaben: Ordner und Dateien an andere Nutzer
|
||||
// oder Gruppen geben, ohne dass Daten umziehen.
|
||||
//
|
||||
// Das Zugriffsmodell in einem Satz: Zugriff hat, wem die Ressource gehört –
|
||||
// oder wer einen Freigabeeintrag darauf besitzt, direkt oder über eine Gruppe,
|
||||
// auf der Ressource selbst oder auf einem Ordner darüber.
|
||||
//
|
||||
// Die Vererbung nach unten ist der Grund, warum das nicht mit einer einfachen
|
||||
// Abfrage geht: Wer einen Ordner freigibt, meint dessen ganzen Inhalt – sonst
|
||||
// müsste man jede Datei einzeln freigeben, und jede neu hinzugefügte gleich
|
||||
// mit. Deshalb läuft die Prüfung den Ordnerbaum nach oben, bis sie einen
|
||||
// Eintrag findet.
|
||||
package acl
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrNoAccess: weder Eigentümer noch Freigabe. Handler antworten damit
|
||||
// bewusst „nicht gefunden" statt „verboten" – sonst verriete die Antwort,
|
||||
// dass die ID überhaupt existiert.
|
||||
ErrNoAccess = errors.New("no access to this resource")
|
||||
// ErrReadOnly: Zugriff ja, Schreiben nein.
|
||||
ErrReadOnly = errors.New("read-only access")
|
||||
ErrNotFound = errors.New("resource not found")
|
||||
)
|
||||
|
||||
// Permission ist absichtlich klein gehalten. Feinere Rechte (löschen vs.
|
||||
// ändern) brächten Fragen mit sich, die für eine persönliche Cloud niemand
|
||||
// stellt.
|
||||
type Permission string
|
||||
|
||||
const (
|
||||
PermRead Permission = "read"
|
||||
PermWrite Permission = "write"
|
||||
)
|
||||
|
||||
func (p Permission) valid() bool { return p == PermRead || p == PermWrite }
|
||||
|
||||
// Access beschreibt das Ergebnis einer Zugriffsprüfung.
|
||||
type Access struct {
|
||||
// OwnerID ist der *Prinzipal* der Ressource – ein Konto oder eine Gruppe,
|
||||
// nicht zwingend der Anfragende. Alle Speicher- und Datenbankoperationen
|
||||
// laufen weiter über ihn, denn dort liegen die Daten.
|
||||
//
|
||||
// Ein Prinzipal statt eines Eigentümers, weil Gruppenordner niemandem
|
||||
// persönlich gehören. Die Abfragen prüfen deshalb `owner_id = $1 OR
|
||||
// group_id = $1`; UUIDs sind global eindeutig, eine Gruppen-ID kann also
|
||||
// nie versehentlich auf ein Konto passen.
|
||||
OwnerID uuid.UUID
|
||||
// IsGroup unterscheidet die beiden Fälle dort, wo es darauf ankommt:
|
||||
// beim Anlegen (welche Spalte wird gesetzt) und beim Speicherpfad.
|
||||
IsGroup bool
|
||||
// OwnerUsername, weil Speicherpfade daraus gebaut werden
|
||||
// (`users/{name}/home/…`). Wer in einem geliehenen Ordner arbeitet, schreibt
|
||||
// in den Bereich des Eigentümers, nicht in seinen eigenen.
|
||||
OwnerUsername string
|
||||
// OwnerQuota ist das Kontingent des Eigentümers. Wer in einen geliehenen
|
||||
// Ordner hochlädt, verbraucht dessen Platz – die Bytes liegen ja in dessen
|
||||
// Bereich. Alles andere wäre ein Weg, fremde Kontingente zu umgehen.
|
||||
OwnerQuota int64
|
||||
// Permission ist das, was der Anfragende darf.
|
||||
Permission Permission
|
||||
// Shared unterscheidet eigenen Besitz von geliehenem Zugriff.
|
||||
Shared bool
|
||||
}
|
||||
|
||||
func (a Access) CanWrite() bool { return a.Permission == PermWrite }
|
||||
|
||||
// OwnerRef und GroupRef liefern die Werte für die Spalten owner_id / group_id.
|
||||
// Genau einer ist gesetzt – so verlangt es die CHECK-Bedingung.
|
||||
func (a Access) OwnerRef() *uuid.UUID {
|
||||
if a.IsGroup {
|
||||
return nil
|
||||
}
|
||||
id := a.OwnerID
|
||||
return &id
|
||||
}
|
||||
|
||||
func (a Access) GroupRef() *uuid.UUID {
|
||||
if !a.IsGroup {
|
||||
return nil
|
||||
}
|
||||
id := a.OwnerID
|
||||
return &id
|
||||
}
|
||||
|
||||
// StorageRoot ist das Wurzelsegment für Speicherpfade. Gruppendaten liegen
|
||||
// bewusst nicht unter `users/…`: Auf der Platte soll man ansehen können, dass
|
||||
// ein Ordner keinem Konto gehört – sonst sucht man die Daten einer Gruppe im
|
||||
// Verzeichnis eines Nutzers, der zufällig ihr Ersteller war.
|
||||
func (a Access) StorageRoot() string {
|
||||
if a.IsGroup {
|
||||
return "groups/" + a.OwnerID.String()
|
||||
}
|
||||
return "users/" + a.OwnerUsername
|
||||
}
|
||||
|
||||
type Store struct {
|
||||
db *pgxpool.Pool
|
||||
}
|
||||
|
||||
func NewStore(db *pgxpool.Pool) *Store {
|
||||
return &Store{db: db}
|
||||
}
|
||||
|
||||
// ── Zugriffsauflösung ────────────────────────────────────────────────────────
|
||||
|
||||
// ResolveFolder klärt, was userID mit diesem Ordner darf.
|
||||
func (s *Store) ResolveFolder(ctx context.Context, userID, folderID uuid.UUID) (Access, error) {
|
||||
// Gruppenordner haben keinen Eigentümer, deshalb beide Fälle in einer
|
||||
// Abfrage. COALESCE macht die Gruppen-ID zum Prinzipal, wenn owner_id leer
|
||||
// ist – genau die ID, mit der die Store-Abfragen weiterarbeiten.
|
||||
var principal uuid.UUID
|
||||
var isGroup bool
|
||||
var ownerName string
|
||||
var quota int64
|
||||
err := s.db.QueryRow(ctx,
|
||||
`SELECT COALESCE(f.owner_id, f.group_id),
|
||||
f.group_id IS NOT NULL,
|
||||
COALESCE(u.username, ''),
|
||||
COALESCE(u.quota_bytes, g.quota_bytes, 0)
|
||||
FROM folders f
|
||||
LEFT JOIN users u ON u.id = f.owner_id
|
||||
LEFT JOIN groups g ON g.id = f.group_id
|
||||
WHERE f.id = $1`, folderID).Scan(&principal, &isGroup, &ownerName, "a)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return Access{}, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return Access{}, fmt.Errorf("load folder principal: %w", err)
|
||||
}
|
||||
|
||||
if isGroup {
|
||||
// Mitgliedschaft ersetzt hier die Freigabe: Wer in der Gruppe ist, darf
|
||||
// schreiben; wer sie verlässt, verliert den Zugriff im selben Moment.
|
||||
member, err := s.isGroupMember(ctx, userID, principal)
|
||||
if err != nil {
|
||||
return Access{}, err
|
||||
}
|
||||
if !member {
|
||||
return Access{}, ErrNoAccess
|
||||
}
|
||||
return Access{
|
||||
OwnerID: principal, IsGroup: true, OwnerQuota: quota,
|
||||
Permission: PermWrite, Shared: true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
if principal == userID {
|
||||
return Access{OwnerID: principal, OwnerUsername: ownerName, OwnerQuota: quota, Permission: PermWrite}, nil
|
||||
}
|
||||
ownerID, ownerQuota := principal, quota
|
||||
|
||||
perm, err := s.bestPermissionForFolder(ctx, userID, folderID)
|
||||
if err != nil {
|
||||
return Access{}, err
|
||||
}
|
||||
if perm == "" {
|
||||
return Access{}, ErrNoAccess
|
||||
}
|
||||
return Access{OwnerID: ownerID, OwnerUsername: ownerName, OwnerQuota: ownerQuota, Permission: perm, Shared: true}, nil
|
||||
}
|
||||
|
||||
// ResolveFile klärt, was userID mit dieser Datei darf. Geerbt wird über den
|
||||
// Ordner, in dem sie liegt.
|
||||
func (s *Store) ResolveFile(ctx context.Context, userID, fileID uuid.UUID) (Access, error) {
|
||||
var principal uuid.UUID
|
||||
var isGroup bool
|
||||
var ownerName string
|
||||
var quota int64
|
||||
var folderID *uuid.UUID
|
||||
err := s.db.QueryRow(ctx,
|
||||
`SELECT COALESCE(x.owner_id, x.group_id),
|
||||
x.group_id IS NOT NULL,
|
||||
COALESCE(u.username, ''),
|
||||
COALESCE(u.quota_bytes, g.quota_bytes, 0),
|
||||
x.folder_id
|
||||
FROM files x
|
||||
LEFT JOIN users u ON u.id = x.owner_id
|
||||
LEFT JOIN groups g ON g.id = x.group_id
|
||||
WHERE x.id = $1`, fileID).Scan(&principal, &isGroup, &ownerName, "a, &folderID)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return Access{}, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return Access{}, fmt.Errorf("load file principal: %w", err)
|
||||
}
|
||||
|
||||
if isGroup {
|
||||
member, err := s.isGroupMember(ctx, userID, principal)
|
||||
if err != nil {
|
||||
return Access{}, err
|
||||
}
|
||||
if !member {
|
||||
return Access{}, ErrNoAccess
|
||||
}
|
||||
return Access{
|
||||
OwnerID: principal, IsGroup: true, OwnerQuota: quota,
|
||||
Permission: PermWrite, Shared: true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
if principal == userID {
|
||||
return Access{OwnerID: principal, OwnerUsername: ownerName, OwnerQuota: quota, Permission: PermWrite}, nil
|
||||
}
|
||||
ownerID, ownerQuota := principal, quota
|
||||
|
||||
// Direkte Freigabe auf der Datei.
|
||||
perm, err := s.directFilePermission(ctx, userID, fileID)
|
||||
if err != nil {
|
||||
return Access{}, err
|
||||
}
|
||||
// Geerbt aus dem Ordner darüber. Das stärkere Recht gewinnt: eine
|
||||
// Schreibfreigabe auf dem Ordner soll nicht dadurch entwertet werden, dass
|
||||
// jemand zusätzlich eine Lesefreigabe auf einer einzelnen Datei erteilt hat.
|
||||
if folderID != nil && perm != PermWrite {
|
||||
inherited, err := s.bestPermissionForFolder(ctx, userID, *folderID)
|
||||
if err != nil {
|
||||
return Access{}, err
|
||||
}
|
||||
perm = stronger(perm, inherited)
|
||||
}
|
||||
if perm == "" {
|
||||
return Access{}, ErrNoAccess
|
||||
}
|
||||
return Access{OwnerID: ownerID, OwnerUsername: ownerName, OwnerQuota: ownerQuota, Permission: perm, Shared: true}, nil
|
||||
}
|
||||
|
||||
// RequireFolderWrite / RequireFileWrite sind die bequeme Form für Handler, die
|
||||
// ändern wollen.
|
||||
func (s *Store) RequireFolderWrite(ctx context.Context, userID, folderID uuid.UUID) (Access, error) {
|
||||
a, err := s.ResolveFolder(ctx, userID, folderID)
|
||||
if err != nil {
|
||||
return a, err
|
||||
}
|
||||
if !a.CanWrite() {
|
||||
return a, ErrReadOnly
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
|
||||
func (s *Store) RequireFileWrite(ctx context.Context, userID, fileID uuid.UUID) (Access, error) {
|
||||
a, err := s.ResolveFile(ctx, userID, fileID)
|
||||
if err != nil {
|
||||
return a, err
|
||||
}
|
||||
if !a.CanWrite() {
|
||||
return a, ErrReadOnly
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
|
||||
// IsGroupMember ist die öffentliche Fassung von isGroupMember – gebraucht von
|
||||
// internal/tags, um zu prüfen, ob jemand einer Gruppe ein Etikett anlegen darf.
|
||||
func (s *Store) IsGroupMember(ctx context.Context, userID, groupID uuid.UUID) (bool, error) {
|
||||
return s.isGroupMember(ctx, userID, groupID)
|
||||
}
|
||||
|
||||
func (s *Store) isGroupMember(ctx context.Context, userID, groupID uuid.UUID) (bool, error) {
|
||||
var exists bool
|
||||
err := s.db.QueryRow(ctx,
|
||||
`SELECT EXISTS (SELECT 1 FROM group_members WHERE user_id = $1 AND group_id = $2)`,
|
||||
userID, groupID).Scan(&exists)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("check group membership: %w", err)
|
||||
}
|
||||
return exists, nil
|
||||
}
|
||||
|
||||
// bestPermissionForFolder läuft vom Ordner aus nach oben und liefert das
|
||||
// stärkste Recht, das userID auf dem Weg findet – direkt oder über eine Gruppe.
|
||||
// Leerer Rückgabewert heißt: keinerlei Zugriff.
|
||||
func (s *Store) bestPermissionForFolder(ctx context.Context, userID, folderID uuid.UUID) (Permission, error) {
|
||||
// Rekursiv nach oben statt nach unten: Der Pfad zur Wurzel ist ein paar
|
||||
// Ebenen lang, der Unterbaum kann Tausende Einträge haben.
|
||||
const q = `
|
||||
WITH RECURSIVE ancestry AS (
|
||||
SELECT id, parent_id FROM folders WHERE id = $1
|
||||
UNION ALL
|
||||
SELECT f.id, f.parent_id
|
||||
FROM folders f
|
||||
JOIN ancestry a ON f.id = a.parent_id
|
||||
)
|
||||
SELECT e.permission
|
||||
FROM acl_entries e
|
||||
JOIN ancestry a ON a.id = e.resource_folder_id
|
||||
WHERE e.subject_user_id = $2
|
||||
OR e.subject_group_id IN (
|
||||
SELECT group_id FROM group_members WHERE user_id = $2
|
||||
)`
|
||||
|
||||
rows, err := s.db.Query(ctx, q, folderID, userID)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("resolve folder permission: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var best Permission
|
||||
for rows.Next() {
|
||||
var p Permission
|
||||
if err := rows.Scan(&p); err != nil {
|
||||
return "", fmt.Errorf("scan permission: %w", err)
|
||||
}
|
||||
best = stronger(best, p)
|
||||
}
|
||||
return best, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) directFilePermission(ctx context.Context, userID, fileID uuid.UUID) (Permission, error) {
|
||||
rows, err := s.db.Query(ctx,
|
||||
`SELECT permission FROM acl_entries
|
||||
WHERE resource_file_id = $1
|
||||
AND (subject_user_id = $2
|
||||
OR subject_group_id IN (SELECT group_id FROM group_members WHERE user_id = $2))`,
|
||||
fileID, userID)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("resolve file permission: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var best Permission
|
||||
for rows.Next() {
|
||||
var p Permission
|
||||
if err := rows.Scan(&p); err != nil {
|
||||
return "", fmt.Errorf("scan permission: %w", err)
|
||||
}
|
||||
best = stronger(best, p)
|
||||
}
|
||||
return best, rows.Err()
|
||||
}
|
||||
|
||||
// stronger: Mehrere Wege zur selben Ressource (direkt und über zwei Gruppen)
|
||||
// sind normal. Das großzügigste Recht gilt – alles andere wäre für den Nutzer
|
||||
// nicht nachvollziehbar.
|
||||
func stronger(a, b Permission) Permission {
|
||||
if a == PermWrite || b == PermWrite {
|
||||
return PermWrite
|
||||
}
|
||||
if a == PermRead || b == PermRead {
|
||||
return PermRead
|
||||
}
|
||||
return ""
|
||||
}
|
||||
Reference in new issue
Block a user