bb2729ad12
- 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
274 lines
8.1 KiB
Go
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})
|
|
}
|