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