waveterm/pkg/faviconcache/faviconcache.go
2025-02-07 16:11:40 -08:00

196 lines
5.2 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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
}