fix(prefs): persist favicon choice and apply to HTML results
Some checks failed
Build and Push Docker Image / build-and-push (push) Failing after 9s
Mirror to GitHub / mirror (push) Failing after 5s
Tests / test (push) Successful in 28s

- Save favicon cookie on POST /preferences; reflect selection in template
- Add getFaviconService helper; pass favicon service into FromResponse
- Compute ResultView.FaviconIconURL (none/google/duckduckgo/self proxy)
- Update result_item and video_item templates; add httpapi/views tests

Made-with: Cursor
This commit is contained in:
ashisgreat22 2026-03-23 23:08:21 +01:00
parent 518215f62e
commit 90ea4c9f56
7 changed files with 171 additions and 43 deletions

View file

@ -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)
}
}

View file

@ -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)
}
}