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