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/settings.go b/internal/api/settings.go index 276dd45..14dd3b4 100644 --- a/internal/api/settings.go +++ b/internal/api/settings.go @@ -5,11 +5,13 @@ import ( "fmt" "log/slog" "net/http" + "path/filepath" "strings" "github.com/alexei/docker-watcher/internal/crypto" "github.com/alexei/docker-watcher/internal/npm" "github.com/alexei/docker-watcher/internal/store" + "github.com/alexei/docker-watcher/internal/volume" "github.com/alexei/docker-watcher/internal/webhook" ) @@ -24,8 +26,9 @@ type settingsRequest struct { NpmEmail string `json:"npm_email"` NpmPassword string `json:"npm_password"` PollingInterval string `json:"polling_interval"` - SSLCertificateID *int `json:"ssl_certificate_id,omitempty"` - StaleThresholdDays *int `json:"stale_threshold_days,omitempty"` + SSLCertificateID *int `json:"ssl_certificate_id,omitempty"` + StaleThresholdDays *int `json:"stale_threshold_days,omitempty"` + AllowedVolumePaths *string `json:"allowed_volume_paths,omitempty"` } // getSettings handles GET /api/settings. @@ -48,8 +51,9 @@ func (s *Server) getSettings(w http.ResponseWriter, r *http.Request) { "has_npm_password": settings.NpmPassword != "", "polling_interval": settings.PollingInterval, "ssl_certificate_id": settings.SSLCertificateID, - "stale_threshold_days": settings.StaleThresholdDays, - "updated_at": settings.UpdatedAt, + "stale_threshold_days": settings.StaleThresholdDays, + "allowed_volume_paths": settings.AllowedVolumePaths, + "updated_at": settings.UpdatedAt, }) } @@ -110,6 +114,23 @@ func (s *Server) updateSettings(w http.ResponseWriter, r *http.Request) { } updated.StaleThresholdDays = *req.StaleThresholdDays } + if req.AllowedVolumePaths != nil { + // Validate it's valid JSON array of strings. + paths, err := volume.ParseAllowedPaths(*req.AllowedVolumePaths) + if err != nil { + respondError(w, http.StatusBadRequest, "allowed_volume_paths must be a JSON array of strings") + return + } + // Validate each path is absolute. + for _, p := range paths { + if !filepath.IsAbs(p) { + respondError(w, http.StatusBadRequest, "each allowed volume path must be absolute") + return + } + } + updated.AllowedVolumePaths = *req.AllowedVolumePaths + _ = paths // validated + } if err := s.store.UpdateSettings(updated); err != nil { respondError(w, http.StatusInternalServerError, "failed to update settings: "+err.Error()) diff --git a/internal/api/volume_browser.go b/internal/api/volume_browser.go new file mode 100644 index 0000000..65ba5f3 --- /dev/null +++ b/internal/api/volume_browser.go @@ -0,0 +1,209 @@ +package api + +import ( + "errors" + "fmt" + "io" + "log/slog" + "net/http" + "path/filepath" + "strings" + + "github.com/go-chi/chi/v5" + + "github.com/alexei/docker-watcher/internal/store" + "github.com/alexei/docker-watcher/internal/volume" +) + +// sanitizeFilename removes characters unsafe for Content-Disposition headers. +func sanitizeFilename(name string) string { + return strings.Map(func(r rune) rune { + if r == '"' || r == '\\' || r == '\n' || r == '\r' { + return '_' + } + return r + }, name) +} + +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 + } + + // Verify volume belongs to this project. + if vol.ProjectID != projectID { + respondNotFound(w, "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"), + AllowedVolumePaths: settings.AllowedVolumePaths, + } + + 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, + "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 with forced download. + safeName := sanitizeFilename(filepath.Base(relPath)) + w.Header().Set("Content-Type", "application/octet-stream") + w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, safeName)) + 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) { + safeName := sanitizeFilename(name) + w.Header().Set("Content-Type", "application/zip") + w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s.zip"`, safeName)) + 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. Overrides the global body limit for large files. +func (s *Server) uploadToVolume(w http.ResponseWriter, r *http.Request) { + // Override the global 1MB body limit for uploads. + r.Body = http.MaxBytesReader(w, r.Body, maxUploadSize) + + 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 + } + + // Strip directory components from filename to prevent directory creation attacks. + targetRel := filepath.Join(relPath, filepath.Base(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/api/volumes.go b/internal/api/volumes.go index a3234dd..3a50bb5 100644 --- a/internal/api/volumes.go +++ b/internal/api/volumes.go @@ -11,6 +11,7 @@ import ( "github.com/go-chi/chi/v5" "github.com/alexei/docker-watcher/internal/store" + "github.com/alexei/docker-watcher/internal/volume" ) // safeNamePattern restricts volume names to alphanumeric, dash, underscore, and dot. @@ -35,12 +36,13 @@ type volumeRequest struct { var validScopes = map[string]bool{ "instance": true, "stage": true, "project": true, "project_named": true, "named": true, "ephemeral": true, + "absolute": true, } -// validateVolumeScope validates the scope and name combination. -func validateVolumeScope(scope, name string) string { +// validateVolumeScope validates the scope, name, and source combination. +func validateVolumeScope(scope, name, source, allowedPathsJSON string) string { if !validScopes[scope] { - return "scope must be one of: instance, stage, project, project_named, named, ephemeral" + return "scope must be one of: instance, stage, project, project_named, named, ephemeral, absolute" } if (scope == "project_named" || scope == "named") && strings.TrimSpace(name) == "" { return "name is required for " + scope + " scope" @@ -48,6 +50,34 @@ func validateVolumeScope(scope, name string) string { if name != "" && !safeNamePattern.MatchString(name) { return "name must start with a letter or digit and contain only letters, digits, dashes, underscores, or dots" } + if scope == "absolute" { + if source == "" { + return "source path is required for absolute scope" + } + if !filepath.IsAbs(source) { + return "absolute scope requires an absolute source path" + } + // Validate against allowlist. + allowed, err := volume.ParseAllowedPaths(allowedPathsJSON) + if err != nil { + return "failed to parse allowed volume paths" + } + if len(allowed) == 0 { + return "absolute volume paths are disabled — configure allowed paths in settings first" + } + matched := false + cleanSource := filepath.Clean(source) + for _, prefix := range allowed { + cleanPrefix := filepath.Clean(prefix) + if strings.HasPrefix(cleanSource, cleanPrefix+string(filepath.Separator)) || cleanSource == cleanPrefix { + matched = true + break + } + } + if !matched { + return "source path is not under any allowed volume path" + } + } return "" } @@ -99,6 +129,12 @@ func (s *Server) listVolumeScopes(w http.ResponseWriter, r *http.Request) { NeedsName: false, PathExample: "(tmpfs — no host path)", }, + { + Scope: "absolute", + Description: "Direct host path. Must be under an allowed path configured in settings. Use for external mounts like NFS or pre-existing directories.", + NeedsName: false, + PathExample: "/mnt/nfs/data (must match allowed paths)", + }, } respondJSON(w, http.StatusOK, scopes) } @@ -179,7 +215,15 @@ func (s *Server) createVolume(w http.ResponseWriter, r *http.Request) { } } - if errMsg := validateVolumeScope(scope, req.Name); errMsg != "" { + // Fetch settings for absolute path allowlist validation. + settings, err := s.store.GetSettings() + if err != nil { + slog.Error("failed to get settings for volume validation", "error", err) + respondError(w, http.StatusInternalServerError, "failed to validate volume") + return + } + + if errMsg := validateVolumeScope(scope, req.Name, req.Source, settings.AllowedVolumePaths); errMsg != "" { respondError(w, http.StatusBadRequest, errMsg) return } @@ -235,7 +279,17 @@ func (s *Server) updateVolume(w http.ResponseWriter, r *http.Request) { updated.Target = req.Target } if req.Scope != "" { - if errMsg := validateVolumeScope(req.Scope, req.Name); errMsg != "" { + settings, err := s.store.GetSettings() + if err != nil { + slog.Error("failed to get settings for volume validation", "error", err) + respondError(w, http.StatusInternalServerError, "failed to validate volume") + return + } + source := updated.Source + if req.Source != "" { + source = req.Source + } + if errMsg := validateVolumeScope(req.Scope, req.Name, source, settings.AllowedVolumePaths); errMsg != "" { respondError(w, http.StatusBadRequest, errMsg) return } 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/store/models.go b/internal/store/models.go index 80b41a7..d98bf3f 100644 --- a/internal/store/models.go +++ b/internal/store/models.go @@ -57,6 +57,7 @@ type Settings struct { BaseVolumePath string `json:"base_volume_path"` SSLCertificateID int `json:"ssl_certificate_id"` StaleThresholdDays int `json:"stale_threshold_days"` + AllowedVolumePaths string `json:"allowed_volume_paths"` // JSON array of allowed absolute paths UpdatedAt string `json:"updated_at"` } @@ -120,12 +121,14 @@ const ( VolumeScopeProjectNamed VolumeScope = "project_named" VolumeScopeNamed VolumeScope = "named" VolumeScopeEphemeral VolumeScope = "ephemeral" + VolumeScopeAbsolute VolumeScope = "absolute" ) // ValidVolumeScopes contains all valid scope values for validation. var ValidVolumeScopes = []VolumeScope{ VolumeScopeInstance, VolumeScopeStage, VolumeScopeProject, VolumeScopeProjectNamed, VolumeScopeNamed, VolumeScopeEphemeral, + VolumeScopeAbsolute, } // IsValidVolumeScope returns true if the given string is a valid scope. diff --git a/internal/store/settings.go b/internal/store/settings.go index d9ea761..324df99 100644 --- a/internal/store/settings.go +++ b/internal/store/settings.go @@ -9,10 +9,14 @@ func (s *Store) GetSettings() (Settings, error) { var st Settings err := s.db.QueryRow( `SELECT domain, server_ip, network, subdomain_pattern, notification_url, - npm_url, npm_email, npm_password, webhook_secret, polling_interval, base_volume_path, ssl_certificate_id, stale_threshold_days, updated_at + npm_url, npm_email, npm_password, webhook_secret, polling_interval, + base_volume_path, ssl_certificate_id, stale_threshold_days, + allowed_volume_paths, updated_at FROM settings WHERE id = 1`, ).Scan(&st.Domain, &st.ServerIP, &st.Network, &st.SubdomainPattern, &st.NotificationURL, - &st.NpmURL, &st.NpmEmail, &st.NpmPassword, &st.WebhookSecret, &st.PollingInterval, &st.BaseVolumePath, &st.SSLCertificateID, &st.StaleThresholdDays, &st.UpdatedAt) + &st.NpmURL, &st.NpmEmail, &st.NpmPassword, &st.WebhookSecret, &st.PollingInterval, + &st.BaseVolumePath, &st.SSLCertificateID, &st.StaleThresholdDays, + &st.AllowedVolumePaths, &st.UpdatedAt) if err != nil { return Settings{}, fmt.Errorf("query settings: %w", err) } @@ -25,10 +29,14 @@ func (s *Store) UpdateSettings(st Settings) error { _, err := s.db.Exec( `UPDATE settings SET domain=?, server_ip=?, network=?, subdomain_pattern=?, notification_url=?, - npm_url=?, npm_email=?, npm_password=?, webhook_secret=?, polling_interval=?, base_volume_path=?, ssl_certificate_id=?, stale_threshold_days=?, updated_at=? + npm_url=?, npm_email=?, npm_password=?, webhook_secret=?, polling_interval=?, + base_volume_path=?, ssl_certificate_id=?, stale_threshold_days=?, + allowed_volume_paths=?, updated_at=? WHERE id = 1`, st.Domain, st.ServerIP, st.Network, st.SubdomainPattern, st.NotificationURL, - st.NpmURL, st.NpmEmail, st.NpmPassword, st.WebhookSecret, st.PollingInterval, st.BaseVolumePath, st.SSLCertificateID, st.StaleThresholdDays, st.UpdatedAt, + st.NpmURL, st.NpmEmail, st.NpmPassword, st.WebhookSecret, st.PollingInterval, + st.BaseVolumePath, st.SSLCertificateID, st.StaleThresholdDays, + st.AllowedVolumePaths, st.UpdatedAt, ) if err != nil { return fmt.Errorf("update settings: %w", err) diff --git a/internal/store/store.go b/internal/store/store.go index 4b9440a..e3e843d 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -88,6 +88,8 @@ func (s *Store) runMigrations() error { // Add name column and rename mode→scope for volume scopes redesign (2026-03-31). `ALTER TABLE volumes ADD COLUMN name TEXT NOT NULL DEFAULT ''`, `ALTER TABLE volumes ADD COLUMN scope TEXT NOT NULL DEFAULT ''`, + // Add allowed_volume_paths to settings for absolute volume scope allowlist (2026-04-01). + `ALTER TABLE settings ADD COLUMN allowed_volume_paths TEXT NOT NULL DEFAULT '[]'`, } for _, m := range migrations { diff --git a/internal/volume/browser.go b/internal/volume/browser.go new file mode 100644 index 0000000..35cd5ca --- /dev/null +++ b/internal/volume/browser.go @@ -0,0 +1,208 @@ +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. +// Resolves symlinks to prevent symlink-based traversal attacks. +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) + + // Resolve the root path (follow symlinks in the root itself). + absRoot, err := filepath.Abs(rootPath) + if err != nil { + return "", fmt.Errorf("resolve root: %w", err) + } + if realRoot, err := filepath.EvalSymlinks(absRoot); err == nil { + absRoot = realRoot + } + + // Resolve the target path including symlinks. + absResolved, err := filepath.Abs(absPath) + if err != nil { + return "", fmt.Errorf("resolve path: %w", err) + } + if realResolved, err := filepath.EvalSymlinks(absResolved); err == nil { + absResolved = realResolved + } + + 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..769eef4 --- /dev/null +++ b/internal/volume/resolver.go @@ -0,0 +1,108 @@ +package volume + +import ( + "encoding/json" + "fmt" + "path/filepath" + "strings" + + "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 + AllowedVolumePaths string // JSON array of allowed absolute paths (from settings) +} + +// 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") + } + + if scope == "absolute" { + return resolveAbsolute(vol.Source, params.AllowedVolumePaths) + } + + 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 + } +} + +// resolveAbsolute validates that the source path is under one of the allowed prefixes. +func resolveAbsolute(source, allowedPathsJSON string) (string, error) { + if source == "" { + return "", fmt.Errorf("absolute scope requires a source path") + } + + cleaned := filepath.Clean(source) + if !filepath.IsAbs(cleaned) { + return "", fmt.Errorf("absolute scope requires an absolute source path (starting with /)") + } + + allowed, err := parseAllowedPaths(allowedPathsJSON) + if err != nil { + return "", fmt.Errorf("failed to parse allowed volume paths: %w", err) + } + if len(allowed) == 0 { + return "", fmt.Errorf("absolute volume paths are disabled (no allowed paths configured in settings)") + } + + for _, prefix := range allowed { + prefixClean := filepath.Clean(prefix) + if strings.HasPrefix(cleaned, prefixClean+string(filepath.Separator)) || cleaned == prefixClean { + return cleaned, nil + } + } + + return "", fmt.Errorf("path %q is not under any allowed volume path", source) +} + +// parseAllowedPaths parses a JSON array of path strings. +func parseAllowedPaths(jsonStr string) ([]string, error) { + if jsonStr == "" || jsonStr == "[]" { + return nil, nil + } + var paths []string + if err := json.Unmarshal([]byte(jsonStr), &paths); err != nil { + return nil, err + } + return paths, nil +} + +// ParseAllowedPaths is the exported version for use in API validation. +func ParseAllowedPaths(jsonStr string) ([]string, error) { + return parseAllowedPaths(jsonStr) +} diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index ef1b321..56618f7 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -21,7 +21,8 @@ import type { StandaloneProxy, ValidationResult, Volume, - VolumeScopeInfo + VolumeScopeInfo, + BrowseResult } from './types'; // ── Helpers ───────────────────────────────────────────────────────── @@ -352,6 +353,66 @@ export function deleteVolume( return del<{ deleted: string }>(`/api/projects/${projectId}/volumes/${volId}`); } +export function browseVolume( + projectId: string, + volId: string, + params?: { path?: string; stage?: string; tag?: string } +): Promise { + const query = new URLSearchParams(); + if (params?.path) query.set('path', params.path); + if (params?.stage) query.set('stage', params.stage); + if (params?.tag) query.set('tag', params.tag); + const qs = query.toString(); + return get(`/api/projects/${projectId}/volumes/${volId}/browse${qs ? `?${qs}` : ''}`); +} + +export function volumeDownloadUrl( + projectId: string, + volId: string, + params?: { path?: string; stage?: string; tag?: string } +): string { + const query = new URLSearchParams(); + if (params?.path) query.set('path', params.path); + if (params?.stage) query.set('stage', params.stage); + if (params?.tag) query.set('tag', params.tag); + const token = typeof localStorage !== 'undefined' ? localStorage.getItem('auth_token') : null; + if (token) query.set('token', token); + const qs = query.toString(); + return `/api/projects/${projectId}/volumes/${volId}/download${qs ? `?${qs}` : ''}`; +} + +export async function uploadToVolume( + projectId: string, + volId: string, + files: FileList, + params?: { path?: string; stage?: string; tag?: string } +): Promise<{ uploaded: string[]; count: number }> { + const query = new URLSearchParams(); + if (params?.path) query.set('path', params.path); + if (params?.stage) query.set('stage', params.stage); + if (params?.tag) query.set('tag', params.tag); + const qs = query.toString(); + + const formData = new FormData(); + for (let i = 0; i < files.length; i++) { + formData.append('files', files[i]); + } + + const token = typeof localStorage !== 'undefined' ? localStorage.getItem('auth_token') : null; + const headers: Record = {}; + if (token) headers['Authorization'] = `Bearer ${token}`; + + const res = await fetch(`/api/projects/${projectId}/volumes/${volId}/upload${qs ? `?${qs}` : ''}`, { + method: 'POST', + headers, + body: formData, + }); + + const envelope = await res.json(); + if (!envelope.success) throw new Error(envelope.error ?? 'Upload failed'); + return envelope.data; +} + // ── Event Log ─────────────────────────────────────────────────────── export function fetchEventLog(params?: { diff --git a/web/src/lib/i18n/en.json b/web/src/lib/i18n/en.json index efda799..74025ef 100644 --- a/web/src/lib/i18n/en.json +++ b/web/src/lib/i18n/en.json @@ -144,6 +144,22 @@ "updateFailed": "Failed to update volume", "deleteFailed": "Failed to delete volume" }, + "volumeBrowser": { + "title": "Volume Browser", + "loadFailed": "Failed to load directory", + "empty": "This directory is empty.", + "name": "Name", + "size": "Size", + "modified": "Modified", + "downloadAll": "Download volume as ZIP", + "downloadFolder": "Download folder as ZIP", + "upload": "Upload files", + "uploaded": "Uploaded", + "files": "file(s)", + "uploadFailed": "Failed to upload files", + "browse": "Browse", + "download": "Download" + }, "quickDeploy": { "title": "Quick Deploy", "description": "Deploy a container image with zero configuration. Paste an image URL, review the defaults, and deploy.", diff --git a/web/src/lib/i18n/ru.json b/web/src/lib/i18n/ru.json index 4bb6005..c884f71 100644 --- a/web/src/lib/i18n/ru.json +++ b/web/src/lib/i18n/ru.json @@ -144,6 +144,22 @@ "updateFailed": "Не удалось обновить том", "deleteFailed": "Не удалось удалить том" }, + "volumeBrowser": { + "title": "Обзор тома", + "loadFailed": "Не удалось загрузить каталог", + "empty": "Этот каталог пуст.", + "name": "Имя", + "size": "Размер", + "modified": "Изменён", + "downloadAll": "Скачать том как ZIP", + "downloadFolder": "Скачать папку как ZIP", + "upload": "Загрузить файлы", + "uploaded": "Загружено", + "files": "файл(ов)", + "uploadFailed": "Не удалось загрузить файлы", + "browse": "Обзор", + "download": "Скачать" + }, "quickDeploy": { "title": "Быстрый деплой", "description": "Разверните образ контейнера без настройки. Вставьте URL образа, проверьте параметры и разверните.", diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index ac7b78a..e9ea7a3 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -107,6 +107,7 @@ export interface Settings { base_volume_path: string; ssl_certificate_id: number; stale_threshold_days: number; + allowed_volume_paths: string; updated_at: string; } @@ -162,7 +163,7 @@ export interface EntityPickerItem { } /** Volume scope determines the sharing level. */ -export type VolumeScope = 'instance' | 'stage' | 'project' | 'project_named' | 'named' | 'ephemeral'; +export type VolumeScope = 'instance' | 'stage' | 'project' | 'project_named' | 'named' | 'ephemeral' | 'absolute'; /** Volume mount configuration for a project. */ export interface Volume { @@ -185,6 +186,20 @@ export interface VolumeScopeInfo { path_example: string; } +/** A file or directory entry in a volume listing. */ +export interface FileEntry { + name: string; + is_dir: boolean; + size: number; + mod_time: string; +} + +/** Response from the volume browse endpoint. */ +export interface BrowseResult { + path: string; + entries: FileEntry[]; +} + /** Docker daemon health check result. */ export interface DockerHealth { connected: boolean; diff --git a/web/src/routes/projects/[id]/volumes/+page.svelte b/web/src/routes/projects/[id]/volumes/+page.svelte index add58fd..3ceca97 100644 --- a/web/src/routes/projects/[id]/volumes/+page.svelte +++ b/web/src/routes/projects/[id]/volumes/+page.svelte @@ -4,9 +4,13 @@ import * as api from '$lib/api'; import { toasts } from '$lib/stores/toast'; import { t } from '$lib/i18n'; - import { IconChevronRight, IconPlus, IconEdit, IconTrash, IconCheck, IconX } from '$lib/components/icons'; + import { IconChevronRight, IconPlus, IconEdit, IconTrash, IconCheck, IconX, IconSearch, IconExternalLink } from '$lib/components/icons'; import Skeleton from '$lib/components/Skeleton.svelte'; + function downloadUrl(volId: string): string { + return api.volumeDownloadUrl(projectId, volId); + } + let volumes = $state([]); let scopeInfos = $state([]); let loading = $state(true); @@ -253,6 +257,14 @@
+ {#if vol.scope !== 'ephemeral'} + + + + + + + {/if}
diff --git a/web/src/routes/projects/[id]/volumes/[volId]/browse/+page.svelte b/web/src/routes/projects/[id]/volumes/[volId]/browse/+page.svelte new file mode 100644 index 0000000..d28f5f5 --- /dev/null +++ b/web/src/routes/projects/[id]/volumes/[volId]/browse/+page.svelte @@ -0,0 +1,242 @@ + + + + {$t('volumeBrowser.title')} - {$t('app.name')} + + +
+ +
+ +
+

{$t('volumeBrowser.title')}

+
+ + +
+
+
+ + + + + {#if loading} + + {:else if error} +
+

{error}

+ +
+ {:else if entries.length === 0} +
+

{$t('volumeBrowser.empty')}

+
+ {:else} +
+ + + + + + + + + + {#if currentPath} + { + const parts = currentPath.split('/').filter(Boolean); + parts.pop(); + navigateTo(parts.join('/')); + }}> + + + + + {/if} + {#each entries.sort((a, b) => { + if (a.is_dir !== b.is_dir) return a.is_dir ? -1 : 1; + return a.name.localeCompare(b.name); + }) as entry (entry.name)} + handleEntryClick(entry)} + > + + + + + {/each} + +
{$t('volumeBrowser.name')}{$t('volumeBrowser.size')}{$t('volumeBrowser.modified')}
+ 📁.. +
+ {fileIcon(entry)} + {#if entry.is_dir} + {entry.name} + {:else} + {entry.name} + {/if} + + {entry.is_dir ? '—' : formatSize(entry.size)} + + {formatDate(entry.mod_time)} +
+
+ {/if} +
diff --git a/web/src/routes/projects/[id]/volumes/[volId]/browse/+page.ts b/web/src/routes/projects/[id]/volumes/[volId]/browse/+page.ts new file mode 100644 index 0000000..a3d1578 --- /dev/null +++ b/web/src/routes/projects/[id]/volumes/[volId]/browse/+page.ts @@ -0,0 +1 @@ +export const ssr = false;