Files
goclaw/internal/http/skills_versions.go
T
viettranx 28fab9507a feat(storage): add lazy folder loading, SSE size endpoint, and enhanced file viewer
- 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
2026-03-14 18:13:52 +07:00

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