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>
264 lines
7.9 KiB
Go
264 lines
7.9 KiB
Go
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)
|
||
}
|
||
}
|