Files
Duy /zuey/ 532ff91d8e fix(security): harden upstream critical surfaces (#32)
* fix(security): harden upstream critical surfaces

Refs #30

* fix(security): close pre-landing review gaps

Refs #30

* fix(security): close official release blockers
2026-05-20 16:33:49 +07:00

748 lines
24 KiB
Go

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