Files
goclaw/internal/http/workspace_upload.go
viettranx 4d31fe0dda fix(workspace): fix file move bugs, migrate DnD to @dnd-kit
- Fix cross-scope move in team workspace "All" view (prevented invalid
  nested paths by detecting mismatched chat_id scopes)
- Add error toast on move failure instead of silent catch
- Auto-create destination subdirectories on move (os.MkdirAll)
- Add admin bypass for team workspace HTTP auth (matching RPC pattern)
- Clear stale activePath after move in storage page
- Migrate file tree DnD from native HTML5 to @dnd-kit with:
  - PointerSensor (distance=8 prevents accidental drags)
  - DragOverlay via portal (fixes offset in Radix Dialog)
  - Folder auto-expand on 800ms hover during drag
- Silent refresh after move to prevent tree flash/scroll loss
2026-03-25 22:17:33 +07:00

311 lines
11 KiB
Go

package http
import (
"fmt"
"io"
"log/slog"
"net/http"
"os"
"path/filepath"
"strings"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/bus"
"github.com/nextlevelbuilder/goclaw/internal/channels/media"
"github.com/nextlevelbuilder/goclaw/internal/config"
"github.com/nextlevelbuilder/goclaw/internal/i18n"
"github.com/nextlevelbuilder/goclaw/internal/permissions"
"github.com/nextlevelbuilder/goclaw/internal/store"
"github.com/nextlevelbuilder/goclaw/internal/tools"
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
)
// WorkspaceUploadHandler handles file uploads to team workspaces.
type WorkspaceUploadHandler struct {
teamStore store.TeamStore
dataDir string
msgBus *bus.MessageBus
}
// NewWorkspaceUploadHandler creates a new workspace upload handler.
func NewWorkspaceUploadHandler(teamStore store.TeamStore, dataDir string, msgBus *bus.MessageBus) *WorkspaceUploadHandler {
return &WorkspaceUploadHandler{teamStore: teamStore, dataDir: dataDir, msgBus: msgBus}
}
// RegisterRoutes registers workspace file management endpoints.
func (h *WorkspaceUploadHandler) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("POST /v1/teams/{teamId}/workspace/upload", requireAuth("", h.handleUpload))
mux.HandleFunc("PUT /v1/teams/{teamId}/workspace/move", requireAuth("", h.handleMove))
}
func (h *WorkspaceUploadHandler) handleUpload(w http.ResponseWriter, r *http.Request) {
locale := extractLocale(r)
ctx := r.Context()
if h.teamStore == nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgTeamsNotConfigured)})
return
}
// Parse team ID from path.
teamID, err := uuid.Parse(r.PathValue("teamId"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid team_id"})
return
}
// Team membership check (admins bypass — same pattern as RPC handlers).
userID := store.UserIDFromContext(ctx)
role := permissions.Role(store.RoleFromContext(ctx))
if !permissions.HasMinRole(role, permissions.RoleAdmin) {
if has, err := h.teamStore.HasTeamAccess(ctx, teamID, userID); err != nil || !has {
writeJSON(w, http.StatusForbidden, map[string]string{"error": i18n.T(locale, i18n.MsgPermissionDenied, "team workspace")})
return
}
}
// Determine workspace mode (shared vs isolated).
chatID := r.URL.Query().Get("chat_id")
team, err := h.teamStore.GetTeam(ctx, teamID)
if err != nil {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "team not found"})
return
}
shared := tools.IsSharedWorkspace(team.Settings)
if !shared && chatID == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgRequired, "chat_id")})
return
}
if shared {
chatID = "" // shared mode ignores chat_id
}
// Enforce file size limit at HTTP level.
r.Body = http.MaxBytesReader(w, r.Body, tools.MaxFileSizeBytes)
if err := r.ParseMultipartForm(tools.MaxFileSizeBytes); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgFileTooLarge)})
return
}
file, header, err := r.FormFile("file")
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgMissingFileField)})
return
}
defer file.Close()
// Sanitize filename.
origName := filepath.Base(header.Filename)
if origName == "." || origName == "/" || strings.Contains(origName, "..") {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidFilename)})
return
}
// Check blocked extensions.
ext := strings.ToLower(filepath.Ext(origName))
if tools.IsBlockedExtension(ext) {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": fmt.Sprintf("file type %s is not allowed", ext)})
return
}
// Resolve tenant-scoped workspace directory.
tenantID := store.TenantIDFromContext(ctx)
slug := store.TenantSlugFromContext(ctx)
scopeDir := config.TenantTeamDir(h.dataDir, tenantID, slug, teamID)
if chatID != "" {
scopeDir = filepath.Join(scopeDir, chatID)
}
if err := os.MkdirAll(scopeDir, 0750); err != nil {
slog.Error("workspace_upload: mkdir failed", "dir", scopeDir, "error", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgInternalError, "failed to create directory")})
return
}
// File quota check.
entries, _ := os.ReadDir(scopeDir)
fileCount := 0
for _, e := range entries {
if !e.IsDir() {
fileCount++
}
}
if fileCount >= tools.MaxFilesPerScope {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": fmt.Sprintf("workspace file limit reached (%d files)", tools.MaxFilesPerScope)})
return
}
// Path boundary check (symlink escape prevention).
diskPath := filepath.Clean(filepath.Join(scopeDir, origName))
scopeReal, _ := filepath.EvalSymlinks(filepath.Clean(scopeDir))
if scopeReal == "" {
scopeReal = filepath.Clean(scopeDir)
}
if !strings.HasPrefix(diskPath, scopeReal+string(filepath.Separator)) && diskPath != scopeReal {
slog.Warn("security.workspace_upload_escape", "path", origName, "scope", scopeReal)
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidFilename)})
return
}
// Write file to disk.
out, err := os.Create(diskPath)
if err != nil {
slog.Error("workspace_upload: create file failed", "path", diskPath, "error", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgInternalError, "failed to save file")})
return
}
defer out.Close()
written, err := io.Copy(out, file)
if err != nil {
os.Remove(diskPath)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgInternalError, "failed to save file")})
return
}
// Broadcast workspace file change event for real-time UI updates.
if h.msgBus != nil {
bus.BroadcastForTenant(h.msgBus, protocol.EventWorkspaceFileChanged, tenantID, map[string]string{
"team_id": teamID.String(),
"chat_id": chatID,
"file_name": origName,
"action": "created",
})
}
mimeType := media.DetectMIMEType(origName)
slog.Info("workspace_upload: file uploaded", "team", teamID, "chat_id", chatID, "file", origName, "size", written)
writeJSON(w, http.StatusOK, map[string]any{
"path": diskPath,
"filename": origName,
"size": written,
"mime_type": mimeType,
})
}
// handleMove moves/renames a file within a team workspace.
// Query params: ?from=name&to=name&chat_id=xxx
func (h *WorkspaceUploadHandler) handleMove(w http.ResponseWriter, r *http.Request) {
locale := extractLocale(r)
ctx := r.Context()
if h.teamStore == nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgTeamsNotConfigured)})
return
}
teamID, err := uuid.Parse(r.PathValue("teamId"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid team_id"})
return
}
// Team membership check (admins bypass — same pattern as RPC handlers).
userID := store.UserIDFromContext(ctx)
role := permissions.Role(store.RoleFromContext(ctx))
if !permissions.HasMinRole(role, permissions.RoleAdmin) {
if has, err := h.teamStore.HasTeamAccess(ctx, teamID, userID); err != nil || !has {
writeJSON(w, http.StatusForbidden, map[string]string{"error": i18n.T(locale, i18n.MsgPermissionDenied, "team workspace")})
return
}
}
fromName := r.URL.Query().Get("from")
toName := r.URL.Query().Get("to")
if fromName == "" || toName == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgRequired, "from, to")})
return
}
// Reject path traversal.
if strings.Contains(fromName, "..") || strings.Contains(toName, "..") ||
strings.Contains(fromName, "\\") || strings.Contains(toName, "\\") {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidPath)})
return
}
// Resolve workspace scope.
chatID := r.URL.Query().Get("chat_id")
team, err := h.teamStore.GetTeam(ctx, teamID)
if err != nil {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "team not found"})
return
}
shared := tools.IsSharedWorkspace(team.Settings)
if !shared && chatID == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgRequired, "chat_id")})
return
}
if shared {
chatID = ""
}
tenantID := store.TenantIDFromContext(ctx)
slug := store.TenantSlugFromContext(ctx)
scopeDir := config.TenantTeamDir(h.dataDir, tenantID, slug, teamID)
if chatID != "" {
scopeDir = filepath.Join(scopeDir, chatID)
}
// Resolve and validate source.
srcPath := filepath.Clean(filepath.Join(scopeDir, fromName))
scopeReal, _ := filepath.EvalSymlinks(filepath.Clean(scopeDir))
if scopeReal == "" {
scopeReal = filepath.Clean(scopeDir)
}
srcReal, err := filepath.EvalSymlinks(srcPath)
if err != nil {
writeJSON(w, http.StatusNotFound, map[string]string{"error": fmt.Sprintf("file not found: %s", fromName)})
return
}
if !strings.HasPrefix(srcReal, scopeReal+string(filepath.Separator)) && srcReal != scopeReal {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidPath)})
return
}
// Resolve and validate destination.
destPath := filepath.Clean(filepath.Join(scopeDir, toName))
if !strings.HasPrefix(destPath, scopeReal+string(filepath.Separator)) && destPath != scopeReal {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidPath)})
return
}
// Auto-create destination parent directories (security already validated above).
destDir := filepath.Dir(destPath)
if err := os.MkdirAll(destDir, 0750); err != nil {
slog.Error("workspace_move: mkdir failed", "dir", destDir, "error", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgInternalError, "failed to create directory")})
return
}
// Prevent overwriting.
if _, err := os.Stat(destPath); err == nil {
writeJSON(w, http.StatusConflict, map[string]string{"error": "a file with that name already exists at the destination"})
return
}
if err := os.Rename(srcPath, destPath); err != nil {
slog.Error("workspace_move: failed", "from", fromName, "to", toName, "error", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgInternalError, "failed to move file")})
return
}
// Broadcast change event.
if h.msgBus != nil {
bus.BroadcastForTenant(h.msgBus, protocol.EventWorkspaceFileChanged, tenantID, map[string]string{
"team_id": teamID.String(),
"chat_id": chatID,
"file_name": filepath.Base(toName),
"action": "moved",
})
}
slog.Info("workspace_move: file moved", "team", teamID, "from", fromName, "to", toName)
writeJSON(w, http.StatusOK, map[string]any{
"from": fromName,
"to": toName,
})
}