Files
tiny-forge/internal/api/volumes.go
T
alexei.dolgolyov bb2729ad12 fix: address volume scopes review findings
- CRITICAL: validate volume Name against path traversal (safe regex)
- HIGH: log data migration errors instead of silently ignoring
- HIGH: reject empty source when switching from ephemeral scope
2026-03-31 23:31:27 +03:00

274 lines
8.1 KiB
Go

package api
import (
"errors"
"log/slog"
"net/http"
"path/filepath"
"regexp"
"strings"
"github.com/go-chi/chi/v5"
"github.com/alexei/docker-watcher/internal/store"
)
// safeNamePattern restricts volume names to alphanumeric, dash, underscore, and dot.
var safeNamePattern = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._-]*$`)
// validateVolumePath checks that the source path does not contain path traversal.
func validateVolumePath(source string) bool {
cleaned := filepath.Clean(source)
return !strings.Contains(cleaned, "..")
}
// volumeRequest is the expected JSON body for creating/updating a volume.
type volumeRequest struct {
Source string `json:"source"`
Target string `json:"target"`
Mode string `json:"mode,omitempty"` // legacy — ignored if scope is set
Scope string `json:"scope"`
Name string `json:"name"`
}
// validScopes lists all accepted scope values.
var validScopes = map[string]bool{
"instance": true, "stage": true, "project": true,
"project_named": true, "named": true, "ephemeral": true,
}
// validateVolumeScope validates the scope and name combination.
func validateVolumeScope(scope, name string) string {
if !validScopes[scope] {
return "scope must be one of: instance, stage, project, project_named, named, ephemeral"
}
if (scope == "project_named" || scope == "named") && strings.TrimSpace(name) == "" {
return "name is required for " + scope + " scope"
}
if name != "" && !safeNamePattern.MatchString(name) {
return "name must start with a letter or digit and contain only letters, digits, dashes, underscores, or dots"
}
return ""
}
// scopeDescriptions returns metadata about each scope for the UI.
type scopeInfo struct {
Scope string `json:"scope"`
Description string `json:"description"`
NeedsName bool `json:"needs_name"`
PathExample string `json:"path_example"`
}
// listVolumeScopes handles GET /api/volumes/scopes.
// Returns all available scopes with descriptions for UI hints.
func (s *Server) listVolumeScopes(w http.ResponseWriter, r *http.Request) {
scopes := []scopeInfo{
{
Scope: "instance",
Description: "Each deploy gets its own isolated directory. Data is not shared between deploys.",
NeedsName: false,
PathExample: "{base}/{project}/{stage}-{tag}/{source}",
},
{
Scope: "stage",
Description: "All deploys within the same stage share this volume. Data persists across blue-green deployments.",
NeedsName: false,
PathExample: "{base}/{project}/{stage}/{source}",
},
{
Scope: "project",
Description: "Shared across all stages of the project. Good for common config or shared assets.",
NeedsName: false,
PathExample: "{base}/{project}/{source}",
},
{
Scope: "project_named",
Description: "A named volume within the project. Multiple stages can reference the same name to share data selectively.",
NeedsName: true,
PathExample: "{base}/{project}/_named/{name}/{source}",
},
{
Scope: "named",
Description: "A globally named volume shared across projects. Use for cross-project resources like shared databases.",
NeedsName: true,
PathExample: "{base}/_named/{name}/{source}",
},
{
Scope: "ephemeral",
Description: "In-memory tmpfs mount. Fast but data is lost when the container stops. Good for temp files and caches.",
NeedsName: false,
PathExample: "(tmpfs — no host path)",
},
}
respondJSON(w, http.StatusOK, scopes)
}
// listVolumes handles GET /api/projects/{id}/volumes.
func (s *Server) listVolumes(w http.ResponseWriter, r *http.Request) {
projectID := chi.URLParam(r, "id")
if _, err := s.store.GetProjectByID(projectID); err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "project")
return
}
slog.Error("failed to get project", "project_id", projectID, "error", err)
respondError(w, http.StatusInternalServerError, "failed to get project")
return
}
vols, err := s.store.GetVolumesByProjectID(projectID)
if err != nil {
slog.Error("failed to list volumes", "project_id", projectID, "error", err)
respondError(w, http.StatusInternalServerError, "failed to list volumes")
return
}
respondJSON(w, http.StatusOK, vols)
}
// createVolume handles POST /api/projects/{id}/volumes.
func (s *Server) createVolume(w http.ResponseWriter, r *http.Request) {
projectID := chi.URLParam(r, "id")
if _, err := s.store.GetProjectByID(projectID); err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "project")
return
}
slog.Error("failed to get project", "project_id", projectID, "error", err)
respondError(w, http.StatusInternalServerError, "failed to get project")
return
}
var req volumeRequest
if !decodeJSON(w, r, &req) {
return
}
// Ephemeral volumes don't need a source path.
if req.Scope != "ephemeral" {
if req.Source == "" {
respondError(w, http.StatusBadRequest, "source is required")
return
}
if !validateVolumePath(req.Source) {
respondError(w, http.StatusBadRequest, "source path must not contain '..'")
return
}
}
if req.Target == "" {
respondError(w, http.StatusBadRequest, "target is required")
return
}
if !validateVolumePath(req.Target) {
respondError(w, http.StatusBadRequest, "target path must not contain '..'")
return
}
// Resolve scope — support legacy mode field.
scope := req.Scope
if scope == "" {
switch req.Mode {
case "isolated":
scope = "instance"
case "shared":
scope = "project"
default:
scope = "project"
}
}
if errMsg := validateVolumeScope(scope, req.Name); errMsg != "" {
respondError(w, http.StatusBadRequest, errMsg)
return
}
vol, err := s.store.CreateVolume(store.Volume{
ProjectID: projectID,
Source: req.Source,
Target: req.Target,
Scope: scope,
Name: strings.TrimSpace(req.Name),
})
if err != nil {
slog.Error("failed to create volume", "project_id", projectID, "error", err)
respondError(w, http.StatusInternalServerError, "failed to create volume")
return
}
respondJSON(w, http.StatusCreated, vol)
}
// updateVolume handles PUT /api/projects/{id}/volumes/{volId}.
func (s *Server) updateVolume(w http.ResponseWriter, r *http.Request) {
volID := chi.URLParam(r, "volId")
existing, err := s.store.GetVolumeByID(volID)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "volume")
return
}
slog.Error("failed to get volume", "volume_id", volID, "error", err)
respondError(w, http.StatusInternalServerError, "failed to get volume")
return
}
var req volumeRequest
if !decodeJSON(w, r, &req) {
return
}
updated := existing
if req.Source != "" {
if !validateVolumePath(req.Source) {
respondError(w, http.StatusBadRequest, "source path must not contain '..'")
return
}
updated.Source = req.Source
}
if req.Target != "" {
if !validateVolumePath(req.Target) {
respondError(w, http.StatusBadRequest, "target path must not contain '..'")
return
}
updated.Target = req.Target
}
if req.Scope != "" {
if errMsg := validateVolumeScope(req.Scope, req.Name); errMsg != "" {
respondError(w, http.StatusBadRequest, errMsg)
return
}
updated.Scope = req.Scope
updated.Name = strings.TrimSpace(req.Name)
}
// Non-ephemeral scopes require a source path.
if updated.Scope != "ephemeral" && updated.Source == "" {
respondError(w, http.StatusBadRequest, "source is required for non-ephemeral scopes")
return
}
if err := s.store.UpdateVolume(updated); err != nil {
slog.Error("failed to update volume", "volume_id", volID, "error", err)
respondError(w, http.StatusInternalServerError, "failed to update volume")
return
}
respondJSON(w, http.StatusOK, updated)
}
// deleteVolume handles DELETE /api/projects/{id}/volumes/{volId}.
func (s *Server) deleteVolume(w http.ResponseWriter, r *http.Request) {
volID := chi.URLParam(r, "volId")
if err := s.store.DeleteVolume(volID); err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "volume")
return
}
slog.Error("failed to delete volume", "volume_id", volID, "error", err)
respondError(w, http.StatusInternalServerError, "failed to delete volume")
return
}
respondJSON(w, http.StatusOK, map[string]string{"deleted": volID})
}