package http import ( "encoding/json" "fmt" "io" "log/slog" "mime" "net/http" "os" "path/filepath" "strconv" "strings" "sync" "time" "github.com/nextlevelbuilder/goclaw/internal/config" "github.com/nextlevelbuilder/goclaw/internal/i18n" "github.com/nextlevelbuilder/goclaw/internal/permissions" "github.com/nextlevelbuilder/goclaw/internal/skills" "github.com/nextlevelbuilder/goclaw/internal/store" "github.com/nextlevelbuilder/goclaw/internal/tools" ) // StorageHandler provides HTTP endpoints for browsing and managing // files inside the ~/.goclaw/ data directory. // Skills directories are browsable (read-only) but deletion is blocked. // sizeCacheEntry holds a cached storage size calculation for one tenant. type sizeCacheEntry struct { total int64 files int cachedAt time.Time } type StorageHandler struct { baseDir string // global data dir (resolved absolute path to ~/.goclaw/) tenants store.TenantStore // sizeCache caches the total storage size per tenant for 60 minutes. sizeCache sync.Map // tenantBaseDir (string) → *sizeCacheEntry } // NewStorageHandler creates a handler for workspace storage management. func NewStorageHandler(baseDir string, tenants ...store.TenantStore) *StorageHandler { h := &StorageHandler{baseDir: baseDir} if len(tenants) > 0 { h.tenants = tenants[0] } return h } // RegisterRoutes registers storage management routes on the given mux. func (h *StorageHandler) RegisterRoutes(mux *http.ServeMux) { mux.HandleFunc("GET /v1/storage/files", h.auth(h.handleList)) mux.HandleFunc("GET /v1/storage/files/{path...}", h.auth(h.handleRead)) mux.HandleFunc("DELETE /v1/storage/files/{path...}", requireAuth(permissions.RoleAdmin, h.requireTenantAdmin(h.handleDelete))) mux.HandleFunc("GET /v1/storage/size", h.auth(h.handleSize)) mux.HandleFunc("POST /v1/storage/files", requireAuth(permissions.RoleAdmin, h.requireTenantAdmin(h.handleUpload))) mux.HandleFunc("PUT /v1/storage/move", requireAuth(permissions.RoleAdmin, h.requireTenantAdmin(h.handleMove))) } func (h *StorageHandler) auth(next http.HandlerFunc) http.HandlerFunc { return requireAuth("", next) } func (h *StorageHandler) requireTenantAdmin(next http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if pkgGatewayToken == "" && store.TenantIDFromContext(r.Context()) == store.MasterTenantID { next(w, r) return } if !requireTenantAdmin(w, r, h.tenants) { return } next(w, r) } } // tenantBaseDir resolves the data directory scoped to the requesting tenant. // Master tenant returns the global baseDir (backward compat). func (h *StorageHandler) tenantBaseDir(r *http.Request) string { tid := store.TenantIDFromContext(r.Context()) slug := store.TenantSlugFromContext(r.Context()) return config.TenantDataDir(h.baseDir, tid, slug) } // protectedDirs are top-level directories where upload, move, and deletion are blocked. // These are system-managed: skills (managed via Skills page), media (managed via media handler), // tenants (tenant isolation root — each tenant's data is scoped internally). var protectedDirs = []string{"skills", "skills-store", "media", "tenants"} // topLevelPath returns the first path component of rel. func topLevelPath(rel string) string { if before, _, ok := strings.Cut(rel, "/"); ok { return before } return rel } func isProtectedPath(rel string) bool { top := topLevelPath(rel) for _, d := range protectedDirs { if strings.EqualFold(top, d) { return true } } return false } // isHiddenPath reports paths that should not be surfaced in the Storage UI/API. // Master tenant keeps its legacy base dir for backward compatibility, but must // not expose the cross-tenant isolation root. func (h *StorageHandler) isHiddenPath(r *http.Request, rel string) bool { if rel == "" { return false } if store.TenantIDFromContext(r.Context()) != store.MasterTenantID { return false } return strings.EqualFold(topLevelPath(rel), "tenants") } func pathWithinDir(path, dir string) bool { rel, err := filepath.Rel(dir, path) if err != nil { return false } return rel == "." || (rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))) } func evalSymlinkOrClean(path string) string { realPath, err := filepath.EvalSymlinks(path) if err == nil { return filepath.Clean(realPath) } return filepath.Clean(path) } func (h *StorageHandler) isHiddenRealPath(r *http.Request, base, realPath string) bool { if store.TenantIDFromContext(r.Context()) != store.MasterTenantID { return false } realTenantRoot, err := filepath.EvalSymlinks(filepath.Join(base, "tenants")) if err != nil { return false } return pathWithinDir(filepath.Clean(realPath), filepath.Clean(realTenantRoot)) } func (h *StorageHandler) validateExistingStoragePath(r *http.Request, base, absPath string) bool { realBase := evalSymlinkOrClean(base) realPath, err := filepath.EvalSymlinks(absPath) if err != nil { return false } realPath = filepath.Clean(realPath) if !pathWithinDir(realPath, realBase) { slog.Warn("security.storage_symlink_escape", "resolved", realPath, "base", realBase) return false } if h.isHiddenRealPath(r, base, realPath) { slog.Warn("security.storage_hidden_symlink_path", "resolved", realPath, "base", realBase) return false } return true } func (h *StorageHandler) validateStorageParent(r *http.Request, base, parent string) bool { realBase := evalSymlinkOrClean(base) current := filepath.Clean(parent) for { if realParent, err := filepath.EvalSymlinks(current); err == nil { realParent = filepath.Clean(realParent) if !pathWithinDir(realParent, realBase) { slog.Warn("security.storage_parent_escape", "resolved", realParent, "base", realBase) return false } if h.isHiddenRealPath(r, base, realParent) { slog.Warn("security.storage_hidden_parent", "resolved", realParent, "base", realBase) return false } return true } next := filepath.Dir(current) if next == current { return false } current = next } } // handleList lists files and directories under ~/.goclaw/ with depth limiting. // Query params: // - ?path= scopes the listing to a subtree // - ?depth= max depth to walk (default 3, max 20) func (h *StorageHandler) handleList(w http.ResponseWriter, r *http.Request) { locale := extractLocale(r) subPath := r.URL.Query().Get("path") if strings.Contains(subPath, "..") { slog.Warn("security.storage_traversal", "path", subPath) writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidPath)}) return } maxDepth := 3 if d := r.URL.Query().Get("depth"); d != "" { if v, err := strconv.Atoi(d); err == nil && v >= 1 && v <= 20 { maxDepth = v } } base := h.tenantBaseDir(r) rootDir := base if subPath != "" { if h.isHiddenPath(r, subPath) { writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgNotFound, "path", subPath)}) return } rootDir = filepath.Join(base, filepath.Clean(subPath)) if !strings.HasPrefix(rootDir, base) { slog.Warn("security.storage_escape", "resolved", rootDir, "root", base) writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidPath)}) return } } type fileEntry struct { Path string `json:"path"` Name string `json:"name"` IsDir bool `json:"isDir"` Size int64 `json:"size"` HasChildren bool `json:"hasChildren,omitempty"` Protected bool `json:"protected"` } var entries []fileEntry filepath.WalkDir(rootDir, func(path string, d os.DirEntry, err error) error { if err != nil { return nil } if path == rootDir { return nil } rel, _ := filepath.Rel(base, path) // Hide tenant isolation root from master storage listing. if h.isHiddenPath(r, rel) { if d.IsDir() { return filepath.SkipDir } return nil } // Skip symlinks if d.Type()&os.ModeSymlink != 0 { return nil } // Skip system artifacts if skills.IsSystemArtifact(rel) { if d.IsDir() { return filepath.SkipDir } return nil } // Calculate depth relative to rootDir relToRoot, _ := filepath.Rel(rootDir, path) depth := strings.Count(relToRoot, string(filepath.Separator)) + 1 // Beyond depth boundary: record the dir (with hasChildren hint) but don't descend. if d.IsDir() && depth > maxDepth { e := fileEntry{ Path: rel, Name: d.Name(), IsDir: true, Protected: isProtectedPath(rel), } if dirEntries, err := os.ReadDir(path); err == nil && len(dirEntries) > 0 { e.HasChildren = true } entries = append(entries, e) return filepath.SkipDir } entry := fileEntry{ Path: rel, Name: d.Name(), IsDir: d.IsDir(), } if !d.IsDir() { if info, err := d.Info(); err == nil { entry.Size = info.Size() } } // For directories at max depth, check if they have children if d.IsDir() && depth == maxDepth { if dirEntries, err := os.ReadDir(path); err == nil && len(dirEntries) > 0 { entry.HasChildren = true } } entry.Protected = isProtectedPath(rel) entries = append(entries, entry) return nil }) if entries == nil { entries = []fileEntry{} } writeJSON(w, http.StatusOK, map[string]any{ "files": entries, "baseDir": base, }) } // sizeCacheTTL is how long storage size calculations are cached. const sizeCacheTTL = 60 * time.Minute // handleSize streams the total storage size via SSE. // Cached for 60 minutes; returns cached result immediately if valid. func (h *StorageHandler) handleSize(w http.ResponseWriter, r *http.Request) { flusher, ok := w.(http.Flusher) if !ok { locale := extractLocale(r) writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgStreamingNotSupported)}) return } w.Header().Set("Content-Type", "text/event-stream") w.Header().Set("Cache-Control", "no-cache") w.Header().Set("Connection", "keep-alive") w.WriteHeader(http.StatusOK) sizeBase := h.tenantBaseDir(r) // Check per-tenant cache if entry, ok := h.sizeCache.Load(sizeBase); ok { ce := entry.(*sizeCacheEntry) if time.Since(ce.cachedAt) < sizeCacheTTL { writeSizeEvent(w, flusher, map[string]any{"total": ce.total, "files": ce.files, "done": true, "cached": true}) return } } // Walk and stream progress var total int64 var fileCount int lastFlush := time.Now() filepath.WalkDir(sizeBase, func(path string, d os.DirEntry, err error) error { if err != nil { return nil } rel, _ := filepath.Rel(sizeBase, path) // Skip hidden tenant root before d.IsDir() so we can SkipDir. if h.isHiddenPath(r, rel) { if d.IsDir() { return filepath.SkipDir } return nil } if d.IsDir() { return nil } if r.Context().Err() != nil { return filepath.SkipAll } if d.Type()&os.ModeSymlink != 0 { return nil } if skills.IsSystemArtifact(rel) { return nil } if info, err := d.Info(); err == nil { total += info.Size() fileCount++ } if fileCount%50 == 0 || time.Since(lastFlush) > 200*time.Millisecond { writeSizeEvent(w, flusher, map[string]any{"current": total, "files": fileCount}) lastFlush = time.Now() } return nil }) // Update per-tenant cache h.sizeCache.Store(sizeBase, &sizeCacheEntry{total: total, files: fileCount, cachedAt: time.Now()}) // Send final event writeSizeEvent(w, flusher, map[string]any{"total": total, "files": fileCount, "done": true, "cached": false}) } func writeSizeEvent(w http.ResponseWriter, flusher http.Flusher, data map[string]any) { jsonData, _ := json.Marshal(data) fmt.Fprintf(w, "data: %s\n\n", jsonData) flusher.Flush() } // handleRead reads a single file's content by relative path. func (h *StorageHandler) handleRead(w http.ResponseWriter, r *http.Request) { locale := extractLocale(r) relPath := r.PathValue("path") if relPath == "" { writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgRequired, "path")}) return } if strings.Contains(relPath, "..") { slog.Warn("security.storage_traversal", "path", relPath) writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidPath)}) return } if h.isHiddenPath(r, relPath) { writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgFileNotFound)}) return } readBase := h.tenantBaseDir(r) absPath := filepath.Join(readBase, filepath.Clean(relPath)) if !strings.HasPrefix(absPath, readBase+string(filepath.Separator)) { slog.Warn("security.storage_escape", "resolved", absPath, "root", readBase) writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidPath)}) return } info, err := os.Lstat(absPath) if err != nil || info.IsDir() { writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgFileNotFound)}) return } if info.Mode()&os.ModeSymlink != 0 { slog.Warn("security.storage_symlink", "path", absPath) writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidPath)}) return } if !h.validateExistingStoragePath(r, readBase, absPath) { writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgFileNotFound)}) return } data, err := os.ReadFile(absPath) if err != nil { writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgFailedToReadFile)}) return } // Raw mode: serve the file with its native content type (for images, downloads, etc.) if r.URL.Query().Get("raw") == "true" { ct := mime.TypeByExtension(filepath.Ext(absPath)) if ct == "" { ct = http.DetectContentType(data) } w.Header().Set("Content-Type", ct) w.Header().Set("Cache-Control", "private, max-age=300") if r.URL.Query().Get("download") == "true" { w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filepath.Base(absPath))) } w.Header().Set("Content-Length", strconv.FormatInt(info.Size(), 10)) w.Write(data) return } writeJSON(w, http.StatusOK, map[string]any{ "content": string(data), "path": relPath, "size": info.Size(), }) } // handleDelete removes a file or directory (recursively). // Rejects deletion of the root dir and any path inside excluded directories. func (h *StorageHandler) handleDelete(w http.ResponseWriter, r *http.Request) { locale := extractLocale(r) relPath := r.PathValue("path") if relPath == "" { writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgRequired, "path")}) return } if strings.Contains(relPath, "..") { slog.Warn("security.storage_traversal", "path", relPath) writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidPath)}) return } if isProtectedPath(relPath) { writeJSON(w, http.StatusForbidden, map[string]string{"error": i18n.T(locale, i18n.MsgCannotDeleteSkillsDir)}) return } delBase := h.tenantBaseDir(r) absPath := filepath.Join(delBase, filepath.Clean(relPath)) if !strings.HasPrefix(absPath, delBase+string(filepath.Separator)) { slog.Warn("security.storage_escape", "resolved", absPath, "root", delBase) writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidPath)}) return } // Verify path exists info, err := os.Lstat(absPath) if err != nil { writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgNotFound, "path", relPath)}) return } if !h.validateExistingStoragePath(r, delBase, absPath) { writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgNotFound, "path", relPath)}) return } if info.Mode()&os.ModeSymlink != 0 { // Remove symlink itself, not target err = os.Remove(absPath) } else if info.IsDir() { err = os.RemoveAll(absPath) } else { err = os.Remove(absPath) } if err != nil { slog.Error("storage.delete_failed", "path", absPath, "error", err) writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgFailedToDeleteFile)}) return } // Invalidate cached size for this tenant after successful deletion. h.sizeCache.Delete(delBase) slog.Info("storage.deleted", "path", relPath) writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"}) } // handleUpload uploads a file into the storage data directory. // Admin-only. Rejects uploads into protected directories (skills, skills-store). func (h *StorageHandler) handleUpload(w http.ResponseWriter, r *http.Request) { locale := extractLocale(r) subPath := r.URL.Query().Get("path") if strings.Contains(subPath, "..") { slog.Warn("security.storage_upload_traversal", "path", subPath) writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidPath)}) return } // Reject upload into protected directories. if subPath != "" && isProtectedPath(subPath) { writeJSON(w, http.StatusForbidden, map[string]string{"error": i18n.T(locale, i18n.MsgCannotDeleteSkillsDir)}) return } // Enforce file size limit. 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 target directory within tenant-scoped data dir. base := h.tenantBaseDir(r) targetDir := base if subPath != "" { targetDir = filepath.Join(base, filepath.Clean(subPath)) if !strings.HasPrefix(targetDir, base) { slog.Warn("security.storage_upload_escape", "resolved", targetDir, "root", base) writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidPath)}) return } } if !h.validateStorageParent(r, base, targetDir) { writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidPath)}) return } if err := os.MkdirAll(targetDir, 0750); err != nil { slog.Error("storage.upload_mkdir_failed", "dir", targetDir, "error", err) writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgInternalError, "failed to create directory")}) return } if !h.validateStorageParent(r, base, targetDir) { writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidPath)}) return } diskPath := filepath.Join(targetDir, origName) out, err := os.CreateTemp(targetDir, ".upload-*") if err != nil { slog.Error("storage.upload_create_failed", "dir", targetDir, "error", err) writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgInternalError, "failed to save file")}) return } tmpPath := out.Name() defer os.Remove(tmpPath) written, err := io.Copy(out, file) if err != nil { out.Close() writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgInternalError, "failed to save file")}) return } if err := out.Close(); err != nil { writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgInternalError, "failed to save file")}) return } if !h.validateStorageParent(r, base, targetDir) { writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidPath)}) return } if err := os.Rename(tmpPath, diskPath); err != nil { writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgInternalError, "failed to save file")}) return } // Invalidate size cache for this tenant. h.sizeCache.Delete(base) relPath := origName if subPath != "" { relPath = filepath.Join(subPath, origName) } slog.Info("storage.uploaded", "path", relPath, "size", written) writeJSON(w, http.StatusOK, map[string]any{ "path": relPath, "filename": origName, "size": written, }) } // handleMove moves/renames a file within the storage data directory. // Admin-only. Rejects moves involving protected directories. // Query params: ?from=relPath&to=relPath func (h *StorageHandler) handleMove(w http.ResponseWriter, r *http.Request) { locale := extractLocale(r) fromRel := r.URL.Query().Get("from") toRel := r.URL.Query().Get("to") if fromRel == "" || toRel == "" { writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgRequired, "from, to")}) return } // Reject path traversal in both paths. if strings.Contains(fromRel, "..") || strings.Contains(toRel, "..") { slog.Warn("security.storage_move_traversal", "from", fromRel, "to", toRel) writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidPath)}) return } // Reject moves involving protected directories. if isProtectedPath(fromRel) || isProtectedPath(toRel) { writeJSON(w, http.StatusForbidden, map[string]string{"error": i18n.T(locale, i18n.MsgCannotDeleteSkillsDir)}) return } base := h.tenantBaseDir(r) // Resolve and validate source path. srcAbs := filepath.Join(base, filepath.Clean(fromRel)) if !strings.HasPrefix(srcAbs, base+string(filepath.Separator)) { writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidPath)}) return } srcReal, err := filepath.EvalSymlinks(srcAbs) if err != nil { writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgFileNotFound)}) return } baseReal := evalSymlinkOrClean(base) srcReal = filepath.Clean(srcReal) if !pathWithinDir(srcReal, baseReal) { slog.Warn("security.storage_move_src_escape", "resolved", srcReal, "base", baseReal) writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidPath)}) return } if h.isHiddenRealPath(r, base, srcReal) { slog.Warn("security.storage_move_hidden_src", "resolved", srcReal, "base", baseReal) writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidPath)}) return } // Resolve and validate destination path. destAbs := filepath.Join(base, filepath.Clean(toRel)) if !strings.HasPrefix(destAbs, base+string(filepath.Separator)) { writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidPath)}) return } // Ensure destination parent exists. destDir := filepath.Dir(destAbs) if !h.validateStorageParent(r, base, destDir) { writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidPath)}) return } if err := os.MkdirAll(destDir, 0750); err != nil { slog.Error("storage.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 } if !h.validateStorageParent(r, base, destDir) { writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidPath)}) return } // Prevent overwriting existing file. if _, err := os.Stat(destAbs); err == nil { writeJSON(w, http.StatusConflict, map[string]string{"error": "a file with that name already exists at the destination"}) return } // Atomic move. if err := os.Rename(srcAbs, destAbs); err != nil { slog.Error("storage.move_failed", "from", fromRel, "to", toRel, "error", err) writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgInternalError, "failed to move file")}) return } // Invalidate cached size for this tenant after successful move. h.sizeCache.Delete(base) slog.Info("storage.moved", "from", fromRel, "to", toRel) writeJSON(w, http.StatusOK, map[string]any{ "from": fromRel, "to": toRel, }) }