diff --git a/internal/httpapi/handlers.go b/internal/httpapi/handlers.go
index 4516981..d4f7063 100644
--- a/internal/httpapi/handlers.go
+++ b/internal/httpapi/handlers.go
@@ -45,7 +45,7 @@ func NewHandler(searchSvc *search.Service, autocompleteSuggestions func(ctx cont
searchSvc: searchSvc,
autocompleteSvc: autocompleteSuggestions,
sourceURL: sourceURL,
- faviconCache: faviconCache,
+ faviconCache: faviconCache,
}
}
@@ -65,6 +65,17 @@ func (h *Handler) getTheme(r *http.Request) string {
return "light"
}
+// getFaviconService returns the favicon provider from cookie (default "none").
+func (h *Handler) getFaviconService(r *http.Request) string {
+ if cookie, err := r.Cookie("favicon"); err == nil {
+ switch cookie.Value {
+ case "none", "google", "duckduckgo", "self":
+ return cookie.Value
+ }
+ }
+ return "none"
+}
+
// Index renders the homepage with the search box.
func (h *Handler) Index(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" {
@@ -103,7 +114,7 @@ func (h *Handler) Search(w http.ResponseWriter, r *http.Request) {
req, err := search.ParseSearchRequest(r)
if err != nil {
if format == "html" || format == "" {
- pd := views.PageData{SourceURL: h.sourceURL, Query: q, Theme: h.getTheme(r)}
+ pd := views.PageData{SourceURL: h.sourceURL, Query: q, Theme: h.getTheme(r), FaviconService: h.getFaviconService(r)}
if views.IsHTMXRequest(r) {
views.RenderSearchFragment(w, pd)
} else {
@@ -118,7 +129,7 @@ func (h *Handler) Search(w http.ResponseWriter, r *http.Request) {
resp, err := h.searchSvc.Search(r.Context(), req)
if err != nil {
if req.Format == contracts.FormatHTML {
- pd := views.PageData{SourceURL: h.sourceURL, Query: req.Query, Theme: h.getTheme(r)}
+ pd := views.PageData{SourceURL: h.sourceURL, Query: req.Query, Theme: h.getTheme(r), FaviconService: h.getFaviconService(r)}
if views.IsHTMXRequest(r) {
views.RenderSearchFragment(w, pd)
} else {
@@ -132,7 +143,7 @@ func (h *Handler) Search(w http.ResponseWriter, r *http.Request) {
if req.Format == contracts.FormatHTML {
pd := views.FromResponse(resp, req.Query, req.Pageno,
- r.FormValue("category"), r.FormValue("time"), r.FormValue("type"))
+ r.FormValue("category"), r.FormValue("time"), r.FormValue("type"), h.getFaviconService(r))
pd.Theme = h.getTheme(r)
if err := views.RenderSearchAuto(w, r, pd); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
@@ -182,6 +193,19 @@ func (h *Handler) Preferences(w http.ResponseWriter, r *http.Request) {
SameSite: http.SameSiteLaxMode,
})
}
+ // Persist favicon provider preference.
+ favicon := strings.TrimSpace(r.FormValue("favicon"))
+ switch favicon {
+ case "none", "google", "duckduckgo", "self":
+ http.SetCookie(w, &http.Cookie{
+ Name: "favicon",
+ Value: favicon,
+ Path: "/",
+ MaxAge: 86400 * 365,
+ HttpOnly: false,
+ SameSite: http.SameSiteLaxMode,
+ })
+ }
http.Redirect(w, r, "/preferences", http.StatusFound)
return
}
@@ -192,7 +216,7 @@ func (h *Handler) Preferences(w http.ResponseWriter, r *http.Request) {
theme = cookie.Value
}
}
- if err := views.RenderPreferences(w, h.sourceURL, theme); err != nil {
+ if err := views.RenderPreferences(w, h.sourceURL, theme, h.getFaviconService(r)); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
diff --git a/internal/httpapi/httpapi_test.go b/internal/httpapi/httpapi_test.go
index 71b1095..7e27da0 100644
--- a/internal/httpapi/httpapi_test.go
+++ b/internal/httpapi/httpapi_test.go
@@ -32,17 +32,17 @@ import (
// mockUpstreamHandler returns controlled JSON responses.
func mockUpstreamJSON(query string) contracts.SearchResponse {
return contracts.SearchResponse{
- Query: query,
- NumberOfResults: 2,
+ Query: query,
+ NumberOfResults: 2,
Results: []contracts.MainResult{
{Title: "Upstream Result 1", URL: ptr("https://upstream.example/1"), Content: "From upstream", Engine: "upstream"},
{Title: "Upstream Result 2", URL: ptr("https://upstream.example/2"), Content: "From upstream", Engine: "upstream"},
},
- Answers: []map[string]any{},
- Corrections: []string{},
- Infoboxes: []map[string]any{},
- Suggestions: []string{"upstream suggestion"},
- UnresponsiveEngines: [][2]string{},
+ Answers: []map[string]any{},
+ Corrections: []string{},
+ Infoboxes: []map[string]any{},
+ Suggestions: []string{"upstream suggestion"},
+ UnresponsiveEngines: [][2]string{},
}
}
@@ -74,6 +74,7 @@ func newTestServer(t *testing.T) (*httptest.Server, *httpapi.Handler) {
mux.HandleFunc("/", h.Index)
mux.HandleFunc("/search", h.Search)
mux.HandleFunc("/autocompleter", h.Autocompleter)
+ mux.HandleFunc("/preferences", h.Preferences)
server := httptest.NewServer(mux)
t.Cleanup(server.Close)
@@ -228,3 +229,50 @@ func TestSearch_SourceURLInFooter(t *testing.T) {
t.Error("expected AGPLv3 link in footer")
}
}
+
+func TestPreferences_PostSetsFaviconCookie(t *testing.T) {
+ server, _ := newTestServer(t)
+ client := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error {
+ return http.ErrUseLastResponse
+ }}
+ req, _ := http.NewRequest(http.MethodPost, server.URL+"/preferences", strings.NewReader("favicon=google&theme=dark"))
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ resp, err := client.Do(req)
+ if err != nil {
+ t.Fatalf("request failed: %v", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusFound {
+ t.Fatalf("expected redirect 302, got %d", resp.StatusCode)
+ }
+ found := false
+ for _, c := range resp.Cookies() {
+ if c.Name == "favicon" {
+ found = true
+ if c.Value != "google" {
+ t.Fatalf("expected favicon cookie google, got %q", c.Value)
+ }
+ }
+ }
+ if !found {
+ t.Fatal("expected favicon cookie to be set")
+ }
+}
+
+func TestPreferences_GetReflectsFaviconCookie(t *testing.T) {
+ server, _ := newTestServer(t)
+ req, _ := http.NewRequest(http.MethodGet, server.URL+"/preferences", nil)
+ req.AddCookie(&http.Cookie{Name: "favicon", Value: "duckduckgo"})
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ t.Fatalf("request failed: %v", err)
+ }
+ defer resp.Body.Close()
+
+ body, _ := io.ReadAll(resp.Body)
+ html := string(body)
+ if !strings.Contains(html, `option value="duckduckgo" selected`) {
+ t.Fatalf("expected duckduckgo option selected, body: %s", html)
+ }
+}
diff --git a/internal/views/templates/preferences.html b/internal/views/templates/preferences.html
index 67ae31b..cdb5de8 100644
--- a/internal/views/templates/preferences.html
+++ b/internal/views/templates/preferences.html
@@ -74,10 +74,10 @@
Fetch favicons for result URLs. "None" is most private.
diff --git a/internal/views/templates/result_item.html b/internal/views/templates/result_item.html
index 37e5aa8..5cc10d7 100644
--- a/internal/views/templates/result_item.html
+++ b/internal/views/templates/result_item.html
@@ -4,8 +4,8 @@
{{.SafeTitle}}
- {{if .Domain}}
-

+ {{if .FaviconIconURL}}
+

{{end}}
{{.URL}}
{{.Engine}}
diff --git a/internal/views/templates/video_item.html b/internal/views/templates/video_item.html
index 337ca93..1d0601a 100644
--- a/internal/views/templates/video_item.html
+++ b/internal/views/templates/video_item.html
@@ -12,6 +12,9 @@
{{.SafeTitle}}
+ {{if .FaviconIconURL}}
+

+ {{end}}
{{if .URL}}
{{.URL}}
{{end}}
diff --git a/internal/views/views.go b/internal/views/views.go
index 54a1a84..cdf034b 100644
--- a/internal/views/views.go
+++ b/internal/views/views.go
@@ -56,16 +56,17 @@ type PageData struct {
ShowHeader bool
IsImageSearch bool
// Theme is the user's selected theme (light/dark) from cookie
- Theme string
+ Theme string
+ FaviconService string
// New fields for three-column layout
Categories []string
- CategoryIcons map[string]string
+ CategoryIcons map[string]string
DisabledCategories []string
- ActiveCategory string
- TimeFilters []FilterOption
- TypeFilters []FilterOption
- ActiveTime string
- ActiveType string
+ ActiveCategory string
+ TimeFilters []FilterOption
+ TypeFilters []FilterOption
+ ActiveTime string
+ ActiveType string
}
// ResultView is a template-friendly wrapper around a MainResult.
@@ -76,6 +77,8 @@ type ResultView struct {
TemplateName string
// Domain is the hostname extracted from the result URL, used for favicon proxying.
Domain string
+ // FaviconIconURL is the resolved favicon image URL for the user's favicon preference (empty = hide).
+ FaviconIconURL string
// SafeTitle and SafeContent are HTML-unescaped versions for rendering.
// The API returns HTML entities which Go templates escape by default.
SafeTitle template.HTML
@@ -84,8 +87,8 @@ type ResultView struct {
// PageNumber represents a numbered pagination button.
type PageNumber struct {
- Num int
- IsCurrent bool
+ Num int
+ IsCurrent bool
}
// InfoboxView is a template-friendly infobox.
@@ -102,9 +105,9 @@ type FilterOption struct {
}
var (
- tmplFull *template.Template
- tmplIndex *template.Template
- tmplFragment *template.Template
+ tmplFull *template.Template
+ tmplIndex *template.Template
+ tmplFragment *template.Template
tmplPreferences *template.Template
)
@@ -152,8 +155,26 @@ func OpenSearchXML(baseURL string) ([]byte, error) {
return []byte(result), nil
}
+// faviconIconURL returns a safe img src for the given service and hostname, or "" for none/invalid.
+func faviconIconURL(service, domain string) string {
+ domain = strings.TrimSpace(domain)
+ if domain == "" {
+ return ""
+ }
+ switch service {
+ case "google":
+ return "https://www.google.com/s2/favicons?domain=" + url.QueryEscape(domain) + "&sz=32"
+ case "duckduckgo":
+ return "https://icons.duckduckgo.com/ip3/" + domain + ".ico"
+ case "self":
+ return "/favicon/" + domain
+ default:
+ return ""
+ }
+}
+
// FromResponse builds PageData from a search response and request params.
-func FromResponse(resp contracts.SearchResponse, query string, pageno int, activeCategory, activeTime, activeType string) PageData {
+func FromResponse(resp contracts.SearchResponse, query string, pageno int, activeCategory, activeTime, activeType, faviconService string) PageData {
// Set defaults
if activeCategory == "" {
activeCategory = "all"
@@ -164,9 +185,10 @@ func FromResponse(resp contracts.SearchResponse, query string, pageno int, activ
Pageno: pageno,
NumberOfResults: resp.NumberOfResults,
UnresponsiveEngines: resp.UnresponsiveEngines,
+ FaviconService: faviconService,
// New: categories with icons
- Categories: []string{"all", "news", "images", "videos", "maps"},
+ Categories: []string{"all", "news", "images", "videos", "maps"},
DisabledCategories: []string{"shopping", "music", "weather"},
CategoryIcons: map[string]string{
"all": "🌐",
@@ -220,11 +242,12 @@ func FromResponse(resp contracts.SearchResponse, query string, pageno int, activ
}
r.Thumbnail = util.SanitizeResultURL(r.Thumbnail)
pd.Results[i] = ResultView{
- MainResult: r,
- TemplateName: tmplName,
- Domain: domain,
- SafeTitle: template.HTML(html.UnescapeString(r.Title)),
- SafeContent: template.HTML(html.UnescapeString(r.Content)),
+ MainResult: r,
+ TemplateName: tmplName,
+ Domain: domain,
+ FaviconIconURL: faviconIconURL(faviconService, domain),
+ SafeTitle: template.HTML(html.UnescapeString(r.Title)),
+ SafeContent: template.HTML(html.UnescapeString(r.Content)),
}
}
@@ -328,8 +351,12 @@ func RenderSearchAuto(w http.ResponseWriter, r *http.Request, data PageData) err
}
// RenderPreferences renders the full preferences page.
-func RenderPreferences(w http.ResponseWriter, sourceURL, theme string) error {
+func RenderPreferences(w http.ResponseWriter, sourceURL, theme, faviconService string) error {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
- return tmplPreferences.ExecuteTemplate(w, "base", PageData{ShowHeader: true, SourceURL: sourceURL, Theme: theme})
+ return tmplPreferences.ExecuteTemplate(w, "base", PageData{
+ ShowHeader: true,
+ SourceURL: sourceURL,
+ Theme: theme,
+ FaviconService: faviconService,
+ })
}
-
diff --git a/internal/views/views_test.go b/internal/views/views_test.go
index 65cff50..f83c017 100644
--- a/internal/views/views_test.go
+++ b/internal/views/views_test.go
@@ -1,6 +1,7 @@
package views
import (
+ "strings"
"testing"
"github.com/metamorphosis-dev/samsa/internal/contracts"
@@ -37,7 +38,7 @@ func mockEmptyResponse() contracts.SearchResponse {
func TestFromResponse_Basic(t *testing.T) {
resp := mockSearchResponse("samsa trial", 42)
- data := FromResponse(resp, "samsa trial", 1, "", "", "")
+ data := FromResponse(resp, "samsa trial", 1, "", "", "", "none")
if data.Query != "samsa trial" {
t.Errorf("expected query 'samsa trial', got %q", data.Query)
@@ -55,7 +56,7 @@ func TestFromResponse_Basic(t *testing.T) {
func TestFromResponse_Pagination(t *testing.T) {
resp := mockSearchResponse("test", 100)
- data := FromResponse(resp, "test", 3, "", "", "")
+ data := FromResponse(resp, "test", 3, "", "", "", "none")
if data.PrevPage != 2 {
t.Errorf("expected PrevPage 2, got %d", data.PrevPage)
@@ -80,7 +81,7 @@ func TestFromResponse_Pagination(t *testing.T) {
}
func TestFromResponse_Empty(t *testing.T) {
- data := FromResponse(mockEmptyResponse(), "", 1, "", "", "")
+ data := FromResponse(mockEmptyResponse(), "", 1, "", "", "", "none")
if data.NumberOfResults != 0 {
t.Errorf("expected 0 results, got %d", data.NumberOfResults)
@@ -90,6 +91,31 @@ func TestFromResponse_Empty(t *testing.T) {
}
}
+func TestFromResponse_FaviconIconURL(t *testing.T) {
+ u := "https://example.com/path"
+ resp := contracts.SearchResponse{
+ Query: "q",
+ NumberOfResults: 1,
+ Results: []contracts.MainResult{{Title: "t", URL: &u, Engine: "bing"}},
+ Answers: []map[string]any{},
+ Corrections: []string{},
+ Infoboxes: []map[string]any{},
+ Suggestions: []string{},
+ UnresponsiveEngines: [][2]string{},
+ }
+ data := FromResponse(resp, "q", 1, "", "", "", "google")
+ if len(data.Results) != 1 {
+ t.Fatalf("expected 1 result, got %d", len(data.Results))
+ }
+ got := data.Results[0].FaviconIconURL
+ if got == "" || !strings.Contains(got, "google.com/s2/favicons") {
+ t.Fatalf("expected google favicon URL, got %q", got)
+ }
+ if !strings.Contains(got, "example.com") {
+ t.Fatalf("expected domain in favicon URL, got %q", got)
+ }
+}
+
func TestIsHTMXRequest(t *testing.T) {
tests := []struct {
name string