package api import ( "errors" "fmt" "io" "log/slog" "net/http" "path/filepath" "github.com/go-chi/chi/v5" "github.com/alexei/docker-watcher/internal/store" "github.com/alexei/docker-watcher/internal/volume" ) const maxUploadSize = 100 * 1024 * 1024 // 100MB // resolveVolumeRoot looks up a volume and resolves its host path. func (s *Server) resolveVolumeRoot(w http.ResponseWriter, r *http.Request) (string, bool) { projectID := chi.URLParam(r, "id") volID := chi.URLParam(r, "volId") proj, err := s.store.GetProjectByID(projectID) if err != nil { if errors.Is(err, store.ErrNotFound) { respondNotFound(w, "project") return "", false } slog.Error("failed to get project", "project_id", projectID, "error", err) respondError(w, http.StatusInternalServerError, "failed to get project") return "", false } vol, err := s.store.GetVolumeByID(volID) if err != nil { if errors.Is(err, store.ErrNotFound) { respondNotFound(w, "volume") return "", false } slog.Error("failed to get volume", "volume_id", volID, "error", err) respondError(w, http.StatusInternalServerError, "failed to get volume") return "", false } if vol.Scope == "ephemeral" { respondError(w, http.StatusBadRequest, "ephemeral volumes have no host path to browse") return "", false } settings, err := s.store.GetSettings() if err != nil { slog.Error("failed to get settings", "error", err) respondError(w, http.StatusInternalServerError, "failed to get settings") return "", false } q := r.URL.Query() params := volume.ResolveParams{ BasePath: settings.BaseVolumePath, ProjectName: proj.Name, StageName: q.Get("stage"), ImageTag: q.Get("tag"), } rootPath, err := volume.ResolvePath(vol, params) if err != nil { respondError(w, http.StatusBadRequest, err.Error()) return "", false } return rootPath, true } // browseVolume handles GET /api/projects/{id}/volumes/{volId}/browse?path=&stage=&tag= func (s *Server) browseVolume(w http.ResponseWriter, r *http.Request) { rootPath, ok := s.resolveVolumeRoot(w, r) if !ok { return } relPath := r.URL.Query().Get("path") entries, err := volume.ListDir(rootPath, relPath) if err != nil { slog.Error("failed to list directory", "root", rootPath, "path", relPath, "error", err) respondError(w, http.StatusInternalServerError, "failed to list directory") return } respondJSON(w, http.StatusOK, map[string]any{ "path": relPath, "root": rootPath, "entries": entries, }) } // downloadVolume handles GET /api/projects/{id}/volumes/{volId}/download?path=&stage=&tag= // Downloads a single file directly, or a directory/root as a zip archive. func (s *Server) downloadVolume(w http.ResponseWriter, r *http.Request) { rootPath, ok := s.resolveVolumeRoot(w, r) if !ok { return } relPath := r.URL.Query().Get("path") // If path is empty or points to a directory, serve as zip. if relPath == "" { s.serveZip(w, rootPath, "", "volume") return } // Check if it's a file or directory. f, info, err := volume.OpenFile(rootPath, relPath) if err != nil { // Might be a directory — try zip. entries, listErr := volume.ListDir(rootPath, relPath) if listErr == nil && entries != nil { name := filepath.Base(relPath) s.serveZip(w, rootPath, relPath, name) return } slog.Error("failed to open file", "root", rootPath, "path", relPath, "error", err) respondError(w, http.StatusNotFound, "file not found") return } defer f.Close() // Serve single file. w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filepath.Base(relPath))) w.Header().Set("Content-Length", fmt.Sprintf("%d", info.Size())) w.WriteHeader(http.StatusOK) io.Copy(w, f) } func (s *Server) serveZip(w http.ResponseWriter, rootPath, relPath, name string) { w.Header().Set("Content-Type", "application/zip") w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s.zip"`, name)) w.WriteHeader(http.StatusOK) if err := volume.WriteZip(rootPath, relPath, w); err != nil { slog.Error("failed to write zip", "root", rootPath, "path", relPath, "error", err) } } // uploadToVolume handles POST /api/projects/{id}/volumes/{volId}/upload?path=&stage=&tag= // Accepts multipart form uploads. func (s *Server) uploadToVolume(w http.ResponseWriter, r *http.Request) { rootPath, ok := s.resolveVolumeRoot(w, r) if !ok { return } if err := r.ParseMultipartForm(maxUploadSize); err != nil { respondError(w, http.StatusBadRequest, "upload too large (max 100MB)") return } relPath := r.URL.Query().Get("path") uploaded := []string{} for _, fileHeaders := range r.MultipartForm.File { for _, fh := range fileHeaders { f, err := fh.Open() if err != nil { slog.Error("failed to open upload", "filename", fh.Filename, "error", err) continue } targetRel := filepath.Join(relPath, fh.Filename) if err := volume.SaveFile(rootPath, targetRel, f); err != nil { f.Close() slog.Error("failed to save upload", "filename", fh.Filename, "error", err) respondError(w, http.StatusInternalServerError, "failed to save file: "+fh.Filename) return } f.Close() uploaded = append(uploaded, fh.Filename) } } respondJSON(w, http.StatusOK, map[string]any{ "uploaded": uploaded, "count": len(uploaded), }) }