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:
commit
31a027c93a
225 files changed
+38042
No files matched your search
@@ -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)
|
||||
}
|
||||
Reference in new issue
Block a user