From 352264509ce0747f6d95934ca8de4106355ecb2f Mon Sep 17 00:00:00 2001 From: Franz Kafka Date: Mon, 23 Mar 2026 14:35:19 +0000 Subject: [PATCH] feat: self-hosted favicon resolver via /favicon/ Adds a Kafka-hosted favicon proxy at /favicon/: - Fetches favicon.ico from the target domain - In-memory cache with 1-hour TTL and ETag support (304 Not Modified) - Max 64KB per favicon to prevent memory abuse - Privacy: user browser talks to Kafka, not Google/DuckDuckGo New "Self (Kafka)" option in the favicon service selector. Defaults to None. No third-party requests when self is chosen. --- cmd/samsa/main.go | 1 + internal/httpapi/handlers.go | 117 ++++++++++++++++++++++ internal/views/static/js/settings.js | 9 +- internal/views/templates/preferences.html | 1 + 4 files changed, 124 insertions(+), 4 deletions(-) diff --git a/cmd/samsa/main.go b/cmd/samsa/main.go index 8e09667..080e3a2 100644 --- a/cmd/samsa/main.go +++ b/cmd/samsa/main.go @@ -90,6 +90,7 @@ func main() { mux.HandleFunc("/healthz", h.Healthz) mux.HandleFunc("/autocompleter", h.Autocompleter) mux.HandleFunc("/opensearch.xml", h.OpenSearch(cfg.Server.BaseURL)) + mux.HandleFunc("/favicon/", h.Favicon) // Serve embedded static files (CSS, JS, images). staticFS, err := views.StaticFS() diff --git a/internal/httpapi/handlers.go b/internal/httpapi/handlers.go index 7589a82..a7d787e 100644 --- a/internal/httpapi/handlers.go +++ b/internal/httpapi/handlers.go @@ -18,11 +18,17 @@ package httpapi import ( "context" + "crypto/sha256" + "encoding/hex" "encoding/json" + "io" "net/http" "strings" + "sync" + "time" "github.com/metamorphosis-dev/samsa/internal/contracts" + "github.com/metamorphosis-dev/samsa/internal/httpclient" "github.com/metamorphosis-dev/samsa/internal/search" "github.com/metamorphosis-dev/samsa/internal/views" ) @@ -159,3 +165,114 @@ func (h *Handler) Preferences(w http.ResponseWriter, r *http.Request) { http.Error(w, err.Error(), http.StatusInternalServerError) } } + +// faviconCacheEntry holds a cached favicon body and its ETag. +type faviconCacheEntry struct { + body []byte + etag string + cachedAt time.Time +} + +// faviconCache is a simple in-memory cache for fetched favicons. +// Entries expire after 1 hour. +var faviconCache = struct { + m map[string]faviconCacheEntry + sync.RWMutex +}{m: make(map[string]faviconCacheEntry)} + +const faviconCacheTTL = 1 * time.Hour + +// Favicon serves a fetched favicon for the given domain, with ETag support +// and a 1-hour in-memory cache. This lets Kafka act as a privacy-preserving +// favicon proxy: the user's browser talks to Kafka, not Google or DuckDuckGo. +func (h *Handler) Favicon(w http.ResponseWriter, r *http.Request) { + domain := strings.TrimPrefix(r.URL.Path, "/favicon/") + domain = strings.TrimSuffix(domain, "/") + domain = strings.TrimSpace(domain) + + if domain == "" || strings.Contains(domain, "/") { + http.Error(w, "invalid domain", http.StatusBadRequest) + return + } + + // Check cache. + faviconCache.RLock() + entry, ok := faviconCache.m[domain] + faviconCache.RUnlock() + + now := time.Now() + if ok && now.Sub(entry.cachedAt) < faviconCacheTTL { + // ETag-based cache validation. + if etag := r.Header.Get("If-None-Match"); etag != "" && etag == entry.etag { + w.WriteHeader(http.StatusNotModified) + return + } + w.Header().Set("Content-Type", "image/x-icon") + w.Header().Set("ETag", entry.etag) + w.Header().Set("Cache-Control", "private, max-age=3600") + w.WriteHeader(http.StatusOK) + w.Write(entry.body) + return + } + + // Fetch from the domain's favicon.ico. + fetchURL := "https://" + domain + "/favicon.ico" + req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, fetchURL, nil) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + req.Header.Set("User-Agent", "Kafka/0.1 (+https://git.ashisgreat.xyz/penal-colony/samsa)") + req.Header.Set("Accept", "image/x-icon,image/png,image/webp,*/*") + + client := httpclient.NewClient(5 * time.Second) + resp, err := client.Do(req) + if err != nil { + http.Error(w, "favicon fetch failed", http.StatusBadGateway) + return + } + defer resp.Body.Close() + + // Upstream favicon server issues a redirect or error. + if resp.StatusCode != http.StatusOK { + http.Error(w, "favicon not found", http.StatusNotFound) + return + } + + body, err := io.ReadAll(http.MaxBytesReader(w, resp.Body, 64*1024)) + if err != nil { + http.Error(w, "favicon too large", http.StatusBadGateway) + return + } + + etag := resp.Header.Get("ETag") + if etag == "" { + // Fallback ETag: hash of body. + h := sha256.Sum256(body) + etag = `"` + hex.EncodeToString(h[:8]) + `"` + } + + // Store in cache. + faviconCache.Lock() + faviconCache.m[domain] = faviconCacheEntry{ + body: body, + etag: etag, + cachedAt: now, + } + faviconCache.Unlock() + + if etagMatch := r.Header.Get("If-None-Match"); etagMatch != "" && etagMatch == etag { + w.WriteHeader(http.StatusNotModified) + return + } + + contentType := resp.Header.Get("Content-Type") + if contentType == "" { + contentType = "image/x-icon" + } + w.Header().Set("Content-Type", contentType) + w.Header().Set("ETag", etag) + w.Header().Set("Cache-Control", "private, max-age=3600") + w.WriteHeader(http.StatusOK) + w.Write(body) +} diff --git a/internal/views/static/js/settings.js b/internal/views/static/js/settings.js index 324d35f..4845028 100644 --- a/internal/views/static/js/settings.js +++ b/internal/views/static/js/settings.js @@ -48,7 +48,8 @@ function applyTheme(theme) { function applyFavicon(service) { var faviconMap = { google: function(domain) { return 'https://www.google.com/s2/favicons?domain=' + encodeURIComponent(domain) + '&sz=32'; }, - duckduckgo: function(domain) { return 'https://icons.duckduckgo.com/ip3/' + encodeURIComponent(domain) + '.ico'; } + duckduckgo: function(domain) { return 'https://icons.duckduckgo.com/ip3/' + encodeURIComponent(domain) + '.ico'; }, + self: function(domain) { return '/favicon/' + encodeURIComponent(domain); } }; var imgs = document.querySelectorAll('.result-favicon'); imgs.forEach(function(img) { @@ -56,7 +57,7 @@ function applyFavicon(service) { if (!domain) return; if (service === 'none') { img.style.display = 'none'; - } else { + } else if (faviconMap[service]) { img.style.display = ''; img.src = faviconMap[service](domain); } @@ -125,8 +126,8 @@ function renderPanel(prefs) { var faviconOptions = ''; - ['none', 'google', 'duckduckgo'].forEach(function(src) { - var labels = { none: 'None', google: 'Google', duckduckgo: 'DuckDuckGo' }; + ['none', 'google', 'duckduckgo', 'self'].forEach(function(src) { + var labels = { none: 'None', google: 'Google', duckduckgo: 'DuckDuckGo', self: 'Self (Kafka)' }; var selected = prefs.favicon === src ? ' selected' : ''; faviconOptions += ''; }); diff --git a/internal/views/templates/preferences.html b/internal/views/templates/preferences.html index 53a90e3..4f7980a 100644 --- a/internal/views/templates/preferences.html +++ b/internal/views/templates/preferences.html @@ -78,6 +78,7 @@ +