Skip to content
Draft
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
213 changes: 213 additions & 0 deletions badge.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
package main

import (
"context"
"fmt"
"log"
"net/http"
"time"
)

const (
// Badge colors (shields.io style)
colorGreen = "#4c1" // Bright green - highly available (2+ providers)
colorYellow = "#dfb317" // Yellow - low availability (1 provider)
colorRed = "#e05d44" // Red - unavailable (0 providers)
colorGray = "#9f9f9f" // Gray - error/unknown state

// Badge dimensions
labelWidth = 32 // Width for "IPFS" label
badgeHeight = 20
)

// generateBadgeSVG creates a shields.io-style SVG badge showing provider count and CID suffix.
func generateBadgeSVG(providerCount int, cidSuffix string) []byte {
var color, status string

switch {
case providerCount == 0:
color = colorRed
status = fmt.Sprintf("unavailable ...%s", cidSuffix)
case providerCount == 1:
color = colorYellow
status = fmt.Sprintf("1 provider ...%s", cidSuffix)
default:
color = colorGreen
status = fmt.Sprintf("%d providers ...%s", providerCount, cidSuffix)
}

return buildBadgeSVG("IPFS", status, color)
}

// getCIDSuffix returns the last 6 characters of a CID string.
func getCIDSuffix(cidStr string) string {
if len(cidStr) <= 6 {
return cidStr
}
return cidStr[len(cidStr)-6:]
}

// generateErrorBadgeSVG creates a badge indicating an error occurred.
func generateErrorBadgeSVG(cidSuffix string) []byte {
return buildBadgeSVG("IPFS", fmt.Sprintf("error ...%s", cidSuffix), colorGray)
}

// generatePendingBadgeSVG creates a badge indicating the check is in progress.
func generatePendingBadgeSVG(cidSuffix string) []byte {
return buildBadgeSVG("IPFS", fmt.Sprintf("checking ...%s", cidSuffix), colorGray)
}

// buildBadgeSVG constructs the SVG markup for a badge with given label, status, and color.
func buildBadgeSVG(label, status, color string) []byte {
// Calculate widths based on text length (approximate)
statusWidth := len(status)*7 + 10
totalWidth := labelWidth + statusWidth
labelX := labelWidth / 2
statusX := labelWidth + statusWidth/2

svg := fmt.Sprintf(`<svg xmlns="http://www.w3.org/2000/svg" width="%d" height="%d">
<linearGradient id="b" x2="0" y2="100%%">
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
<stop offset="1" stop-opacity=".1"/>
</linearGradient>
<clipPath id="a">
<rect width="%d" height="%d" rx="3" fill="#fff"/>
</clipPath>
<g clip-path="url(#a)">
<rect width="%d" height="%d" fill="#555"/>
<rect x="%d" width="%d" height="%d" fill="%s"/>
<rect width="%d" height="%d" fill="url(#b)"/>
</g>
<g fill="#fff" text-anchor="middle" font-family="DejaVu Sans,Verdana,Geneva,sans-serif" font-size="11">
<text x="%d" y="15" fill="#010101" fill-opacity=".3">%s</text>
<text x="%d" y="14">%s</text>
<text x="%d" y="15" fill="#010101" fill-opacity=".3">%s</text>
<text x="%d" y="14">%s</text>
</g>
</svg>`,
totalWidth, badgeHeight,
totalWidth, badgeHeight,
labelWidth, badgeHeight,
labelWidth, statusWidth, badgeHeight, color,
totalWidth, badgeHeight,
labelX, label,
labelX, label,
statusX, status,
statusX, status,
)

return []byte(svg)
}
Comment on lines +60 to +100
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️SECURITY HOLE: please escape user-derived text before interpolating into SVG

status carries user input (the last 6 chars of the raw cid query param) and is dropped into <text>...</text> with fmt.Sprintf, no escaping. The 6-char window makes a clean <script> injection impractical today, so this is more defense-in-depth than an exploitable bug, but the response is image/svg+xml (which browsers happily parse as XML, scripts and all) so it's a sharp edge worth removing.

Run label and status through html.EscapeString (or xml.EscapeText) before formatting. Cheap, and means a future change to the suffix length or wording can't accidentally turn this into a real XSS.


// countWorkingProviders counts providers that are connected and have data available.
func countWorkingProviders(providers *[]providerOutput) int {
if providers == nil {
return 0
}

count := 0
for _, p := range *providers {
if p.ConnectionError == "" &&
(p.DataAvailableOverBitswap.Found || p.DataAvailableOverHTTP.Found) {
count++
}
}
return count
}

// BadgeHandler handles HTTP requests for badge images.
type BadgeHandler struct {
daemon *daemon
cache *BadgeCache
checkTimeout time.Duration
}

// NewBadgeHandler creates a new badge handler.
func NewBadgeHandler(d *daemon, cache *BadgeCache, checkTimeout time.Duration) *BadgeHandler {
return &BadgeHandler{
daemon: d,
cache: cache,
checkTimeout: checkTimeout,
}
}

// ServeHTTP handles badge requests.
// For uncached CIDs, it returns a "checking..." badge immediately and triggers
// a background check. Subsequent requests will receive the cached result.
func (h *BadgeHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")

cidStr := r.URL.Query().Get("cid")
if cidStr == "" {
http.Error(w, "missing 'cid' query parameter", http.StatusBadRequest)
return
}

cidSuffix := getCIDSuffix(cidStr)

// Try to get from cache first (includes completed results)
if entry, ok := h.cache.Get(cidStr); ok {
if !entry.Pending {
// Completed result - serve it
h.serveSVG(w, entry.SVG)
return
}
// Still pending - serve the pending badge
h.serveSVG(w, entry.SVG)
return
}

// Check if already pending (avoid duplicate background checks)
if h.cache.IsPending(cidStr) {
svg := generatePendingBadgeSVG(cidSuffix)
h.serveSVG(w, svg)
return
}

// Not in cache and not pending - start background check
pendingSVG := generatePendingBadgeSVG(cidSuffix)
h.cache.SetPending(cidStr, cidSuffix, pendingSVG)

// Trigger background check
go h.runBackgroundCheck(cidStr, cidSuffix)

// Return pending badge immediately
h.serveSVG(w, pendingSVG)
Comment on lines +148 to +175
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The "avoid duplicate background checks" guarantee races

The dedup logic is split across three independent locked calls:

if entry, ok := h.cache.Get(cidStr); ok { ... }
if h.cache.IsPending(cidStr) { ... }
h.cache.SetPending(cidStr, ...)
go h.runBackgroundCheck(...)

Two concurrent first-time requests for the same CID can both pass Get and IsPending, both hit SetPending, and both spawn a goroutine. The PR description claims this is prevented, but the implementation does not enforce it.

Consider collapsing into one atomic call on BadgeCache, e.g. TryStartCheck(cid, suffix, pendingSVG) (entry *BadgeCacheEntry, started bool) that does the get-or-set under a single write lock and tells the caller whether they should kick off the goroutine.

Bonus: the twoif entry.Pending branches at lines 150-157 currently do the same thing (both call serveSVG(w, entry.SVG)). Either drop the branch or make the pending case actually behave differently.

}
Comment on lines +137 to +176
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️SECURITY HOLE: please validate the cid before doing any work

Right now cidStr comes straight from the query string and is used as the cache key, the SVG status text, and the input to a background runCidCheck goroutine. The first time we actually try to parse it is inside the goroutine, via resolveInput.

Because the endpoint is opt-in but CORS-open (*), anyone can hit /badge?cid=<random> in a loop and trigger:

  • a new entry in BadgeCache (no max-size bound, default 24h TTL),
  • a new goroutine doing a full DHT + IPNI lookup with a 30s context,
  • a cached "error" badge that then sits there for 24h.

Easy fix that closes most of this: parse and validate up-front (the same resolveInput call you already use, or at minimum cid.Decode with a fallback to the IPNS/DNSLink path), return 400 for anything that isn't a valid CID / IPNS name / DNSLink, and only then proceed.


// runBackgroundCheck performs the CID check in the background and caches the result.
func (h *BadgeHandler) runBackgroundCheck(cidStr, cidSuffix string) {
ctx, cancel := context.WithTimeout(context.Background(), h.checkTimeout)
defer cancel()

cidKey, _, err := resolveInput(ctx, h.daemon.ns, cidStr)
if err != nil {
log.Printf("Badge: failed to resolve CID %s: %v", cidStr, err)
svg := generateErrorBadgeSVG(cidSuffix)
h.cache.Set(cidStr, 0, cidSuffix, svg)
return
}

result, err := h.daemon.runCidCheck(ctx, cidKey, defaultIndexerURL, false)
if err != nil {
log.Printf("Badge: failed to check CID %s: %v", cidStr, err)
svg := generateErrorBadgeSVG(cidSuffix)
h.cache.Set(cidStr, 0, cidSuffix, svg)
return
}

// Count working providers and generate badge
providerCount := countWorkingProviders(result)
svg := generateBadgeSVG(providerCount, cidSuffix)

// Cache the result
h.cache.Set(cidStr, providerCount, cidSuffix, svg)
log.Printf("Badge: cached result for %s: %d providers", cidStr, providerCount)
}

// serveSVG writes an SVG response with appropriate headers.
func (h *BadgeHandler) serveSVG(w http.ResponseWriter, svg []byte) {
w.Header().Set("Content-Type", "image/svg+xml")
w.Header().Set("Cache-Control", "public, max-age=3600") // Browser cache for 1 hour
w.Write(svg)
}
Comment on lines +209 to +213
Copy link
Copy Markdown
Member

@lidel lidel May 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: don't cache pending/error responses for an hour

Cache-Control: public, max-age=3600 is sent unconditionally, including on the "checking..." pending response. That means a user's first request can be cached at the browser/CDN for an hour, even though the real result lands seconds later, which defeats the polling pattern badges normally rely on.

Two related tweaks:

  • pending/error responses: return different Cache-Control: no-cache (or max-age=10). Settled results: keep max-age=3600, or align it with --badge-cache-ttl.
  • in BadgeCache, give error entries a much shorter TTL than success entries (something like 1-5 min). Today a transient DHT hiccup turns into a 24h visible "error" badge.

120 changes: 120 additions & 0 deletions badge_cache.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
package main

import (
"sync"
"time"
)

// BadgeCache provides a thread-safe in-memory cache for badge results.
// This prevents repeated expensive CID checks for frequently requested badges.
type BadgeCache struct {
mu sync.RWMutex
items map[string]*BadgeCacheEntry
ttl time.Duration
}

// BadgeCacheEntry holds cached badge data for a CID.
type BadgeCacheEntry struct {
ProviderCount int
CIDSuffix string
SVG []byte
Timestamp time.Time
Pending bool // true if check is in progress
}

// NewBadgeCache creates a new badge cache with the specified TTL.
func NewBadgeCache(ttl time.Duration) *BadgeCache {
cache := &BadgeCache{
items: make(map[string]*BadgeCacheEntry),
ttl: ttl,
}
// Start background cleanup goroutine
go cache.cleanupLoop()
return cache
}
Comment on lines +10 to +34
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: bound the cache and give it a Stop()

Two small changes that go a long way once the input is validated:

  • Cap entries with a MaxEntries (e.g. 10k) and evict on insertion when full. Even random eviction is fine for v1, LRU is nicer, 2Q even better. Otherwise the only thing keeping memory bounded is the 24h TTL plus a cleanup tick that runs every TTL/2.
  • NewBadgeCache starts go cache.cleanupLoop() with no way to stop it. Add a Stop() (close a channel, exit the loop) and call it on server shutdown. As-is, every test that creates a cache leaks a ticker + goroutine.


// Get retrieves a cached entry if it exists and hasn't expired.
func (c *BadgeCache) Get(cid string) (*BadgeCacheEntry, bool) {
c.mu.RLock()
defer c.mu.RUnlock()

entry, ok := c.items[cid]
if !ok {
return nil, false
}

if time.Since(entry.Timestamp) > c.ttl {
return nil, false
}

return entry, true
}

// Set stores a badge cache entry.
func (c *BadgeCache) Set(cid string, providerCount int, cidSuffix string, svg []byte) {
c.mu.Lock()
defer c.mu.Unlock()

c.items[cid] = &BadgeCacheEntry{
ProviderCount: providerCount,
CIDSuffix: cidSuffix,
SVG: svg,
Timestamp: time.Now(),
Pending: false,
}
}

// SetPending marks a CID as having a check in progress.
func (c *BadgeCache) SetPending(cid string, cidSuffix string, svg []byte) {
c.mu.Lock()
defer c.mu.Unlock()

c.items[cid] = &BadgeCacheEntry{
CIDSuffix: cidSuffix,
SVG: svg,
Timestamp: time.Now(),
Pending: true,
}
}

// IsPending checks if a CID has a pending check in progress.
func (c *BadgeCache) IsPending(cid string) bool {
c.mu.RLock()
defer c.mu.RUnlock()

entry, ok := c.items[cid]
if !ok {
return false
}
return entry.Pending
}

// cleanupLoop periodically removes expired entries from the cache.
func (c *BadgeCache) cleanupLoop() {
ticker := time.NewTicker(c.ttl / 2)
defer ticker.Stop()

for range ticker.C {
c.cleanup()
}
}

// cleanup removes all expired entries from the cache.
func (c *BadgeCache) cleanup() {
c.mu.Lock()
defer c.mu.Unlock()

now := time.Now()
for cid, entry := range c.items {
if now.Sub(entry.Timestamp) > c.ttl {
delete(c.items, cid)
}
}
}

// Size returns the current number of entries in the cache.
func (c *BadgeCache) Size() int {
c.mu.RLock()
defer c.mu.RUnlock()
return len(c.items)
}
Loading