waveterm/pkg/faviconcache/faviconcache.go

197 lines
5.2 KiB
Go
Raw Normal View History

2025-02-08 00:11:40 +00:00
// Copyright 2025, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
package faviconcache
import (
"context"
"encoding/base64"
"fmt"
"io"
"log"
"net/http"
"net/url"
"strings"
"sync"
"time"
"github.com/wavetermdev/waveterm/pkg/panichandler"
)
// --- Constants and Types ---
// cacheDuration is how long a cached entry is considered “fresh.”
const cacheDuration = 24 * time.Hour
// maxIconSize limits the favicon size to 256 KB.
const maxIconSize = 256 * 1024 // in bytes
// FaviconCacheItem represents one cached favicon entry.
type FaviconCacheItem struct {
// Data is the base64-encoded data URL string (e.g. "data:image/png;base64,...")
Data string
// LastFetched is when this entry was last updated.
LastFetched time.Time
}
// --- Global variables for managing in-flight fetches ---
// We use a mutex and a simple map to prevent multiple simultaneous fetches for the same domain.
var (
fetchLock sync.Mutex
fetching = make(map[string]bool)
)
// Use a semaphore (buffered channel) to limit concurrent fetches to 5.
var fetchSemaphore = make(chan bool, 5)
var (
faviconCacheLock sync.Mutex
faviconCache = make(map[string]*FaviconCacheItem)
)
// --- GetFavicon ---
//
// GetFavicon takes a URL string and returns a base64-encoded src URL for an <img>
// tag. If the favicon is already in cache and “fresh,” it returns it immediately.
// Otherwise it kicks off a background fetch (if one isnt already in progress)
// and returns whatever is in the cache (which may be empty).
func GetFavicon(urlStr string) string {
// Parse the URL and extract the domain.
parsedURL, err := url.Parse(urlStr)
if err != nil {
log.Printf("GetFavicon: invalid URL %q: %v", urlStr, err)
return ""
}
domain := parsedURL.Hostname()
if domain == "" {
log.Printf("GetFavicon: no hostname found in URL %q", urlStr)
return ""
}
// Try to get from our cache.
item, found := GetFromCache(domain)
if found {
// If the cached entry is not stale, return it.
if time.Since(item.LastFetched) < cacheDuration {
return item.Data
}
}
// Either the item was not found or its stale:
// Launch an async fetch if one isnt already running for this domain.
triggerAsyncFetch(domain)
// Return the cached value (even if stale or empty).
return item.Data
}
// triggerAsyncFetch starts a goroutine to update the favicon cache
// for the given domain if one isnt already in progress.
func triggerAsyncFetch(domain string) {
fetchLock.Lock()
if fetching[domain] {
// Already fetching this domain; nothing to do.
fetchLock.Unlock()
return
}
// Mark this domain as in-flight.
fetching[domain] = true
fetchLock.Unlock()
go func() {
defer func() {
panichandler.PanicHandler("Favicon:triggerAsyncFetch", recover())
}()
// Acquire a slot in the semaphore.
fetchSemaphore <- true
// When done, ensure that we clear the “fetching” flag.
defer func() {
<-fetchSemaphore
fetchLock.Lock()
delete(fetching, domain)
fetchLock.Unlock()
}()
iconStr, err := fetchFavicon(domain)
if err != nil {
log.Printf("triggerAsyncFetch: error fetching favicon for %s: %v", domain, err)
}
SetInCache(domain, FaviconCacheItem{Data: iconStr, LastFetched: time.Now()})
}()
}
func fetchFavicon(domain string) (string, error) {
// Create a context that times out after 5 seconds.
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
// Special case for github.com - use their dark favicon from assets domain
url := "https://" + domain + "/favicon.ico"
if domain == "github.com" {
url = "https://github.githubassets.com/favicons/favicon-dark.png"
}
// Create a new HTTP request with the context.
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return "", fmt.Errorf("error creating request for %s: %w", url, err)
}
// Execute the HTTP request.
resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", fmt.Errorf("error fetching favicon from %s: %w", url, err)
}
defer resp.Body.Close()
// Ensure we got a 200 OK.
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("non-OK HTTP status: %d fetching %s", resp.StatusCode, url)
}
// Read the favicon bytes.
data, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("error reading favicon data from %s: %w", url, err)
}
// Encode the image bytes to base64.
b64Data := base64.StdEncoding.EncodeToString(data)
if len(b64Data) > maxIconSize {
return "", fmt.Errorf("favicon too large: %d bytes", len(b64Data))
}
// Try to detect MIME type from Content-Type header first
mimeType := resp.Header.Get("Content-Type")
if mimeType == "" {
// If no Content-Type header, detect from content
mimeType = http.DetectContentType(data)
}
if !strings.HasPrefix(mimeType, "image/") {
return "", fmt.Errorf("unexpected MIME type: %s", mimeType)
}
return "data:" + mimeType + ";base64," + b64Data, nil
}
// TODO store in blockstore
func GetFromCache(key string) (FaviconCacheItem, bool) {
faviconCacheLock.Lock()
defer faviconCacheLock.Unlock()
item, found := faviconCache[key]
if !found {
return FaviconCacheItem{}, false
}
return *item, true
}
func SetInCache(key string, item FaviconCacheItem) {
faviconCacheLock.Lock()
defer faviconCacheLock.Unlock()
faviconCache[key] = &item
}