mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-06-10 10:10:49 +00:00
d5ede64595
- Workspace binary files: use HTTP /v1/files/ endpoint for blob fetch and download instead of WS text content (fixes corruption). Skip WS readFile for non-text files, set metadata only for ImageViewer. - Workspace scope filter: prefix chatID as top-level folder in unscoped listing so scope switching shows correct tree structure. - Workspace WS ops: strip chatID prefix from file_name before WS calls (backend already scopes by chat_id param, was causing doubled path). - Storage depth: record dirs beyond maxDepth with hasChildren flag before SkipDir so they appear in tree with lazy-load indicator. - Remove unused team_workspace_files UNION from ListTaskScopes query.
381 lines
11 KiB
Go
381 lines
11 KiB
Go
package http
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"log/slog"
|
|
"mime"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/nextlevelbuilder/goclaw/internal/i18n"
|
|
"github.com/nextlevelbuilder/goclaw/internal/skills"
|
|
)
|
|
|
|
// StorageHandler provides HTTP endpoints for browsing and managing
|
|
// files inside the ~/.goclaw/ data directory.
|
|
// Skills directories are browsable (read-only) but deletion is blocked.
|
|
type StorageHandler struct {
|
|
baseDir string // resolved absolute path to ~/.goclaw/
|
|
token string
|
|
|
|
// sizeCache caches the total storage size for 60 minutes.
|
|
sizeCache struct {
|
|
mu sync.Mutex
|
|
total int64
|
|
files int
|
|
cachedAt time.Time
|
|
}
|
|
}
|
|
|
|
// NewStorageHandler creates a handler for workspace storage management.
|
|
func NewStorageHandler(baseDir, token string) *StorageHandler {
|
|
return &StorageHandler{baseDir: baseDir, token: token}
|
|
}
|
|
|
|
// 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...}", h.auth(h.handleDelete))
|
|
mux.HandleFunc("GET /v1/storage/size", h.auth(h.handleSize))
|
|
}
|
|
|
|
func (h *StorageHandler) auth(next http.HandlerFunc) http.HandlerFunc {
|
|
return requireAuth(h.token, "", next)
|
|
}
|
|
|
|
// protectedDirs are top-level directories where deletion is blocked
|
|
// (managed separately via the Skills page).
|
|
var protectedDirs = []string{"skills", "skills-store"}
|
|
|
|
func isProtectedPath(rel string) bool {
|
|
top := rel
|
|
if before, _, ok := strings.Cut(rel, "/"); ok {
|
|
top = before
|
|
}
|
|
// Also handle forward slash on all platforms
|
|
if i := strings.IndexByte(top, '/'); i >= 0 {
|
|
top = top[:i]
|
|
}
|
|
for _, d := range protectedDirs {
|
|
if strings.EqualFold(top, d) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// 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
|
|
}
|
|
}
|
|
|
|
rootDir := h.baseDir
|
|
if subPath != "" {
|
|
rootDir = filepath.Join(h.baseDir, filepath.Clean(subPath))
|
|
if !strings.HasPrefix(rootDir, h.baseDir) {
|
|
slog.Warn("security.storage_escape", "resolved", rootDir, "root", h.baseDir)
|
|
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(h.baseDir, path)
|
|
|
|
// 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": h.baseDir,
|
|
})
|
|
}
|
|
|
|
// 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)
|
|
|
|
// Check cache
|
|
h.sizeCache.mu.Lock()
|
|
if !h.sizeCache.cachedAt.IsZero() && time.Since(h.sizeCache.cachedAt) < sizeCacheTTL {
|
|
total := h.sizeCache.total
|
|
files := h.sizeCache.files
|
|
h.sizeCache.mu.Unlock()
|
|
writeSizeEvent(w, flusher, map[string]any{"total": total, "files": files, "done": true, "cached": true})
|
|
return
|
|
}
|
|
h.sizeCache.mu.Unlock()
|
|
|
|
// Walk and stream progress
|
|
var total int64
|
|
var fileCount int
|
|
lastFlush := time.Now()
|
|
|
|
filepath.WalkDir(h.baseDir, func(path string, d os.DirEntry, err error) error {
|
|
if err != nil || d.IsDir() {
|
|
return nil
|
|
}
|
|
// Check client disconnect
|
|
if r.Context().Err() != nil {
|
|
return filepath.SkipAll
|
|
}
|
|
if d.Type()&os.ModeSymlink != 0 {
|
|
return nil
|
|
}
|
|
rel, _ := filepath.Rel(h.baseDir, path)
|
|
if skills.IsSystemArtifact(rel) {
|
|
return nil
|
|
}
|
|
if info, err := d.Info(); err == nil {
|
|
total += info.Size()
|
|
fileCount++
|
|
}
|
|
// Emit progress every 50 files or 200ms
|
|
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 cache
|
|
h.sizeCache.mu.Lock()
|
|
h.sizeCache.total = total
|
|
h.sizeCache.files = fileCount
|
|
h.sizeCache.cachedAt = time.Now()
|
|
h.sizeCache.mu.Unlock()
|
|
|
|
// 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
|
|
}
|
|
|
|
absPath := filepath.Join(h.baseDir, filepath.Clean(relPath))
|
|
if !strings.HasPrefix(absPath, h.baseDir+string(filepath.Separator)) {
|
|
slog.Warn("security.storage_escape", "resolved", absPath, "root", h.baseDir)
|
|
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
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
absPath := filepath.Join(h.baseDir, filepath.Clean(relPath))
|
|
if !strings.HasPrefix(absPath, h.baseDir+string(filepath.Separator)) {
|
|
slog.Warn("security.storage_escape", "resolved", absPath, "root", h.baseDir)
|
|
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 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
|
|
}
|
|
|
|
slog.Info("storage.deleted", "path", relPath)
|
|
writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
|
|
}
|