package api import ( "errors" "fmt" "io" "log/slog" "net/http" "path/filepath" "strings" "github.com/go-chi/chi/v5" "github.com/alexei/docker-watcher/internal/store" "github.com/alexei/docker-watcher/internal/volume" ) // sanitizeFilename removes characters unsafe for Content-Disposition headers. func sanitizeFilename(name string) string { return strings.Map(func(r rune) rune { if r == '"' || r == '\\' || r == '\n' || r == '\r' { return '_' } return r }, name) } 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 } // Verify volume belongs to this project. if vol.ProjectID != projectID { respondNotFound(w, "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, "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 with forced download. safeName := sanitizeFilename(filepath.Base(relPath)) w.Header().Set("Content-Type", "application/octet-stream") w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, safeName)) 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) { safeName := sanitizeFilename(name) w.Header().Set("Content-Type", "application/zip") w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s.zip"`, safeName)) 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. Overrides the global body limit for large files. func (s *Server) uploadToVolume(w http.ResponseWriter, r *http.Request) { // Override the global 1MB body limit for uploads. r.Body = http.MaxBytesReader(w, r.Body, maxUploadSize) 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 } // Strip directory components from filename to prevent directory creation attacks. targetRel := filepath.Join(relPath, filepath.Base(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), }) }