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

+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)
}
}