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