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:
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
}
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
@@ -17,6 +16,7 @@ import (
|
||||
"github.com/alexei/docker-watcher/internal/notify"
|
||||
"github.com/alexei/docker-watcher/internal/npm"
|
||||
"github.com/alexei/docker-watcher/internal/store"
|
||||
"github.com/alexei/docker-watcher/internal/volume"
|
||||
"github.com/moby/moby/api/types/mount"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
@@ -619,13 +619,7 @@ func (d *Deployer) mergeEnvVars(project store.Project, stageID string) []string
|
||||
}
|
||||
|
||||
// computeVolumeMounts builds Docker mount specifications from the project's volume config.
|
||||
// Resolves the host path based on the volume's scope:
|
||||
// - instance: {base}/{project}/{stage}-{tag}/{source}
|
||||
// - stage: {base}/{project}/{stage}/{source}
|
||||
// - project: {base}/{project}/{source}
|
||||
// - project_named: {base}/{project}/_named/{name}/{source}
|
||||
// - named: {base}/_named/{name}/{source}
|
||||
// - ephemeral: tmpfs mount (no host path)
|
||||
// Uses the shared volume.ResolvePath for path resolution.
|
||||
func (d *Deployer) computeVolumeMounts(projectID, projectName, stageName, imageTag, basePath string) []mount.Mount {
|
||||
vols, err := d.store.GetVolumesByProjectID(projectID)
|
||||
if err != nil {
|
||||
@@ -637,9 +631,15 @@ func (d *Deployer) computeVolumeMounts(projectID, projectName, stageName, imageT
|
||||
return nil
|
||||
}
|
||||
|
||||
params := volume.ResolveParams{
|
||||
BasePath: basePath,
|
||||
ProjectName: projectName,
|
||||
StageName: stageName,
|
||||
ImageTag: imageTag,
|
||||
}
|
||||
|
||||
mounts := make([]mount.Mount, 0, len(vols))
|
||||
for _, vol := range vols {
|
||||
// Resolve scope — use Scope field, fall back to Mode for backward compat.
|
||||
scope := vol.Scope
|
||||
if scope == "" {
|
||||
switch vol.Mode {
|
||||
@@ -659,22 +659,10 @@ func (d *Deployer) computeVolumeMounts(projectID, projectName, stageName, imageT
|
||||
continue
|
||||
}
|
||||
|
||||
// Build host path based on scope.
|
||||
var source string
|
||||
switch scope {
|
||||
case "instance":
|
||||
source = filepath.Join(basePath, projectName, fmt.Sprintf("%s-%s", stageName, imageTag), vol.Source)
|
||||
case "stage":
|
||||
source = filepath.Join(basePath, projectName, stageName, vol.Source)
|
||||
case "project":
|
||||
source = filepath.Join(basePath, projectName, vol.Source)
|
||||
case "project_named":
|
||||
source = filepath.Join(basePath, projectName, "_named", vol.Name, vol.Source)
|
||||
case "named":
|
||||
source = filepath.Join(basePath, "_named", vol.Name, vol.Source)
|
||||
default:
|
||||
// Fallback: treat as project scope.
|
||||
source = filepath.Join(basePath, projectName, vol.Source)
|
||||
source, err := volume.ResolvePath(vol, params)
|
||||
if err != nil {
|
||||
slog.Warn("resolve volume path", "volume_id", vol.ID, "error", err)
|
||||
continue
|
||||
}
|
||||
|
||||
mounts = append(mounts, mount.Mount{
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
package volume
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// FileEntry represents a single file or directory in a volume listing.
|
||||
type FileEntry struct {
|
||||
Name string `json:"name"`
|
||||
IsDir bool `json:"is_dir"`
|
||||
Size int64 `json:"size"`
|
||||
ModTime time.Time `json:"mod_time"`
|
||||
}
|
||||
|
||||
// ListDir returns the contents of a directory within a volume root.
|
||||
// The relativePath is validated to stay within rootPath.
|
||||
func ListDir(rootPath, relativePath string) ([]FileEntry, error) {
|
||||
absPath, err := safePath(rootPath, relativePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
info, err := os.Stat(absPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return []FileEntry{}, nil
|
||||
}
|
||||
return nil, fmt.Errorf("stat directory: %w", err)
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return nil, fmt.Errorf("path is not a directory")
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir(absPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read directory: %w", err)
|
||||
}
|
||||
|
||||
result := make([]FileEntry, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
info, err := e.Info()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
result = append(result, FileEntry{
|
||||
Name: e.Name(),
|
||||
IsDir: e.IsDir(),
|
||||
Size: info.Size(),
|
||||
ModTime: info.ModTime().UTC(),
|
||||
})
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// OpenFile opens a file within the volume root for reading.
|
||||
// The caller is responsible for closing the returned file.
|
||||
func OpenFile(rootPath, relativePath string) (*os.File, os.FileInfo, error) {
|
||||
absPath, err := safePath(rootPath, relativePath)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
info, err := os.Stat(absPath)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("stat file: %w", err)
|
||||
}
|
||||
if info.IsDir() {
|
||||
return nil, nil, fmt.Errorf("path is a directory, use download as zip")
|
||||
}
|
||||
|
||||
f, err := os.Open(absPath)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("open file: %w", err)
|
||||
}
|
||||
return f, info, nil
|
||||
}
|
||||
|
||||
// WriteZip writes the contents of a directory (or the entire root) as a zip archive to w.
|
||||
func WriteZip(rootPath, relativePath string, w io.Writer) error {
|
||||
absPath, err := safePath(rootPath, relativePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
info, err := os.Stat(absPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("stat path: %w", err)
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return fmt.Errorf("path is not a directory")
|
||||
}
|
||||
|
||||
zw := zip.NewWriter(w)
|
||||
defer zw.Close()
|
||||
|
||||
return filepath.Walk(absPath, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
rel, err := filepath.Rel(absPath, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if rel == "." {
|
||||
return nil
|
||||
}
|
||||
// Use forward slashes in zip entries.
|
||||
rel = filepath.ToSlash(rel)
|
||||
|
||||
if info.IsDir() {
|
||||
_, err := zw.Create(rel + "/")
|
||||
return err
|
||||
}
|
||||
|
||||
header, err := zip.FileInfoHeader(info)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
header.Name = rel
|
||||
header.Method = zip.Deflate
|
||||
|
||||
writer, err := zw.CreateHeader(header)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
_, err = io.Copy(writer, f)
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
// SaveFile writes uploaded content to a file within the volume root.
|
||||
func SaveFile(rootPath, relativePath string, r io.Reader) error {
|
||||
absPath, err := safePath(rootPath, relativePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Ensure parent directory exists.
|
||||
dir := filepath.Dir(absPath)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return fmt.Errorf("create directory: %w", err)
|
||||
}
|
||||
|
||||
f, err := os.Create(absPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create file: %w", err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
if _, err := io.Copy(f, r); err != nil {
|
||||
return fmt.Errorf("write file: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// safePath resolves a relative path within rootPath and validates it doesn't escape.
|
||||
func safePath(rootPath, relativePath string) (string, error) {
|
||||
if relativePath == "" {
|
||||
return rootPath, nil
|
||||
}
|
||||
|
||||
// Clean and ensure no traversal.
|
||||
cleaned := filepath.Clean(relativePath)
|
||||
if strings.Contains(cleaned, "..") {
|
||||
return "", fmt.Errorf("path traversal not allowed")
|
||||
}
|
||||
|
||||
absPath := filepath.Join(rootPath, cleaned)
|
||||
|
||||
// Double-check the resolved path is within the root.
|
||||
absRoot, err := filepath.Abs(rootPath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("resolve root: %w", err)
|
||||
}
|
||||
absResolved, err := filepath.Abs(absPath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("resolve path: %w", err)
|
||||
}
|
||||
if !strings.HasPrefix(absResolved, absRoot) {
|
||||
return "", fmt.Errorf("path traversal not allowed")
|
||||
}
|
||||
|
||||
return absPath, nil
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package volume
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/alexei/docker-watcher/internal/store"
|
||||
)
|
||||
|
||||
// ResolveParams holds the parameters needed to resolve a volume's host path.
|
||||
type ResolveParams struct {
|
||||
BasePath string
|
||||
ProjectName string
|
||||
StageName string // required for instance and stage scopes
|
||||
ImageTag string // required for instance scope
|
||||
}
|
||||
|
||||
// ResolvePath returns the absolute host path for a volume based on its scope.
|
||||
// Returns an error for ephemeral volumes (no host path) or missing parameters.
|
||||
func ResolvePath(vol store.Volume, params ResolveParams) (string, error) {
|
||||
scope := vol.Scope
|
||||
if scope == "" {
|
||||
switch vol.Mode {
|
||||
case "isolated":
|
||||
scope = "instance"
|
||||
default:
|
||||
scope = "project"
|
||||
}
|
||||
}
|
||||
|
||||
if scope == "ephemeral" {
|
||||
return "", fmt.Errorf("ephemeral volumes have no host path")
|
||||
}
|
||||
|
||||
switch scope {
|
||||
case "instance":
|
||||
if params.StageName == "" || params.ImageTag == "" {
|
||||
return "", fmt.Errorf("instance scope requires stage and tag parameters")
|
||||
}
|
||||
return filepath.Join(params.BasePath, params.ProjectName, fmt.Sprintf("%s-%s", params.StageName, params.ImageTag), vol.Source), nil
|
||||
case "stage":
|
||||
if params.StageName == "" {
|
||||
return "", fmt.Errorf("stage scope requires stage parameter")
|
||||
}
|
||||
return filepath.Join(params.BasePath, params.ProjectName, params.StageName, vol.Source), nil
|
||||
case "project":
|
||||
return filepath.Join(params.BasePath, params.ProjectName, vol.Source), nil
|
||||
case "project_named":
|
||||
return filepath.Join(params.BasePath, params.ProjectName, "_named", vol.Name, vol.Source), nil
|
||||
case "named":
|
||||
return filepath.Join(params.BasePath, "_named", vol.Name, vol.Source), nil
|
||||
default:
|
||||
return filepath.Join(params.BasePath, params.ProjectName, vol.Source), nil
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user