Files
Aether/backend/internal/acl/handler.go
T
HoneyandClaude Opus 5 31a027c93a 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>
2026-08-24 09:25:36 +02:00

264 lines
7.9 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
}