Files
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

274 lines
7.7 KiB
Go
Raw Permalink 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 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)
}