feat(volume-browser): phase 1 - path resolver & file system API

- Extract volume path resolution into shared internal/volume/resolver.go
- File browser operations: ListDir, OpenFile, WriteZip, SaveFile
- Strict path traversal protection (double-validated)
- API endpoints: browse, download (file or zip), upload (multipart)
- Refactor deployer to use shared resolver
This commit is contained in:
2026-04-01 22:59:02 +03:00
parent 6660c78649
commit 4a0f223d61
5 changed files with 454 additions and 25 deletions
+3
View File
@@ -139,6 +139,8 @@ func (s *Server) Router() chi.Router {
r.Get("/stages/{stage}/instances", s.listInstances)
r.Get("/stages/{stage}/instances/{iid}/stats", s.getInstanceStats)
r.Get("/volumes", s.listVolumes)
r.Get("/volumes/{volId}/browse", s.browseVolume)
r.Get("/volumes/{volId}/download", s.downloadVolume)
// Admin-only project mutations.
r.Group(func(r chi.Router) {
@@ -169,6 +171,7 @@ func (s *Server) Router() chi.Router {
r.Post("/volumes", s.createVolume)
r.Put("/volumes/{volId}", s.updateVolume)
r.Delete("/volumes/{volId}", s.deleteVolume)
r.Post("/volumes/{volId}/upload", s.uploadToVolume)
})
})
r.Get("/deploys", s.listDeploys)
+185
View File
@@ -0,0 +1,185 @@
package api
import (
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"path/filepath"
"github.com/go-chi/chi/v5"
"github.com/alexei/docker-watcher/internal/store"
"github.com/alexei/docker-watcher/internal/volume"
)
const maxUploadSize = 100 * 1024 * 1024 // 100MB
// resolveVolumeRoot looks up a volume and resolves its host path.
func (s *Server) resolveVolumeRoot(w http.ResponseWriter, r *http.Request) (string, bool) {
projectID := chi.URLParam(r, "id")
volID := chi.URLParam(r, "volId")
proj, err := s.store.GetProjectByID(projectID)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "project")
return "", false
}
slog.Error("failed to get project", "project_id", projectID, "error", err)
respondError(w, http.StatusInternalServerError, "failed to get project")
return "", false
}
vol, err := s.store.GetVolumeByID(volID)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "volume")
return "", false
}
slog.Error("failed to get volume", "volume_id", volID, "error", err)
respondError(w, http.StatusInternalServerError, "failed to get volume")
return "", false
}
if vol.Scope == "ephemeral" {
respondError(w, http.StatusBadRequest, "ephemeral volumes have no host path to browse")
return "", false
}
settings, err := s.store.GetSettings()
if err != nil {
slog.Error("failed to get settings", "error", err)
respondError(w, http.StatusInternalServerError, "failed to get settings")
return "", false
}
q := r.URL.Query()
params := volume.ResolveParams{
BasePath: settings.BaseVolumePath,
ProjectName: proj.Name,
StageName: q.Get("stage"),
ImageTag: q.Get("tag"),
}
rootPath, err := volume.ResolvePath(vol, params)
if err != nil {
respondError(w, http.StatusBadRequest, err.Error())
return "", false
}
return rootPath, true
}
// browseVolume handles GET /api/projects/{id}/volumes/{volId}/browse?path=&stage=&tag=
func (s *Server) browseVolume(w http.ResponseWriter, r *http.Request) {
rootPath, ok := s.resolveVolumeRoot(w, r)
if !ok {
return
}
relPath := r.URL.Query().Get("path")
entries, err := volume.ListDir(rootPath, relPath)
if err != nil {
slog.Error("failed to list directory", "root", rootPath, "path", relPath, "error", err)
respondError(w, http.StatusInternalServerError, "failed to list directory")
return
}
respondJSON(w, http.StatusOK, map[string]any{
"path": relPath,
"root": rootPath,
"entries": entries,
})
}
// downloadVolume handles GET /api/projects/{id}/volumes/{volId}/download?path=&stage=&tag=
// Downloads a single file directly, or a directory/root as a zip archive.
func (s *Server) downloadVolume(w http.ResponseWriter, r *http.Request) {
rootPath, ok := s.resolveVolumeRoot(w, r)
if !ok {
return
}
relPath := r.URL.Query().Get("path")
// If path is empty or points to a directory, serve as zip.
if relPath == "" {
s.serveZip(w, rootPath, "", "volume")
return
}
// Check if it's a file or directory.
f, info, err := volume.OpenFile(rootPath, relPath)
if err != nil {
// Might be a directory — try zip.
entries, listErr := volume.ListDir(rootPath, relPath)
if listErr == nil && entries != nil {
name := filepath.Base(relPath)
s.serveZip(w, rootPath, relPath, name)
return
}
slog.Error("failed to open file", "root", rootPath, "path", relPath, "error", err)
respondError(w, http.StatusNotFound, "file not found")
return
}
defer f.Close()
// Serve single file.
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filepath.Base(relPath)))
w.Header().Set("Content-Length", fmt.Sprintf("%d", info.Size()))
w.WriteHeader(http.StatusOK)
io.Copy(w, f)
}
func (s *Server) serveZip(w http.ResponseWriter, rootPath, relPath, name string) {
w.Header().Set("Content-Type", "application/zip")
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s.zip"`, name))
w.WriteHeader(http.StatusOK)
if err := volume.WriteZip(rootPath, relPath, w); err != nil {
slog.Error("failed to write zip", "root", rootPath, "path", relPath, "error", err)
}
}
// uploadToVolume handles POST /api/projects/{id}/volumes/{volId}/upload?path=&stage=&tag=
// Accepts multipart form uploads.
func (s *Server) uploadToVolume(w http.ResponseWriter, r *http.Request) {
rootPath, ok := s.resolveVolumeRoot(w, r)
if !ok {
return
}
if err := r.ParseMultipartForm(maxUploadSize); err != nil {
respondError(w, http.StatusBadRequest, "upload too large (max 100MB)")
return
}
relPath := r.URL.Query().Get("path")
uploaded := []string{}
for _, fileHeaders := range r.MultipartForm.File {
for _, fh := range fileHeaders {
f, err := fh.Open()
if err != nil {
slog.Error("failed to open upload", "filename", fh.Filename, "error", err)
continue
}
targetRel := filepath.Join(relPath, fh.Filename)
if err := volume.SaveFile(rootPath, targetRel, f); err != nil {
f.Close()
slog.Error("failed to save upload", "filename", fh.Filename, "error", err)
respondError(w, http.StatusInternalServerError, "failed to save file: "+fh.Filename)
return
}
f.Close()
uploaded = append(uploaded, fh.Filename)
}
}
respondJSON(w, http.StatusOK, map[string]any{
"uploaded": uploaded,
"count": len(uploaded),
})
}