-
Notifications
You must be signed in to change notification settings - Fork 22
feat: add badge endpoint for IPFS availability badges #121
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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) | ||
| } | ||
|
|
||
| // 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
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 Consider collapsing into one atomic call on Bonus: the two |
||
| } | ||
|
Comment on lines
+137
to
+176
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Right now Because the endpoint is opt-in but CORS-open (
Easy fix that closes most of this: parse and validate up-front (the same |
||
|
|
||
| // 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
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nit: don't cache
Two related tweaks:
|
||
| 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
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nit: bound the cache and give it a Two small changes that go a long way once the input is validated:
|
||
|
|
||
| // 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) | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
statuscarries user input (the last 6 chars of the rawcidquery param) and is dropped into<text>...</text>withfmt.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 isimage/svg+xml(which browsers happily parse as XML, scripts and all) so it's a sharp edge worth removing.Run
labelandstatusthroughhtml.EscapeString(orxml.EscapeText) before formatting. Cheap, and means a future change to the suffix length or wording can't accidentally turn this into a real XSS.