Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ Currently works with npm, PyPI, pub.dev, Composer, and Cargo, which all include
|----------|-------------------|:--------:|:---------:|
| npm | JavaScript | Yes | ✓ |
| Cargo | Rust | Yes | ✓ |
| RubyGems | Ruby | | ✓ |
| RubyGems | Ruby | Yes | ✓ |
| Go proxy | Go | | ✓ |
| Hex | Elixir | | ✓ |
| pub.dev | Dart | Yes | ✓ |
Expand Down
2 changes: 1 addition & 1 deletion docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -209,7 +209,7 @@ Durations support days (`7d`), hours (`48h`), and minutes (`30m`). Set to `0` to

Resolution order: package override, then ecosystem override, then global default. This lets you set a conservative default while exempting trusted packages.

Currently supported for npm, PyPI, pub.dev, Composer, Cargo, NuGet, and Conda. These ecosystems include publish timestamps in their metadata.
Currently supported for npm, PyPI, pub.dev, Composer, Cargo, NuGet, Conda, and RubyGems. These ecosystems include publish timestamps in their metadata.

## Docker

Expand Down
192 changes: 191 additions & 1 deletion internal/handler/gem.go
Original file line number Diff line number Diff line change
@@ -1,10 +1,15 @@
package handler

import (
"bufio"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"

"github.com/git-pkgs/purl"
)

const (
Expand Down Expand Up @@ -41,7 +46,7 @@ func (h *GemHandler) Routes() http.Handler {

// Compact index (bundler 2.x+)
mux.HandleFunc("GET /versions", h.proxyUpstream)
mux.HandleFunc("GET /info/{name}", h.proxyUpstream)
mux.HandleFunc("GET /info/{name}", h.handleCompactIndex)

// Quick index
mux.HandleFunc("GET /quick/Marshal.4.8/{filename}", h.proxyUpstream)
Expand Down Expand Up @@ -98,6 +103,191 @@ func (h *GemHandler) parseGemFilename(filename string) (name, version string) {
return "", ""
}

// handleCompactIndex serves the compact index for a gem, filtering versions
// based on cooldown when enabled.
func (h *GemHandler) handleCompactIndex(w http.ResponseWriter, r *http.Request) {
if h.proxy.Cooldown == nil || !h.proxy.Cooldown.Enabled() {
h.proxyUpstream(w, r)
return
}

name := r.PathValue("name")
if name == "" {
http.Error(w, "invalid gem name", http.StatusBadRequest)
return
}

h.proxy.Logger.Info("gem compact index request with cooldown", "name", name)

indexResp, filteredVersions, err := h.fetchIndexAndVersions(r, name)
if err != nil {
h.proxy.Logger.Error("upstream compact index request failed", "error", err)
http.Error(w, "upstream request failed", http.StatusBadGateway)
return
}
defer func() { _ = indexResp.Body.Close() }()

if indexResp.StatusCode != http.StatusOK {
copyResponseHeaders(w, indexResp.Header)
w.WriteHeader(indexResp.StatusCode)
_, _ = io.Copy(w, indexResp.Body)
return
}

if filteredVersions == nil {
h.proxy.Logger.Warn("failed to fetch version timestamps, proxying unfiltered", "name", name)
copyResponseHeaders(w, indexResp.Header)
w.WriteHeader(http.StatusOK)
_, _ = io.Copy(w, indexResp.Body)
return
}

h.writeFilteredIndex(w, indexResp, name, filteredVersions)
}

// fetchIndexAndVersions fetches the compact index and versions API concurrently.
// Returns the index response, a set of versions to filter (nil if versions API failed),
// and an error if the index fetch itself failed.
func (h *GemHandler) fetchIndexAndVersions(r *http.Request, name string) (*http.Response, map[string]bool, error) {
type versionsResult struct {
filtered map[string]bool
err error
}

versionsCh := make(chan versionsResult, 1)
go func() {
filtered, err := h.fetchFilteredVersions(r, name)
versionsCh <- versionsResult{filtered: filtered, err: err}
}()

indexResp, err := h.fetchCompactIndex(r, name)

versionsRes := <-versionsCh

if err != nil {
return nil, nil, err
}

if versionsRes.err != nil {
return indexResp, nil, nil
}

return indexResp, versionsRes.filtered, nil
}

// fetchCompactIndex fetches the compact index from upstream.
func (h *GemHandler) fetchCompactIndex(r *http.Request, name string) (*http.Response, error) {
indexURL := h.upstreamURL + "/info/" + name
req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, indexURL, nil)
if err != nil {
return nil, err
}
for _, hdr := range []string{"Accept", "Accept-Encoding", "If-None-Match", "If-Modified-Since"} {
if v := r.Header.Get(hdr); v != "" {
req.Header.Set(hdr, v)
}
}
return h.proxy.HTTPClient.Do(req)
}

// writeFilteredIndex writes the compact index response with cooldown-filtered versions removed.
func (h *GemHandler) writeFilteredIndex(w http.ResponseWriter, resp *http.Response, name string, filtered map[string]bool) {
for k, vv := range resp.Header {
if strings.EqualFold(k, "Content-Length") {
continue // length will change after filtering
}
for _, v := range vv {
w.Header().Add(k, v)
}
}
w.WriteHeader(http.StatusOK)

scanner := bufio.NewScanner(resp.Body)
for scanner.Scan() {
line := scanner.Text()

if line == "---" {
_, _ = fmt.Fprintln(w, line)
continue
}

version := line
if spaceIdx := strings.IndexByte(line, ' '); spaceIdx > 0 {
version = line[:spaceIdx]
}

if filtered[version] {
h.proxy.Logger.Info("cooldown: filtering gem version",
"gem", name, "version", version)
continue
}

_, _ = fmt.Fprintln(w, line)
}
}

// copyResponseHeaders copies HTTP headers from a response to a writer.
func copyResponseHeaders(w http.ResponseWriter, headers http.Header) {
for k, vv := range headers {
for _, v := range vv {
w.Header().Add(k, v)
}
}
}

// gemVersion represents a version entry from the RubyGems versions API.
type gemVersion struct {
Number string `json:"number"`
Platform string `json:"platform"`
CreatedAt string `json:"created_at"`
}

// fetchFilteredVersions fetches the versions API and returns a set of version
// strings that should be filtered out by cooldown.
func (h *GemHandler) fetchFilteredVersions(r *http.Request, name string) (map[string]bool, error) {
versionsURL := fmt.Sprintf("%s/api/v1/versions/%s.json", h.upstreamURL, name)
req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, versionsURL, nil)
if err != nil {
return nil, err
}

resp, err := h.proxy.HTTPClient.Do(req)
if err != nil {
return nil, err
}
defer func() { _ = resp.Body.Close() }()

if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("versions API returned %d", resp.StatusCode)
}

var versions []gemVersion
if err := json.NewDecoder(resp.Body).Decode(&versions); err != nil {
return nil, err
}

packagePURL := purl.MakePURLString("gem", name, "")
filtered := make(map[string]bool)

for _, v := range versions {
createdAt, err := time.Parse(time.RFC3339, v.CreatedAt)
if err != nil {
continue
}

if !h.proxy.Cooldown.IsAllowed("gem", packagePURL, createdAt) {
// Build version string matching compact index format
versionStr := v.Number
if v.Platform != "" && v.Platform != "ruby" {
versionStr = v.Number + "-" + v.Platform
}
filtered[versionStr] = true
}
}

return filtered, nil
}

// proxyUpstream forwards a request to rubygems.org without caching.
func (h *GemHandler) proxyUpstream(w http.ResponseWriter, r *http.Request) {
upstreamURL := h.upstreamURL + r.URL.Path
Expand Down
Loading