diff --git a/internal/api/router.go b/internal/api/router.go index cd478c4..a908f1b 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -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) diff --git a/internal/api/volume_browser.go b/internal/api/volume_browser.go new file mode 100644 index 0000000..aecdc0a --- /dev/null +++ b/internal/api/volume_browser.go @@ -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), + }) +} diff --git a/internal/deployer/deployer.go b/internal/deployer/deployer.go index 0d2cc71..e944c2d 100644 --- a/internal/deployer/deployer.go +++ b/internal/deployer/deployer.go @@ -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{ diff --git a/internal/volume/browser.go b/internal/volume/browser.go new file mode 100644 index 0000000..d02e256 --- /dev/null +++ b/internal/volume/browser.go @@ -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 +} diff --git a/internal/volume/resolver.go b/internal/volume/resolver.go new file mode 100644 index 0000000..3bac51a --- /dev/null +++ b/internal/volume/resolver.go @@ -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 + } +}