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 ""
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package acl
|
||||
|
||||
import "testing"
|
||||
|
||||
// stronger entscheidet, welches Recht gilt, wenn jemand eine Ressource über
|
||||
// mehrere Wege erreicht – direkt und zusätzlich über zwei Gruppen etwa. Kippt
|
||||
// diese Funktion, bekommt jemand entweder zu viel oder unerwartet zu wenig.
|
||||
func TestStrongerPermissionWins(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
a, b Permission
|
||||
want Permission
|
||||
}{
|
||||
{"nichts und nichts", "", "", ""},
|
||||
{"nur lesen", PermRead, "", PermRead},
|
||||
{"lesen und schreiben", PermRead, PermWrite, PermWrite},
|
||||
{"schreiben und lesen", PermWrite, PermRead, PermWrite},
|
||||
{"zweimal schreiben", PermWrite, PermWrite, PermWrite},
|
||||
{"zweimal lesen", PermRead, PermRead, PermRead},
|
||||
{"schreiben aus dem Nichts", "", PermWrite, PermWrite},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := stronger(tt.a, tt.b); got != tt.want {
|
||||
t.Errorf("stronger(%q, %q) = %q, want %q", tt.a, tt.b, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Ein unbekanntes Recht darf nicht durchrutschen: Die Datenbank hat zwar einen
|
||||
// CHECK, aber die Prüfung soll vorher greifen und eine klare Fehlermeldung
|
||||
// liefern statt eines Constraint-Fehlers.
|
||||
func TestPermissionValidation(t *testing.T) {
|
||||
for _, p := range []Permission{PermRead, PermWrite} {
|
||||
if !p.valid() {
|
||||
t.Errorf("%q sollte gültig sein", p)
|
||||
}
|
||||
}
|
||||
for _, p := range []Permission{"", "admin", "owner", "READ", "write "} {
|
||||
if p.valid() {
|
||||
t.Errorf("%q sollte ungültig sein", p)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Access.CanWrite ist die Weiche vor jeder Änderung.
|
||||
func TestAccessCanWrite(t *testing.T) {
|
||||
if (Access{Permission: PermRead}).CanWrite() {
|
||||
t.Error("Lesezugriff darf nicht schreiben dürfen")
|
||||
}
|
||||
if !(Access{Permission: PermWrite}).CanWrite() {
|
||||
t.Error("Schreibzugriff muss schreiben dürfen")
|
||||
}
|
||||
// Der Nullwert entsteht, wenn eine Auflösung fehlschlägt – der darf
|
||||
// keinesfalls Schreibrechte bedeuten.
|
||||
if (Access{}).CanWrite() {
|
||||
t.Error("leerer Zugriff darf nicht schreiben dürfen")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
package acl
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/google/uuid"
|
||||
|
||||
"aether/internal/audit"
|
||||
"aether/internal/auth"
|
||||
"aether/pkg/middleware"
|
||||
"aether/pkg/response"
|
||||
)
|
||||
|
||||
// Handler bedient den Freigabedialog. Die Zugriffsprüfung selbst passiert im
|
||||
// files-Paket; hier geht es nur ums Erteilen, Ansehen und Widerrufen.
|
||||
//
|
||||
// Freigeben darf ausschließlich der Eigentümer. Wer selbst nur eine
|
||||
// Schreibfreigabe hat, kann sie nicht weiterreichen – sonst verlöre der
|
||||
// Eigentümer die Kontrolle darüber, wer seine Daten sieht.
|
||||
type Handler struct {
|
||||
store *Store
|
||||
auditor *audit.Logger
|
||||
}
|
||||
|
||||
func NewHandler(store *Store, auditor *audit.Logger) *Handler {
|
||||
return &Handler{store: store, auditor: auditor}
|
||||
}
|
||||
|
||||
// logShare hält fest, wer wem Zugriff auf was gegeben oder genommen hat. Bei
|
||||
// Freigaben ist das die interessanteste Spur überhaupt: Sie erklärt, wie jemand
|
||||
// an Daten kam, die ihm nicht gehören.
|
||||
func (h *Handler) logShare(r *http.Request, action, resourceType, resourceID, detail string) {
|
||||
user := auth.UserFromContext(r.Context())
|
||||
e := audit.Event{
|
||||
Action: action,
|
||||
TargetType: resourceType,
|
||||
TargetID: resourceID,
|
||||
Detail: detail,
|
||||
IP: middleware.RealIP(r),
|
||||
}
|
||||
if user != nil {
|
||||
e.ActorID = &user.ID
|
||||
e.ActorName = user.Username
|
||||
}
|
||||
h.auditor.Log(r.Context(), e)
|
||||
}
|
||||
|
||||
// ownedFolder / ownedFile liefern die Ressourcen-ID und den Anfragenden –
|
||||
// aber nur, wenn ihm die Ressource auch gehört. Alles andere ist bereits
|
||||
// beantwortet.
|
||||
func (h *Handler) ownedFolder(w http.ResponseWriter, r *http.Request) (uuid.UUID, uuid.UUID, bool) {
|
||||
user := auth.UserFromContext(r.Context())
|
||||
id, err := uuid.Parse(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusBadRequest, "invalid folder id")
|
||||
return uuid.Nil, uuid.Nil, false
|
||||
}
|
||||
a, err := h.store.ResolveFolder(r.Context(), user.ID, id)
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusNotFound, "folder not found")
|
||||
return uuid.Nil, uuid.Nil, false
|
||||
}
|
||||
if a.OwnerID != user.ID {
|
||||
response.Error(w, http.StatusForbidden, "only the owner can manage sharing")
|
||||
return uuid.Nil, uuid.Nil, false
|
||||
}
|
||||
return id, user.ID, true
|
||||
}
|
||||
|
||||
func (h *Handler) ownedFile(w http.ResponseWriter, r *http.Request) (uuid.UUID, uuid.UUID, bool) {
|
||||
user := auth.UserFromContext(r.Context())
|
||||
id, err := uuid.Parse(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusBadRequest, "invalid file id")
|
||||
return uuid.Nil, uuid.Nil, false
|
||||
}
|
||||
a, err := h.store.ResolveFile(r.Context(), user.ID, id)
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusNotFound, "file not found")
|
||||
return uuid.Nil, uuid.Nil, false
|
||||
}
|
||||
if a.OwnerID != user.ID {
|
||||
response.Error(w, http.StatusForbidden, "only the owner can manage sharing")
|
||||
return uuid.Nil, uuid.Nil, false
|
||||
}
|
||||
return id, user.ID, true
|
||||
}
|
||||
|
||||
func userFrom(r *http.Request) uuid.UUID {
|
||||
if u := auth.UserFromContext(r.Context()); u != nil {
|
||||
return u.ID
|
||||
}
|
||||
return uuid.Nil
|
||||
}
|
||||
|
||||
type grantRequest struct {
|
||||
SubjectID string `json:"subject_id"`
|
||||
SubjectType string `json:"subject_type"` // "user" | "group"
|
||||
Permission Permission `json:"permission"`
|
||||
}
|
||||
|
||||
// GET /api/v1/folders/{id}/acl
|
||||
func (h *Handler) ListFolderACL(w http.ResponseWriter, r *http.Request) {
|
||||
id, _, ok := h.ownedFolder(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
entries, err := h.store.ListForFolder(r.Context(), id)
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusInternalServerError, "could not load shares")
|
||||
return
|
||||
}
|
||||
response.JSON(w, http.StatusOK, entries)
|
||||
}
|
||||
|
||||
// POST /api/v1/folders/{id}/acl
|
||||
func (h *Handler) GrantFolderACL(w http.ResponseWriter, r *http.Request) {
|
||||
id, owner, ok := h.ownedFolder(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
req, ok := decodeGrant(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
entry, err := h.store.GrantFolder(r.Context(), id, req.subjectID, req.isGroup, req.perm, owner)
|
||||
if err == nil {
|
||||
h.logShare(r, audit.ActionShareGrant, "folder", id.String(),
|
||||
string(req.perm)+" for "+req.subjectType()+" "+req.subjectID.String())
|
||||
}
|
||||
writeGrantResult(w, entry, err)
|
||||
}
|
||||
|
||||
// GET /api/v1/files/{id}/acl
|
||||
func (h *Handler) ListFileACL(w http.ResponseWriter, r *http.Request) {
|
||||
id, _, ok := h.ownedFile(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
entries, err := h.store.ListForFile(r.Context(), id)
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusInternalServerError, "could not load shares")
|
||||
return
|
||||
}
|
||||
response.JSON(w, http.StatusOK, entries)
|
||||
}
|
||||
|
||||
// POST /api/v1/files/{id}/acl
|
||||
func (h *Handler) GrantFileACL(w http.ResponseWriter, r *http.Request) {
|
||||
id, owner, ok := h.ownedFile(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
req, ok := decodeGrant(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
entry, err := h.store.GrantFile(r.Context(), id, req.subjectID, req.isGroup, req.perm, owner)
|
||||
if err == nil {
|
||||
h.logShare(r, audit.ActionShareGrant, "file", id.String(),
|
||||
string(req.perm)+" for "+req.subjectType()+" "+req.subjectID.String())
|
||||
}
|
||||
writeGrantResult(w, entry, err)
|
||||
}
|
||||
|
||||
// DELETE /api/v1/acl/{id}
|
||||
func (h *Handler) RevokeACL(w http.ResponseWriter, r *http.Request) {
|
||||
user := userFrom(r)
|
||||
if user == uuid.Nil {
|
||||
response.Error(w, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
entryID, err := uuid.Parse(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusBadRequest, "invalid id")
|
||||
return
|
||||
}
|
||||
if err := h.store.Revoke(r.Context(), entryID, user); errors.Is(err, ErrNotFound) {
|
||||
response.Error(w, http.StatusNotFound, "share not found")
|
||||
return
|
||||
} else if err != nil {
|
||||
response.Error(w, http.StatusInternalServerError, "could not revoke share")
|
||||
return
|
||||
}
|
||||
h.logShare(r, audit.ActionShareRevoke, "acl", entryID.String(), "")
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// GET /api/v1/shared-with-me
|
||||
func (h *Handler) SharedWithMe(w http.ResponseWriter, r *http.Request) {
|
||||
user := userFrom(r)
|
||||
if user == uuid.Nil {
|
||||
response.Error(w, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
items, err := h.store.ListSharedWith(r.Context(), user)
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusInternalServerError, "could not load shared items")
|
||||
return
|
||||
}
|
||||
response.JSON(w, http.StatusOK, items)
|
||||
}
|
||||
|
||||
// GET /api/v1/share-recipients
|
||||
func (h *Handler) Recipients(w http.ResponseWriter, r *http.Request) {
|
||||
user := userFrom(r)
|
||||
if user == uuid.Nil {
|
||||
response.Error(w, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
list, err := h.store.Recipients(r.Context(), user)
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusInternalServerError, "could not load recipients")
|
||||
return
|
||||
}
|
||||
response.JSON(w, http.StatusOK, list)
|
||||
}
|
||||
|
||||
// ── Hilfen ───────────────────────────────────────────────────────────────────
|
||||
|
||||
type parsedGrant struct {
|
||||
subjectID uuid.UUID
|
||||
isGroup bool
|
||||
perm Permission
|
||||
}
|
||||
|
||||
func (g parsedGrant) subjectType() string { return subjectTypeName(g.isGroup) }
|
||||
|
||||
func decodeGrant(w http.ResponseWriter, r *http.Request) (parsedGrant, bool) {
|
||||
var body grantRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
response.Error(w, http.StatusBadRequest, "invalid request body")
|
||||
return parsedGrant{}, false
|
||||
}
|
||||
id, err := uuid.Parse(body.SubjectID)
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusBadRequest, "invalid subject_id")
|
||||
return parsedGrant{}, false
|
||||
}
|
||||
if body.SubjectType != "user" && body.SubjectType != "group" {
|
||||
response.Error(w, http.StatusBadRequest, `subject_type must be "user" or "group"`)
|
||||
return parsedGrant{}, false
|
||||
}
|
||||
if !body.Permission.valid() {
|
||||
response.Error(w, http.StatusBadRequest, `permission must be "read" or "write"`)
|
||||
return parsedGrant{}, false
|
||||
}
|
||||
return parsedGrant{subjectID: id, isGroup: body.SubjectType == "group", perm: body.Permission}, true
|
||||
}
|
||||
|
||||
func writeGrantResult(w http.ResponseWriter, entry *Entry, err error) {
|
||||
switch {
|
||||
case errors.Is(err, ErrSelfShare):
|
||||
response.Error(w, http.StatusBadRequest, "you already have access to this")
|
||||
case err != nil:
|
||||
response.Error(w, http.StatusInternalServerError, "could not share")
|
||||
default:
|
||||
response.JSON(w, http.StatusCreated, entry)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
package acl
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
)
|
||||
|
||||
var ErrSelfShare = errors.New("cannot share with yourself")
|
||||
|
||||
// Entry ist eine Freigabe, wie sie der Eigentümer im Dialog sieht.
|
||||
type Entry struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
SubjectType string `json:"subject_type"` // "user" | "group"
|
||||
SubjectID uuid.UUID `json:"subject_id"`
|
||||
SubjectName string `json:"subject_name"`
|
||||
Permission Permission `json:"permission"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// SharedResource ist ein Eintrag in „Geteilt mit mir".
|
||||
type SharedResource struct {
|
||||
Type string `json:"type"` // "folder" | "file"
|
||||
ID uuid.UUID `json:"id"`
|
||||
Name string `json:"name"`
|
||||
OwnerID uuid.UUID `json:"owner_id"`
|
||||
OwnerName string `json:"owner_name"`
|
||||
Permission Permission `json:"permission"`
|
||||
Size int64 `json:"size"`
|
||||
MimeType string `json:"mime_type"`
|
||||
SharedAt time.Time `json:"shared_at"`
|
||||
// ViaGroup nennt die Gruppe, über die der Zugriff kommt – sonst wundert
|
||||
// man sich, wo die Freigabe herkommt, die man nie persönlich bekommen hat.
|
||||
ViaGroup string `json:"via_group"`
|
||||
}
|
||||
|
||||
// ── Freigaben verwalten ──────────────────────────────────────────────────────
|
||||
|
||||
// GrantFolder gibt einen Ordner frei. subjectIsGroup entscheidet, ob subjectID
|
||||
// ein Nutzer oder eine Gruppe ist.
|
||||
func (s *Store) GrantFolder(ctx context.Context, folderID, subjectID uuid.UUID, subjectIsGroup bool, perm Permission, grantedBy uuid.UUID) (*Entry, error) {
|
||||
return s.grant(ctx, &folderID, nil, subjectID, subjectIsGroup, perm, grantedBy)
|
||||
}
|
||||
|
||||
func (s *Store) GrantFile(ctx context.Context, fileID, subjectID uuid.UUID, subjectIsGroup bool, perm Permission, grantedBy uuid.UUID) (*Entry, error) {
|
||||
return s.grant(ctx, nil, &fileID, subjectID, subjectIsGroup, perm, grantedBy)
|
||||
}
|
||||
|
||||
func (s *Store) grant(ctx context.Context, folderID, fileID *uuid.UUID, subjectID uuid.UUID, subjectIsGroup bool, perm Permission, grantedBy uuid.UUID) (*Entry, error) {
|
||||
if !perm.valid() {
|
||||
return nil, fmt.Errorf("invalid permission %q", perm)
|
||||
}
|
||||
// Sich selbst freigeben ist sinnlos und würde in „Geteilt mit mir" die
|
||||
// eigenen Ordner doppelt anzeigen.
|
||||
if !subjectIsGroup && subjectID == grantedBy {
|
||||
return nil, ErrSelfShare
|
||||
}
|
||||
|
||||
var subjUser, subjGroup *uuid.UUID
|
||||
if subjectIsGroup {
|
||||
subjGroup = &subjectID
|
||||
} else {
|
||||
subjUser = &subjectID
|
||||
}
|
||||
|
||||
var e Entry
|
||||
err := s.db.QueryRow(ctx,
|
||||
`INSERT INTO acl_entries
|
||||
(subject_user_id, subject_group_id, resource_folder_id, resource_file_id, permission, granted_by)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
RETURNING id, permission, created_at`,
|
||||
subjUser, subjGroup, folderID, fileID, perm, grantedBy,
|
||||
).Scan(&e.ID, &e.Permission, &e.CreatedAt)
|
||||
|
||||
// Eine zweite Freigabe an denselben Empfänger ist keine neue, sondern eine
|
||||
// Änderung des Rechts – sonst müsste man erst widerrufen, um von „lesen"
|
||||
// auf „schreiben" zu wechseln. Die vier partiellen Unique-Indizes lassen
|
||||
// sich nicht in einer einzigen ON-CONFLICT-Klausel abdecken, deshalb hier
|
||||
// über den Fehlercode.
|
||||
var pgErr *pgconn.PgError
|
||||
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
|
||||
return s.updateExisting(ctx, folderID, fileID, subjUser, subjGroup, perm)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("grant access: %w", err)
|
||||
}
|
||||
|
||||
e.SubjectID = subjectID
|
||||
e.SubjectType = subjectTypeName(subjectIsGroup)
|
||||
return &e, nil
|
||||
}
|
||||
|
||||
func (s *Store) updateExisting(ctx context.Context, folderID, fileID, subjUser, subjGroup *uuid.UUID, perm Permission) (*Entry, error) {
|
||||
var e Entry
|
||||
err := s.db.QueryRow(ctx,
|
||||
`UPDATE acl_entries SET permission = $1
|
||||
WHERE resource_folder_id IS NOT DISTINCT FROM $2
|
||||
AND resource_file_id IS NOT DISTINCT FROM $3
|
||||
AND subject_user_id IS NOT DISTINCT FROM $4
|
||||
AND subject_group_id IS NOT DISTINCT FROM $5
|
||||
RETURNING id, permission, created_at`,
|
||||
perm, folderID, fileID, subjUser, subjGroup,
|
||||
).Scan(&e.ID, &e.Permission, &e.CreatedAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("update access: %w", err)
|
||||
}
|
||||
if subjGroup != nil {
|
||||
e.SubjectID, e.SubjectType = *subjGroup, "group"
|
||||
} else if subjUser != nil {
|
||||
e.SubjectID, e.SubjectType = *subjUser, "user"
|
||||
}
|
||||
return &e, nil
|
||||
}
|
||||
|
||||
// Revoke entfernt eine Freigabe. ownerID stellt sicher, dass nur der
|
||||
// Eigentümer der Ressource widerrufen kann.
|
||||
func (s *Store) Revoke(ctx context.Context, entryID, ownerID uuid.UUID) error {
|
||||
tag, err := s.db.Exec(ctx,
|
||||
`DELETE FROM acl_entries e
|
||||
WHERE e.id = $1
|
||||
AND ( EXISTS (SELECT 1 FROM folders f WHERE f.id = e.resource_folder_id AND f.owner_id = $2)
|
||||
OR EXISTS (SELECT 1 FROM files x WHERE x.id = e.resource_file_id AND x.owner_id = $2) )`,
|
||||
entryID, ownerID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("revoke access: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListForFolder / ListForFile zeigen dem Eigentümer, wer aktuell Zugriff hat.
|
||||
func (s *Store) ListForFolder(ctx context.Context, folderID uuid.UUID) ([]Entry, error) {
|
||||
return s.list(ctx, `e.resource_folder_id = $1`, folderID)
|
||||
}
|
||||
|
||||
func (s *Store) ListForFile(ctx context.Context, fileID uuid.UUID) ([]Entry, error) {
|
||||
return s.list(ctx, `e.resource_file_id = $1`, fileID)
|
||||
}
|
||||
|
||||
func (s *Store) list(ctx context.Context, where string, resourceID uuid.UUID) ([]Entry, error) {
|
||||
rows, err := s.db.Query(ctx, `
|
||||
SELECT e.id,
|
||||
CASE WHEN e.subject_group_id IS NOT NULL THEN 'group' ELSE 'user' END,
|
||||
COALESCE(e.subject_group_id, e.subject_user_id),
|
||||
COALESCE(g.name, NULLIF(u.display_name, ''), u.username, ''),
|
||||
e.permission, e.created_at
|
||||
FROM acl_entries e
|
||||
LEFT JOIN users u ON u.id = e.subject_user_id
|
||||
LEFT JOIN groups g ON g.id = e.subject_group_id
|
||||
WHERE `+where+`
|
||||
ORDER BY e.created_at`, resourceID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list access entries: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := []Entry{}
|
||||
for rows.Next() {
|
||||
var e Entry
|
||||
if err := rows.Scan(&e.ID, &e.SubjectType, &e.SubjectID, &e.SubjectName,
|
||||
&e.Permission, &e.CreatedAt); err != nil {
|
||||
return nil, fmt.Errorf("scan access entry: %w", err)
|
||||
}
|
||||
out = append(out, e)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// ListSharedWith liefert alles, was userID freigegeben bekommen hat – direkt
|
||||
// oder über eine Gruppe. Das ist der Inhalt von „Geteilt mit mir".
|
||||
//
|
||||
// Nur die obersten freigegebenen Knoten erscheinen: Wer einen Ordner und darin
|
||||
// eine Datei freigegeben bekommt, soll den Ordner sehen und die Datei darin,
|
||||
// nicht beides nebeneinander.
|
||||
func (s *Store) ListSharedWith(ctx context.Context, userID uuid.UUID) ([]SharedResource, error) {
|
||||
const q = `
|
||||
SELECT 'folder', f.id, f.name, f.owner_id,
|
||||
COALESCE(NULLIF(o.display_name, ''), o.username),
|
||||
e.permission, 0::bigint, '', e.created_at,
|
||||
COALESCE(g.name, '')
|
||||
FROM acl_entries e
|
||||
JOIN folders f ON f.id = e.resource_folder_id
|
||||
JOIN users o ON o.id = f.owner_id
|
||||
LEFT JOIN groups g ON g.id = e.subject_group_id
|
||||
WHERE (e.subject_user_id = $1
|
||||
OR e.subject_group_id IN (SELECT group_id FROM group_members WHERE user_id = $1))
|
||||
-- Ordner weglassen, deren Elternteil ohnehin schon freigegeben ist.
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM acl_entries pe
|
||||
JOIN folders pf ON pf.id = pe.resource_folder_id
|
||||
WHERE pf.id = f.parent_id
|
||||
AND (pe.subject_user_id = $1
|
||||
OR pe.subject_group_id IN (SELECT group_id FROM group_members WHERE user_id = $1))
|
||||
)
|
||||
|
||||
UNION ALL
|
||||
|
||||
SELECT 'file', x.id, x.name, x.owner_id,
|
||||
COALESCE(NULLIF(o.display_name, ''), o.username),
|
||||
e.permission, x.size, x.mime_type, e.created_at,
|
||||
COALESCE(g.name, '')
|
||||
FROM acl_entries e
|
||||
JOIN files x ON x.id = e.resource_file_id
|
||||
JOIN users o ON o.id = x.owner_id
|
||||
LEFT JOIN groups g ON g.id = e.subject_group_id
|
||||
WHERE x.is_deleted = false
|
||||
AND (e.subject_user_id = $1
|
||||
OR e.subject_group_id IN (SELECT group_id FROM group_members WHERE user_id = $1))
|
||||
-- Datei weglassen, wenn ihr Ordner bereits freigegeben ist.
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM acl_entries fe
|
||||
WHERE fe.resource_folder_id = x.folder_id
|
||||
AND (fe.subject_user_id = $1
|
||||
OR fe.subject_group_id IN (SELECT group_id FROM group_members WHERE user_id = $1))
|
||||
)
|
||||
|
||||
ORDER BY 1, 3`
|
||||
|
||||
rows, err := s.db.Query(ctx, q, userID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list shared resources: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := []SharedResource{}
|
||||
for rows.Next() {
|
||||
var r SharedResource
|
||||
if err := rows.Scan(&r.Type, &r.ID, &r.Name, &r.OwnerID, &r.OwnerName,
|
||||
&r.Permission, &r.Size, &r.MimeType, &r.SharedAt, &r.ViaGroup); err != nil {
|
||||
return nil, fmt.Errorf("scan shared resource: %w", err)
|
||||
}
|
||||
out = append(out, r)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// ── Verzeichnis für den Freigabedialog ───────────────────────────────────────
|
||||
|
||||
type Recipient struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"` // "user" | "group"
|
||||
}
|
||||
|
||||
// Recipients listet mögliche Empfänger. Bewusst nur ID, Anzeigename und Art –
|
||||
// Quota, Admin-Status und E-Mail gehen einen normalen Nutzer nichts an, auch
|
||||
// wenn er teilen darf.
|
||||
func (s *Store) Recipients(ctx context.Context, exclude uuid.UUID) ([]Recipient, error) {
|
||||
rows, err := s.db.Query(ctx, `
|
||||
SELECT id, COALESCE(NULLIF(display_name, ''), username), 'user'
|
||||
FROM users WHERE id <> $1
|
||||
UNION ALL
|
||||
SELECT id, name, 'group' FROM groups
|
||||
ORDER BY 3 DESC, 2`, exclude)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list recipients: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := []Recipient{}
|
||||
for rows.Next() {
|
||||
var r Recipient
|
||||
if err := rows.Scan(&r.ID, &r.Name, &r.Type); err != nil {
|
||||
return nil, fmt.Errorf("scan recipient: %w", err)
|
||||
}
|
||||
out = append(out, r)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func subjectTypeName(isGroup bool) string {
|
||||
if isGroup {
|
||||
return "group"
|
||||
}
|
||||
return "user"
|
||||
}
|
||||
Reference in new issue
Block a user