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

+273
View File
@@ -0,0 +1,273 @@
package files
import (
"encoding/json"
"errors"
"fmt"
_ "image/gif"
_ "image/jpeg"
_ "image/png"
"net/http"
"strings"
"github.com/google/uuid"
"aether/pkg/response"
)
// Ordner anlegen, umbenennen, verschieben und löschen.
//
// Umbenennen und Verschieben ziehen die Blobs auf der Platte mit – der
// Speicherpfad soll lesbar bleiben und dem Ordnerbaum entsprechen.
// POST /api/v1/folders
func (h *Handler) CreateFolder(w http.ResponseWriter, r *http.Request) {
var body struct {
Name string `json:"name"`
ParentID *string `json:"parent_id"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || strings.TrimSpace(body.Name) == "" {
response.Error(w, http.StatusBadRequest, "name required")
return
}
// Ordnernamen werden Teil des Storage-Pfads – gleiche Regeln wie für Dateien.
folderName := SanitizeFilename(body.Name)
if folderName == "" {
response.Error(w, http.StatusBadRequest, "name required")
return
}
// Ein Unterordner in einem geliehenen Ordner gehört dem Eigentümer des
// Elternordners, nicht dem, der ihn anlegt: Die Bytes liegen in dessen
// Bereich, und beim Entzug der Freigabe soll er nicht plötzlich fremde
// Ordner in seinem Baum haben.
var parentID *uuid.UUID
if body.ParentID != nil && *body.ParentID != "" {
pid, err := uuid.Parse(*body.ParentID)
if err != nil {
response.Error(w, http.StatusBadRequest, "invalid parent_id")
return
}
parentID = &pid
}
owner, ok := h.resolveFolder(w, r, parentID, true)
if !ok {
return
}
f := &Folder{
OwnerID: owner.OwnerRef(),
GroupID: owner.GroupRef(),
Name: folderName,
ParentID: parentID,
}
created, err := h.store.CreateFolder(r.Context(), f)
if errors.Is(err, ErrNameTaken) {
response.Error(w, http.StatusConflict, "folder name already exists")
return
}
if err != nil {
response.Error(w, http.StatusInternalServerError, "database error")
return
}
response.JSON(w, http.StatusCreated, created)
}
// PATCH /api/v1/folders/{id}/rename
func (h *Handler) RenameFolder(w http.ResponseWriter, r *http.Request) {
id, err := parseID(r, "id")
if err != nil {
response.Error(w, http.StatusBadRequest, "invalid folder id")
return
}
owner, ok := h.resolveFolder(w, r, &id, true)
if !ok {
return
}
// Der Wurzelordner einer Gruppe ist kein gewöhnlicher Ordner, sondern der
// Zugang der Gruppe. Verwaltet wird er im Admin-Bereich.
if isRoot, err := h.store.IsGroupRoot(r.Context(), id); err != nil {
response.Error(w, http.StatusInternalServerError, "database error")
return
} else if isRoot {
response.Error(w, http.StatusForbidden, ErrGroupRootProtected.Error())
return
}
var body struct {
Name string `json:"name"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || strings.TrimSpace(body.Name) == "" {
response.Error(w, http.StatusBadRequest, "name required")
return
}
newName := SanitizeFilename(body.Name)
if newName == "" {
response.Error(w, http.StatusBadRequest, "name required")
return
}
// Collect files before renaming so we have the old storage keys.
filesToMove, err := h.store.ListFilesInSubtree(r.Context(), owner.OwnerID, id)
if err != nil {
response.Error(w, http.StatusInternalServerError, "database error")
return
}
// Rename the folder in DB first — GetFolderPath will now return the new path.
folder, err := h.store.RenameFolder(r.Context(), id, owner.OwnerID, newName)
if errors.Is(err, ErrNotFound) {
response.Error(w, http.StatusNotFound, "folder not found")
return
}
if errors.Is(err, ErrNameTaken) {
response.Error(w, http.StatusConflict, "name already exists")
return
}
if err != nil {
response.Error(w, http.StatusInternalServerError, "database error")
return
}
// Cascade: move each blob to the updated path.
ResyncBlobPaths(r.Context(), h.store, h.storage, owner.StorageRoot(), filesToMove)
response.JSON(w, http.StatusOK, folder)
}
// DELETE /api/v1/folders/{id}
func (h *Handler) DeleteFolder(w http.ResponseWriter, r *http.Request) {
id, err := parseID(r, "id")
if err != nil {
response.Error(w, http.StatusBadRequest, "invalid folder id")
return
}
owner, ok := h.resolveFolder(w, r, &id, true)
if !ok {
return
}
// Der Wurzelordner einer Gruppe ist kein gewöhnlicher Ordner, sondern der
// Zugang der Gruppe. Verwaltet wird er im Admin-Bereich.
if isRoot, err := h.store.IsGroupRoot(r.Context(), id); err != nil {
response.Error(w, http.StatusInternalServerError, "database error")
return
} else if isRoot {
response.Error(w, http.StatusForbidden, ErrGroupRootProtected.Error())
return
}
_, err = h.store.GetFolder(r.Context(), id, owner.OwnerID)
if errors.Is(err, ErrNotFound) {
response.Error(w, http.StatusNotFound, "folder not found")
return
}
if err != nil {
response.Error(w, http.StatusInternalServerError, "database error")
return
}
// Move blobs to .trash/ before soft-deleting so the file system stays consistent.
filesToTrash, err := h.store.ListFilesInSubtree(r.Context(), owner.OwnerID, id)
if err != nil {
response.Error(w, http.StatusInternalServerError, "database error")
return
}
for _, f := range filesToTrash {
trashKey := fmt.Sprintf("%s/.trash/%s_%s", owner.StorageRoot(), f.ID.String()[:8], f.Name)
if err := h.storage.Move(r.Context(), f.StorageKey, trashKey); err != nil {
continue
}
_ = h.store.UpdateFileStorageKey(r.Context(), f.ID, trashKey)
}
if err := h.store.DeleteFolder(r.Context(), id, owner.OwnerID); err != nil {
response.Error(w, http.StatusInternalServerError, "database error")
return
}
w.WriteHeader(http.StatusNoContent)
}
// PATCH /api/v1/folders/{id}/move
func (h *Handler) MoveFolder(w http.ResponseWriter, r *http.Request) {
id, err := parseID(r, "id")
if err != nil {
response.Error(w, http.StatusBadRequest, "invalid folder id")
return
}
owner, ok := h.resolveFolder(w, r, &id, true)
if !ok {
return
}
// Der Wurzelordner einer Gruppe ist kein gewöhnlicher Ordner, sondern der
// Zugang der Gruppe. Verwaltet wird er im Admin-Bereich.
if isRoot, err := h.store.IsGroupRoot(r.Context(), id); err != nil {
response.Error(w, http.StatusInternalServerError, "database error")
return
} else if isRoot {
response.Error(w, http.StatusForbidden, ErrGroupRootProtected.Error())
return
}
var body struct {
ParentID *uuid.UUID `json:"parent_id"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
response.Error(w, http.StatusBadRequest, "invalid body")
return
}
// Reject self-move.
if body.ParentID != nil && *body.ParentID == id {
response.Error(w, http.StatusBadRequest, "cannot move folder into itself")
return
}
// Verify target folder exists and is owned by user.
if body.ParentID != nil {
if _, err := h.store.GetFolder(r.Context(), *body.ParentID, owner.OwnerID); err != nil {
response.Error(w, http.StatusNotFound, "target folder not found")
return
}
}
// Collect files before moving so we have the old storage keys.
filesToMove, err := h.store.ListFilesInSubtree(r.Context(), owner.OwnerID, id)
if err != nil {
response.Error(w, http.StatusInternalServerError, "database error")
return
}
folder, err := h.store.MoveFolder(r.Context(), id, owner.OwnerID, body.ParentID)
if errors.Is(err, ErrNotFound) {
response.Error(w, http.StatusNotFound, "folder not found")
return
}
if errors.Is(err, ErrCycle) {
response.Error(w, http.StatusBadRequest, "cannot move folder into its own subtree")
return
}
if errors.Is(err, ErrNameTaken) {
response.Error(w, http.StatusConflict, "name already exists in target folder")
return
}
if err != nil {
response.Error(w, http.StatusInternalServerError, "database error")
return
}
// Cascade blob moves to updated paths.
ResyncBlobPaths(r.Context(), h.store, h.storage, owner.StorageRoot(), filesToMove)
response.JSON(w, http.StatusOK, folder)
}