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

+351
View File
@@ -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, &quota)
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, &quota, &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 ""
}
+60
View File
@@ -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")
}
}
+263
View File
@@ -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)
}
}
+288
View File
@@ -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"
}