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

@@ -0,0 +1,115 @@
package admin
import (
"encoding/json"
"errors"
"net/http"
"strings"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"github.com/rs/zerolog/log"
"aether/internal/audit"
"aether/internal/files"
"aether/pkg/response"
)
// Gruppenordner werden ausschließlich hier verwaltet. Bewusst nicht in der
// Dateiansicht: Ein Gruppenordner gehört keinem der Mitglieder, also soll ihn
// auch keines von ihnen entfernen können.
// GET /api/v1/admin/group-folders
func (h *Handler) ListGroupFolders(w http.ResponseWriter, r *http.Request) {
if _, ok := requireAdmin(r); !ok {
response.Error(w, http.StatusForbidden, "admin required")
return
}
list, err := h.files.ListGroupFolders(r.Context())
if err != nil {
response.Error(w, http.StatusInternalServerError, "could not load group folders")
return
}
response.JSON(w, http.StatusOK, list)
}
// POST /api/v1/admin/group-folders
func (h *Handler) CreateGroupFolder(w http.ResponseWriter, r *http.Request) {
if _, ok := requireAdmin(r); !ok {
response.Error(w, http.StatusForbidden, "admin required")
return
}
var body struct {
GroupID string `json:"group_id"`
Name string `json:"name"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
response.Error(w, http.StatusBadRequest, "invalid body")
return
}
groupID, err := uuid.Parse(body.GroupID)
if err != nil {
response.Error(w, http.StatusBadRequest, "invalid group_id")
return
}
// Gleiche Namensregeln wie überall – der Name wird Teil des Ablagepfads.
name := files.SanitizeFilename(strings.TrimSpace(body.Name))
if name == "" {
response.Error(w, http.StatusBadRequest, "name required")
return
}
folder, err := h.files.CreateGroupFolder(r.Context(), groupID, name)
if errors.Is(err, files.ErrGroupFolderExists) {
response.Error(w, http.StatusConflict, "this group already has a folder")
return
}
if errors.Is(err, files.ErrNameTaken) {
response.Error(w, http.StatusConflict, "a folder with that name already exists")
return
}
if err != nil {
response.Error(w, http.StatusInternalServerError, "could not create group folder")
return
}
h.logAdmin(r, audit.ActionGroupFolderCreate, "folder", folder.ID.String(), folder.Name,
"group "+groupID.String())
response.JSON(w, http.StatusCreated, folder)
}
// DELETE /api/v1/admin/group-folders/{id}
func (h *Handler) DeleteGroupFolder(w http.ResponseWriter, r *http.Request) {
if _, ok := requireAdmin(r); !ok {
response.Error(w, http.StatusForbidden, "admin required")
return
}
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
response.Error(w, http.StatusBadRequest, "invalid folder id")
return
}
groupID, err := h.files.DeleteGroupFolderTree(r.Context(), id)
if errors.Is(err, files.ErrNotFound) {
response.Error(w, http.StatusNotFound, "group folder not found")
return
}
if err != nil {
response.Error(w, http.StatusInternalServerError, "could not delete group folder")
return
}
// Speicher erst nach dem Datenbank-Commit, und am Stück statt Blob für
// Blob: Da eine Gruppe genau einen Ordner hat, ist `groups/<id>` exakt ihr
// Bereich – inklusive Papierkorb und Versionen. Einzeln gelöscht blieben
// leere Verzeichnisse zurück.
if err := h.storage.DeletePrefix(r.Context(), "groups/"+groupID.String()); err != nil {
log.Ctx(r.Context()).Warn().Err(err).Str("group_id", groupID.String()).
Msg("group folder deleted but storage remains")
}
h.logAdmin(r, audit.ActionGroupFolderDelete, "folder", id.String(), "",
"group "+groupID.String()+" and all its files")
w.WriteHeader(http.StatusNoContent)
}