package api import ( "errors" "net/http" "path/filepath" "strings" "github.com/go-chi/chi/v5" "github.com/alexei/docker-watcher/internal/store" ) // 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"` } // listVolumes handles GET /api/projects/{id}/volumes. func (s *Server) listVolumes(w http.ResponseWriter, r *http.Request) { projectID := chi.URLParam(r, "id") // Verify project exists. if _, err := s.store.GetProjectByID(projectID); err != nil { if errors.Is(err, store.ErrNotFound) { respondNotFound(w, "project") return } respondError(w, http.StatusInternalServerError, "failed to get project: "+err.Error()) return } vols, err := s.store.GetVolumesByProjectID(projectID) if err != nil { respondError(w, http.StatusInternalServerError, "failed to list volumes: "+err.Error()) 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") // Verify project exists. if _, err := s.store.GetProjectByID(projectID); err != nil { if errors.Is(err, store.ErrNotFound) { respondNotFound(w, "project") return } respondError(w, http.StatusInternalServerError, "failed to get project: "+err.Error()) return } var req volumeRequest if !decodeJSON(w, r, &req) { return } if req.Source == "" { respondError(w, http.StatusBadRequest, "source is required") return } if req.Target == "" { respondError(w, http.StatusBadRequest, "target is required") return } if !validateVolumePath(req.Source) { respondError(w, http.StatusBadRequest, "source path must not contain '..'") return } if !validateVolumePath(req.Target) { respondError(w, http.StatusBadRequest, "target path must not contain '..'") return } if req.Mode == "" { req.Mode = "shared" } if req.Mode != "shared" && req.Mode != "isolated" { respondError(w, http.StatusBadRequest, "mode must be 'shared' or 'isolated'") return } vol, err := s.store.CreateVolume(store.Volume{ ProjectID: projectID, Source: req.Source, Target: req.Target, Mode: req.Mode, }) if err != nil { respondError(w, http.StatusInternalServerError, "failed to create volume: "+err.Error()) 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 } respondError(w, http.StatusInternalServerError, "failed to get volume: "+err.Error()) return } var req volumeRequest if !decodeJSON(w, r, &req) { return } updated := existing if req.Source != "" { updated.Source = req.Source } if req.Target != "" { updated.Target = req.Target } if req.Mode != "" { if req.Mode != "shared" && req.Mode != "isolated" { respondError(w, http.StatusBadRequest, "mode must be 'shared' or 'isolated'") return } updated.Mode = req.Mode } if err := s.store.UpdateVolume(updated); err != nil { respondError(w, http.StatusInternalServerError, "failed to update volume: "+err.Error()) 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 } respondError(w, http.StatusInternalServerError, "failed to delete volume: "+err.Error()) return } respondJSON(w, http.StatusOK, map[string]string{"deleted": volID}) }