package api import ( "errors" "log/slog" "net/http" "path/filepath" "regexp" "strings" "github.com/go-chi/chi/v5" "github.com/alexei/tinyforge/internal/store" "github.com/alexei/tinyforge/internal/volume" ) // 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, "absolute": true, } // 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, absolute" } 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" } 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 "" } // 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)", }, { 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) } // 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" } } // 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 } 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 != "" { 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 } 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}) }