mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-06-15 00:13:49 +00:00
28fab9507a
- Backend: depth-limited WalkDir (max 3 levels default) with on-demand subtree loading
- Backend: new GET /v1/storage/size SSE endpoint with 60min in-memory cache
- Backend: raw binary file serving (?raw=true) with MIME detection and download support
- Frontend: lazy tree expansion with loading spinners for deep folders
- Frontend: streaming size display with cache info tooltip
- Frontend: image viewer (blob URL), unsupported file UI, download button, colored size badges
- Frontend: file-type icons for 13 categories (md, json, yaml, images, video, etc.)
- Fix sidebar connection status text overflow on collapse
- Apply go fix modernization (interface{} → any) across http handlers
221 lines
6.1 KiB
Go
221 lines
6.1 KiB
Go
package http
|
|
|
|
import (
|
|
"log/slog"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/nextlevelbuilder/goclaw/internal/skills"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
"github.com/nextlevelbuilder/goclaw/internal/i18n"
|
|
"github.com/nextlevelbuilder/goclaw/internal/store"
|
|
)
|
|
|
|
// handleListVersions returns all available version numbers for a skill.
|
|
func (h *SkillsHandler) handleListVersions(w http.ResponseWriter, r *http.Request) {
|
|
locale := store.LocaleFromContext(r.Context())
|
|
id, err := uuid.Parse(r.PathValue("id"))
|
|
if err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "skill")})
|
|
return
|
|
}
|
|
|
|
_, slug, currentVersion, ok := h.skills.GetSkillFilePath(id)
|
|
if !ok {
|
|
writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgNotFound, "skill", id.String())})
|
|
return
|
|
}
|
|
|
|
slugDir := filepath.Join(h.baseDir, slug)
|
|
entries, err := os.ReadDir(slugDir)
|
|
if err != nil {
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"versions": []int{currentVersion},
|
|
"current": currentVersion,
|
|
})
|
|
return
|
|
}
|
|
|
|
var versions []int
|
|
for _, e := range entries {
|
|
if !e.IsDir() {
|
|
continue
|
|
}
|
|
v, err := strconv.Atoi(e.Name())
|
|
if err != nil || v < 1 {
|
|
continue
|
|
}
|
|
versions = append(versions, v)
|
|
}
|
|
sort.Ints(versions)
|
|
if len(versions) == 0 {
|
|
versions = []int{currentVersion}
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"versions": versions,
|
|
"current": currentVersion,
|
|
})
|
|
}
|
|
|
|
// handleListFiles returns all files in a skill version directory.
|
|
func (h *SkillsHandler) handleListFiles(w http.ResponseWriter, r *http.Request) {
|
|
locale := store.LocaleFromContext(r.Context())
|
|
id, err := uuid.Parse(r.PathValue("id"))
|
|
if err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "skill")})
|
|
return
|
|
}
|
|
|
|
_, slug, currentVersion, ok := h.skills.GetSkillFilePath(id)
|
|
if !ok {
|
|
writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgNotFound, "skill", id.String())})
|
|
return
|
|
}
|
|
|
|
version := currentVersion
|
|
if v := r.URL.Query().Get("version"); v != "" {
|
|
parsed, err := strconv.Atoi(v)
|
|
if err != nil || parsed < 1 {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidVersion)})
|
|
return
|
|
}
|
|
version = parsed
|
|
}
|
|
|
|
versionDir := filepath.Join(h.baseDir, slug, strconv.Itoa(version))
|
|
if _, err := os.Stat(versionDir); err != nil {
|
|
writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgVersionNotFound)})
|
|
return
|
|
}
|
|
|
|
type fileEntry struct {
|
|
Path string `json:"path"`
|
|
Name string `json:"name"`
|
|
IsDir bool `json:"isDir"`
|
|
Size int64 `json:"size"`
|
|
}
|
|
|
|
var files []fileEntry
|
|
filepath.WalkDir(versionDir, func(path string, d os.DirEntry, err error) error {
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
rel, _ := filepath.Rel(versionDir, path)
|
|
if rel == "." {
|
|
return nil
|
|
}
|
|
// Skip system artifacts (__MACOSX, .DS_Store, etc.)
|
|
if skills.IsSystemArtifact(rel) {
|
|
if d.IsDir() {
|
|
return filepath.SkipDir
|
|
}
|
|
return nil
|
|
}
|
|
// Skip symlinks — prevent escape from skill directory
|
|
if d.Type()&os.ModeSymlink != 0 {
|
|
return nil
|
|
}
|
|
entry := fileEntry{
|
|
Path: rel,
|
|
Name: d.Name(),
|
|
IsDir: d.IsDir(),
|
|
}
|
|
if !d.IsDir() {
|
|
if info, err := d.Info(); err == nil {
|
|
entry.Size = info.Size()
|
|
}
|
|
}
|
|
files = append(files, entry)
|
|
return nil
|
|
})
|
|
|
|
if files == nil {
|
|
files = []fileEntry{}
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"files": files})
|
|
}
|
|
|
|
// handleReadFile reads a single file from a skill version directory.
|
|
func (h *SkillsHandler) handleReadFile(w http.ResponseWriter, r *http.Request) {
|
|
locale := store.LocaleFromContext(r.Context())
|
|
id, err := uuid.Parse(r.PathValue("id"))
|
|
if err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "skill")})
|
|
return
|
|
}
|
|
|
|
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.skill_files_traversal", "path", relPath)
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidPath)})
|
|
return
|
|
}
|
|
|
|
_, slug, currentVersion, ok := h.skills.GetSkillFilePath(id)
|
|
if !ok {
|
|
writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgNotFound, "skill", id.String())})
|
|
return
|
|
}
|
|
|
|
version := currentVersion
|
|
if v := r.URL.Query().Get("version"); v != "" {
|
|
parsed, err := strconv.Atoi(v)
|
|
if err != nil || parsed < 1 {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidVersion)})
|
|
return
|
|
}
|
|
version = parsed
|
|
}
|
|
|
|
versionDir := filepath.Join(h.baseDir, slug, strconv.Itoa(version))
|
|
absPath := filepath.Join(versionDir, filepath.Clean(relPath))
|
|
|
|
// Verify resolved path is within the version directory
|
|
if !strings.HasPrefix(absPath, versionDir+string(filepath.Separator)) {
|
|
slog.Warn("security.skill_files_escape", "resolved", absPath, "root", versionDir)
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidPath)})
|
|
return
|
|
}
|
|
|
|
// Use Lstat to detect symlinks — reject them to prevent directory escape
|
|
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.skill_files_symlink", "path", absPath)
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidPath)})
|
|
return
|
|
}
|
|
|
|
// Skip system artifacts
|
|
if skills.IsSystemArtifact(relPath) {
|
|
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
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"content": string(data),
|
|
"path": relPath,
|
|
"size": info.Size(),
|
|
})
|
|
}
|