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
+13 -25
View File
@@ -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{