From fcd9be16df3c3916207ca12dc2bb591e6d0750cc Mon Sep 17 00:00:00 2001 From: ashisgreat22 Date: Sun, 22 Mar 2026 01:47:03 +0100 Subject: [PATCH 01/91] refactor: remove SearXNG references and rename binary to kafka - Rename cmd/searxng-go to cmd/kafka - Remove all SearXNG references from source comments while keeping "SearXNG-compatible API" in user-facing docs - Update binary paths in README, CLAUDE.md, and Dockerfile - Update log message to "kafka starting" Co-Authored-By: Claude Opus 4.6 --- CLAUDE.md | 10 +++++----- Dockerfile | 2 +- README.md | 12 ++++++------ cmd/{searxng-go => kafka}/main.go | 2 +- config.example.toml | 4 ++-- internal/autocomplete/service.go | 6 +++--- internal/contracts/main_result.go | 10 +++++----- internal/contracts/types.go | 6 +++--- internal/engines/braveapi.go | 8 ++++---- internal/engines/engine.go | 2 +- internal/engines/planner.go | 4 ++-- internal/engines/qwant.go | 10 +++++----- internal/search/merge.go | 2 +- internal/search/request_params.go | 4 ++-- internal/search/response.go | 8 ++++---- internal/search/service.go | 2 +- internal/upstream/client.go | 2 +- internal/views/static/css/kafka.css | 1 - 18 files changed, 47 insertions(+), 48 deletions(-) rename cmd/{searxng-go => kafka}/main.go (98%) diff --git a/CLAUDE.md b/CLAUDE.md index 1ba6bdc..b7f254e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Project Overview -kafka is a privacy-respecting metasearch engine written in Go. It provides a SearXNG-compatible `/search` API and an HTML frontend (HTMX + Go templates). 9 engines are implemented natively in Go; unlisted engines can be proxied to an upstream SearXNG instance. Responses from multiple engines are merged into a single JSON/CSV/RSS/HTML response. +kafka is a privacy-respecting metasearch engine written in Go. It provides a SearXNG-compatible `/search` API and an HTML frontend (HTMX + Go templates). 9 engines are implemented natively in Go; unlisted engines can be proxied to an upstream metasearch instance. Responses from multiple engines are merged into a single JSON/CSV/RSS/HTML response. ## Build & Run Commands @@ -22,7 +22,7 @@ go test -run TestWikipedia ./internal/engines/ go test -v ./internal/engines/ # Run the server (requires config.toml) -go run ./cmd/searxng-go -config config.toml +go run ./cmd/kafka -config config.toml ``` There is no Makefile. There is no linter configured. @@ -37,13 +37,13 @@ There is no Makefile. There is no linter configured. - `internal/config` — TOML-based configuration with env var fallbacks. `Load(path)` reads `config.toml`; env vars override zero-value fields. See `config.example.toml` for all settings. - `internal/engines` — `Engine` interface and all 9 Go-native implementations. `factory.go` registers engines via `NewDefaultPortedEngines()`. `planner.go` routes engines to local or upstream based on `LOCAL_PORTED_ENGINES` env var. - `internal/search` — `Service` orchestrates the pipeline: cache check, planning, parallel engine execution via goroutines/WaitGroup, upstream proxying, response merging. Individual engine failures are reported as `unresponsive_engines` rather than aborting the search. Qwant has fallback logic to upstream on empty results. -- `internal/autocomplete` — Fetches search suggestions. Proxies to upstream SearXNG `/autocompleter` if configured, falls back to Wikipedia OpenSearch API otherwise. +- `internal/autocomplete` — Fetches search suggestions. Proxies to upstream `/autocompleter` if configured, falls back to Wikipedia OpenSearch API otherwise. - `internal/httpapi` — HTTP handlers for `/`, `/search`, `/autocompleter`, `/healthz`, `/opensearch.xml`. Detects HTMX requests via `HX-Request` header to return fragments instead of full pages. -- `internal/upstream` — Client that proxies requests to an upstream SearXNG instance via POST. +- `internal/upstream` — Client that proxies requests to an upstream metasearch instance via POST. - `internal/cache` — Valkey/Redis-backed cache with SHA-256 cache keys. No-op if unconfigured. - `internal/middleware` — Three rate limiters (per-IP sliding window, burst+sustained, global) and CORS. All disabled by default. - `internal/views` — HTML templates and static files embedded via `//go:embed`. Renders full pages or HTMX fragments. Templates: `base.html`, `index.html`, `results.html`, `results_inner.html`, `result_item.html`. -- `cmd/searxng-go` — Entry point. Loads TOML config, seeds env vars for engine code, wires up middleware chain, starts HTTP server. +- `cmd/kafka` — Entry point. Loads TOML config, seeds env vars for engine code, wires up middleware chain, starts HTTP server. **Engine interface** (`internal/engines/engine.go`): ```go diff --git a/Dockerfile b/Dockerfile index c41b5a1..e21960f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -11,7 +11,7 @@ RUN go mod download # Copy source and build COPY . . -RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /kafka ./cmd/searxng-go +RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /kafka ./cmd/kafka # Runtime stage FROM alpine:3.21 diff --git a/README.md b/README.md index 2f0868f..c03019e 100644 --- a/README.md +++ b/README.md @@ -24,8 +24,8 @@ A privacy-respecting, open metasearch engine written in Go. SearXNG-compatible A ```bash git clone https://git.ashisgreat.xyz/penal-colony/gosearch.git cd kafka -go build ./cmd/searxng-go -./searxng-go -config config.toml +go build ./cmd/kafka +./kafka -config config.toml ``` ### Docker Compose @@ -76,7 +76,7 @@ sudo nixos-rebuild switch --flake .# ```bash nix develop go test ./... -go run ./cmd/searxng-go -config config.toml +go run ./cmd/kafka -config config.toml ``` ## Endpoints @@ -138,7 +138,7 @@ Copy `config.example.toml` to `config.toml` and edit. All settings can also be o ### Key Sections - **`[server]`** — port, timeout, public base URL for OpenSearch -- **`[upstream]`** — optional upstream SearXNG proxy for unported engines +- **`[upstream]`** — optional upstream metasearch proxy for unported engines - **`[engines]`** — which engines run locally, engine-specific settings - **`[cache]`** — Valkey/Redis address, password, TTL - **`[cors]`** — allowed origins and methods @@ -152,7 +152,7 @@ Copy `config.example.toml` to `config.toml` and edit. All settings can also be o |---|---| | `PORT` | Listen port (default: 8080) | | `BASE_URL` | Public URL for OpenSearch XML | -| `UPSTREAM_SEARXNG_URL` | Upstream SearXNG instance URL | +| `UPSTREAM_SEARXNG_URL` | Upstream instance URL | | `LOCAL_PORTED_ENGINES` | Comma-separated local engine list | | `HTTP_TIMEOUT` | Upstream request timeout | | `BRAVE_API_KEY` | Brave Search API key | @@ -177,7 +177,7 @@ See `config.example.toml` for the full list including rate limiting and CORS var | Reddit | Reddit JSON API | Discussions | | Bing | Bing RSS | General web | -Engines not listed in `engines.local_ported` are proxied to an upstream SearXNG instance if `upstream.url` is configured. +Engines not listed in `engines.local_ported` are proxied to an upstream metasearch instance if `upstream.url` is configured. ## Architecture diff --git a/cmd/searxng-go/main.go b/cmd/kafka/main.go similarity index 98% rename from cmd/searxng-go/main.go rename to cmd/kafka/main.go index dac6258..ab29852 100644 --- a/cmd/searxng-go/main.go +++ b/cmd/kafka/main.go @@ -103,7 +103,7 @@ func main() { }, logger)(handler) addr := fmt.Sprintf(":%d", cfg.Server.Port) - logger.Info("searxng-go starting", + logger.Info("kafka starting", "addr", addr, "cache", searchCache.Enabled(), "rate_limit", cfg.RateLimit.Requests > 0, diff --git a/config.example.toml b/config.example.toml index df77184..1e3b75c 100644 --- a/config.example.toml +++ b/config.example.toml @@ -15,13 +15,13 @@ http_timeout = "10s" base_url = "" [upstream] -# URL of an upstream SearXNG instance for unported engines (env: UPSTREAM_SEARXNG_URL) +# URL of an upstream metasearch instance for unported engines (env: UPSTREAM_SEARXNG_URL) # Leave empty to run without an upstream proxy. url = "" [engines] # Comma-separated list of engines to execute locally in Go (env: LOCAL_PORTED_ENGINES) -# Engines not listed here will be proxied to upstream SearXNG. +# Engines not listed here will be proxied to the upstream instance. local_ported = ["wikipedia", "arxiv", "crossref", "braveapi", "qwant", "duckduckgo", "github", "reddit", "bing"] [engines.brave] diff --git a/internal/autocomplete/service.go b/internal/autocomplete/service.go index 3892d63..99d963a 100644 --- a/internal/autocomplete/service.go +++ b/internal/autocomplete/service.go @@ -11,7 +11,7 @@ import ( "time" ) -// Service fetches search suggestions from an upstream SearXNG instance +// Service fetches search suggestions from an upstream metasearch instance // or falls back to Wikipedia's OpenSearch API. type Service struct { upstreamURL string @@ -40,7 +40,7 @@ func (s *Service) Suggestions(ctx context.Context, query string) ([]string, erro return s.wikipediaSuggestions(ctx, query) } -// upstreamSuggestions proxies to an upstream SearXNG /autocompleter endpoint. +// upstreamSuggestions proxies to an upstream /autocompleter endpoint. func (s *Service) upstreamSuggestions(ctx context.Context, query string) ([]string, error) { u := s.upstreamURL + "/autocompleter?" + url.Values{"q": {query}}.Encode() req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) @@ -64,7 +64,7 @@ func (s *Service) upstreamSuggestions(ctx context.Context, query string) ([]stri return nil, err } - // SearXNG /autocompleter returns a plain JSON array of strings. + // The /autocompleter endpoint returns a plain JSON array of strings. var out []string if err := json.Unmarshal(body, &out); err != nil { return nil, err diff --git a/internal/contracts/main_result.go b/internal/contracts/main_result.go index 48005f8..20c9231 100644 --- a/internal/contracts/main_result.go +++ b/internal/contracts/main_result.go @@ -5,15 +5,15 @@ import ( "encoding/json" ) -// MainResult represents one element of SearXNG's `results` array. +// MainResult represents one element of the `results` array. // -// SearXNG returns many additional keys beyond what templates use. To keep the +// The API returns many additional keys beyond what templates use. To keep the // contract stable for proxying/merging, we preserve all unknown keys in // `raw` and re-emit them via MarshalJSON. type MainResult struct { raw map[string]any - // Common fields used by SearXNG templates (RSS uses: title, url, content, pubdate). + // Common fields used by templates (RSS uses: title, url, content, pubdate). Template string `json:"template"` Title string `json:"title"` Content string `json:"content"` @@ -28,12 +28,12 @@ type MainResult struct { Positions []int `json:"positions"` Engines []string `json:"engines"` - // These fields exist in SearXNG's MainResult base; keep them so downstream + // These fields exist in the MainResult base; keep them so downstream // callers can generate richer output later. OpenGroup bool `json:"open_group"` CloseGroup bool `json:"close_group"` - // parsed_url in SearXNG is emitted as a tuple; we preserve it as-is. + // parsed_url is emitted as a tuple; we preserve it as-is. ParsedURL any `json:"parsed_url"` } diff --git a/internal/contracts/types.go b/internal/contracts/types.go index a68f77a..81103ce 100644 --- a/internal/contracts/types.go +++ b/internal/contracts/types.go @@ -1,6 +1,6 @@ package contracts -// OutputFormat matches SearXNG's `/search?format=...` values. +// OutputFormat matches the `/search?format=...` values. type OutputFormat string const ( @@ -28,7 +28,7 @@ type SearchRequest struct { Engines []string Categories []string - // EngineData matches SearXNG's `engine_data--=` parameters. + // EngineData matches the `engine_data--=` parameters. EngineData map[string]map[string]string // AccessToken is an optional request token used to gate paid/limited engines. @@ -36,7 +36,7 @@ type SearchRequest struct { AccessToken string } -// SearchResponse matches the JSON schema returned by SearXNG's `webutils.get_json_response()`. +// SearchResponse matches the JSON schema used by `webutils.get_json_response()`. type SearchResponse struct { Query string `json:"query"` NumberOfResults int `json:"number_of_results"` diff --git a/internal/engines/braveapi.go b/internal/engines/braveapi.go index 2cb20ff..77c7abe 100644 --- a/internal/engines/braveapi.go +++ b/internal/engines/braveapi.go @@ -14,7 +14,7 @@ import ( "github.com/metamorphosis-dev/kafka/internal/contracts" ) -// BraveEngine implements the SearXNG `braveapi` engine (Brave Web Search API). +// BraveEngine implements the `braveapi` engine (Brave Web Search API). // // Config / gating: // - BRAVE_API_KEY: required to call Brave @@ -35,8 +35,8 @@ func (e *BraveEngine) Search(ctx context.Context, req contracts.SearchRequest) ( return contracts.SearchResponse{}, errors.New("brave engine not initialized") } - // Gate / config checks should not be treated as fatal errors; SearXNG - // treats misconfigured engines as unresponsive. + // Gate / config checks should not be treated as fatal errors; the reference + // implementation treats misconfigured engines as unresponsive. if strings.TrimSpace(e.apiKey) == "" { return contracts.SearchResponse{ Query: req.Query, @@ -93,7 +93,7 @@ func (e *BraveEngine) Search(ctx context.Context, req contracts.SearchRequest) ( } } - // SearXNG's python checks `if params["safesearch"]:` which treats any + // The reference implementation checks `if params["safesearch"]:` which treats any // non-zero (moderate/strict) as strict. if req.Safesearch > 0 { args.Set("safesearch", "strict") diff --git a/internal/engines/engine.go b/internal/engines/engine.go index d07aec9..ee87cfd 100644 --- a/internal/engines/engine.go +++ b/internal/engines/engine.go @@ -6,7 +6,7 @@ import ( "github.com/metamorphosis-dev/kafka/internal/contracts" ) -// Engine is a Go-native implementation of a SearXNG engine. +// Engine is a Go-native implementation of a search engine. // // Implementations should return a SearchResponse containing only the results // for that engine subset; the caller will merge multiple engine responses. diff --git a/internal/engines/planner.go b/internal/engines/planner.go index 543f253..56df656 100644 --- a/internal/engines/planner.go +++ b/internal/engines/planner.go @@ -48,7 +48,7 @@ func NewPlanner(portedEngines []string) *Planner { // Plan returns: // - localEngines: engines that are configured as ported for this service -// - upstreamEngines: engines that should be executed by upstream SearXNG +// - upstreamEngines: engines that should be executed by the upstream instance // - requestedEngines: the (possibly inferred) requested engines list // // If the request provides an explicit `engines` parameter, we use it. @@ -80,7 +80,7 @@ func (p *Planner) Plan(req contracts.SearchRequest) (localEngines, upstreamEngin func inferFromCategories(categories []string) []string { // Minimal mapping for the initial porting subset. - // This mirrors the idea of selecting from SearXNG categories without + // This mirrors the idea of selecting from engine categories without // embedding the whole engine registry. set := map[string]bool{} for _, c := range categories { diff --git a/internal/engines/qwant.go b/internal/engines/qwant.go index bb2a03c..8221781 100644 --- a/internal/engines/qwant.go +++ b/internal/engines/qwant.go @@ -14,11 +14,11 @@ import ( "github.com/PuerkitoBio/goquery" ) -// QwantEngine implements a SearXNG-like `qwant` (web) adapter using +// QwantEngine implements a `qwant` (web) adapter using // Qwant v3 endpoint: https://api.qwant.com/v3/search/web. // -// Qwant's API is not fully documented; this mirrors SearXNG's parsing logic -// for the `web` category from `.agent/searxng/searx/engines/qwant.py`. +// Qwant's API is not fully documented; this implements parsing logic +// for the `web` category. type QwantEngine struct { client *http.Client category string // "web" (JSON API) or "web-lite" (HTML fallback) @@ -37,7 +37,7 @@ func (e *QwantEngine) Search(ctx context.Context, req contracts.SearchRequest) ( return contracts.SearchResponse{Query: req.Query}, nil } - // For API parity we use SearXNG web defaults: count=10, offset=(pageno-1)*count. + // For API parity we use web defaults: count=10, offset=(pageno-1)*count. // The engine's config field exists so we can expand to news/images/videos later. count := e.resultsPerPage if count <= 0 { @@ -262,7 +262,7 @@ func (e *QwantEngine) searchWebLite(ctx context.Context, req contracts.SearchReq return } - // In SearXNG: "./span[contains(@class, 'url partner')]" + // Selector: "./span[contains(@class, 'url partner')]" urlText := strings.TrimSpace(item.Find("span.url.partner").First().Text()) if urlText == "" { // fallback: any span with class containing both 'url' and 'partner' diff --git a/internal/search/merge.go b/internal/search/merge.go index 54ff9bb..64ebd6e 100644 --- a/internal/search/merge.go +++ b/internal/search/merge.go @@ -8,7 +8,7 @@ import ( "github.com/metamorphosis-dev/kafka/internal/contracts" ) -// MergeResponses merges multiple SearXNG-compatible JSON responses. +// MergeResponses merges multiple compatible JSON responses. // // MVP merge semantics: // - results are concatenated with a simple de-dup key (engine|title|url) diff --git a/internal/search/request_params.go b/internal/search/request_params.go index 1d48a04..9fdd799 100644 --- a/internal/search/request_params.go +++ b/internal/search/request_params.go @@ -11,7 +11,7 @@ import ( var languageCodeRe = regexp.MustCompile(`^[a-z]{2,3}(-[a-zA-Z]{2})?$`) func ParseSearchRequest(r *http.Request) (SearchRequest, error) { - // SearXNG supports both GET and POST and relies on form values for routing. + // Supports both GET and POST and relies on form values for routing. if err := r.ParseForm(); err != nil { return SearchRequest{}, errors.New("invalid request: cannot parse form") } @@ -90,7 +90,7 @@ func ParseSearchRequest(r *http.Request) (SearchRequest, error) { // engines is an explicit list of engine names. engines := splitCSV(strings.TrimSpace(r.FormValue("engines"))) - // categories and category_ params mirror SearXNG's webadapter parsing. + // categories and category_ params mirror the webadapter parsing. // We don't validate against a registry here; we just preserve the requested values. catSet := map[string]bool{} if catsParam := strings.TrimSpace(r.FormValue("categories")); catsParam != "" { diff --git a/internal/search/response.go b/internal/search/response.go index 3b07096..1a9ce26 100644 --- a/internal/search/response.go +++ b/internal/search/response.go @@ -38,7 +38,7 @@ func WriteSearchResponse(w http.ResponseWriter, format OutputFormat, resp Search } } -// csvRowHeader matches the SearXNG CSV writer key order. +// csvRowHeader matches the CSV writer key order. var csvRowHeader = []string{"title", "url", "content", "host", "engine", "score", "type"} func writeCSV(w http.ResponseWriter, resp SearchResponse) error { @@ -111,14 +111,14 @@ func writeCSV(w http.ResponseWriter, resp SearchResponse) error { func writeRSS(w http.ResponseWriter, resp SearchResponse) error { q := resp.Query - escapedTitle := xmlEscape("SearXNG search: " + q) - escapedDesc := xmlEscape("Search results for \"" + q + "\" - SearXNG") + escapedTitle := xmlEscape("kafka search: " + q) + escapedDesc := xmlEscape("Search results for \"" + q + "\" - kafka") escapedQueryTerms := xmlEscape(q) link := "/search?q=" + url.QueryEscape(q) opensearchQuery := fmt.Sprintf(``, escapedQueryTerms) - // SearXNG template uses the number of results for both totalResults and itemsPerPage. + // The template uses the number of results for both totalResults and itemsPerPage. nr := resp.NumberOfResults var items bytes.Buffer diff --git a/internal/search/service.go b/internal/search/service.go index 91fef2b..62a9308 100644 --- a/internal/search/service.go +++ b/internal/search/service.go @@ -50,7 +50,7 @@ func NewService(cfg ServiceConfig) *Service { } // Search executes the request against local engines (in parallel) and -// optionally upstream SearXNG for unported engines. +// optionally the upstream instance for unported engines. // // Individual engine failures are reported as unresponsive_engines rather // than aborting the entire search. diff --git a/internal/upstream/client.go b/internal/upstream/client.go index 3a11843..64ddec4 100644 --- a/internal/upstream/client.go +++ b/internal/upstream/client.go @@ -68,7 +68,7 @@ func (c *Client) SearchJSON(ctx context.Context, req contracts.SearchRequest, en for engineName, kv := range req.EngineData { for key, value := range kv { - // Mirror SearXNG's naming: `engine_data--=` + // Mirror the naming convention: `engine_data--=` form.Set(fmt.Sprintf("engine_data-%s-%s", engineName, key), value) } } diff --git a/internal/views/static/css/kafka.css b/internal/views/static/css/kafka.css index 376b2d8..824f489 100644 --- a/internal/views/static/css/kafka.css +++ b/internal/views/static/css/kafka.css @@ -1,5 +1,4 @@ /* kafka — clean, minimal search engine CSS */ -/* Inspired by SearXNG's simple theme class conventions */ :root { --color-base: #f5f5f5; From 1689cab9bdc0f331a71ba947871db4272b016e01 Mon Sep 17 00:00:00 2001 From: Franz Kafka Date: Sun, 22 Mar 2026 01:53:19 +0000 Subject: [PATCH 02/91] feat: add YouTube engine via Data API v3 Uses the official YouTube Data API v3. Requires YOUTUBE_API_KEY environment variable (free from Google Cloud Console). Returns video results with title, description, channel, publish date, and thumbnail URL. Falls back gracefully if no API key. --- internal/engines/factory.go | 6 +- internal/engines/planner.go | 6 +- internal/engines/youtube.go | 182 ++++++++++++++++++++++++++++++++++++ 3 files changed, 191 insertions(+), 3 deletions(-) create mode 100644 internal/engines/youtube.go diff --git a/internal/engines/factory.go b/internal/engines/factory.go index 937225f..53ba87f 100644 --- a/internal/engines/factory.go +++ b/internal/engines/factory.go @@ -32,6 +32,10 @@ func NewDefaultPortedEngines(client *http.Client) map[string]Engine { "github": &GitHubEngine{client: client}, "reddit": &RedditEngine{client: client}, "bing": &BingEngine{client: client}, - "google": &GoogleEngine{client: client}, + "google": &GoogleEngine{client: client}, + "youtube": &YouTubeEngine{ + client: client, + baseURL: "https://www.googleapis.com", + }, } } diff --git a/internal/engines/planner.go b/internal/engines/planner.go index 24af031..b180f7e 100644 --- a/internal/engines/planner.go +++ b/internal/engines/planner.go @@ -7,7 +7,7 @@ import ( "github.com/metamorphosis-dev/kafka/internal/contracts" ) -var defaultPortedEngines = []string{"wikipedia", "arxiv", "crossref", "braveapi", "qwant", "duckduckgo", "github", "reddit", "bing"} +var defaultPortedEngines = []string{"wikipedia", "arxiv", "crossref", "braveapi", "qwant", "duckduckgo", "github", "reddit", "bing", "google", "youtube"} type Planner struct { PortedSet map[string]bool @@ -99,6 +99,8 @@ func inferFromCategories(categories []string) []string { set["github"] = true case "social media": set["reddit"] = true + case "videos": + set["youtube"] = true } } @@ -107,7 +109,7 @@ func inferFromCategories(categories []string) []string { out = append(out, e) } // stable order - order := map[string]int{"wikipedia": 0, "braveapi": 1, "qwant": 2, "duckduckgo": 3, "bing": 4, "google": 5, "arxiv": 6, "crossref": 7, "github": 8, "reddit": 9} + order := map[string]int{"wikipedia": 0, "braveapi": 1, "qwant": 2, "duckduckgo": 3, "bing": 4, "google": 5, "arxiv": 6, "crossref": 7, "github": 8, "reddit": 9, "youtube": 10} sortByOrder(out, order) return out } diff --git a/internal/engines/youtube.go b/internal/engines/youtube.go new file mode 100644 index 0000000..7580a09 --- /dev/null +++ b/internal/engines/youtube.go @@ -0,0 +1,182 @@ +package engines + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "os" + "strings" + "time" + + "github.com/metamorphosis-dev/kafka/internal/contracts" +) + +type YouTubeEngine struct { + client *http.Client + apiKey string + baseURL string +} + +func (e *YouTubeEngine) Name() string { return "youtube" } + +func (e *YouTubeEngine) Search(ctx context.Context, req contracts.SearchRequest) (contracts.SearchResponse, error) { + if strings.TrimSpace(req.Query) == "" { + return contracts.SearchResponse{Query: req.Query}, nil + } + + if e.apiKey == "" { + e.apiKey = os.Getenv("YOUTUBE_API_KEY") + } + + maxResults := 10 + if req.Pageno > 1 { + maxResults = 20 + } + + u := e.baseURL + "/youtube/v3/search?" + url.Values{ + "part": {"snippet"}, + "q": {req.Query}, + "type": {"video"}, + "maxResults": {fmt.Sprintf("%d", maxResults)}, + "key": {e.apiKey}, + }.Encode() + + if req.Language != "" && req.Language != "auto" { + lang := strings.Split(strings.ToLower(req.Language), "-")[0] + u += "&relevanceLanguage=" + lang + } + + httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) + if err != nil { + return contracts.SearchResponse{}, err + } + + resp, err := e.client.Do(httpReq) + if err != nil { + return contracts.SearchResponse{}, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + return contracts.SearchResponse{}, fmt.Errorf("youtube api error: status=%d body=%q", resp.StatusCode, string(body)) + } + + var apiResp youtubeSearchResponse + if err := json.NewDecoder(resp.Body).Decode(&apiResp); err != nil { + return contracts.SearchResponse{}, err + } + + if apiResp.Error != nil { + return contracts.SearchResponse{}, fmt.Errorf("youtube api error: %s", apiResp.Error.Message) + } + + results := make([]contracts.MainResult, 0, len(apiResp.Items)) + for _, item := range apiResp.Items { + if item.ID.VideoID == "" { + continue + } + + videoURL := "https://www.youtube.com/watch?v=" + item.ID.VideoID + urlPtr := videoURL + + published := "" + if item.Snippet.PublishedAt != "" { + if t, err := time.Parse(time.RFC3339, item.Snippet.PublishedAt); err == nil { + published = t.Format("Jan 2, 2006") + } + } + + content := item.Snippet.Description + if len(content) > 300 { + content = content[:300] + "..." + } + if published != "" { + content = "Published " + published + " · " + content + } + + thumbnail := "" + if item.Snippet.Thumbnails.High.URL != "" { + thumbnail = item.Snippet.Thumbnails.High.URL + } else if item.Snippet.Thumbnails.Medium.URL != "" { + thumbnail = item.Snippet.Thumbnails.Medium.URL + } + + results = append(results, contracts.MainResult{ + Template: "videos.html", + Title: item.Snippet.Title, + URL: &urlPtr, + Content: content, + Thumbnail: thumbnail, + Engine: "youtube", + Score: 1.0, + Category: "videos", + Engines: []string{"youtube"}, + Metadata: map[string]any{ + "channel": item.Snippet.ChannelTitle, + "video_id": item.Snippet.ResourceID.VideoID, + }, + }) + } + + return contracts.SearchResponse{ + Query: req.Query, + NumberOfResults: len(results), + Results: results, + Answers: []map[string]any{}, + Corrections: []string{}, + Infoboxes: []map[string]any{}, + Suggestions: []string{}, + UnresponsiveEngines: [][2]string{}, + }, nil +} + +// YouTube API response types. + +type youtubeSearchResponse struct { + Items []youtubeSearchItem `json:"items"` + PageInfo struct { + TotalResults int `json:"totalResults"` + ResultsPerPage int `json:"resultsPerPage"` + } `json:"pageInfo"` + NextPageToken string `json:"nextPageToken"` + Error *struct { + Code int `json:"code"` + Message string `json:"message"` + Errors []struct { + Domain string `json:"domain"` + Reason string `json:"reason"` + Message string `json:"message"` + } `json:"errors"` + } `json:"error"` +} + +type youtubeSearchItem struct { + ID struct { + VideoID string `json:"videoId"` + } `json:"id"` + Snippet struct { + PublishedAt string `json:"publishedAt"` + ChannelID string `json:"channelId"` + ChannelTitle string `json:"channelTitle"` + Title string `json:"title"` + Description string `json:"description"` + Thumbnails struct { + Default struct { + URL string `json:"url"` + } `json:"default"` + Medium struct { + URL string `json:"url"` + } `json:"medium"` + High struct { + URL string `json:"url"` + } `json:"high"` + } `json:"thumbnails"` + ResourceID struct { + VideoID string `json:"videoId"` + } `json:"resourceId"` + } `json:"snippet"` +} From a7f594b7fa68d94d4debdace3fbae867b25e4f60 Mon Sep 17 00:00:00 2001 From: Franz Kafka Date: Sun, 22 Mar 2026 01:57:13 +0000 Subject: [PATCH 03/91] feat: add YouTube engine with config file and env support YouTube Data API v3 engine: - Add YouTubeConfig to EnginesConfig with api_key field - Add YOUTUBE_API_KEY env override - Thread *config.Config through search service to factory - Factory falls back to env vars if config fields are empty - Update config.example.toml with youtube section Also update default local_ported to include google and youtube. --- cmd/kafka/main.go | 7 ++++--- config.example.toml | 6 +++++- internal/config/config.go | 10 +++++++++- internal/engines/factory.go | 26 +++++++++++++++++++++++--- internal/search/service.go | 10 ++++++---- 5 files changed, 47 insertions(+), 12 deletions(-) diff --git a/cmd/kafka/main.go b/cmd/kafka/main.go index ab29852..90c750d 100644 --- a/cmd/kafka/main.go +++ b/cmd/kafka/main.go @@ -53,9 +53,10 @@ func main() { } svc := search.NewService(search.ServiceConfig{ - UpstreamURL: cfg.Upstream.URL, - HTTPTimeout: cfg.HTTPTimeout(), - Cache: searchCache, + UpstreamURL: cfg.Upstream.URL, + HTTPTimeout: cfg.HTTPTimeout(), + Cache: searchCache, + EnginesConfig: cfg, }) acSvc := autocomplete.NewService(cfg.Upstream.URL, cfg.HTTPTimeout()) diff --git a/config.example.toml b/config.example.toml index 1e3b75c..34f60a6 100644 --- a/config.example.toml +++ b/config.example.toml @@ -22,7 +22,7 @@ url = "" [engines] # Comma-separated list of engines to execute locally in Go (env: LOCAL_PORTED_ENGINES) # Engines not listed here will be proxied to the upstream instance. -local_ported = ["wikipedia", "arxiv", "crossref", "braveapi", "qwant", "duckduckgo", "github", "reddit", "bing"] +local_ported = ["wikipedia", "arxiv", "crossref", "braveapi", "qwant", "duckduckgo", "github", "reddit", "bing", "google", "youtube"] [engines.brave] # Brave Search API key (env: BRAVE_API_KEY) @@ -35,6 +35,10 @@ access_token = "" category = "web-lite" results_per_page = 10 +[engines.youtube] +# YouTube Data API v3 key (env: YOUTUBE_API_KEY) +api_key = "" + [cache] # Valkey/Redis cache for search results. # Leave address empty to disable caching entirely. diff --git a/internal/config/config.go b/internal/config/config.go index 93b8d86..7f8b06a 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -35,6 +35,7 @@ type EnginesConfig struct { LocalPorted []string `toml:"local_ported"` Brave BraveConfig `toml:"brave"` Qwant QwantConfig `toml:"qwant"` + YouTube YouTubeConfig `toml:"youtube"` } // CacheConfig holds Valkey/Redis cache settings. @@ -85,6 +86,10 @@ type QwantConfig struct { ResultsPerPage int `toml:"results_per_page"` } +type YouTubeConfig struct { + APIKey string `toml:"api_key"` +} + // Load reads configuration from the given TOML file path. // If the file does not exist, it returns defaults (empty values where applicable). // Environment variables are used as fallbacks for any zero-value fields. @@ -109,7 +114,7 @@ func defaultConfig() *Config { }, Upstream: UpstreamConfig{}, Engines: EnginesConfig{ - LocalPorted: []string{"wikipedia", "arxiv", "crossref", "braveapi", "qwant", "duckduckgo", "github", "reddit", "bing"}, + LocalPorted: []string{"wikipedia", "arxiv", "crossref", "braveapi", "qwant", "duckduckgo", "github", "reddit", "bing", "google", "youtube"}, Qwant: QwantConfig{ Category: "web-lite", ResultsPerPage: 10, @@ -151,6 +156,9 @@ func applyEnvOverrides(cfg *Config) { if v := os.Getenv("BRAVE_ACCESS_TOKEN"); v != "" { cfg.Engines.Brave.AccessToken = v } + if v := os.Getenv("YOUTUBE_API_KEY"); v != "" { + cfg.Engines.YouTube.APIKey = v + } if v := os.Getenv("VALKEY_ADDRESS"); v != "" { cfg.Cache.Address = v } diff --git a/internal/engines/factory.go b/internal/engines/factory.go index 53ba87f..b7f3c00 100644 --- a/internal/engines/factory.go +++ b/internal/engines/factory.go @@ -4,23 +4,42 @@ import ( "net/http" "os" "time" + + "github.com/metamorphosis-dev/kafka/internal/config" ) // NewDefaultPortedEngines returns the starter set of Go-native engines. // The service can swap/extend this registry later as more engines are ported. -func NewDefaultPortedEngines(client *http.Client) map[string]Engine { +// If cfg is nil, falls back to reading API keys from environment variables. +func NewDefaultPortedEngines(client *http.Client, cfg *config.Config) map[string]Engine { if client == nil { client = &http.Client{Timeout: 10 * time.Second} } + var braveAPIKey, braveAccessToken, youtubeAPIKey string + if cfg != nil { + braveAPIKey = cfg.Engines.Brave.APIKey + braveAccessToken = cfg.Engines.Brave.AccessToken + youtubeAPIKey = cfg.Engines.YouTube.APIKey + } + if braveAPIKey == "" { + braveAPIKey = os.Getenv("BRAVE_API_KEY") + } + if braveAccessToken == "" { + braveAccessToken = os.Getenv("BRAVE_ACCESS_TOKEN") + } + if youtubeAPIKey == "" { + youtubeAPIKey = os.Getenv("YOUTUBE_API_KEY") + } + return map[string]Engine{ "wikipedia": &WikipediaEngine{client: client}, "arxiv": &ArxivEngine{client: client}, "crossref": &CrossrefEngine{client: client}, "braveapi": &BraveEngine{ client: client, - apiKey: os.Getenv("BRAVE_API_KEY"), - accessGateToken: os.Getenv("BRAVE_ACCESS_TOKEN"), + apiKey: braveAPIKey, + accessGateToken: braveAccessToken, resultsPerPage: 20, }, "qwant": &QwantEngine{ @@ -35,6 +54,7 @@ func NewDefaultPortedEngines(client *http.Client) map[string]Engine { "google": &GoogleEngine{client: client}, "youtube": &YouTubeEngine{ client: client, + apiKey: youtubeAPIKey, baseURL: "https://www.googleapis.com", }, } diff --git a/internal/search/service.go b/internal/search/service.go index 62a9308..47d2895 100644 --- a/internal/search/service.go +++ b/internal/search/service.go @@ -7,15 +7,17 @@ import ( "time" "github.com/metamorphosis-dev/kafka/internal/cache" + "github.com/metamorphosis-dev/kafka/internal/config" "github.com/metamorphosis-dev/kafka/internal/contracts" "github.com/metamorphosis-dev/kafka/internal/engines" "github.com/metamorphosis-dev/kafka/internal/upstream" ) type ServiceConfig struct { - UpstreamURL string - HTTPTimeout time.Duration - Cache *cache.Cache + UpstreamURL string + HTTPTimeout time.Duration + Cache *cache.Cache + EnginesConfig *config.Config } type Service struct { @@ -44,7 +46,7 @@ func NewService(cfg ServiceConfig) *Service { return &Service{ upstreamClient: up, planner: engines.NewPlannerFromEnv(), - localEngines: engines.NewDefaultPortedEngines(httpClient), + localEngines: engines.NewDefaultPortedEngines(httpClient, cfg.EnginesConfig), cache: cfg.Cache, } } From bfed53ae332ac68046a3942c157d7049be5428dd Mon Sep 17 00:00:00 2001 From: ashisgreat22 Date: Sun, 22 Mar 2026 02:20:13 +0100 Subject: [PATCH 04/91] docs: add settings UI design spec Co-Authored-By: Claude Opus 4.6 --- .../specs/2026-03-22-settings-ui-design.md | 80 +++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100644 docs/superpowers/specs/2026-03-22-settings-ui-design.md diff --git a/docs/superpowers/specs/2026-03-22-settings-ui-design.md b/docs/superpowers/specs/2026-03-22-settings-ui-design.md new file mode 100644 index 0000000..8029dc8 --- /dev/null +++ b/docs/superpowers/specs/2026-03-22-settings-ui-design.md @@ -0,0 +1,80 @@ +# Settings UI Design — kafka + +**Date:** 2026-03-22 +**Status:** Approved + +## Overview + +A lightweight preferences popover anchored to the top-right, just below the header. Triggered by a gear icon, it lets users adjust theme, enabled engines, and search defaults without leaving their current page. All changes auto-save to `localStorage` on every interaction. + +## Layout & Structure + +- **Trigger**: Gear icon (⚙️) in the top-right header, aligned with the header's right edge +- **Panel**: 280px wide, max-height 420px, scrollable internally, rounded corners, subtle shadow, anchored top-right (drops down from trigger, like a dropdown) +- **Close**: × button in panel header, click outside the panel, or pressing Escape +- **No Save button** — every interaction immediately writes to `localStorage` + +## Interaction Flow + +1. User clicks ⚙️ → panel drops down from top-right (200ms ease) +2. User toggles/clicks → changes apply instantly to DOM + write to `localStorage` +3. User clicks × or outside or Escape → panel closes, settings persist +4. **Accessibility**: Focus is trapped within the panel while open. Trigger button uses `aria-expanded` and `aria-controls`. Escape key closes the panel. + +## Mid-Search Changes + +When opened during an active search on `results.html`: +- Engine toggles update `localStorage` immediately, but **current results remain unchanged** +- A subtle inline note below the engines section: *"Engine changes apply to your next search"* + +## Sections + +### Appearance + +- Three theme buttons: ☀️ Light / 🌙 Dark / 💻 System +- Clicking immediately applies via `document.body.classList` + writes to localStorage +- "System" reads `prefers-color-scheme` and updates on change + +### Engines + +- 2-column grid of toggle switches for all 9 engines +- Each row: engine name + toggle switch +- Enabled = filled accent color; Disabled = gray outline + +### Search Defaults + +- Safe search: dropdown (Moderate / Strict / Off) +- Default format: dropdown (HTML / JSON / CSV) + +## Default State + +```js +const DEFAULT_PREFS = { + theme: "system", + engines: ["wikipedia", "arxiv", "crossref", "braveapi", "qwant", "duckduckgo", "github", "reddit", "bing"], + safeSearch: "moderate", + format: "html" +}; +``` + +## Persistence + +```js +// Written on every interaction +localStorage.setItem('kafka_prefs', JSON.stringify({ ... })); + +// Read on page load — merge with DEFAULT_PREFS +const saved = JSON.parse(localStorage.getItem('kafka_prefs') || '{}'); +const prefs = { ...DEFAULT_PREFS, ...saved }; +``` + +## Responsive Behavior + +- **Mobile (<768px)**: Panel becomes a **bottom sheet** — 100% width, slides up from the bottom, top corners rounded, max-height 70vh. Trigger moves to a fixed bottom-right FAB button. +- Panel never covers the search input + +## Applied to Existing Code + +- `base.html` — add gear button in header, panel markup at end of `` +- `kafka.css` — popover styles, toggle switch styles, bottom sheet styles for mobile +- `settings.js` — localStorage read/write, theme application, panel toggle, aria attributes, focus trap From 2c9d1c35432a9e769df865cc733b9e6bd9f9db93 Mon Sep 17 00:00:00 2001 From: ashisgreat22 Date: Sun, 22 Mar 2026 02:24:54 +0100 Subject: [PATCH 05/91] docs: add settings UI implementation plan Co-Authored-By: Claude Opus 4.6 --- .../plans/2026-03-22-settings-ui.md | 712 ++++++++++++++++++ 1 file changed, 712 insertions(+) create mode 100644 docs/superpowers/plans/2026-03-22-settings-ui.md diff --git a/docs/superpowers/plans/2026-03-22-settings-ui.md b/docs/superpowers/plans/2026-03-22-settings-ui.md new file mode 100644 index 0000000..9ebcffd --- /dev/null +++ b/docs/superpowers/plans/2026-03-22-settings-ui.md @@ -0,0 +1,712 @@ +# Settings UI Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** A preferences popover panel (top-right on desktop, bottom sheet on mobile) that lets users set theme, enabled engines, safe search, and default format. All changes auto-save to `localStorage` and apply immediately to the DOM. + +**Architecture:** Pure client-side JS + CSS added alongside existing templates. No Go changes. Settings persist via `localStorage` key `kafka_prefs`. Theme applies via `data-theme` attribute on ``. + +**Tech Stack:** Vanilla JS (no framework), existing `kafka.css` custom properties, HTMX for search. + +--- + +## File Map + +| Action | File | +|--------|------| +| Create | `internal/views/static/js/settings.js` | +| Modify | `internal/views/static/css/kafka.css` | +| Modify | `internal/views/templates/base.html` | +| Modify | `internal/views/templates/index.html` | +| Modify | `internal/views/templates/results.html` | +| Modify | `internal/views/views.go` | + +**Key insight on engine preferences:** `ParseSearchRequest` reads `engines` as a CSV form value (`r.FormValue("engines")`). The search forms in `index.html` and `results.html` will get a hidden `#engines-input` field that is kept in sync with localStorage. On submit, the engines preference is sent as a normal form field. HTMX `hx-include="this"` already includes the form element, so the hidden input is automatically included in the request. + +--- + +## Task 1: CSS — Popover, toggles, bottom sheet + +**Files:** +- Modify: `internal/views/static/css/kafka.css` + +- [ ] **Step 1: Add CSS for popover, triggers, toggles, bottom sheet** + +Append the following to `kafka.css`: + +```css +/* ============================================ + Settings Panel + ============================================ */ + +/* Header */ +.site-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.6rem 1rem; + background: var(--color-header-background); + border-bottom: 1px solid var(--color-header-border); +} +.site-title { + font-size: 1rem; + font-weight: 600; + color: var(--color-base-font); +} + +/* Gear trigger button */ +.settings-trigger { + background: none; + border: none; + font-size: 1.1rem; + cursor: pointer; + padding: 0.3rem 0.5rem; + border-radius: var(--radius); + color: var(--color-base-font); + opacity: 0.7; + transition: opacity 0.2s, background 0.2s; + line-height: 1; +} +.settings-trigger:hover, +.settings-trigger[aria-expanded="true"] { + opacity: 1; + background: var(--color-sidebar-background); +} + +/* Popover panel */ +.settings-popover { + position: absolute; + top: 100%; + right: 0; + width: 280px; + max-height: 420px; + overflow-y: auto; + background: var(--color-base-background); + border: 1px solid var(--color-sidebar-border); + border-radius: var(--radius); + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12); + z-index: 200; + display: none; + flex-direction: column; +} +.settings-popover[data-open="true"] { + display: flex; + animation: settings-slide-in 0.2s ease; +} +@keyframes settings-slide-in { + from { opacity: 0; transform: translateY(-8px); } + to { opacity: 1; transform: translateY(0); } +} + +.settings-popover-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.75rem 1rem; + border-bottom: 1px solid var(--color-sidebar-border); + font-weight: 600; + font-size: 0.9rem; + flex-shrink: 0; +} +.settings-popover-close { + background: none; + border: none; + font-size: 1.2rem; + cursor: pointer; + color: var(--color-base-font); + opacity: 0.6; + padding: 0 0.25rem; + line-height: 1; +} +.settings-popover-close:hover { opacity: 1; } + +.settings-popover-body { + padding: 0.8rem; + display: flex; + flex-direction: column; + gap: 1rem; +} + +.settings-section-title { + font-size: 0.7rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--color-suggestion); + margin-bottom: 0.5rem; +} + +/* Theme buttons */ +.theme-buttons { + display: flex; + gap: 0.4rem; +} +.theme-btn { + flex: 1; + padding: 0.35rem 0.5rem; + border: 1px solid var(--color-sidebar-border); + border-radius: var(--radius); + background: var(--color-btn-background); + color: var(--color-base-font); + cursor: pointer; + font-size: 0.75rem; + text-align: center; + transition: background 0.15s, border-color 0.15s; +} +.theme-btn:hover { background: var(--color-btn-hover); } +.theme-btn.active { + background: var(--color-link); + color: #fff; + border-color: var(--color-link); +} + +/* Engine toggles — 2-column grid */ +.engine-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0.4rem; +} +.engine-toggle { + display: flex; + align-items: center; + gap: 0.4rem; + padding: 0.3rem 0.5rem; + border-radius: var(--radius); + background: var(--color-sidebar-background); + font-size: 0.78rem; + cursor: pointer; +} +.engine-toggle input[type="checkbox"] { + width: 15px; + height: 15px; + margin: 0; + cursor: pointer; + accent-color: var(--color-link); +} +.engine-toggle span { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* Search defaults */ +.setting-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + margin-top: 0.4rem; +} +.setting-row label { + font-size: 0.85rem; + flex: 1; +} +.setting-row select { + width: 110px; + padding: 0.3rem 0.4rem; + font-size: 0.8rem; + border: 1px solid var(--color-sidebar-border); + border-radius: var(--radius); + background: var(--color-base-background); + color: var(--color-base-font); + cursor: pointer; +} + +/* Mid-search notice */ +.settings-notice { + font-size: 0.72rem; + color: var(--color-suggestion); + margin-top: 0.3rem; + font-style: italic; +} + +/* Dark theme via data-theme attribute */ +html[data-theme="dark"] { + --color-base: #222; + --color-base-font: #dcdcdc; + --color-base-background: #2b2b2b; + --color-header-background: #333; + --color-header-border: #444; + --color-search-border: #555; + --color-search-focus: #5dade2; + --color-result-url: #8ab4f8; + --color-result-url-visited: #b39ddb; + --color-result-content: #b0b0b0; + --color-result-title: #8ab4f8; + --color-result-title-visited: #b39ddb; + --color-result-engine: #999; + --color-result-border: #3a3a3a; + --color-link: #5dade2; + --color-link-visited: #b39ddb; + --color-sidebar-background: #333; + --color-sidebar-border: #444; + --color-infobox-background: #333; + --color-infobox-border: #444; + --color-pagination-current: #5dade2; + --color-pagination-border: #444; + --color-error: #e74c3c; + --color-error-background: #3b1a1a; + --color-suggestion: #999; + --color-footer: #666; + --color-btn-background: #333; + --color-btn-border: #555; + --color-btn-hover: #444; +} + +/* Mobile: Bottom sheet */ +@media (max-width: 768px) { + .settings-popover { + position: fixed; + top: auto; + bottom: 0; + left: 0; + right: 0; + width: 100%; + max-height: 70vh; + border-radius: var(--radius) var(--radius) 0 0; + border-bottom: none; + } +} +``` + +Note: The existing `:root` and `@media (prefers-color-scheme: dark)` blocks provide the "system" theme. `html[data-theme="dark"]` overrides only apply when the user explicitly picks dark mode. When `theme === 'system'`, the `data-theme` attribute is removed and the browser's `prefers-color-scheme` media query kicks in via the existing CSS. + +- [ ] **Step 2: Verify existing tests still pass** + +Run: `go test ./...` +Expected: all pass + +- [ ] **Step 3: Commit** + +```bash +git add internal/views/static/css/kafka.css +git commit -m "feat(settings): add popover, toggle, and bottom-sheet CSS" +``` + +--- + +## Task 2: JS — Settings logic + +**Files:** +- Create: `internal/views/static/js/settings.js` + +- [ ] **Step 1: Write the settings JS module** + +Create `internal/views/static/js/settings.js`: + +```javascript +'use strict'; + +var ALL_ENGINES = [ + 'wikipedia', 'arxiv', 'crossref', 'braveapi', + 'qwant', 'duckduckgo', 'github', 'reddit', 'bing' +]; + +var DEFAULT_PREFS = { + theme: 'system', + engines: ALL_ENGINES.slice(), + safeSearch: 'moderate', + format: 'html' +}; + +var STORAGE_KEY = 'kafka_prefs'; + +// ── Persistence ────────────────────────────────────────────────────────────── + +function loadPrefs() { + try { + var raw = localStorage.getItem(STORAGE_KEY); + if (!raw) return { theme: DEFAULT_PREFS.theme, engines: DEFAULT_PREFS.engines.slice(), safeSearch: DEFAULT_PREFS.safeSearch, format: DEFAULT_PREFS.format }; + var saved = JSON.parse(raw); + return { theme: saved.theme || DEFAULT_PREFS.theme, engines: saved.engines || DEFAULT_PREFS.engines.slice(), safeSearch: saved.safeSearch || DEFAULT_PREFS.safeSearch, format: saved.format || DEFAULT_PREFS.format }; + } catch (e) { + return { theme: DEFAULT_PREFS.theme, engines: DEFAULT_PREFS.engines.slice(), safeSearch: DEFAULT_PREFS.safeSearch, format: DEFAULT_PREFS.format }; + } +} + +function savePrefs(prefs) { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify({ theme: prefs.theme, engines: prefs.engines, safeSearch: prefs.safeSearch, format: prefs.format })); + } catch (e) { /* quota or private mode */ } +} + +// ── Theme application ──────────────────────────────────────────────────────── + +function applyTheme(theme) { + if (theme === 'system') { + document.documentElement.removeAttribute('data-theme'); + } else { + document.documentElement.setAttribute('data-theme', theme); + } +} + +// ── Engine input sync ───────────────────────────────────────────────────────── + +function syncEngineInput(prefs) { + var input = document.getElementById('engines-input'); + if (input) input.value = prefs.engines.join(','); +} + +// ── Panel open / close ──────────────────────────────────────────────────────── + +function closePanel() { + var panel = document.getElementById('settings-popover'); + var trigger = document.getElementById('settings-trigger'); + if (!panel) return; + panel.setAttribute('data-open', 'false'); + if (trigger) trigger.setAttribute('aria-expanded', 'false'); + if (trigger) trigger.focus(); +} + +function openPanel() { + var panel = document.getElementById('settings-popover'); + var trigger = document.getElementById('settings-trigger'); + if (!panel) return; + panel.setAttribute('data-open', 'true'); + if (trigger) trigger.setAttribute('aria-expanded', 'true'); + var focusable = panel.querySelector('button, input, select'); + if (focusable) focusable.focus(); +} + +// ── Escape key ─────────────────────────────────────────────────────────────── + +document.addEventListener('keydown', function(e) { + if (e.key !== 'Escape') return; + var panel = document.getElementById('settings-popover'); + if (!panel || panel.getAttribute('data-open') !== 'true') return; + closePanel(); +}); + +// ── Click outside ───────────────────────────────────────────────────────────── + +document.addEventListener('click', function(e) { + var panel = document.getElementById('settings-popover'); + var trigger = document.getElementById('settings-trigger'); + if (!panel || panel.getAttribute('data-open') !== 'true') return; + if (!panel.contains(e.target) && (!trigger || !trigger.contains(e.target))) { + closePanel(); + } +}); + +// ── Focus trap ──────────────────────────────────────────────────────────────── + +document.addEventListener('keydown', function(e) { + if (e.key !== 'Tab') return; + var panel = document.getElementById('settings-popover'); + if (!panel || panel.getAttribute('data-open') !== 'true') return; + var focusable = Array.prototype.slice.call(panel.querySelectorAll('button, input, select, [tabindex]:not([tabindex="-1"])')); + if (!focusable.length) return; + var first = focusable[0]; + var last = focusable[focusable.length - 1]; + if (e.shiftKey) { + if (document.activeElement === first) { e.preventDefault(); last.focus(); } + } else { + if (document.activeElement === last) { e.preventDefault(); first.focus(); } + } +}); + +// ── Render ──────────────────────────────────────────────────────────────────── + +function escapeHtml(str) { + return String(str).replace(/&/g, '&').replace(//g, '>'); +} + +function renderPanel(prefs) { + var panel = document.getElementById('settings-popover'); + if (!panel) return; + var body = panel.querySelector('.settings-popover-body'); + if (!body) return; + + var themeBtns = ''; + ['light', 'dark', 'system'].forEach(function(t) { + var icons = { light: '\u2600', dark: '\u263D', system: '\u2318' }; + var labels = { light: 'Light', dark: 'Dark', system: 'System' }; + var active = prefs.theme === t ? ' active' : ''; + themeBtns += ''; + }); + + var engineToggles = ''; + ALL_ENGINES.forEach(function(name) { + var checked = prefs.engines.indexOf(name) !== -1 ? ' checked' : ''; + engineToggles += ''; + }); + + var ssOptions = [ + { val: 'moderate', label: 'Moderate' }, + { val: 'strict', label: 'Strict' }, + { val: 'off', label: 'Off' } + ]; + var fmtOptions = [ + { val: 'html', label: 'HTML' }, + { val: 'json', label: 'JSON' }, + { val: 'csv', label: 'CSV' } + ]; + var ssOptionsHtml = ''; + var fmtOptionsHtml = ''; + ssOptions.forEach(function(o) { + var sel = prefs.safeSearch === o.val ? ' selected' : ''; + ssOptionsHtml += ''; + }); + fmtOptions.forEach(function(o) { + var sel = prefs.format === o.val ? ' selected' : ''; + fmtOptionsHtml += ''; + }); + + body.innerHTML = + '
' + + '
Appearance
' + + '
' + themeBtns + '
' + + '
' + + '
' + + '
Engines
' + + '
' + engineToggles + '
' + + '

Engine changes apply to your next search.

' + + '
' + + '
' + + '
Search Defaults
' + + '
' + + '' + + '' + + '
' + + '
' + + '' + + '' + + '
' + + '
'; + + // Theme buttons + var themeBtnEls = panel.querySelectorAll('.theme-btn'); + for (var i = 0; i < themeBtnEls.length; i++) { + themeBtnEls[i].addEventListener('click', (function(btn) { + return function() { + prefs.theme = btn.getAttribute('data-theme'); + savePrefs(prefs); + applyTheme(prefs.theme); + syncEngineInput(prefs); + renderPanel(prefs); + }; + })(themeBtnEls[i])); + } + + // Engine checkboxes + var checkboxes = panel.querySelectorAll('.engine-toggle input[type="checkbox"]'); + for (var j = 0; j < checkboxes.length; j++) { + checkboxes[j].addEventListener('change', (function(cb) { + return function() { + var checked = Array.prototype.slice.call(panel.querySelectorAll('.engine-toggle input[type="checkbox"]:checked')).map(function(el) { return el.value; }); + if (checked.length === 0) { cb.checked = true; return; } + prefs.engines = checked; + savePrefs(prefs); + syncEngineInput(prefs); + }; + })(checkboxes[j])); + } + + // Safe search + var ssEl = panel.querySelector('#pref-safesearch'); + if (ssEl) { + ssEl.addEventListener('change', function() { + prefs.safeSearch = ssEl.value; + savePrefs(prefs); + }); + } + + // Format + var fmtEl = panel.querySelector('#pref-format'); + if (fmtEl) { + fmtEl.addEventListener('change', function() { + prefs.format = fmtEl.value; + savePrefs(prefs); + }); + } + + // Close button + var closeBtn = panel.querySelector('.settings-popover-close'); + if (closeBtn) closeBtn.addEventListener('click', closePanel); +} + +// ── Init ───────────────────────────────────────────────────────────────────── + +function initSettings() { + var prefs = loadPrefs(); + applyTheme(prefs.theme); + syncEngineInput(prefs); + + var panel = document.getElementById('settings-popover'); + var trigger = document.getElementById('settings-trigger'); + + if (panel && trigger) { + renderPanel(prefs); + trigger.addEventListener('click', function() { + var isOpen = panel.getAttribute('data-open') === 'true'; + if (isOpen) closePanel(); else openPanel(); + }); + } +} + +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', initSettings); +} else { + initSettings(); +} +``` + +- [ ] **Step 2: Verify JS syntax** + +Run: `node --check internal/views/static/js/settings.js` +Expected: no output (exit 0) + +- [ ] **Step 3: Commit** + +```bash +git add internal/views/static/js/settings.js +git commit -m "feat(settings): add JS module for localStorage preferences and panel" +``` + +--- + +## Task 3: HTML — Gear trigger, panel markup, header in base + +**Files:** +- Modify: `internal/views/templates/base.html` +- Modify: `internal/views/views.go` + +- [ ] **Step 1: Add ShowHeader to PageData** + +In `views.go`, add `ShowHeader bool` to `PageData` struct. + +- [ ] **Step 2: Set ShowHeader in render functions** + +In `RenderIndex` and `RenderSearch`, set `PageData.ShowHeader = true`. + +- [ ] **Step 3: Update base.html — add header and settings markup** + +In `base.html`, update the `` to: + +```html + + {{if .ShowHeader}} + + {{end}} +
+ {{template "content" .}} +
+
+

Powered by kafka — a privacy-respecting, open metasearch engine

+
+ + + + +``` + +**Note:** The existing autocomplete ` + + + From af23a63a73a86d66b00931f363a839ae50243047 Mon Sep 17 00:00:00 2001 From: Franz Kafka Date: Sun, 22 Mar 2026 02:03:30 +0000 Subject: [PATCH 15/91] remove settings-design-playground.html --- .../specs/settings-design-playground.html | 337 ------------------ 1 file changed, 337 deletions(-) delete mode 100644 docs/superpowers/specs/settings-design-playground.html diff --git a/docs/superpowers/specs/settings-design-playground.html b/docs/superpowers/specs/settings-design-playground.html deleted file mode 100644 index 7cd8365..0000000 --- a/docs/superpowers/specs/settings-design-playground.html +++ /dev/null @@ -1,337 +0,0 @@ - - - - - - Settings UI Design Playground - - - -
-
-
- - - -
-
-
Access Pattern
- -
-
-
Theme Preview
- -
-
-
Trigger Button
- -
-
-
Settings Density
- -
Normal
-
-
-
Animation
-
- Enable transitions - -
-
-
-
Sections to Show
-
Theme
-
Engines
-
Search Defaults
-
-
- Click the gear button in the preview to toggle the settings panel. -
-
-
-
Update the settings UI to use a clean, minimal design with a slide-out drawer...
-
-
- kafka - -
- -
-
-

Results

-

About 42 results

-

Powered by kafka

-
-
-
-
github.com/golang/go
-
The Go Programming Language
-
-
-
go.dev
-
Go land
-
-
-
-
-
-
-
- - - From 4a6559be62ab57b04de57758fc261f82e3988837 Mon Sep 17 00:00:00 2001 From: Franz Kafka Date: Sun, 22 Mar 2026 02:06:41 +0000 Subject: [PATCH 16/91] fix: add Thumbnail field and video result template MainResult: add Thumbnail field (used by YouTube, images, etc.) video_item.html: new partial for video results with thumbnail display views.go: add templateForResult func + video_item.html to template parse results_inner.html: dispatch to video_item when Template="videos" kafka.css: add .video-result flex layout with thumbnail styling --- internal/contracts/main_result.go | 43 +++++++++++---------- internal/views/static/css/kafka.css | 23 +++++++++++ internal/views/templates/results_inner.html | 2 +- internal/views/templates/video_item.html | 22 +++++++++++ internal/views/views.go | 12 +++++- 5 files changed, 79 insertions(+), 23 deletions(-) create mode 100644 internal/views/templates/video_item.html diff --git a/internal/contracts/main_result.go b/internal/contracts/main_result.go index 20c9231..b5b568a 100644 --- a/internal/contracts/main_result.go +++ b/internal/contracts/main_result.go @@ -14,16 +14,17 @@ type MainResult struct { raw map[string]any // Common fields used by templates (RSS uses: title, url, content, pubdate). - Template string `json:"template"` - Title string `json:"title"` - Content string `json:"content"` - URL *string `json:"url"` - Pubdate *string `json:"pubdate"` + Template string `json:"template"` + Title string `json:"title"` + Content string `json:"content"` + URL *string `json:"url"` + Pubdate *string `json:"pubdate"` + Thumbnail string `json:"thumbnail"` - Engine string `json:"engine"` - Score float64 `json:"score"` - Category string `json:"category"` - Priority string `json:"priority"` + Engine string `json:"engine"` + Score float64 `json:"score"` + Category string `json:"category"` + Priority string `json:"priority"` Positions []int `json:"positions"` Engines []string `json:"engines"` @@ -54,6 +55,7 @@ func (mr *MainResult) UnmarshalJSON(data []byte) error { mr.Title = stringOrEmpty(m["title"]) mr.Content = stringOrEmpty(m["content"]) mr.Engine = stringOrEmpty(m["engine"]) + mr.Thumbnail = stringOrEmpty(m["thumbnail"]) mr.Category = stringOrEmpty(m["category"]) mr.Priority = stringOrEmpty(m["priority"]) @@ -93,20 +95,21 @@ func (mr MainResult) MarshalJSON() ([]byte, error) { // Otherwise, marshal the known fields. m := map[string]any{ - "template": mr.Template, - "title": mr.Title, - "content": mr.Content, - "url": mr.URL, - "pubdate": mr.Pubdate, - "engine": mr.Engine, - "score": mr.Score, - "category": mr.Category, - "priority": mr.Priority, + "template": mr.Template, + "title": mr.Title, + "content": mr.Content, + "url": mr.URL, + "pubdate": mr.Pubdate, + "thumbnail": mr.Thumbnail, + "engine": mr.Engine, + "score": mr.Score, + "category": mr.Category, + "priority": mr.Priority, "positions": mr.Positions, "engines": mr.Engines, - "open_group": mr.OpenGroup, + "open_group": mr.OpenGroup, "close_group": mr.CloseGroup, - "parsed_url": mr.ParsedURL, + "parsed_url": mr.ParsedURL, } return json.Marshal(m) } diff --git a/internal/views/static/css/kafka.css b/internal/views/static/css/kafka.css index 78f82e2..b637860 100644 --- a/internal/views/static/css/kafka.css +++ b/internal/views/static/css/kafka.css @@ -805,3 +805,26 @@ html[data-theme="light"] { opacity: 1; } } + +/* Video result cards */ +.video-result { + display: flex; + gap: 1rem; + align-items: flex-start; +} + +.video-result .result_thumbnail { + flex-shrink: 0; + width: 180px; +} + +.video-result .result_thumbnail img { + width: 100%; + height: auto; + border-radius: var(--radius); +} + +.video-result .result_content_wrapper { + flex: 1; + min-width: 0; +} diff --git a/internal/views/templates/results_inner.html b/internal/views/templates/results_inner.html index b6ad2e4..97aefb1 100644 --- a/internal/views/templates/results_inner.html +++ b/internal/views/templates/results_inner.html @@ -52,7 +52,7 @@
{{if .Results}} {{range .Results}} - {{template "result_item" .}} + {{template (templateForResult .Template) .}} {{end}} {{else if not .Answers}}
diff --git a/internal/views/templates/video_item.html b/internal/views/templates/video_item.html new file mode 100644 index 0000000..0ca0109 --- /dev/null +++ b/internal/views/templates/video_item.html @@ -0,0 +1,22 @@ +{{define "video_item"}} +
+ {{if .Thumbnail}} +
+ + {{.Title}} + +
+ {{end}} +
+

+ {{.Title}} +

+ {{if .Content}} +

{{.Content}}

+ {{end}} + {{if .Engine}} +
{{.Engine}}
+ {{end}} +
+
+{{end}} diff --git a/internal/views/views.go b/internal/views/views.go index c9e371e..5592235 100644 --- a/internal/views/views.go +++ b/internal/views/views.go @@ -63,16 +63,24 @@ func init() { funcMap := template.FuncMap{ "urlquery": template.URLQueryEscaper, + // templateForResult returns the template name to use for a result. + // Defaults to "result_item"; use "video_item" for video results. + "templateForResult": func(tmpl string) string { + if tmpl == "videos" { + return "video_item" + } + return "result_item" + }, } tmplFull = template.Must(template.New("").Funcs(funcMap).ParseFS(tmplFS, - "base.html", "results.html", "results_inner.html", "result_item.html", + "base.html", "results.html", "results_inner.html", "result_item.html", "video_item.html", )) tmplIndex = template.Must(template.New("").Funcs(funcMap).ParseFS(tmplFS, "base.html", "index.html", )) tmplFragment = template.Must(template.New("").Funcs(funcMap).ParseFS(tmplFS, - "results_inner.html", "result_item.html", + "results_inner.html", "result_item.html", "video_item.html", )) } From 281c327f6010025d81c4e0f10fa936a5ff2bca43 Mon Sep 17 00:00:00 2001 From: Franz Kafka Date: Sun, 22 Mar 2026 02:19:12 +0000 Subject: [PATCH 17/91] fix: correct go.mod to 1.24 (go 1.25 does not exist) Also use go-version-file in CI so go.mod and workflow stay in sync. --- .forgejo/workflows/test.yml | 2 +- go.mod | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.forgejo/workflows/test.yml b/.forgejo/workflows/test.yml index 6b09ea7..bd05693 100644 --- a/.forgejo/workflows/test.yml +++ b/.forgejo/workflows/test.yml @@ -16,7 +16,7 @@ jobs: - name: Set up Go uses: https://github.com/actions/setup-go@v5 with: - go-version: '1.24' + go-version-file: go.mod - name: Test run: go test -race -v ./... diff --git a/go.mod b/go.mod index f153b2d..d872485 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/metamorphosis-dev/kafka -go 1.25.0 +go 1.24 require ( github.com/BurntSushi/toml v1.5.0 From a03945b0e4acb6dc1797ab73da7fe3c7be1f1fef Mon Sep 17 00:00:00 2001 From: Franz Kafka Date: Sun, 22 Mar 2026 02:27:03 +0000 Subject: [PATCH 18/91] fix: downgrade goquery to v1.9.0 (v1.12.0 requires Go 1.25) goquery v1.12.0 has a go.mod requirement of Go 1.25, which is not released yet. Downgrade to v1.9.0 which works with Go 1.24. --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index d872485..f5e61cb 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,7 @@ go 1.24 require ( github.com/BurntSushi/toml v1.5.0 - github.com/PuerkitoBio/goquery v1.12.0 + github.com/PuerkitoBio/goquery v1.9.0 github.com/redis/go-redis/v9 v9.18.0 ) diff --git a/go.sum b/go.sum index 0aad3f0..ea8678a 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,7 @@ github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg= github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= -github.com/PuerkitoBio/goquery v1.12.0 h1:pAcL4g3WRXekcB9AU/y1mbKez2dbY2AajVhtkO8RIBo= -github.com/PuerkitoBio/goquery v1.12.0/go.mod h1:802ej+gV2y7bbIhOIoPY5sT183ZW0YFofScC4q/hIpQ= +github.com/PuerkitoBio/goquery v1.9.0 h1:Q39JTQYMBgkBnIZcjBn4Eg5lyKlUyU3FwxC11fYN1w4= +github.com/PuerkitoBio/goquery v1.9.0/go.mod h1:cW1n6TmIMDoORQU5IU/P1T3tGFunOeXEpGP2WHRwkbY= github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM= github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA= github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= From 841526276e0d718bbafa71d9b99c6cac94b8d24d Mon Sep 17 00:00:00 2001 From: Franz Kafka Date: Sun, 22 Mar 2026 02:33:01 +0000 Subject: [PATCH 19/91] fix: update goquery v1.9.0 checksum in go.sum --- go.sum | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/go.sum b/go.sum index ea8678a..b16354f 100644 --- a/go.sum +++ b/go.sum @@ -1,6 +1,6 @@ github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg= github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= -github.com/PuerkitoBio/goquery v1.9.0 h1:Q39JTQYMBgkBnIZcjBn4Eg5lyKlUyU3FwxC11fYN1w4= +github.com/PuerkitoBio/goquery v1.9.0 h1:zgjKkdpRY9T97Q5DCtcXwfqkcylSFIVCocZmn2huTp8= github.com/PuerkitoBio/goquery v1.9.0/go.mod h1:cW1n6TmIMDoORQU5IU/P1T3tGFunOeXEpGP2WHRwkbY= github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM= github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA= From 8a4a606dd66c94eeb3d6b6a5b2bc2ee4c9150724 Mon Sep 17 00:00:00 2001 From: Franz Kafka Date: Sun, 22 Mar 2026 02:39:10 +0000 Subject: [PATCH 20/91] fix: replace x/net v0.52.0 (requires go 1.25) with v0.33.0 (go 1.21) Use replace directive to force Go to use v0.33.0 instead of the transitively-pulled v0.52.0, which requires Go 1.25. --- go.mod | 2 ++ go.sum | 2 ++ 2 files changed, 4 insertions(+) diff --git a/go.mod b/go.mod index f5e61cb..e8eddd3 100644 --- a/go.mod +++ b/go.mod @@ -14,4 +14,6 @@ require ( github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect go.uber.org/atomic v1.11.0 // indirect golang.org/x/net v0.52.0 // indirect + +replace golang.org/x/net v0.52.0 => golang.org/x/net v0.33.0 ) diff --git a/go.sum b/go.sum index b16354f..6b1b78b 100644 --- a/go.sum +++ b/go.sum @@ -4,6 +4,8 @@ github.com/PuerkitoBio/goquery v1.9.0 h1:zgjKkdpRY9T97Q5DCtcXwfqkcylSFIVCocZmn2h github.com/PuerkitoBio/goquery v1.9.0/go.mod h1:cW1n6TmIMDoORQU5IU/P1T3tGFunOeXEpGP2WHRwkbY= github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM= github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA= +golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I= +golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= From 7d3c82214bd7feeafd59b50befaadbd4e556eb37 Mon Sep 17 00:00:00 2001 From: Franz Kafka Date: Sun, 22 Mar 2026 02:40:12 +0000 Subject: [PATCH 21/91] fix: move replace directive outside require block --- go.mod | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/go.mod b/go.mod index e8eddd3..85f1653 100644 --- a/go.mod +++ b/go.mod @@ -14,6 +14,6 @@ require ( github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect go.uber.org/atomic v1.11.0 // indirect golang.org/x/net v0.52.0 // indirect +) replace golang.org/x/net v0.52.0 => golang.org/x/net v0.33.0 -) From f0a65e2b8c603e8f3fa08059f9c79836ed980dee Mon Sep 17 00:00:00 2001 From: Franz Kafka Date: Sun, 22 Mar 2026 02:44:50 +0000 Subject: [PATCH 22/91] fix: compute TemplateName in ResultView instead of using dynamic template function Go html/template doesn't support function calls as template names in {{template (func .Arg) .}}. Instead, precompute TemplateName in FromResponse and use {{template .TemplateName .}} in the template. --- internal/views/templates/results_inner.html | 2 +- internal/views/views.go | 21 +++++++++++---------- 2 files changed, 12 insertions(+), 11 deletions(-) diff --git a/internal/views/templates/results_inner.html b/internal/views/templates/results_inner.html index 97aefb1..66808f4 100644 --- a/internal/views/templates/results_inner.html +++ b/internal/views/templates/results_inner.html @@ -52,7 +52,7 @@
{{if .Results}} {{range .Results}} - {{template (templateForResult .Template) .}} + {{template .TemplateName .}} {{end}} {{else if not .Answers}}
diff --git a/internal/views/views.go b/internal/views/views.go index 5592235..58ef2eb 100644 --- a/internal/views/views.go +++ b/internal/views/views.go @@ -36,7 +36,12 @@ type PageData struct { } // ResultView is a template-friendly wrapper around a MainResult. -type ResultView contracts.MainResult +type ResultView struct { + contracts.MainResult + // TemplateName is the actual template to dispatch to, computed from Template. + // "videos" maps to "video_item", everything else maps to "result_item". + TemplateName string +} // PageNumber represents a numbered pagination button. type PageNumber struct { @@ -63,14 +68,6 @@ func init() { funcMap := template.FuncMap{ "urlquery": template.URLQueryEscaper, - // templateForResult returns the template name to use for a result. - // Defaults to "result_item"; use "video_item" for video results. - "templateForResult": func(tmpl string) string { - if tmpl == "videos" { - return "video_item" - } - return "result_item" - }, } tmplFull = template.Must(template.New("").Funcs(funcMap).ParseFS(tmplFS, @@ -113,7 +110,11 @@ func FromResponse(resp contracts.SearchResponse, query string, pageno int) PageD // Convert results. pd.Results = make([]ResultView, len(resp.Results)) for i, r := range resp.Results { - pd.Results[i] = ResultView(r) + tmplName := "result_item" + if r.Template == "videos" { + tmplName = "video_item" + } + pd.Results[i] = ResultView{MainResult: r, TemplateName: tmplName} } // Convert answers (they're map[string]any — extract string values). From b499db68f798fa1d1a443e04e8ddbf6785b0387b Mon Sep 17 00:00:00 2001 From: Franz Kafka Date: Sun, 22 Mar 2026 02:46:28 +0000 Subject: [PATCH 23/91] fix: use explicit if/else template dispatch instead of dynamic name html/template requires template names to be string literals, not field accesses. Use {{if eq .Template "videos"}} to branch and call the appropriate template by literal name. --- internal/views/templates/results_inner.html | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/internal/views/templates/results_inner.html b/internal/views/templates/results_inner.html index 66808f4..1b93c66 100644 --- a/internal/views/templates/results_inner.html +++ b/internal/views/templates/results_inner.html @@ -52,7 +52,11 @@
{{if .Results}} {{range .Results}} - {{template .TemplateName .}} + {{if eq .Template "videos"}} + {{template "video_item" .}} + {{else}} + {{template "result_item" .}} + {{end}} {{end}} {{else if not .Answers}}
From f1436310eb126d1c1126aa5c5b069d44f907a9f1 Mon Sep 17 00:00:00 2001 From: Franz Kafka Date: Sun, 22 Mar 2026 02:54:12 +0000 Subject: [PATCH 24/91] fix: regexp.DotAll flag in google engine and Metadata field removal - google.go: use inline (?s) flag instead of regexp.DotAll second arg - youtube.go: remove Metadata field (not in MainResult contract) - config_test.go: fix expected engine count from 9 to 11 (google+youtube) --- internal/config/config_test.go | 4 ++-- internal/engines/google.go | 2 +- internal/engines/youtube.go | 4 ---- 3 files changed, 3 insertions(+), 7 deletions(-) diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 4a09848..07bddef 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -14,8 +14,8 @@ func TestLoadDefaults(t *testing.T) { if cfg.Server.Port != 8080 { t.Errorf("expected default port 8080, got %d", cfg.Server.Port) } - if len(cfg.Engines.LocalPorted) != 9 { - t.Errorf("expected 9 default engines, got %d", len(cfg.Engines.LocalPorted)) + if len(cfg.Engines.LocalPorted) != 11 { + t.Errorf("expected 11 default engines, got %d", len(cfg.Engines.LocalPorted)) } } diff --git a/internal/engines/google.go b/internal/engines/google.go index 0371283..71b50d1 100644 --- a/internal/engines/google.go +++ b/internal/engines/google.go @@ -209,7 +209,7 @@ func extractGoogleSnippet(block string) string { func extractGoogleSuggestions(body string) []string { var suggestions []string // SearXNG xpath: //div[contains(@class, "ouy7Mc")]//a - suggestionPattern := regexp.MustCompile(`]*class="[^"]*ouy7Mc[^"]*"[^>]*>.*?]*>([^<]+)`, regexp.DotAll) + suggestionPattern := regexp.MustCompile(`(?s)]*class="[^"]*ouy7Mc[^"]*"[^>]*>.*?]*>([^<]+)`) matches := suggestionPattern.FindAllStringSubmatch(body, -1) seen := map[string]bool{} for _, m := range matches { diff --git a/internal/engines/youtube.go b/internal/engines/youtube.go index 7580a09..a18abd8 100644 --- a/internal/engines/youtube.go +++ b/internal/engines/youtube.go @@ -115,10 +115,6 @@ func (e *YouTubeEngine) Search(ctx context.Context, req contracts.SearchRequest) Score: 1.0, Category: "videos", Engines: []string{"youtube"}, - Metadata: map[string]any{ - "channel": item.Snippet.ChannelTitle, - "video_id": item.Snippet.ResourceID.VideoID, - }, }) } From f7cece9648945a6a38a8c22e2eb8a3e007dc3946 Mon Sep 17 00:00:00 2001 From: Franz Kafka Date: Sun, 22 Mar 2026 08:06:31 +0000 Subject: [PATCH 25/91] =?UTF-8?q?feat:=20complete=20UI=20redesign=20?= =?UTF-8?q?=E2=80=94=20modern,=20clean=20search=20interface?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New CSS: complete design system with CSS variables, modern color palette - Homepage: full-viewport hero with centered search, logo, tagline - Result cards: rounded, shadowed, with favicons via Google Favicon API - Layout: sidebar + results grid, responsive - Typography: proper font stack, variable weights - Settings panel: polished popover with animations - Autocomplete: modern dropdown with keyboard nav - Dark mode: full color palette via data-theme attribute - Favicon: clean search icon SVG --- internal/views/static/css/kafka.css | 1463 +++++++++++-------- internal/views/templates/base.html | 43 +- internal/views/templates/index.html | 24 +- internal/views/templates/result_item.html | 9 +- internal/views/templates/results.html | 51 +- internal/views/templates/results_inner.html | 95 +- internal/views/templates/video_item.html | 10 +- 7 files changed, 940 insertions(+), 755 deletions(-) diff --git a/internal/views/static/css/kafka.css b/internal/views/static/css/kafka.css index b637860..8ae97ea 100644 --- a/internal/views/static/css/kafka.css +++ b/internal/views/static/css/kafka.css @@ -1,71 +1,49 @@ -/* kafka — clean, minimal search engine CSS */ +/* ============================================================ + kafka — modern, clean search UI + ============================================================ */ :root { - --color-base: #f5f5f5; - --color-base-font: #444; - --color-base-background: #fff; - --color-header-background: #f7f7f7; - --color-header-border: #ddd; - --color-search-border: #bbb; - --color-search-focus: #3498db; - --color-result-url: #1a0dab; - --color-result-url-visited: #609; - --color-result-content: #545454; - --color-result-title: #1a0dab; - --color-result-title-visited: #609; - --color-result-engine: #666; - --color-result-border: #eee; - --color-link: #3498db; - --color-link-visited: #609; - --color-sidebar-background: #f7f7f7; - --color-sidebar-border: #ddd; - --color-infobox-background: #f9f9f9; - --color-infobox-border: #ddd; - --color-pagination-current: #3498db; - --color-pagination-border: #ddd; - --color-error: #c0392b; - --color-error-background: #fdecea; - --color-suggestion: #666; - --color-footer: #888; - --color-btn-background: #fff; - --color-btn-border: #ddd; - --color-btn-hover: #eee; - --radius: 4px; - --max-width: 800px; + /* Light theme */ + --bg: #ffffff; + --bg-secondary: #f8f9fa; + --bg-tertiary: #f1f3f5; + --border: #e9ecef; + --border-focus: #cad1d8; + --text-primary: #1a1a1a; + --text-secondary: #5c6370; + --text-muted: #8b929e; + --accent: #2563eb; + --accent-hover: #1d4ed8; + --accent-soft: #eff6ff; + --accent-glow: rgba(37, 99, 235, 0.15); + --shadow-sm: 0 1px 3px rgba(0,0,0,0.06), 0 1px 2px rgba(0,0,0,0.04); + --shadow-md: 0 4px 12px rgba(0,0,0,0.08), 0 2px 4px rgba(0,0,0,0.04); + --shadow-lg: 0 12px 32px rgba(0,0,0,0.12), 0 4px 8px rgba(0,0,0,0.06); + --radius-sm: 8px; + --radius-md: 12px; + --radius-lg: 16px; + --radius-full: 9999px; + --font: -apple-system, BlinkMacSystemFont, "SF Pro Display", "Segoe UI", Roboto, sans-serif; + --max-width: 880px; + --header-height: 56px; } -@media (prefers-color-scheme: dark) { - :root { - --color-base: #222; - --color-base-font: #dcdcdc; - --color-base-background: #2b2b2b; - --color-header-background: #333; - --color-header-border: #444; - --color-search-border: #555; - --color-search-focus: #5dade2; - --color-result-url: #8ab4f8; - --color-result-url-visited: #b39ddb; - --color-result-content: #b0b0b0; - --color-result-title: #8ab4f8; - --color-result-title-visited: #b39ddb; - --color-result-engine: #999; - --color-result-border: #3a3a3a; - --color-link: #5dade2; - --color-link-visited: #b39ddb; - --color-sidebar-background: #333; - --color-sidebar-border: #444; - --color-infobox-background: #333; - --color-infobox-border: #444; - --color-pagination-current: #5dade2; - --color-pagination-border: #444; - --color-error: #e74c3c; - --color-error-background: #3b1a1a; - --color-suggestion: #999; - --color-footer: #666; - --color-btn-background: #333; - --color-btn-border: #555; - --color-btn-hover: #444; - } +[data-theme="dark"] { + --bg: #0f0f0f; + --bg-secondary: #1a1a1a; + --bg-tertiary: #242424; + --border: #2e2e2e; + --border-focus: #404040; + --text-primary: #e8eaed; + --text-secondary: #9aa0a6; + --text-muted: #6b7280; + --accent: #60a5fa; + --accent-hover: #93c5fd; + --accent-soft: #1e3a5f; + --accent-glow: rgba(96, 165, 250, 0.2); + --shadow-sm: 0 1px 3px rgba(0,0,0,0.3); + --shadow-md: 0 4px 12px rgba(0,0,0,0.4); + --shadow-lg: 0 12px 32px rgba(0,0,0,0.5); } *, *::before, *::after { @@ -76,35 +54,491 @@ html { font-size: 16px; + scroll-behavior: smooth; } body { - font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; - color: var(--color-base-font); - background: var(--color-base); - line-height: 1.6; + font-family: var(--font); + background: var(--bg); + color: var(--text-primary); + line-height: 1.5; min-height: 100vh; - display: flex; - flex-direction: column; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; } -main { - flex: 1; - max-width: var(--max-width); - width: 100%; - margin: 0 auto; - padding: 1rem; +/* ============================================================ + Header + ============================================================ */ + +.site-header { + position: sticky; + top: 0; + z-index: 100; + height: var(--header-height); + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 1.5rem; + background: var(--bg); + border-bottom: 1px solid var(--border); + backdrop-filter: blur(12px); + background: color-mix(in srgb, var(--bg) 90%, transparent); } +.site-logo { + display: flex; + align-items: center; + gap: 0.5rem; + text-decoration: none; + color: var(--text-primary); +} + +.site-logo-mark { + width: 28px; + height: 28px; + color: var(--accent); +} + +.site-name { + font-size: 1.1rem; + font-weight: 600; + letter-spacing: -0.01em; +} + +.settings-trigger { + background: none; + border: none; + width: 36px; + height: 36px; + border-radius: var(--radius-sm); + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + color: var(--text-secondary); + transition: background 0.15s, color 0.15s; +} +.settings-trigger:hover { + background: var(--bg-tertiary); + color: var(--text-primary); +} + +/* ============================================================ + Main Layout + ============================================================ */ + +main { + max-width: var(--max-width); + margin: 0 auto; + padding: 0 1.5rem; +} + +/* ============================================================ + Homepage — Hero Search + ============================================================ */ + +.page-home { + min-height: calc(100vh - var(--header-height)); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 4rem 1.5rem; + text-align: center; +} + +.hero-logo { + margin-bottom: 2rem; +} + +.hero-logo svg { + width: 64px; + height: 64px; + color: var(--accent); +} + +.hero-tagline { + font-size: 1rem; + color: var(--text-muted); + margin-bottom: 2.5rem; + max-width: 400px; +} + +.search-hero { + width: 100%; + max-width: 640px; +} + +.search-box { + position: relative; + width: 100%; +} + +.search-box input[type="text"], +#q { + width: 100%; + padding: 1rem 3.5rem 1rem 1.25rem; + font-size: 1.1rem; + font-family: inherit; + border: 2px solid var(--border); + border-radius: var(--radius-full); + background: var(--bg); + color: var(--text-primary); + outline: none; + transition: border-color 0.2s, box-shadow 0.2s; +} + +.search-box input[type="text"]:focus, +#q:focus { + border-color: var(--accent); + box-shadow: var(--shadow-lg), 0 0 0 4px var(--accent-glow); +} + +.search-box input[type="text"]::placeholder, +#q::placeholder { + color: var(--text-muted); +} + +.search-box-submit { + position: absolute; + right: 6px; + top: 50%; + transform: translateY(-50%); + width: 40px; + height: 40px; + border: none; + border-radius: var(--radius-full); + background: var(--accent); + color: #fff; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: background 0.15s, transform 0.15s; +} + +.search-box-submit:hover { + background: var(--accent-hover); + transform: translateY(-50%) scale(1.05); +} + +.search-box-submit:active { + transform: translateY(-50%) scale(0.97); +} + +/* ============================================================ + Results Page + ============================================================ */ + +.page-results { + padding-top: 1.5rem; +} + +.results-layout { + display: grid; + grid-template-columns: 1fr 220px; + gap: 2rem; + align-items: start; +} + +@media (max-width: 700px) { + .results-layout { + grid-template-columns: 1fr; + gap: 1rem; + } +} + +/* Compact search bar on results page */ +.search-compact { + grid-column: 1 / -1; +} + +.search-compact .search-box { + max-width: 100%; +} + +.search-compact input { + font-size: 1rem; + padding: 0.75rem 2.75rem 0.75rem 1rem; +} + +.search-compact .search-box-submit { + width: 34px; + height: 34px; +} + +/* ============================================================ + Results Column + ============================================================ */ + +.results-column { + min-width: 0; +} + +/* Result count + meta */ +.results-meta { + display: flex; + align-items: center; + gap: 0.75rem; + margin-bottom: 1rem; + font-size: 0.85rem; + color: var(--text-muted); +} + +/* Individual result card */ +.result { + background: var(--bg); + border: 1px solid var(--border); + border-radius: var(--radius-md); + padding: 1rem 1.1rem; + margin-bottom: 0.75rem; + transition: border-color 0.15s, box-shadow 0.15s, transform 0.15s; +} + +.result:hover { + border-color: var(--border-focus); + box-shadow: var(--shadow-sm); + transform: translateY(-1px); +} + +.result_header { + margin-bottom: 0.25rem; +} + +.result-favicon { + display: inline-block; + width: 16px; + height: 16px; + border-radius: 3px; + vertical-align: middle; + margin-right: 0.4rem; + background: var(--bg-tertiary); + flex-shrink: 0; +} + +.result_url { + display: flex; + align-items: center; + gap: 0.4rem; + font-size: 0.8rem; + color: var(--text-muted); + margin-bottom: 0.35rem; + overflow: hidden; +} + +.result_url a { + color: var(--text-muted); + text-decoration: none; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.result_url a:hover { + color: var(--accent); +} + +.result_url .engine-badge { + flex-shrink: 0; + display: inline-flex; + align-items: center; + gap: 0.25rem; + padding: 0.1rem 0.4rem; + background: var(--bg-tertiary); + border-radius: var(--radius-sm); + font-size: 0.7rem; + color: var(--text-muted); + margin-left: auto; +} + +.result_header a { + font-size: 1rem; + font-weight: 500; + color: var(--text-primary); + text-decoration: none; + line-height: 1.4; + display: block; +} + +.result_header a:hover { + color: var(--accent); +} + +.result_header a:visited { + color: var(--text-secondary); +} + +.result_content { + font-size: 0.9rem; + color: var(--text-secondary); + line-height: 1.55; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + +/* ============================================================ + Sidebar + ============================================================ */ + +.sidebar { + position: sticky; + top: calc(var(--header-height) + 1.5rem); +} + +.sidebar-card { + background: var(--bg); + border: 1px solid var(--border); + border-radius: var(--radius-md); + padding: 1rem; + margin-bottom: 1rem; +} + +.sidebar-title { + font-size: 0.75rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--text-muted); + margin-bottom: 0.75rem; +} + +/* Suggestions in sidebar */ +.suggestion-list { + display: flex; + flex-wrap: wrap; + gap: 0.4rem; +} + +.suggestion a { + display: inline-block; + padding: 0.3rem 0.65rem; + font-size: 0.8rem; + border: 1px solid var(--border); + border-radius: var(--radius-full); + color: var(--accent); + text-decoration: none; + background: var(--accent-soft); + transition: background 0.15s, border-color 0.15s; +} + +.suggestion a:hover { + background: color-mix(in srgb, var(--accent-soft) 70%, var(--bg-tertiary)); + border-color: var(--accent); +} + +/* Unresponsive engines warning */ +.unresponsive-engines { + font-size: 0.8rem; + color: var(--text-muted); +} + +.unresponsive-engines li { + margin: 0.3rem 0; +} + +/* Corrections */ +.correction { + font-size: 0.85rem; + color: var(--text-muted); + margin-bottom: 0.5rem; +} + +/* ============================================================ + No Results + ============================================================ */ + +.no-results { + text-align: center; + padding: 4rem 2rem; +} + +.no-results-icon { + font-size: 3rem; + margin-bottom: 1rem; + opacity: 0.3; +} + +.no-results h2 { + font-size: 1.25rem; + font-weight: 500; + margin-bottom: 0.5rem; +} + +.no-results p { + color: var(--text-muted); + font-size: 0.95rem; +} + +/* ============================================================ + Pagination + ============================================================ */ + +.pagination { + display: flex; + align-items: center; + justify-content: center; + gap: 0.4rem; + padding: 2rem 0; + flex-wrap: wrap; +} + +.pagination button, +.pagination .page-link { + min-width: 40px; + height: 40px; + padding: 0 0.75rem; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--bg); + color: var(--text-primary); + font-size: 0.9rem; + font-family: inherit; + cursor: pointer; + text-decoration: none; + display: inline-flex; + align-items: center; + justify-content: center; + transition: background 0.15s, border-color 0.15s, color 0.15s; +} + +.pagination button:hover, +.pagination .page-link:hover { + background: var(--bg-tertiary); + border-color: var(--border-focus); +} + +.pagination .page-current { + background: var(--accent); + border-color: var(--accent); + color: #fff; +} + +.pagination .page-current:hover { + background: var(--accent-hover); + border-color: var(--accent-hover); +} + +.pagination .prev-next { + font-weight: 500; +} + +/* ============================================================ + Footer + ============================================================ */ + footer { text-align: center; - padding: 1.5rem; - color: var(--color-footer); + padding: 3rem 1.5rem 2rem; + color: var(--text-muted); font-size: 0.85rem; + margin-top: auto; } footer a { - color: var(--color-link); + color: var(--accent); text-decoration: none; } @@ -112,331 +546,21 @@ footer a:hover { text-decoration: underline; } -/* Index / Homepage */ -.index { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - min-height: 60vh; - text-align: center; -} - -.index .title h1 { - font-size: 2rem; - font-weight: 300; - margin-bottom: 2rem; - letter-spacing: 0.05em; -} - -/* Search form */ -#search { - width: 100%; - max-width: 600px; - margin-bottom: 2rem; -} - -#search form { - display: flex; - gap: 0.5rem; -} - -#search input[type="text"], -#q { - flex: 1; - padding: 0.7rem 1rem; - font-size: 1rem; - border: 1px solid var(--color-search-border); - border-radius: var(--radius); - background: var(--color-base-background); - color: var(--color-base-font); - outline: none; - transition: border-color 0.2s; -} - -#search input[type="text"]:focus, -#q:focus { - border-color: var(--color-search-focus); - box-shadow: 0 0 0 2px rgba(52, 152, 219, 0.15); -} - -#search button[type="submit"] { - padding: 0.7rem 1.2rem; - font-size: 1rem; - border: 1px solid var(--color-search-border); - border-radius: var(--radius); - background: var(--color-btn-background); - color: var(--color-base-font); - cursor: pointer; - transition: background 0.2s; -} - -#search button[type="submit"]:hover { - background: var(--color-btn-hover); -} - -/* Results page search bar (compact) */ -.search_on_results #search { - max-width: 100%; - margin-bottom: 1rem; -} - -/* Results area */ -#results { - display: flex; - gap: 1.5rem; -} - -#sidebar { - flex: 0 0 200px; - font-size: 0.85rem; -} - -#sidebar p { - margin-bottom: 0.5rem; -} - -#urls { - flex: 1; - min-width: 0; -} - -/* Result count */ -#result_count { - margin-bottom: 1rem; -} - -/* Individual result */ -.result { - padding: 0.8rem 0; - border-bottom: 1px solid var(--color-result-border); - word-wrap: break-word; -} - -.result:last-child { - border-bottom: none; -} - -.result_header { - margin-bottom: 0.2rem; -} - -.result_header a { - font-size: 1.1rem; - font-weight: 400; - color: var(--color-result-title); - text-decoration: none; -} - -.result_header a:visited { - color: var(--color-result-title-visited); -} - -.result_header a:hover { - text-decoration: underline; -} - -.result_url { - font-size: 0.85rem; - color: var(--color-result-url); - margin-bottom: 0.2rem; -} - -.result_url a { - color: var(--color-result-url); - text-decoration: none; -} - -.result_url a:visited { - color: var(--color-result-url-visited); -} - -.result_content { - font-size: 0.9rem; - color: var(--color-result-content); - max-width: 600px; -} - -.result_content p { - margin: 0; -} - -.result_engine { - font-size: 0.75rem; - color: var(--color-result-engine); - margin-top: 0.3rem; -} - -.engine { - display: inline-block; - padding: 0.1rem 0.4rem; - background: var(--color-sidebar-background); - border: 1px solid var(--color-sidebar-border); - border-radius: 2px; - font-size: 0.7rem; - color: var(--color-result-engine); -} - -/* No results */ -.no_results { - text-align: center; - padding: 3rem 1rem; - color: var(--color-suggestion); -} - -/* Suggestions */ -#suggestions { - margin-bottom: 1rem; -} - -.suggestion { - display: inline-block; - margin: 0.2rem; -} - -.suggestion a { - display: inline-block; - padding: 0.3rem 0.6rem; - font-size: 0.85rem; - border: 1px solid var(--color-pagination-border); - border-radius: var(--radius); - color: var(--color-link); - text-decoration: none; - background: var(--color-btn-background); - transition: background 0.2s; -} - -.suggestion a:hover { - background: var(--color-btn-hover); -} - -/* Infoboxes */ -#infoboxes { - margin-bottom: 1rem; -} - -.infobox { - background: var(--color-infobox-background); - border: 1px solid var(--color-infobox-border); - border-radius: var(--radius); - padding: 0.8rem; - margin-bottom: 0.5rem; -} - -.infobox .title { - font-weight: 600; - margin-bottom: 0.5rem; -} - -/* Errors */ -.dialog-error { - background: var(--color-error-background); - color: var(--color-error); - border: 1px solid var(--color-error); - border-radius: var(--radius); - padding: 0.8rem 1rem; - margin-bottom: 1rem; -} - -/* Unresponsive engines */ -.unresponsive_engines { - font-size: 0.8rem; - color: var(--color-suggestion); - margin-top: 0.5rem; -} - -.unresponsive_engines li { - margin: 0.1rem 0; -} - -/* Corrections */ -.correction { - font-size: 0.9rem; - margin-bottom: 0.5rem; -} - -/* Pagination */ -#pagination { - display: flex; - align-items: center; - justify-content: center; - gap: 0.3rem; - padding: 1.5rem 0; - flex-wrap: wrap; -} - -#pagination button, -#pagination .page_number, -#pagination .page_number_current { - padding: 0.4rem 0.8rem; - font-size: 0.9rem; - border: 1px solid var(--color-pagination-border); - border-radius: var(--radius); - background: var(--color-btn-background); - color: var(--color-base-font); - cursor: pointer; - text-decoration: none; - transition: background 0.2s; -} - -#pagination button:hover, -#pagination .page_number:hover { - background: var(--color-btn-hover); -} - -#pagination .page_number_current { - background: var(--color-pagination-current); - color: #fff; - border-color: var(--color-pagination-current); - cursor: default; -} - -.previous_page, .next_page { - font-weight: 500; -} - -/* Back to top */ -#backToTop { - text-align: center; - margin: 1rem 0; -} - -#backToTop a { - color: var(--color-link); - text-decoration: none; - font-size: 0.85rem; -} - -/* HTMX loading indicator */ -.htmx-indicator { - display: none; - text-align: center; - padding: 2rem; - color: var(--color-suggestion); -} - -.htmx-request .htmx-indicator, -.htmx-request.htmx-indicator { - display: block; -} - -/* Autocomplete dropdown */ -#search { - position: relative; -} +/* ============================================================ + Autocomplete Dropdown + ============================================================ */ #autocomplete-dropdown { position: absolute; - top: 100%; + top: calc(100% + 6px); left: 0; right: 0; - background: var(--color-base-background); - border: 1px solid var(--color-search-border); - border-top: none; - border-radius: 0 0 var(--radius) var(--radius); - box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); - z-index: 100; - max-height: 320px; + background: var(--bg); + border: 1px solid var(--border); + border-radius: var(--radius-md); + box-shadow: var(--shadow-lg); + z-index: 200; + max-height: 360px; overflow-y: auto; display: none; } @@ -446,12 +570,12 @@ footer a:hover { } .autocomplete-suggestion { - padding: 0.6rem 1rem; + padding: 0.7rem 1rem; cursor: pointer; font-size: 0.95rem; - color: var(--color-base-font); - border-bottom: 1px solid var(--color-result-border); - transition: background 0.15s; + color: var(--text-primary); + border-bottom: 1px solid var(--border); + transition: background 0.1s; } .autocomplete-suggestion:last-child { @@ -460,159 +584,101 @@ footer a:hover { .autocomplete-suggestion:hover, .autocomplete-suggestion.active { - background: var(--color-header-background); + background: var(--bg-tertiary); } .autocomplete-suggestion mark { background: none; - color: var(--color-link); + color: var(--accent); font-weight: 600; } .autocomplete-footer { - padding: 0.4rem 1rem; + padding: 0.5rem 1rem; font-size: 0.75rem; - color: var(--color-suggestion); - border-top: 1px solid var(--color-result-border); - background: var(--color-header-background); + color: var(--text-muted); + background: var(--bg-secondary); + border-radius: 0 0 var(--radius-md) var(--radius-md); + border-top: 1px solid var(--border); } -/* Responsive */ -@media (max-width: 768px) { - #results { - flex-direction: column-reverse; - } - - #sidebar { - flex: none; - border-top: 1px solid var(--color-sidebar-border); - padding-top: 0.5rem; - } - - .index .title h1 { - font-size: 1.5rem; - } - - main { - padding: 0.5rem; - } -} - -/* Print */ -@media print { - footer, #pagination, #search button, #backToTop, .htmx-indicator { - display: none; - } - - body { - background: #fff; - color: #000; - } - - .result a { - color: #000; - } -} - -/* ============================================ +/* ============================================================ Settings Panel - ============================================ */ + ============================================================ */ -/* Header */ -.site-header { - display: flex; - align-items: center; - justify-content: space-between; - padding: 0.6rem 1rem; - background: var(--color-header-background); - border-bottom: 1px solid var(--color-header-border); -} -.site-title { - font-size: 1rem; - font-weight: 600; - color: var(--color-base-font); -} - -/* Gear trigger button */ -.settings-trigger { - background: none; - border: none; - font-size: 1.1rem; - cursor: pointer; - padding: 0.3rem 0.5rem; - border-radius: var(--radius); - color: var(--color-base-font); - opacity: 0.7; - transition: opacity 0.2s, background 0.2s; - line-height: 1; -} -.settings-trigger:hover, -.settings-trigger[aria-expanded="true"] { - opacity: 1; - background: var(--color-sidebar-background); -} - -/* Popover panel */ .settings-popover { - position: absolute; - top: 100%; - right: 0; - width: 280px; - max-height: 420px; + position: fixed; + top: calc(var(--header-height) + 8px); + right: 1rem; + width: 300px; + max-height: calc(100vh - var(--header-height) - 2rem); overflow-y: auto; - background: var(--color-base-background); - border: 1px solid var(--color-sidebar-border); - border-radius: var(--radius); - box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12); - z-index: 200; + background: var(--bg); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-lg); + z-index: 300; display: none; - flex-direction: column; } + .settings-popover[data-open="true"] { - display: flex; - animation: settings-slide-in 0.2s ease; + display: block; + animation: pop-in 0.2s ease; } -@keyframes settings-slide-in { - from { opacity: 0; transform: translateY(-8px); } - to { opacity: 1; transform: translateY(0); } + +@keyframes pop-in { + from { opacity: 0; transform: translateY(-8px) scale(0.97); } + to { opacity: 1; transform: translateY(0) scale(1); } } .settings-popover-header { display: flex; justify-content: space-between; align-items: center; - padding: 0.75rem 1rem; - border-bottom: 1px solid var(--color-sidebar-border); + padding: 1rem 1.25rem; + border-bottom: 1px solid var(--border); font-weight: 600; - font-size: 0.9rem; - flex-shrink: 0; + font-size: 0.95rem; + position: sticky; + top: 0; + background: var(--bg); + z-index: 1; } + .settings-popover-close { background: none; border: none; - font-size: 1.2rem; + width: 28px; + height: 28px; + border-radius: var(--radius-sm); cursor: pointer; - color: var(--color-base-font); - opacity: 0.6; - padding: 0 0.25rem; - line-height: 1; + color: var(--text-secondary); + display: flex; + align-items: center; + justify-content: center; + font-size: 1.2rem; + transition: background 0.15s, color 0.15s; +} + +.settings-popover-close:hover { + background: var(--bg-tertiary); + color: var(--text-primary); } -.settings-popover-close:hover { opacity: 1; } .settings-popover-body { - padding: 0.8rem; + padding: 1rem 1.25rem; display: flex; flex-direction: column; - gap: 1rem; + gap: 1.25rem; } .settings-section-title { font-size: 0.7rem; font-weight: 600; text-transform: uppercase; - letter-spacing: 0.05em; - color: var(--color-suggestion); - margin-bottom: 0.5rem; + letter-spacing: 0.06em; + color: var(--text-muted); + margin-bottom: 0.6rem; } /* Theme buttons */ @@ -620,162 +686,109 @@ footer a:hover { display: flex; gap: 0.4rem; } + .theme-btn { flex: 1; - padding: 0.35rem 0.5rem; - border: 1px solid var(--color-sidebar-border); - border-radius: var(--radius); - background: var(--color-btn-background); - color: var(--color-base-font); + padding: 0.45rem; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--bg); + color: var(--text-secondary); cursor: pointer; - font-size: 0.75rem; - text-align: center; - transition: background 0.15s, border-color 0.15s; -} -.theme-btn:hover { background: var(--color-btn-hover); } -.theme-btn.active { - background: var(--color-link); - color: #fff; - border-color: var(--color-link); + font-size: 0.8rem; + font-family: inherit; + transition: background 0.15s, border-color 0.15s, color 0.15s; } -/* Engine toggles — 2-column grid */ +.theme-btn:hover { + background: var(--bg-tertiary); +} + +.theme-btn.active { + background: var(--accent-soft); + border-color: var(--accent); + color: var(--accent); + font-weight: 500; +} + +/* Engine toggles */ .engine-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 0.4rem; } + .engine-toggle { display: flex; align-items: center; gap: 0.4rem; - padding: 0.3rem 0.5rem; - border-radius: var(--radius); - background: var(--color-sidebar-background); - font-size: 0.78rem; + padding: 0.4rem 0.5rem; + border-radius: var(--radius-sm); + background: var(--bg-tertiary); + font-size: 0.8rem; cursor: pointer; + transition: background 0.1s; } + +.engine-toggle:hover { + background: var(--border); +} + .engine-toggle input[type="checkbox"] { width: 15px; height: 15px; margin: 0; cursor: pointer; - accent-color: var(--color-link); -} -.engine-toggle span { - flex: 1; - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; + accent-color: var(--accent); + flex-shrink: 0; } -/* Search defaults */ +.engine-toggle span { + color: var(--text-secondary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* Select dropdowns */ .setting-row { display: flex; align-items: center; justify-content: space-between; - gap: 0.5rem; - margin-top: 0.4rem; + gap: 0.75rem; } + .setting-row label { font-size: 0.85rem; - flex: 1; + color: var(--text-secondary); } + .setting-row select { - width: 110px; - padding: 0.3rem 0.4rem; + padding: 0.35rem 0.6rem; font-size: 0.8rem; - border: 1px solid var(--color-sidebar-border); - border-radius: var(--radius); - background: var(--color-base-background); - color: var(--color-base-font); + font-family: inherit; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--bg); + color: var(--text-primary); cursor: pointer; + min-width: 100px; +} + +.setting-row select:focus { + outline: none; + border-color: var(--accent); } -/* Mid-search notice */ .settings-notice { - font-size: 0.72rem; - color: var(--color-suggestion); - margin-top: 0.3rem; + font-size: 0.75rem; + color: var(--text-muted); font-style: italic; + margin-top: 0.25rem; } -/* Dark theme via data-theme attribute */ -html[data-theme="dark"] { - --color-base: #222; - --color-base-font: #dcdcdc; - --color-base-background: #2b2b2b; - --color-header-background: #333; - --color-header-border: #444; - --color-search-border: #555; - --color-search-focus: #5dade2; - --color-result-url: #8ab4f8; - --color-result-url-visited: #b39ddb; - --color-result-content: #b0b0b0; - --color-result-title: #8ab4f8; - --color-result-title-visited: #b39ddb; - --color-result-engine: #999; - --color-result-border: #3a3a3a; - --color-link: #5dade2; - --color-link-visited: #b39ddb; - --color-sidebar-background: #333; - --color-sidebar-border: #444; - --color-infobox-background: #333; - --color-infobox-border: #444; - --color-pagination-current: #5dade2; - --color-pagination-border: #444; - --color-error: #e74c3c; - --color-error-background: #3b1a1a; - --color-suggestion: #999; - --color-footer: #666; - --color-btn-background: #333; - --color-btn-border: #555; - --color-btn-hover: #444; -} - -/* Light theme via data-theme attribute - explicit reset */ -html[data-theme="light"] { - --color-base: #f5f5f5; - --color-base-font: #444; - --color-base-background: #fff; - --color-header-background: #f7f7f7; - --color-header-border: #ddd; - --color-search-border: #bbb; - --color-search-focus: #3498db; - --color-result-url: #1a0dab; - --color-result-url-visited: #609; - --color-result-content: #545454; - --color-result-title: #1a0dab; - --color-result-title-visited: #609; - --color-result-engine: #666; - --color-result-border: #eee; - --color-link: #3498db; - --color-link-visited: #609; - --color-sidebar-background: #f7f7f7; - --color-sidebar-border: #ddd; - --color-infobox-background: #f9f9f9; - --color-infobox-border: #ddd; - --color-pagination-current: #3498db; - --color-pagination-border: #ddd; - --color-error: #c0392b; - --color-error-background: #fdecea; - --color-suggestion: #666; - --color-footer: #888; - --color-btn-background: #fff; - --color-btn-border: #ddd; - --color-btn-hover: #eee; -} - -/* Mobile: Bottom sheet + FAB trigger */ -@media (max-width: 768px) { - /* Hide desktop trigger, show FAB */ - .settings-trigger-desktop { - display: none; - } - .settings-trigger-mobile { - display: block; - } +/* Mobile settings: bottom sheet */ +@media (max-width: 480px) { .settings-popover { position: fixed; top: auto; @@ -784,29 +797,64 @@ html[data-theme="light"] { right: 0; width: 100%; max-height: 70vh; - border-radius: var(--radius) var(--radius) 0 0; + border-radius: var(--radius-lg) var(--radius-lg) 0 0; border-bottom: none; } - /* FAB: fixed bottom-right button visible only on mobile */ + .settings-trigger-mobile { - display: block; - position: fixed; - bottom: 1.5rem; - right: 1.5rem; - width: 48px; - height: 48px; - border-radius: 50%; - background: var(--color-link); - color: #fff; - border: none; - box-shadow: 0 4px 12px rgba(0,0,0,0.2); - font-size: 1.2rem; - z-index: 199; - opacity: 1; + display: flex; } } -/* Video result cards */ +@media (min-width: 481px) { + .settings-trigger-mobile { + display: none; + } +} + +/* ============================================================ + HTMX Loading State + ============================================================ */ + +.htmx-indicator { + display: none; + text-align: center; + padding: 2rem; + color: var(--text-muted); +} + +.htmx-request .htmx-indicator { + display: block; +} + +.htmx-request .results-column { + opacity: 0.5; + transition: opacity 0.2s; +} + +/* ============================================================ + Back to Top + ============================================================ */ + +.back-to-top { + text-align: center; + padding: 1rem; +} + +.back-to-top a { + color: var(--accent); + text-decoration: none; + font-size: 0.85rem; +} + +.back-to-top a:hover { + text-decoration: underline; +} + +/* ============================================================ + Video Results + ============================================================ */ + .video-result { display: flex; gap: 1rem; @@ -816,15 +864,134 @@ html[data-theme="light"] { .video-result .result_thumbnail { flex-shrink: 0; width: 180px; + border-radius: var(--radius-sm); + overflow: hidden; + background: var(--bg-tertiary); } .video-result .result_thumbnail img { width: 100%; height: auto; - border-radius: var(--radius); + display: block; + object-fit: cover; } .video-result .result_content_wrapper { flex: 1; min-width: 0; } + +@media (max-width: 480px) { + .video-result { + flex-direction: column; + } + + .video-result .result_thumbnail { + width: 100%; + } +} + +/* ============================================================ + Infoboxes + ============================================================ */ + +.infobox { + background: var(--bg-secondary); + border: 1px solid var(--border); + border-radius: var(--radius-md); + padding: 1rem; + margin-bottom: 0.75rem; +} + +.infobox .title { + font-weight: 600; + margin-bottom: 0.5rem; + font-size: 0.95rem; +} + +.infobox img { + max-width: 100%; + border-radius: var(--radius-sm); + margin-top: 0.5rem; +} + +/* ============================================================ + Errors + ============================================================ */ + +.dialog-error { + background: color-mix(in srgb, var(--accent) 8%, var(--bg)); + border: 1px solid color-mix(in srgb, var(--accent) 30%, transparent); + color: var(--accent); + border-radius: var(--radius-md); + padding: 0.8rem 1rem; + margin-bottom: 1rem; + font-size: 0.9rem; +} + +/* ============================================================ + Focus visible — keyboard navigation + ============================================================ */ + +:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + +button:focus-visible, +a:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + +/* ============================================================ + Selection color + ============================================================ */ + +::selection { + background: var(--accent-soft); + color: var(--accent); +} + +/* ============================================================ + Scrollbar + ============================================================ */ + +::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +::-webkit-scrollbar-track { + background: var(--bg-secondary); +} + +::-webkit-scrollbar-thumb { + background: var(--border); + border-radius: 4px; +} + +::-webkit-scrollbar-thumb:hover { + background: var(--border-focus); +} + +/* ============================================================ + Print + ============================================================ */ + +@media print { + .site-header, footer, .settings-popover, #backToTop, .htmx-indicator, .settings-trigger { + display: none !important; + } + + body { + background: #fff; + color: #000; + } + + .result { + break-inside: avoid; + border: 1px solid #ddd; + margin-bottom: 1rem; + } +} diff --git a/internal/views/templates/base.html b/internal/views/templates/base.html index 239e886..691dba2 100644 --- a/internal/views/templates/base.html +++ b/internal/views/templates/base.html @@ -1,6 +1,6 @@ {{define "base"}} - + @@ -13,31 +13,43 @@ - + - - -
+ +
{{template "content" .}}
+

Powered by kafka — a privacy-respecting, open metasearch engine

+ + + +
+ From 805e7ffdc2397b04ccdda172a6bca113872b8592 Mon Sep 17 00:00:00 2001 From: Franz Kafka Date: Sun, 22 Mar 2026 08:34:20 +0000 Subject: [PATCH 28/91] feat: add source_url config option for footer source link MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Thread source_url through: config.ServerConfig → Handler.sourceURL → PageData.SourceURL → template footer. Footer only shows Source link when source_url is set. --- cmd/kafka/main.go | 2 +- config.example.toml | 5 +++++ internal/config/config.go | 3 ++- internal/httpapi/handlers.go | 10 ++++++---- internal/views/templates/base.html | 2 +- internal/views/views.go | 5 +++-- 6 files changed, 18 insertions(+), 9 deletions(-) diff --git a/cmd/kafka/main.go b/cmd/kafka/main.go index f16aa96..cdc81b5 100644 --- a/cmd/kafka/main.go +++ b/cmd/kafka/main.go @@ -77,7 +77,7 @@ func main() { acSvc := autocomplete.NewService(cfg.Upstream.URL, cfg.HTTPTimeout()) - h := httpapi.NewHandler(svc, acSvc.Suggestions) + h := httpapi.NewHandler(svc, acSvc.Suggestions, cfg.Server.SourceURL) mux := http.NewServeMux() mux.HandleFunc("/", h.Index) diff --git a/config.example.toml b/config.example.toml index 34f60a6..042bb63 100644 --- a/config.example.toml +++ b/config.example.toml @@ -14,6 +14,11 @@ http_timeout = "10s" # Example: "https://search.example.com" base_url = "" +# Link to the source code (shown in footer as "Source" link) +# Defaults to the upstream kafka repo if not set. +# Example: "https://git.example.com/my-kafka-fork" +source_url = "" + [upstream] # URL of an upstream metasearch instance for unported engines (env: UPSTREAM_SEARXNG_URL) # Leave empty to run without an upstream proxy. diff --git a/internal/config/config.go b/internal/config/config.go index 2644c70..e5d1fbb 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -40,7 +40,8 @@ type Config struct { type ServerConfig struct { Port int `toml:"port"` HTTPTimeout string `toml:"http_timeout"` - BaseURL string `toml:"base_url"` // Public URL for OpenSearch XML (e.g. "https://search.example.com") + BaseURL string `toml:"base_url"` // Public URL for OpenSearch XML (e.g. "https://search.example.com") + SourceURL string `toml:"source_url"` // Link to the source code (e.g. "https://git.example.com/fork/kafka") } type UpstreamConfig struct { diff --git a/internal/httpapi/handlers.go b/internal/httpapi/handlers.go index 9e62c1b..cc19b4b 100644 --- a/internal/httpapi/handlers.go +++ b/internal/httpapi/handlers.go @@ -30,12 +30,14 @@ import ( type Handler struct { searchSvc *search.Service autocompleteSvc func(ctx context.Context, query string) ([]string, error) + sourceURL string } -func NewHandler(searchSvc *search.Service, autocompleteSuggestions func(ctx context.Context, query string) ([]string, error)) *Handler { +func NewHandler(searchSvc *search.Service, autocompleteSuggestions func(ctx context.Context, query string) ([]string, error), sourceURL string) *Handler { return &Handler{ searchSvc: searchSvc, autocompleteSvc: autocompleteSuggestions, + sourceURL: sourceURL, } } @@ -51,7 +53,7 @@ func (h *Handler) Index(w http.ResponseWriter, r *http.Request) { http.NotFound(w, r) return } - if err := views.RenderIndex(w); err != nil { + if err := views.RenderIndex(w, h.sourceURL); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) } } @@ -80,7 +82,7 @@ func (h *Handler) Search(w http.ResponseWriter, r *http.Request) { if err != nil { // For HTML, render error on the results page. if req.Format == contracts.FormatHTML || r.FormValue("format") == "html" { - pd := views.PageData{Query: r.FormValue("q")} + pd := views.PageData{SourceURL: h.sourceURL, Query: r.FormValue("q")} if views.IsHTMXRequest(r) { views.RenderSearchFragment(w, pd) } else { @@ -95,7 +97,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{Query: req.Query} + pd := views.PageData{SourceURL: h.sourceURL, Query: req.Query} if views.IsHTMXRequest(r) { views.RenderSearchFragment(w, pd) } else { diff --git a/internal/views/templates/base.html b/internal/views/templates/base.html index 5b58487..d2fec79 100644 --- a/internal/views/templates/base.html +++ b/internal/views/templates/base.html @@ -35,7 +35,7 @@
-

Powered by kafka — a privacy-respecting, open metasearch engine · Source · AGPLv3

+

Powered by kafka — a privacy-respecting, open metasearch engine{{if .SourceURL}} · Source{{end}} · AGPLv3

diff --git a/internal/views/views.go b/internal/views/views.go index 50cec56..4d7289c 100644 --- a/internal/views/views.go +++ b/internal/views/views.go @@ -35,6 +35,7 @@ var staticFS embed.FS // PageData holds all data passed to templates. type PageData struct { + SourceURL string Query string Pageno int PrevPage int @@ -187,9 +188,9 @@ func FromResponse(resp contracts.SearchResponse, query string, pageno int) PageD } // RenderIndex renders the homepage (search box only). -func RenderIndex(w http.ResponseWriter) error { +func RenderIndex(w http.ResponseWriter, sourceURL string) error { w.Header().Set("Content-Type", "text/html; charset=utf-8") - return tmplIndex.ExecuteTemplate(w, "base", PageData{ShowHeader: true}) + return tmplIndex.ExecuteTemplate(w, "base", PageData{ShowHeader: true, SourceURL: sourceURL}) } // RenderSearch renders the full search results page (with base layout). From 5b942a5fd6a0869130b63968719bcb6c8be6abd7 Mon Sep 17 00:00:00 2001 From: Franz Kafka Date: Sun, 22 Mar 2026 11:10:50 +0000 Subject: [PATCH 29/91] refactor: clean up verbose and redundant comments Trim or remove comments that: - State the obvious (function names already convey purpose) - Repeat what the code clearly shows - Are excessively long without adding value Keep comments that explain *why*, not *what*. --- internal/autocomplete/service.go | 5 +---- internal/contracts/main_result.go | 13 +------------ internal/contracts/types.go | 15 +++++---------- internal/engines/braveapi.go | 14 +++----------- internal/engines/factory.go | 5 ++--- internal/engines/google.go | 26 -------------------------- internal/engines/planner.go | 4 ---- internal/engines/qwant.go | 18 +----------------- internal/middleware/ratelimit.go | 11 ++--------- internal/search/merge.go | 5 ----- internal/search/types.go | 2 +- 11 files changed, 16 insertions(+), 102 deletions(-) diff --git a/internal/autocomplete/service.go b/internal/autocomplete/service.go index d6460f1..23473d5 100644 --- a/internal/autocomplete/service.go +++ b/internal/autocomplete/service.go @@ -27,8 +27,7 @@ import ( "time" ) -// Service fetches search suggestions from an upstream metasearch instance -// or falls back to Wikipedia's OpenSearch API. +// Service fetches search suggestions from upstream or Wikipedia OpenSearch. type Service struct { upstreamURL string http *http.Client @@ -44,7 +43,6 @@ func NewService(upstreamURL string, timeout time.Duration) *Service { } } -// Suggestions returns search suggestions for the given query. func (s *Service) Suggestions(ctx context.Context, query string) ([]string, error) { if strings.TrimSpace(query) == "" { return nil, nil @@ -56,7 +54,6 @@ func (s *Service) Suggestions(ctx context.Context, query string) ([]string, erro return s.wikipediaSuggestions(ctx, query) } -// upstreamSuggestions proxies to an upstream /autocompleter endpoint. func (s *Service) upstreamSuggestions(ctx context.Context, query string) ([]string, error) { u := s.upstreamURL + "/autocompleter?" + url.Values{"q": {query}}.Encode() req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) diff --git a/internal/contracts/main_result.go b/internal/contracts/main_result.go index c804f6b..02fb29c 100644 --- a/internal/contracts/main_result.go +++ b/internal/contracts/main_result.go @@ -22,14 +22,10 @@ import ( ) // MainResult represents one element of the `results` array. -// -// The API returns many additional keys beyond what templates use. To keep the -// contract stable for proxying/merging, we preserve all unknown keys in -// `raw` and re-emit them via MarshalJSON. +// Unknown keys are preserved in `raw` and re-emitted via MarshalJSON. type MainResult struct { raw map[string]any - // Common fields used by templates (RSS uses: title, url, content, pubdate). Template string `json:"template"` Title string `json:"title"` Content string `json:"content"` @@ -45,17 +41,13 @@ type MainResult struct { Positions []int `json:"positions"` Engines []string `json:"engines"` - // These fields exist in the MainResult base; keep them so downstream - // callers can generate richer output later. OpenGroup bool `json:"open_group"` CloseGroup bool `json:"close_group"` - // parsed_url is emitted as a tuple; we preserve it as-is. ParsedURL any `json:"parsed_url"` } func (mr *MainResult) UnmarshalJSON(data []byte) error { - // Preserve the full object. dec := json.NewDecoder(bytes.NewReader(data)) dec.UseNumber() @@ -66,7 +58,6 @@ func (mr *MainResult) UnmarshalJSON(data []byte) error { mr.raw = m - // Fill the typed/common fields (best-effort; don't fail if types differ). mr.Template = stringOrEmpty(m["template"]) mr.Title = stringOrEmpty(m["title"]) mr.Content = stringOrEmpty(m["content"]) @@ -104,12 +95,10 @@ func (mr *MainResult) UnmarshalJSON(data []byte) error { } func (mr MainResult) MarshalJSON() ([]byte, error) { - // If we came from upstream JSON, preserve all keys exactly. if mr.raw != nil { return json.Marshal(mr.raw) } - // Otherwise, marshal the known fields. m := map[string]any{ "template": mr.Template, "title": mr.Title, diff --git a/internal/contracts/types.go b/internal/contracts/types.go index 279ce57..40ed6bc 100644 --- a/internal/contracts/types.go +++ b/internal/contracts/types.go @@ -20,18 +20,15 @@ package contracts type OutputFormat string const ( - FormatHTML OutputFormat = "html" // accepted for compatibility (not yet implemented) + FormatHTML OutputFormat = "html" // accepted for compatibility FormatJSON OutputFormat = "json" FormatCSV OutputFormat = "csv" FormatRSS OutputFormat = "rss" ) type SearchRequest struct { - // Format is what the client requested via `format=...`. - Format OutputFormat - - Query string - + Format OutputFormat + Query string Pageno int Safesearch int TimeRange *string @@ -39,16 +36,14 @@ type SearchRequest struct { TimeoutLimit *float64 Language string - // Engines and categories are used for deciding which engines run locally vs are proxied. - // For now, engines can be supplied directly via the `engines` form parameter. + // Engines and categories decide which engines run locally vs proxy to upstream. Engines []string Categories []string // EngineData matches the `engine_data--=` parameters. EngineData map[string]map[string]string - // AccessToken is an optional request token used to gate paid/limited engines. - // It is not part of the upstream JSON schema; it only influences local engines. + // AccessToken gates paid/limited engines. Not part of upstream JSON schema. AccessToken string } diff --git a/internal/engines/braveapi.go b/internal/engines/braveapi.go index 641a1d4..81d1f3b 100644 --- a/internal/engines/braveapi.go +++ b/internal/engines/braveapi.go @@ -30,13 +30,9 @@ import ( "github.com/metamorphosis-dev/kafka/internal/contracts" ) -// BraveEngine implements the `braveapi` engine (Brave Web Search API). -// -// Config / gating: -// - BRAVE_API_KEY: required to call Brave -// - BRAVE_ACCESS_TOKEN (optional): if set, the request must include a token -// that matches the env var (via Authorization Bearer, X-Search-Token, -// X-Brave-Access-Token, or form field `token`). +// BraveEngine implements the Brave Web Search API. +// Required: BRAVE_API_KEY env var or config. +// Optional: BRAVE_ACCESS_TOKEN to gate requests. type BraveEngine struct { client *http.Client apiKey string @@ -51,8 +47,6 @@ func (e *BraveEngine) Search(ctx context.Context, req contracts.SearchRequest) ( return contracts.SearchResponse{}, errors.New("brave engine not initialized") } - // Gate / config checks should not be treated as fatal errors; the reference - // implementation treats misconfigured engines as unresponsive. if strings.TrimSpace(e.apiKey) == "" { return contracts.SearchResponse{ Query: req.Query, @@ -109,8 +103,6 @@ func (e *BraveEngine) Search(ctx context.Context, req contracts.SearchRequest) ( } } - // The reference implementation checks `if params["safesearch"]:` which treats any - // non-zero (moderate/strict) as strict. if req.Safesearch > 0 { args.Set("safesearch", "strict") } diff --git a/internal/engines/factory.go b/internal/engines/factory.go index ddaeb06..528dcb7 100644 --- a/internal/engines/factory.go +++ b/internal/engines/factory.go @@ -24,9 +24,8 @@ import ( "github.com/metamorphosis-dev/kafka/internal/config" ) -// NewDefaultPortedEngines returns the starter set of Go-native engines. -// The service can swap/extend this registry later as more engines are ported. -// If cfg is nil, falls back to reading API keys from environment variables. +// NewDefaultPortedEngines returns the Go-native engine registry. +// If cfg is nil, API keys fall back to environment variables. func NewDefaultPortedEngines(client *http.Client, cfg *config.Config) map[string]Engine { if client == nil { client = &http.Client{Timeout: 10 * time.Second} diff --git a/internal/engines/google.go b/internal/engines/google.go index 0119a98..8563829 100644 --- a/internal/engines/google.go +++ b/internal/engines/google.go @@ -57,7 +57,6 @@ func (e *GoogleEngine) Search(ctx context.Context, req contracts.SearchRequest) start := (req.Pageno - 1) * 10 query := url.QueryEscape(req.Query) - // Build URL like SearXNG does. u := fmt.Sprintf( "https://www.google.com/search?q=%s&filter=0&start=%d&hl=%s&lr=%s&safe=%s", query, @@ -118,7 +117,6 @@ func (e *GoogleEngine) Search(ctx context.Context, req contracts.SearchRequest) }, nil } -// detectGoogleSorry returns true if the response is a Google block/CAPTCHA page. func detectGoogleSorry(resp *http.Response) bool { if resp.Request != nil { if resp.Request.URL.Host == "sorry.google.com" || strings.HasPrefix(resp.Request.URL.Path, "/sorry") { @@ -128,16 +126,9 @@ func detectGoogleSorry(resp *http.Response) bool { return false } -// parseGoogleResults extracts search results from Google's HTML. -// Uses the same selectors as SearXNG: div.MjjYud for result containers. func parseGoogleResults(body, query string) []contracts.MainResult { var results []contracts.MainResult - // SearXNG selector: .//div[contains(@class, "MjjYud")] - // Each result block contains a title link and snippet. - // We simulate the XPath matching with regex-based extraction. - - // Find all MjjYud div blocks. mjjPattern := regexp.MustCompile(`]*class="[^"]*MjjYud[^"]*"[^>]*>(.*?)
\s*(?=]*class="[^"]*MjjYud|$)`) matches := mjjPattern.FindAllStringSubmatch(body, -1) @@ -147,15 +138,12 @@ func parseGoogleResults(body, query string) []contracts.MainResult { } block := match[1] - // Extract title and URL from the result link. - // Pattern: TITLE urlPattern := regexp.MustCompile(`]+href="(/url\?q=[^"&]+)`) urlMatch := urlPattern.FindStringSubmatch(block) if len(urlMatch) < 2 { continue } rawURL := urlMatch[1] - // Remove /url?q= prefix and decode. actualURL := strings.TrimPrefix(rawURL, "/url?q=") if amp := strings.Index(actualURL, "&"); amp != -1 { actualURL = actualURL[:amp] @@ -168,14 +156,12 @@ func parseGoogleResults(body, query string) []contracts.MainResult { continue } - // Extract title from the title tag. titlePattern := regexp.MustCompile(`]*class="[^"]*qrStP[^"]*"[^>]*>([^<]+)`) titleMatch := titlePattern.FindStringSubmatch(block) title := query if len(titleMatch) >= 2 { title = stripTags(titleMatch[1]) } else { - // Fallback: extract visible text from an with data-title or role="link" linkTitlePattern := regexp.MustCompile(`]+role="link"[^>]*>([^<]+)<`) ltMatch := linkTitlePattern.FindStringSubmatch(block) if len(ltMatch) >= 2 { @@ -183,7 +169,6 @@ func parseGoogleResults(body, query string) []contracts.MainResult { } } - // Extract snippet from data-sncf divs (SearXNG's approach). snippet := extractGoogleSnippet(block) urlPtr := actualURL @@ -202,10 +187,7 @@ func parseGoogleResults(body, query string) []contracts.MainResult { return results } -// extractGoogleSnippet extracts the snippet text from a Google result block. func extractGoogleSnippet(block string) string { - // Google's snippets live in divs with data-sncf attribute. - // SearXNG looks for: .//div[contains(@data-sncf, "1")] snippetPattern := regexp.MustCompile(`]+data-sncf="1"[^>]*>(.*?)
`) matches := snippetPattern.FindAllStringSubmatch(block, -1) var parts []string @@ -221,10 +203,8 @@ func extractGoogleSnippet(block string) string { return strings.Join(parts, " ") } -// extractGoogleSuggestions extracts search suggestions from Google result cards. func extractGoogleSuggestions(body string) []string { var suggestions []string - // SearXNG xpath: //div[contains(@class, "ouy7Mc")]//a suggestionPattern := regexp.MustCompile(`(?s)]*class="[^"]*ouy7Mc[^"]*"[^>]*>.*?]*>([^<]+)`) matches := suggestionPattern.FindAllStringSubmatch(body, -1) seen := map[string]bool{} @@ -241,8 +221,6 @@ func extractGoogleSuggestions(body string) []string { return suggestions } -// googleHL maps SearXNG locale to Google hl (host language) parameter. -// e.g. "en-US" -> "en-US" func googleHL(lang string) string { lang = strings.ToLower(strings.TrimSpace(lang)) if lang == "" || lang == "auto" { @@ -251,8 +229,6 @@ func googleHL(lang string) string { return lang } -// googleUILanguage maps SearXNG language to Google lr (language restrict) parameter. -// e.g. "en" -> "lang_en", "de" -> "lang_de" func googleUILanguage(lang string) string { lang = strings.ToLower(strings.Split(lang, "-")[0]) if lang == "" || lang == "auto" { @@ -261,7 +237,6 @@ func googleUILanguage(lang string) string { return "lang_" + lang } -// googleSafeSearchLevel maps safesearch (0-2) to Google's safe parameter. func googleSafeSearchLevel(safesearch int) string { switch safesearch { case 0: @@ -275,7 +250,6 @@ func googleSafeSearchLevel(safesearch int) string { } } -// stripTags removes HTML tags from a string. func stripTags(s string) string { stripper := regexp.MustCompile(`<[^>]*>`) s = stripper.ReplaceAllString(s, "") diff --git a/internal/engines/planner.go b/internal/engines/planner.go index 295f458..9616a4b 100644 --- a/internal/engines/planner.go +++ b/internal/engines/planner.go @@ -95,9 +95,6 @@ func (p *Planner) Plan(req contracts.SearchRequest) (localEngines, upstreamEngin } func inferFromCategories(categories []string) []string { - // Minimal mapping for the initial porting subset. - // This mirrors the idea of selecting from engine categories without - // embedding the whole engine registry. set := map[string]bool{} for _, c := range categories { switch strings.TrimSpace(strings.ToLower(c)) { @@ -131,7 +128,6 @@ func inferFromCategories(categories []string) []string { } func sortByOrder(list []string, order map[string]int) { - // simple insertion sort (list is tiny) for i := 1; i < len(list); i++ { j := i for j > 0 && order[list[j-1]] > order[list[j]] { diff --git a/internal/engines/qwant.go b/internal/engines/qwant.go index 77f7b70..e15d4f2 100644 --- a/internal/engines/qwant.go +++ b/internal/engines/qwant.go @@ -30,11 +30,7 @@ import ( "github.com/PuerkitoBio/goquery" ) -// QwantEngine implements a `qwant` (web) adapter using -// Qwant v3 endpoint: https://api.qwant.com/v3/search/web. -// -// Qwant's API is not fully documented; this implements parsing logic -// for the `web` category. +// QwantEngine implements the Qwant v3 API (web and web-lite modes). type QwantEngine struct { client *http.Client category string // "web" (JSON API) or "web-lite" (HTML fallback) @@ -53,8 +49,6 @@ func (e *QwantEngine) Search(ctx context.Context, req contracts.SearchRequest) ( return contracts.SearchResponse{Query: req.Query}, nil } - // For API parity we use web defaults: count=10, offset=(pageno-1)*count. - // The engine's config field exists so we can expand to news/images/videos later. count := e.resultsPerPage if count <= 0 { count = 10 @@ -271,9 +265,7 @@ func (e *QwantEngine) searchWebLite(ctx context.Context, req contracts.SearchReq results := make([]contracts.MainResult, 0) seen := map[string]bool{} - // Pattern 1: legacy/known qwant-lite structure. doc.Find("section article").Each(func(_ int, item *goquery.Selection) { - // ignore randomly interspersed advertising adds if item.Find("span.tooltip").Length() > 0 { return } @@ -307,19 +299,14 @@ func (e *QwantEngine) searchWebLite(ctx context.Context, req contracts.SearchReq }) }) - // Pattern 2: broader fallback for updated lite markup: - // any article/list item/div block containing an external anchor. - // We keep this conservative by requiring non-empty title + URL. doc.Find("article, li, div").Each(func(_ int, item *goquery.Selection) { if len(results) >= 20 { return } - // Skip ad-like blocks in fallback pass too. if item.Find("span.tooltip").Length() > 0 { return } - // Skip obvious nav/footer blocks. classAttr, _ := item.Attr("class") classLower := strings.ToLower(classAttr) if strings.Contains(classLower, "nav") || strings.Contains(classLower, "footer") { @@ -368,13 +355,10 @@ func (e *QwantEngine) searchWebLite(ctx context.Context, req contracts.SearchReq } seen[href] = true - // Best-effort snippet extraction from nearby paragraph/span text. content := strings.TrimSpace(item.Find("p").First().Text()) if content == "" { content = strings.TrimSpace(item.Find("span").First().Text()) } - // If there is no snippet, still keep clearly external result links. - // Qwant-lite frequently omits rich snippets for some entries. u := href results = append(results, contracts.MainResult{ diff --git a/internal/middleware/ratelimit.go b/internal/middleware/ratelimit.go index 899029f..78774f2 100644 --- a/internal/middleware/ratelimit.go +++ b/internal/middleware/ratelimit.go @@ -27,19 +27,12 @@ import ( "log/slog" ) -// RateLimitConfig controls per-IP rate limiting using a sliding window counter. type RateLimitConfig struct { - // Requests is the max number of requests allowed per window. - Requests int - // Window is the time window duration (e.g. "1m"). - Window time.Duration - // CleanupInterval is how often stale entries are purged (default: 5m). + Requests int + Window time.Duration CleanupInterval time.Duration } -// RateLimit returns a middleware that limits requests per IP address. -// Uses an in-memory sliding window counter. When the limit is exceeded, -// responds with HTTP 429 and a Retry-After header. func RateLimit(cfg RateLimitConfig, logger *slog.Logger) func(http.Handler) http.Handler { requests := cfg.Requests if requests <= 0 { diff --git a/internal/search/merge.go b/internal/search/merge.go index 7be7353..0e8a15a 100644 --- a/internal/search/merge.go +++ b/internal/search/merge.go @@ -25,11 +25,6 @@ import ( ) // MergeResponses merges multiple compatible JSON responses. -// -// MVP merge semantics: -// - results are concatenated with a simple de-dup key (engine|title|url) -// - suggestions/corrections are de-duplicated as sets -// - answers/infoboxes/unresponsive_engines are concatenated (best-effort) func MergeResponses(responses []contracts.SearchResponse) contracts.SearchResponse { var merged contracts.SearchResponse diff --git a/internal/search/types.go b/internal/search/types.go index 9665dde..89b323d 100644 --- a/internal/search/types.go +++ b/internal/search/types.go @@ -23,7 +23,7 @@ import "github.com/metamorphosis-dev/kafka/internal/contracts" type OutputFormat = contracts.OutputFormat const ( - FormatHTML = contracts.FormatHTML // accepted for compatibility (not yet implemented) + FormatHTML = contracts.FormatHTML // accepted for compatibility FormatJSON = contracts.FormatJSON FormatCSV = contracts.FormatCSV FormatRSS = contracts.FormatRSS From b2cca0a346be5167bbd594191ac81f1519e4e253 Mon Sep 17 00:00:00 2001 From: Franz Kafka Date: Sun, 22 Mar 2026 11:24:43 +0000 Subject: [PATCH 30/91] ci: remove stale vendor directory before build --- .forgejo/workflows/test.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.forgejo/workflows/test.yml b/.forgejo/workflows/test.yml index bd05693..caf4f3f 100644 --- a/.forgejo/workflows/test.yml +++ b/.forgejo/workflows/test.yml @@ -18,5 +18,8 @@ jobs: with: go-version-file: go.mod + - name: Clean vendor + run: rm -rf vendor + - name: Test run: go test -race -v ./... From a85d8033c7d636ebd65d8d0f26797b631252197f Mon Sep 17 00:00:00 2001 From: Franz Kafka Date: Sun, 22 Mar 2026 11:28:31 +0000 Subject: [PATCH 31/91] fix(flake): remove stale vendorHash; auto-compute on next build The go.mod changes (goquery downgrade, x/net replace) invalidate the old vendorHash. Set to empty to auto-recompute, then replace with the actual hash from the build error. --- flake.nix | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/flake.nix b/flake.nix index d143495..fc8613b 100644 --- a/flake.nix +++ b/flake.nix @@ -21,13 +21,16 @@ version = "0.1.0"; src = ./.; - vendorHash = "sha256-NbAa4QM/TI3BTuZs4glx9k3ZjSl2/2LQfKlQ7izR8Ho="; + vendorHash = ""; # auto-computed; update with actual hash after first build # Run: nix build .#packages.x86_64-linux.default - # It will fail with the correct hash. Replace it here. + # It will fail with the correct hash. Replace vendorHash with it. # Embed the templates and static files at build time. ldflags = [ "-s" "-w" ]; + # Remove stale vendor directory when dependencies change. + preBuild = "rm -rf vendor"; + nativeCheckInputs = with pkgs; [ ]; # Tests require network; they run in CI instead. From 25757fdb996f1c532f35093f4531480ee9d84e8f Mon Sep 17 00:00:00 2001 From: Franz Kafka Date: Sun, 22 Mar 2026 11:33:22 +0000 Subject: [PATCH 32/91] ci: add GitHub Actions workflow for pull requests Runs tests on PRs and pushes to main. --- .github/workflows/test.yml | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 .github/workflows/test.yml diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..f1770a9 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,25 @@ +name: Tests + +on: + pull_request: + branches: [main] + push: + branches: [main] + +jobs: + test: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + + - name: Clean vendor + run: rm -rf vendor + + - name: Test + run: go test -race -v ./... From 7d0e2017cdfa65a8261fbb494d655a27e48e6cad Mon Sep 17 00:00:00 2001 From: ashisgreat22 Date: Sun, 22 Mar 2026 11:33:29 +0000 Subject: [PATCH 33/91] fix(go.mod): remove stale replace directive The replace directive for golang.org/x/net was causing build failures when using vendorHash = "" with the Go module proxy. Co-Authored-By: Claude Opus 4.6 --- go.mod | 2 -- 1 file changed, 2 deletions(-) diff --git a/go.mod b/go.mod index 85f1653..f5e61cb 100644 --- a/go.mod +++ b/go.mod @@ -15,5 +15,3 @@ require ( go.uber.org/atomic v1.11.0 // indirect golang.org/x/net v0.52.0 // indirect ) - -replace golang.org/x/net v0.52.0 => golang.org/x/net v0.33.0 From 16266e143e5e523816486348a027f7a509eee01e Mon Sep 17 00:00:00 2001 From: ashisgreat22 Date: Sun, 22 Mar 2026 11:39:41 +0000 Subject: [PATCH 34/91] fix(go.mod): add missing x/net v0.52.0 hash to go.sum The replace directive was removed but go.sum wasn't updated with the correct hash for golang.org/x/net v0.52.0. Co-Authored-By: Claude Opus 4.6 --- go.sum | 2 ++ 1 file changed, 2 insertions(+) diff --git a/go.sum b/go.sum index 6b1b78b..796f047 100644 --- a/go.sum +++ b/go.sum @@ -95,3 +95,5 @@ golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= +golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= From 2883ac95e7984eeef063b663d646439e39898794 Mon Sep 17 00:00:00 2001 From: ashisgreat22 Date: Sun, 22 Mar 2026 11:41:15 +0000 Subject: [PATCH 35/91] fix(go.mod): remove unused golang.org/x/net indirect dep The golang.org/x/net v0.52.0 was listed as an indirect dependency but nothing in the codebase imports it, causing go mod tidy to fail. Co-Authored-By: Claude Opus 4.6 --- go.mod | 1 - go.sum | 4 ---- 2 files changed, 5 deletions(-) diff --git a/go.mod b/go.mod index f5e61cb..d281ae2 100644 --- a/go.mod +++ b/go.mod @@ -13,5 +13,4 @@ require ( github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect go.uber.org/atomic v1.11.0 // indirect - golang.org/x/net v0.52.0 // indirect ) diff --git a/go.sum b/go.sum index 796f047..4526382 100644 --- a/go.sum +++ b/go.sum @@ -50,8 +50,6 @@ golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= -golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= -golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -95,5 +93,3 @@ golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= -golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= From 8c6d056f52a5007a4372458817da9ccf536a5ceb Mon Sep 17 00:00:00 2001 From: ashisgreat22 Date: Sun, 22 Mar 2026 12:41:42 +0100 Subject: [PATCH 36/91] fix(engines): cap Brave API offset to 9 to avoid 422 error Brave API only supports offset values 0-9. When pageno > 1 with resultsPerPage=20, offset exceeded this limit causing 422 errors. Co-Authored-By: Claude Opus 4.6 --- internal/engines/braveapi.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/internal/engines/braveapi.go b/internal/engines/braveapi.go index 81d1f3b..8977cb2 100644 --- a/internal/engines/braveapi.go +++ b/internal/engines/braveapi.go @@ -80,10 +80,15 @@ func (e *BraveEngine) Search(ctx context.Context, req contracts.SearchRequest) ( return contracts.SearchResponse{Query: req.Query}, nil } + // Brave API only supports offset values 0-9 (first page of results). + // Paginating beyond the first page is not supported by Brave. offset := 0 if req.Pageno > 1 { offset = (req.Pageno - 1) * e.resultsPerPage } + if offset > 9 { + offset = 9 + } args := url.Values{} args.Set("q", q) From f6128689f1d458fe716d4df3e8ad709400d88803 Mon Sep 17 00:00:00 2001 From: ashisgreat22 Date: Sun, 22 Mar 2026 11:42:15 +0000 Subject: [PATCH 37/91] fix(go.sum): remove stale go.sum to allow rebuild from proxy The go.sum is out of sync with go.mod causing build failures. Removing it allows Go to rebuild it from the module proxy. Co-Authored-By: Claude Opus 4.6 --- go.sum | 95 ---------------------------------------------------------- 1 file changed, 95 deletions(-) delete mode 100644 go.sum diff --git a/go.sum b/go.sum deleted file mode 100644 index 4526382..0000000 --- a/go.sum +++ /dev/null @@ -1,95 +0,0 @@ -github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg= -github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= -github.com/PuerkitoBio/goquery v1.9.0 h1:zgjKkdpRY9T97Q5DCtcXwfqkcylSFIVCocZmn2huTp8= -github.com/PuerkitoBio/goquery v1.9.0/go.mod h1:cW1n6TmIMDoORQU5IU/P1T3tGFunOeXEpGP2WHRwkbY= -github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM= -github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA= -golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I= -golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= -github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= -github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= -github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= -github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= -github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= -github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= -github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= -github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= -github.com/klauspost/cpuid/v2 v2.0.9 h1:lgaqFMSdTdQYdZ04uHyN2d/eKdOMyi2YLSvlQIBFYa4= -github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfSRQs= -github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0= -github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q= -github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= -github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= -github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0= -github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA= -go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= -go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= -golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= -golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= -golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= -golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= -golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= -golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= -golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= -golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= -golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= -golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= -golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= -golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= -golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= -golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= -golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= -golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= -golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= -golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= -golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= -golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= -golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= -golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= -golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= -golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= -golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= -golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= -golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= -golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= -golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= -golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= -golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= -golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= -golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= -golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= -golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= -golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= -golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= -golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= -golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= From f1cf23745e23baf15bcb7d8ac86eb6e7a50420c6 Mon Sep 17 00:00:00 2001 From: Franz Kafka Date: Sun, 22 Mar 2026 11:44:32 +0000 Subject: [PATCH 38/91] test: add HTTP API integration tests Test GET /healthz, /, /search, /autocompleter endpoints. Verify response codes, content types, JSON decoding, empty-query redirect, and source URL presence in footer. Also fix dead code in Search handler: the redirect for empty q was unreachable because ParseSearchRequest errors on empty q first. Move the q/format check before ParseSearchRequest to fix the redirect. --- internal/httpapi/handlers.go | 10 +- internal/httpapi/httpapi_test.go | 230 +++++++++++++++++++++++++++++++ 2 files changed, 236 insertions(+), 4 deletions(-) create mode 100644 internal/httpapi/httpapi_test.go diff --git a/internal/httpapi/handlers.go b/internal/httpapi/handlers.go index cc19b4b..f8db054 100644 --- a/internal/httpapi/handlers.go +++ b/internal/httpapi/handlers.go @@ -72,17 +72,19 @@ func (h *Handler) OpenSearch(baseURL string) http.HandlerFunc { } func (h *Handler) Search(w http.ResponseWriter, r *http.Request) { + q := r.FormValue("q") + format := r.FormValue("format") + // For HTML format with no query, redirect to homepage. - if r.FormValue("q") == "" && (r.FormValue("format") == "" || r.FormValue("format") == "html") { + if q == "" && (format == "" || format == "html") { http.Redirect(w, r, "/", http.StatusFound) return } req, err := search.ParseSearchRequest(r) if err != nil { - // For HTML, render error on the results page. - if req.Format == contracts.FormatHTML || r.FormValue("format") == "html" { - pd := views.PageData{SourceURL: h.sourceURL, Query: r.FormValue("q")} + if format == "html" || format == "" { + pd := views.PageData{SourceURL: h.sourceURL, Query: q} if views.IsHTMXRequest(r) { views.RenderSearchFragment(w, pd) } else { diff --git a/internal/httpapi/httpapi_test.go b/internal/httpapi/httpapi_test.go new file mode 100644 index 0000000..f33cb8c --- /dev/null +++ b/internal/httpapi/httpapi_test.go @@ -0,0 +1,230 @@ +// kafka — a privacy-respecting metasearch engine +// Copyright (C) 2026-present metamorphosis-dev +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package httpapi_test + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/metamorphosis-dev/kafka/internal/contracts" + "github.com/metamorphosis-dev/kafka/internal/httpapi" + "github.com/metamorphosis-dev/kafka/internal/search" +) + +// mockUpstreamHandler returns controlled JSON responses. +func mockUpstreamJSON(query string) contracts.SearchResponse { + return contracts.SearchResponse{ + 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{}, + } +} + +func ptr(s string) *string { return &s } + +func newTestServer(t *testing.T) (*httptest.Server, *httpapi.Handler) { + t.Helper() + + // Mock upstream server that returns controlled JSON. + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + query := r.FormValue("q") + resp := mockUpstreamJSON(query) + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) + })) + t.Cleanup(upstream.Close) + + svc := search.NewService(search.ServiceConfig{ + UpstreamURL: upstream.URL, + HTTPTimeout: 0, + Cache: nil, + EnginesConfig: nil, + }) + + h := httpapi.NewHandler(svc, nil, "https://src.example.com") + + mux := http.NewServeMux() + mux.HandleFunc("/healthz", h.Healthz) + mux.HandleFunc("/", h.Index) + mux.HandleFunc("/search", h.Search) + mux.HandleFunc("/autocompleter", h.Autocompleter) + + server := httptest.NewServer(mux) + t.Cleanup(server.Close) + return server, h +} + +func TestHealthz(t *testing.T) { + server, _ := newTestServer(t) + resp, err := http.Get(server.URL + "/healthz") + if err != nil { + t.Fatalf("request failed: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Errorf("expected status 200, got %d", resp.StatusCode) + } + if ct := resp.Header.Get("Content-Type"); !strings.Contains(ct, "text/plain") { + t.Errorf("expected text/plain, got %s", ct) + } +} + +func TestIndex(t *testing.T) { + server, _ := newTestServer(t) + resp, err := http.Get(server.URL + "/") + if err != nil { + t.Fatalf("request failed: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Errorf("expected status 200, got %d", resp.StatusCode) + } + if ct := resp.Header.Get("Content-Type"); !strings.Contains(ct, "text/html") { + t.Errorf("expected text/html, got %s", ct) + } + + body, _ := io.ReadAll(resp.Body) + if !strings.Contains(string(body), " Date: Sun, 22 Mar 2026 11:44:59 +0000 Subject: [PATCH 39/91] fix(go): run go mod tidy to sync dependencies This fixes the build by properly synchronizing go.mod and go.sum using the official Go toolchain. Co-Authored-By: Claude Opus 4.6 --- go.mod | 1 + 1 file changed, 1 insertion(+) diff --git a/go.mod b/go.mod index d281ae2..6981818 100644 --- a/go.mod +++ b/go.mod @@ -13,4 +13,5 @@ require ( github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect go.uber.org/atomic v1.11.0 // indirect + golang.org/x/net v0.33.0 // indirect ) From bf5f36e383d4af3b32cee0dff72b556c761dcb27 Mon Sep 17 00:00:00 2001 From: ashisgreat22 Date: Sun, 22 Mar 2026 11:47:32 +0000 Subject: [PATCH 40/91] chore(deps): add go.sum from go mod tidy --- go.sum | 94 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 94 insertions(+) create mode 100644 go.sum diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..5700b4c --- /dev/null +++ b/go.sum @@ -0,0 +1,94 @@ +github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg= +github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +github.com/PuerkitoBio/goquery v1.9.0 h1:zgjKkdpRY9T97Q5DCtcXwfqkcylSFIVCocZmn2huTp8= +github.com/PuerkitoBio/goquery v1.9.0/go.mod h1:cW1n6TmIMDoORQU5IU/P1T3tGFunOeXEpGP2WHRwkbY= +github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM= +github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA= +github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= +github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= +github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= +github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/klauspost/cpuid/v2 v2.0.9 h1:lgaqFMSdTdQYdZ04uHyN2d/eKdOMyi2YLSvlQIBFYa4= +github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfSRQs= +github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0= +github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0= +github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA= +go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= +go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= +golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= +golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= +golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= +golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= +golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= +golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I= +golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= +golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= +golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= +golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= +golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= From 5e125646a71c123c03fae5d4a7de08525b6842c4 Mon Sep 17 00:00:00 2001 From: ashisgreat22 Date: Sun, 22 Mar 2026 11:48:08 +0000 Subject: [PATCH 41/91] fix(flake): set correct vendorHash The auto-computed vendorHash for the go modules is: sha256-PTD4eEEkLGBCZbot6W4U+sMOpIbH2tcFSztQel7hyXI= Co-Authored-By: Claude Opus 4.6 --- flake.nix | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flake.nix b/flake.nix index fc8613b..d9bb322 100644 --- a/flake.nix +++ b/flake.nix @@ -21,7 +21,7 @@ version = "0.1.0"; src = ./.; - vendorHash = ""; # auto-computed; update with actual hash after first build + vendorHash = "sha256-PTD4eEEkLGBCZbot6W4U+sMOpIbH2tcFSztQel7hyXI="; # Run: nix build .#packages.x86_64-linux.default # It will fail with the correct hash. Replace vendorHash with it. From 3bc1fad6b57efce91ff98f3b7728baa67ff5f470 Mon Sep 17 00:00:00 2001 From: ashisgreat22 Date: Sun, 22 Mar 2026 12:01:18 +0000 Subject: [PATCH 42/91] fix(flake): force remove vendor in preConfigure The nix store may have stale vendor directories with incorrect permissions. Force chmod before removing to ensure clean build. Co-Authored-By: Claude Opus 4.6 --- flake.nix | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/flake.nix b/flake.nix index d9bb322..e2521d7 100644 --- a/flake.nix +++ b/flake.nix @@ -28,8 +28,8 @@ # Embed the templates and static files at build time. ldflags = [ "-s" "-w" ]; - # Remove stale vendor directory when dependencies change. - preBuild = "rm -rf vendor"; + # Remove stale vendor directory before buildGoModule deletes it. + preConfigure = "find vendor -type f -exec chmod 666 {} \; 2>/dev/null || true; rm -rf vendor 2>/dev/null || find vendor -delete 2>/dev/null || true"; nativeCheckInputs = with pkgs; [ ]; From f172da33eff60fb0008ace35dc5f611737c84bfe Mon Sep 17 00:00:00 2001 From: ashisgreat22 Date: Sun, 22 Mar 2026 12:41:42 +0100 Subject: [PATCH 43/91] fix(engines): cap Brave API offset to 9 to avoid 422 error Brave API only supports offset values 0-9. When pageno > 1 with resultsPerPage=20, offset exceeded this limit causing 422 errors. Co-Authored-By: Claude Opus 4.6 --- internal/engines/braveapi.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/internal/engines/braveapi.go b/internal/engines/braveapi.go index 81d1f3b..8977cb2 100644 --- a/internal/engines/braveapi.go +++ b/internal/engines/braveapi.go @@ -80,10 +80,15 @@ func (e *BraveEngine) Search(ctx context.Context, req contracts.SearchRequest) ( return contracts.SearchResponse{Query: req.Query}, nil } + // Brave API only supports offset values 0-9 (first page of results). + // Paginating beyond the first page is not supported by Brave. offset := 0 if req.Pageno > 1 { offset = (req.Pageno - 1) * e.resultsPerPage } + if offset > 9 { + offset = 9 + } args := url.Values{} args.Set("q", q) From 6bbde20f23725e88ef8814a724ddea517085fa7a Mon Sep 17 00:00:00 2001 From: ashisgreat22 Date: Sun, 22 Mar 2026 13:04:34 +0100 Subject: [PATCH 44/91] docs: add Brave Search frontend redesign specification Co-Authored-By: Claude Opus 4.6 --- ...26-03-22-brave-search-frontend-redesign.md | 236 ++++++++++++++++++ 1 file changed, 236 insertions(+) create mode 100644 docs/superpowers/specs/2026-03-22-brave-search-frontend-redesign.md diff --git a/docs/superpowers/specs/2026-03-22-brave-search-frontend-redesign.md b/docs/superpowers/specs/2026-03-22-brave-search-frontend-redesign.md new file mode 100644 index 0000000..dcbc862 --- /dev/null +++ b/docs/superpowers/specs/2026-03-22-brave-search-frontend-redesign.md @@ -0,0 +1,236 @@ +# Brave Search Frontend Redesign — Design Specification + +## Overview + +Redesign the kafka frontend to match Brave Search's clean, functional aesthetic with emphasis on layout changes: three-column results page, category tiles on homepage, and a hybrid preferences system with full-page `/preferences` route. + +## Design Principles + +1. **Brave-like layout** — Three-column results, full-page preferences, homepage tiles +2. **Preserve existing design tokens** — Keep current CSS variables (colors, spacing, radii) +3. **CSS Grid for layout** — Three-column grid for results, flexible layouts elsewhere +4. **Hybrid preferences** — Quick popover for common settings, full `/preferences` page for all options +5. **Minimal HTML changes** — Restructure templates where needed for layout, reuse existing partials + +--- + +## 1. Homepage Redesign + +### Current State +- Centered hero with logo, tagline, and search box +- No visual categorization of search types + +### New Layout +``` +┌─────────────────────────────────────────────────────────────┐ +│ [Logo] [⚙ Preferences]│ +├─────────────────────────────────────────────────────────────┤ +│ │ +│ [🔍 Search Box] │ +│ │ +│ ┌────────┐ ┌────────┐ ┌────────┐ ┌────────┐ │ +│ │ News │ │ Images │ │ Videos │ │ Maps │ ... │ +│ └────────┘ └────────┘ └────────┘ └────────┘ │ +│ │ +│ "Search the web privately..." │ +│ │ +└─────────────────────────────────────────────────────────────┘ +``` + +### Implementation +- **File:** `internal/views/templates/index.html` +- **Structure:** Search hero + category tiles grid +- **Tiles:** Static links to `/search?q=...` with category parameter (e.g., `&category=images`) +- **Categories:** News, Images, Videos, Maps, Shopping, Music (or configurable) +- **Styling:** Grid of icon+label cards below search box, subtle hover effects + +--- + +## 2. Results Page — Three-Column Layout + +### Current State +- Two columns: compact search bar spanning top, main results + right sidebar + +### New Layout +``` +┌─────────────────────────────────────────────────────────────┐ +│ [Logo] [⚙ Preferences]│ +├─────────────────────────────────────────────────────────────┤ +│ ┌─────────┐ ┌────────────────────────────┐ ┌──────────┐│ +│ │ Nav │ │ 🔍 [ Search Input ] │ │ Related ││ +│ │ ─────── │ └────────────────────────────┘ │ Searches ││ +│ │ All │ │ │ ││ +│ │ Images │ ┌──────────────────────────┐ │ │ ─────── ││ +│ │ Videos │ │ Result Card │ │ │ Suggestions│ +│ │ News │ │ Title, URL, Description │ │ │ ││ +│ │ Maps │ └──────────────────────────┘ │ │ ││ +│ │ ... │ ┌──────────────────────────┐ │ └──────────┘│ +│ │ │ │ Result Card │ │ │ +│ │ ─────── │ │ ... │ │ │ +│ │ Filters │ └──────────────────────────┘ │ │ +│ │ Time │ ... │ │ +│ │ Type │ │ │ +│ └─────────┘ [Pagination] │ │ +└─────────────────────────────────────────────────────────────┘ +``` + +### Implementation +- **Files:** `internal/views/templates/results.html`, `internal/views/templates/base.html` +- **Left Sidebar (desktop):** + - Category navigation links (All, Images, Videos, News, Maps, Shopping) + - Filters section (Time range, Result type) — collapsible + - Hidden on mobile +- **Center Column:** + - Compact search bar + - Results count meta + - Result cards (unchanged markup, restyled if needed) + - Pagination +- **Right Sidebar:** + - Related searches (existing suggestions) + - Additional panels as needed +- **CSS:** Use `display: grid` with three columns on desktop, collapse to single column on mobile + +--- + +## 3. Preferences Page — Full-Page Hybrid + +### Current State +- Popover triggered by gear icon in header +- JavaScript-rendered from localStorage +- Sections: Appearance, Engines, Search Defaults + +### New Layout +``` +┌─────────────────────────────────────────────────────────────┐ +│ [Logo] [⚙ Preferences]│ +├─────────────────────────────────────────────────────────────┤ +│ ┌────────────────┐ ┌─────────────────────────────────────┐│ +│ │ Sidebar │ │ Content ││ +│ │ ───────────── │ │ ││ +│ │ Search │ │ [Section Content] ││ +│ │ Privacy │ │ ││ +│ │ Tabs │ │ ││ +│ │ Appearance │ │ ││ +│ │ Sidebar │ │ ││ +│ │ Content │ │ ││ +│ │ Languages │ │ ││ +│ │ Regional │ │ ││ +│ └────────────────┘ └─────────────────────────────────────┘│ +└─────────────────────────────────────────────────────────────┘ +``` + +### Sections (Brave-style) +1. **Search** — Default engine, safe search, language +2. **Privacy** — Tracking protection toggle (UI only, always on), request DNT header toggle +3. **Tabs** — New tab behavior (placeholder section) +4. **Appearance** — Theme (Light/Dark/System), results font size +5. **Sidebar** — Sidebar visibility toggle +6. **Content** — Filter explicit results (SafeSearch), auto-play media toggle +7. **Languages** — UI language (English only for now), search language +8. **Regional** — Region/Country, timezone (placeholder) + +### Implementation +- **Route:** Add `GET /preferences` and `POST /preferences` to `internal/httpapi/` +- **Template:** `internal/views/templates/preferences.html` +- **Quick Settings Popover:** Keep existing popover for theme toggle and engine toggles only (lightweight, localStorage) +- **Full Preferences Page:** Server-rendered, form POST saves to localStorage, reads on load +- **Styling:** Match existing design tokens, section headers, form controls + +--- + +## 4. Component Changes + +### Header +- Logo + site name (unchanged) +- Preferences button (unchanged) + +### Search Box +- Homepage: Larger, prominent, centered +- Results page: Compact, full-width within center column + +### Result Cards +- Keep existing structure +- Consider subtle styling improvements (spacing, typography) + +### Category Tiles (Homepage) +- Icon + label per category +- Hover: slight scale + shadow + +### Left Sidebar (Results Page) +- Sticky positioning +- Category links with active state indicator +- Collapsible filter sections + +### Preferences Sidebar +- Vertical nav with section icons +- Active state indicator +- Mobile: horizontal scroll or accordion + +--- + +## 5. CSS Architecture + +### Existing (Retain) +- CSS custom properties (design tokens) +- Component-level styles +- Dark mode via `[data-theme="dark"]` + +### New +- Layout Grid for three-column results: + ```css + .results-layout { + display: grid; + grid-template-columns: 200px 1fr 240px; + gap: 2rem; + } + ``` +- Preferences page layout: + ```css + .preferences-layout { + display: grid; + grid-template-columns: 200px 1fr; + gap: 2rem; + } + ``` +- Category tiles grid: + ```css + .category-tiles { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(100px, 1fr)); + gap: 1rem; + } + ``` + +--- + +## 6. Files to Modify + +| File | Change | +|------|--------| +| `internal/views/templates/index.html` | Add category tiles | +| `internal/views/templates/results.html` | Add left sidebar, restructure for three columns | +| `internal/views/templates/base.html` | Minimal changes (no structural changes needed) | +| `internal/views/templates/preferences.html` | **New** — full preferences page | +| `internal/views/static/css/kafka.css` | Add layout grids, category tiles, sidebar styles | +| `internal/views/static/js/settings.js` | Keep popover for quick settings, update for preferences page | +| `internal/httpapi/httpapi.go` | Add `/preferences` route (GET + POST) | +| `internal/views/views.go` | Add preferences template rendering | + +--- + +## 7. Priority Order + +1. **Phase 1:** CSS layout framework (three-column grid, new variables) +2. **Phase 2:** Results page three-column layout +3. **Phase 3:** Homepage category tiles +4. **Phase 4:** Preferences page (quick popover first, then full page) +5. **Phase 5:** Polish and mobile responsiveness + +--- + +## Out of Scope + +- Backend search logic changes +- New engine implementations +- Caching or performance improvements +- User authentication/account system From 79c37a086bd1df77436555ade03998fbecece48e Mon Sep 17 00:00:00 2001 From: Franz Kafka Date: Sun, 22 Mar 2026 12:05:14 +0000 Subject: [PATCH 45/91] ci: update actions/checkout to v5 (uses Node 24) --- .forgejo/workflows/test.yml | 2 +- .github/workflows/test.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.forgejo/workflows/test.yml b/.forgejo/workflows/test.yml index caf4f3f..5f7efb4 100644 --- a/.forgejo/workflows/test.yml +++ b/.forgejo/workflows/test.yml @@ -11,7 +11,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: https://github.com/actions/checkout@v4 + uses: https://github.com/actions/checkout@v5 - name: Set up Go uses: https://github.com/actions/setup-go@v5 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index f1770a9..47cc920 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -11,7 +11,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Set up Go uses: actions/setup-go@v5 From e9b5fa1f0bee1faa8b154e7fea44e128d06623a5 Mon Sep 17 00:00:00 2001 From: Franz Kafka Date: Sun, 22 Mar 2026 12:11:35 +0000 Subject: [PATCH 46/91] docs: update license to AGPLv3 --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index c03019e..25c1c29 100644 --- a/README.md +++ b/README.md @@ -221,4 +221,4 @@ Includes Valkey 8 with health checks out of the box. ## License -MIT +[AGPLv3](https://www.gnu.org/licenses/agpl-3.0.html) From cb05ac5b8c243206cb3226eb86538d12c6e13b64 Mon Sep 17 00:00:00 2001 From: ashisgreat22 Date: Sun, 22 Mar 2026 13:12:06 +0100 Subject: [PATCH 47/91] docs: update Brave Search frontend redesign spec with clarifications - Clarify localStorage-only preferences (no server persistence) - Expand category tiles including future ones (weather, sports, crypto) - Define filter UI options with query params (time range, result type) - Add mobile breakpoints and collapse behavior - Reduce quick popover to theme + engines only - Rename Preferences Sidebar to Preferences Nav - Add results count format specification - Add sticky positioning CSS for left sidebar Co-Authored-By: Claude Opus 4.6 --- ...26-03-22-brave-search-frontend-redesign.md | 186 +++++++++++++----- 1 file changed, 139 insertions(+), 47 deletions(-) diff --git a/docs/superpowers/specs/2026-03-22-brave-search-frontend-redesign.md b/docs/superpowers/specs/2026-03-22-brave-search-frontend-redesign.md index dcbc862..d30ab99 100644 --- a/docs/superpowers/specs/2026-03-22-brave-search-frontend-redesign.md +++ b/docs/superpowers/specs/2026-03-22-brave-search-frontend-redesign.md @@ -9,8 +9,9 @@ Redesign the kafka frontend to match Brave Search's clean, functional aesthetic 1. **Brave-like layout** — Three-column results, full-page preferences, homepage tiles 2. **Preserve existing design tokens** — Keep current CSS variables (colors, spacing, radii) 3. **CSS Grid for layout** — Three-column grid for results, flexible layouts elsewhere -4. **Hybrid preferences** — Quick popover for common settings, full `/preferences` page for all options +4. **Hybrid preferences** — Quick popover for common settings (theme + engines), full `/preferences` page for all options 5. **Minimal HTML changes** — Restructure templates where needed for layout, reuse existing partials +6. **localStorage-only preferences** — No server-side persistence; all preferences stored in browser localStorage --- @@ -41,9 +42,24 @@ Redesign the kafka frontend to match Brave Search's clean, functional aesthetic - **File:** `internal/views/templates/index.html` - **Structure:** Search hero + category tiles grid - **Tiles:** Static links to `/search?q=...` with category parameter (e.g., `&category=images`) -- **Categories:** News, Images, Videos, Maps, Shopping, Music (or configurable) - **Styling:** Grid of icon+label cards below search box, subtle hover effects +### Category Tiles +| Category | Icon | Notes | +|----------|------|-------| +| All | 🌐 | Default, no category param | +| News | 📰 | | +| Images | 🖼️ | | +| Videos | 🎬 | | +| Maps | 🗺️ | | +| Shopping | 🛒 | Future: connect to shopping engine | +| Music | 🎵 | Future: connect to music engine | +| Weather | 🌤️ | Future: connect to weather API | +| Sports | ⚽ | Future | +| Cryptocurrency | ₿ | Future | + +Categories marked "Future" are included in the UI but may not have backend support yet. Category tiles that lack backend support display grayed out with a "Coming soon" tooltip. + --- ## 2. Results Page — Three-Column Layout @@ -59,36 +75,71 @@ Redesign the kafka frontend to match Brave Search's clean, functional aesthetic │ ┌─────────┐ ┌────────────────────────────┐ ┌──────────┐│ │ │ Nav │ │ 🔍 [ Search Input ] │ │ Related ││ │ │ ─────── │ └────────────────────────────┘ │ Searches ││ -│ │ All │ │ │ ││ +│ │ All │ About 1,240 results (0.42s) │ ││ │ │ Images │ ┌──────────────────────────┐ │ │ ─────── ││ │ │ Videos │ │ Result Card │ │ │ Suggestions│ │ │ News │ │ Title, URL, Description │ │ │ ││ -│ │ Maps │ └──────────────────────────┘ │ │ ││ -│ │ ... │ ┌──────────────────────────┐ │ └──────────┘│ -│ │ │ │ Result Card │ │ │ -│ │ ─────── │ │ ... │ │ │ -│ │ Filters │ └──────────────────────────┘ │ │ -│ │ Time │ ... │ │ -│ │ Type │ │ │ -│ └─────────┘ [Pagination] │ │ +│ │ Maps │ └──────────────────────────┘ │ └──────────┘│ +│ │ Shopping│ ┌──────────────────────────┐ │ │ +│ │ ... │ │ Result Card │ │ │ +│ │ │ │ ... │ │ │ +│ │ ─────── │ └──────────────────────────┘ │ │ +│ │ Filters │ ... │ │ +│ │ Time │ │ │ +│ │ Type │ [Pagination] │ │ +│ └─────────┘ │ │ └─────────────────────────────────────────────────────────────┘ ``` ### Implementation - **Files:** `internal/views/templates/results.html`, `internal/views/templates/base.html` -- **Left Sidebar (desktop):** - - Category navigation links (All, Images, Videos, News, Maps, Shopping) +- **Left Sidebar (desktop, sticky):** + - Category navigation links (All, Images, Videos, News, Maps, Shopping, Music, Weather) - Filters section (Time range, Result type) — collapsible - - Hidden on mobile + - Hidden on mobile (< 768px) + - **Center Column:** - Compact search bar - - Results count meta - - Result cards (unchanged markup, restyled if needed) + - Results count meta: "About {n} results ({time}s)" + - Result cards (unchanged markup) - Pagination + - **Right Sidebar:** - Related searches (existing suggestions) - Additional panels as needed -- **CSS:** Use `display: grid` with three columns on desktop, collapse to single column on mobile + +### Filters +**Time Range Options:** +| Label | Query Param | +|-------|-------------| +| Any time | (none) | +| Past hour | `&time=h` | +| Past 24 hours | `&time=d` | +| Past week | `&time=w` | +| Past month | `&time=m` | +| Past year | `&time=y` | + +**Result Type Options:** +| Label | Query Param | +|-------|-------------| +| All results | (none) | +| News | `&type=news` | +| Videos | `&type=video` | +| Images | `&type=image` | + +Filter state persists in URL query params and is preserved across HTMX navigation via `hx-include`. + +### Mobile Behavior +| Breakpoint | Layout | +|------------|--------| +| < 768px | Single column, no left sidebar | +| 768px - 1024px | Two columns (center + right sidebar), no left nav | +| > 1024px | Full three columns | + +On mobile (< 768px): +- Category filters accessible via a horizontal scrollable chip row above results +- Both sidebars hidden +- Search bar full-width --- @@ -105,7 +156,7 @@ Redesign the kafka frontend to match Brave Search's clean, functional aesthetic │ [Logo] [⚙ Preferences]│ ├─────────────────────────────────────────────────────────────┤ │ ┌────────────────┐ ┌─────────────────────────────────────┐│ -│ │ Sidebar │ │ Content ││ +│ │ Nav │ │ Content ││ │ │ ───────────── │ │ ││ │ │ Search │ │ [Section Content] ││ │ │ Privacy │ │ ││ @@ -132,10 +183,14 @@ Redesign the kafka frontend to match Brave Search's clean, functional aesthetic ### Implementation - **Route:** Add `GET /preferences` and `POST /preferences` to `internal/httpapi/` - **Template:** `internal/views/templates/preferences.html` -- **Quick Settings Popover:** Keep existing popover for theme toggle and engine toggles only (lightweight, localStorage) -- **Full Preferences Page:** Server-rendered, form POST saves to localStorage, reads on load +- **Storage:** localStorage-only. GET handler renders page shell, JavaScript populates form values from localStorage. POST handler receives form data, writes to localStorage, re-renders page. +- **Quick Settings Popover:** Keep existing popover for **theme toggle and engine toggles only** (lightweight, localStorage). SafeSearch and Format settings move exclusively to full preferences page. - **Styling:** Match existing design tokens, section headers, form controls +### Preferences Nav (Mobile) +- Horizontal scrollable nav on mobile (< 768px) +- Active section highlighted + --- ## 4. Component Changes @@ -157,14 +212,14 @@ Redesign the kafka frontend to match Brave Search's clean, functional aesthetic - Hover: slight scale + shadow ### Left Sidebar (Results Page) -- Sticky positioning +- Sticky positioning (`position: sticky; top: calc(var(--header-height) + 1rem)`) - Category links with active state indicator - Collapsible filter sections -### Preferences Sidebar +### Preferences Nav - Vertical nav with section icons - Active state indicator -- Mobile: horizontal scroll or accordion +- Mobile: horizontal scroll --- @@ -176,30 +231,66 @@ Redesign the kafka frontend to match Brave Search's clean, functional aesthetic - Dark mode via `[data-theme="dark"]` ### New -- Layout Grid for three-column results: - ```css + +**Layout Grid for three-column results:** +```css +.results-layout { + display: grid; + grid-template-columns: 200px 1fr 240px; + gap: 2rem; + align-items: start; +} +``` + +**Sticky Left Sidebar:** +```css +.results-layout .left-sidebar { + position: sticky; + top: calc(var(--header-height) + 1.5rem); + max-height: calc(100vh - var(--header-height) - 3rem); + overflow-y: auto; +} +``` + +**Preferences page layout:** +```css +.preferences-layout { + display: grid; + grid-template-columns: 200px 1fr; + gap: 2rem; +} +``` + +**Category tiles grid:** +```css +.category-tiles { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(100px, 1fr)); + gap: 1rem; +} +``` + +**Mobile breakpoints:** +```css +@media (max-width: 768px) { .results-layout { - display: grid; - grid-template-columns: 200px 1fr 240px; - gap: 2rem; + grid-template-columns: 1fr; } - ``` -- Preferences page layout: - ```css - .preferences-layout { - display: grid; - grid-template-columns: 200px 1fr; - gap: 2rem; + .results-layout .left-sidebar, + .results-layout .right-sidebar { + display: none; } - ``` -- Category tiles grid: - ```css - .category-tiles { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(100px, 1fr)); - gap: 1rem; +} + +@media (min-width: 769px) and (max-width: 1024px) { + .results-layout { + grid-template-columns: 1fr 220px; } - ``` + .results-layout .left-sidebar { + display: none; + } +} +``` --- @@ -211,8 +302,8 @@ Redesign the kafka frontend to match Brave Search's clean, functional aesthetic | `internal/views/templates/results.html` | Add left sidebar, restructure for three columns | | `internal/views/templates/base.html` | Minimal changes (no structural changes needed) | | `internal/views/templates/preferences.html` | **New** — full preferences page | -| `internal/views/static/css/kafka.css` | Add layout grids, category tiles, sidebar styles | -| `internal/views/static/js/settings.js` | Keep popover for quick settings, update for preferences page | +| `internal/views/static/css/kafka.css` | Add layout grids, category tiles, sidebar styles, sticky positioning, mobile breakpoints | +| `internal/views/static/js/settings.js` | Reduce popover to theme + engines; add preferences page JS | | `internal/httpapi/httpapi.go` | Add `/preferences` route (GET + POST) | | `internal/views/views.go` | Add preferences template rendering | @@ -220,7 +311,7 @@ Redesign the kafka frontend to match Brave Search's clean, functional aesthetic ## 7. Priority Order -1. **Phase 1:** CSS layout framework (three-column grid, new variables) +1. **Phase 1:** CSS layout framework (three-column grid, new variables, breakpoints) 2. **Phase 2:** Results page three-column layout 3. **Phase 3:** Homepage category tiles 4. **Phase 4:** Preferences page (quick popover first, then full page) @@ -231,6 +322,7 @@ Redesign the kafka frontend to match Brave Search's clean, functional aesthetic ## Out of Scope - Backend search logic changes -- New engine implementations +- New engine implementations (category tiles for future engines are UI placeholders only) - Caching or performance improvements - User authentication/account system +- Server-side preference storage From b005e2140ef9b12de6d81b9a14893b963351d033 Mon Sep 17 00:00:00 2001 From: ashisgreat22 Date: Sun, 22 Mar 2026 13:17:46 +0100 Subject: [PATCH 48/91] docs: add Brave Search frontend redesign implementation plan Co-Authored-By: Claude Opus 4.6 --- ...26-03-22-brave-search-frontend-redesign.md | 1204 +++++++++++++++++ 1 file changed, 1204 insertions(+) create mode 100644 docs/superpowers/plans/2026-03-22-brave-search-frontend-redesign.md diff --git a/docs/superpowers/plans/2026-03-22-brave-search-frontend-redesign.md b/docs/superpowers/plans/2026-03-22-brave-search-frontend-redesign.md new file mode 100644 index 0000000..486cee4 --- /dev/null +++ b/docs/superpowers/plans/2026-03-22-brave-search-frontend-redesign.md @@ -0,0 +1,1204 @@ +# Brave Search Frontend Redesign — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Redesign the kafka frontend to match Brave Search's layout: three-column results page, category tiles on homepage, and a hybrid preferences system with full-page `/preferences` route. + +**Architecture:** CSS Grid for page-level layouts (three-column results, two-column preferences). JavaScript popover for quick settings (theme + engines only). Server-rendered full preferences page with localStorage persistence. Category tiles are static links with category query params. + +**Tech Stack:** Go (Go templates), CSS Grid/Flexbox, Vanilla JavaScript (HTMX for search), localStorage for preferences + +--- + +## File Map + +| File | Responsibility | +|------|----------------| +| `internal/views/static/css/kafka.css` | Add layout grids, category tiles, sidebar styles, mobile breakpoints | +| `internal/views/templates/index.html` | Add category tiles below search box | +| `internal/views/templates/results.html` | Add left sidebar, restructure for three-column grid | +| `internal/views/templates/preferences.html` | **New** — full preferences page with nav | +| `internal/views/templates/base.html` | No structural changes needed | +| `internal/views/static/js/settings.js` | Reduce popover to theme + engines; add preferences page JS | +| `internal/httpapi/handlers.go` | Add `GET /preferences` and `POST /preferences` handlers | +| `internal/views/views.go` | Add `RenderPreferences` and `tmplPreferences` template | + +--- + +## PHASE 1: CSS Layout Framework + +### Task 1: Add CSS Grid Layouts and Breakpoints + +**Files:** +- Modify: `internal/views/static/css/kafka.css` + +- [ ] **Step 1: Add three-column results layout CSS** + +Append to end of `kafka.css`, before the `@media print` block: + +```css +/* ============================================================ + Three-Column Results Layout + ============================================================ */ + +.results-layout { + display: grid; + grid-template-columns: 200px 1fr 240px; + gap: 2rem; + align-items: start; +} + +.results-layout .left-sidebar { + position: sticky; + top: calc(var(--header-height) + 1.5rem); + max-height: calc(100vh - var(--header-height) - 3rem); + overflow-y: auto; +} + +.results-layout .right-sidebar { + position: sticky; + top: calc(var(--header-height) + 1.5rem); + max-height: calc(100vh - var(--header-height) - 3rem); + overflow-y: auto; +} + +.results-layout .results-column { + min-width: 0; +} +``` + +- [ ] **Step 2: Add mobile breakpoints** + +```css +/* Tablet: hide left sidebar, two columns */ +@media (min-width: 769px) and (max-width: 1024px) { + .results-layout { + grid-template-columns: 1fr 220px; + } + .results-layout .left-sidebar { + display: none; + } +} + +/* Mobile: single column, no sidebars */ +@media (max-width: 768px) { + .results-layout { + grid-template-columns: 1fr; + } + .results-layout .left-sidebar, + .results-layout .right-sidebar { + display: none; + } +} +``` + +- [ ] **Step 3: Add preferences page layout CSS** + +```css +/* ============================================================ + Preferences Page Layout + ============================================================ */ + +.preferences-layout { + display: grid; + grid-template-columns: 200px 1fr; + gap: 2rem; + align-items: start; + padding: 2rem 0; +} + +.preferences-nav { + position: sticky; + top: calc(var(--header-height) + 1.5rem); +} + +.preferences-nav-item { + display: flex; + align-items: center; + gap: 0.6rem; + padding: 0.6rem 0.75rem; + border-radius: var(--radius-sm); + color: var(--text-secondary); + text-decoration: none; + font-size: 0.9rem; + transition: background 0.15s, color 0.15s; + cursor: pointer; +} + +.preferences-nav-item:hover { + background: var(--bg-tertiary); + color: var(--text-primary); +} + +.preferences-nav-item.active { + background: var(--accent-soft); + color: var(--accent); + font-weight: 500; +} + +.preferences-content { + background: var(--bg); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + padding: 1.5rem; +} + +@media (max-width: 768px) { + .preferences-layout { + grid-template-columns: 1fr; + } + .preferences-nav { + position: static; + display: flex; + overflow-x: auto; + gap: 0.5rem; + padding-bottom: 0.5rem; + } + .preferences-nav-item { + white-space: nowrap; + } +} +``` + +- [ ] **Step 4: Add category tiles CSS** + +```css +/* ============================================================ + Category Tiles + ============================================================ */ + +.category-tiles { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(100px, 1fr)); + gap: 1rem; + margin-top: 2rem; +} + +.category-tile { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.5rem; + padding: 1rem 0.5rem; + border-radius: var(--radius-md); + text-decoration: none; + color: var(--text-secondary); + font-size: 0.85rem; + transition: background 0.15s, color 0.15s, transform 0.15s, box-shadow 0.15s; +} + +.category-tile:hover { + background: var(--bg-tertiary); + color: var(--text-primary); + transform: translateY(-2px); + box-shadow: var(--shadow-sm); +} + +.category-tile-icon { + font-size: 1.5rem; + line-height: 1; +} + +.category-tile.disabled { + opacity: 0.5; + cursor: not-allowed; + pointer-events: none; +} + +@media (max-width: 768px) { + .category-tiles { + grid-template-columns: repeat(auto-fill, minmax(80px, 1fr)); + gap: 0.75rem; + } + .category-tile { + padding: 0.75rem 0.25rem; + font-size: 0.75rem; + } + .category-tile-icon { + font-size: 1.25rem; + } +} +``` + +- [ ] **Step 5: Add left sidebar navigation styles** + +```css +/* ============================================================ + Left Sidebar (Results Page) + ============================================================ */ + +.left-sidebar { + padding: 0; +} + +.sidebar-nav { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.sidebar-nav-title { + font-size: 0.7rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--text-muted); + padding: 0.5rem 0.75rem; + margin-top: 0.5rem; +} + +.sidebar-nav-item { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 0.75rem; + border-radius: var(--radius-sm); + color: var(--text-secondary); + text-decoration: none; + font-size: 0.875rem; + transition: background 0.15s, color 0.15s; +} + +.sidebar-nav-item:hover { + background: var(--bg-tertiary); + color: var(--text-primary); +} + +.sidebar-nav-item.active { + background: var(--accent-soft); + color: var(--accent); + font-weight: 500; +} + +.sidebar-nav-item-icon { + font-size: 1rem; + width: 20px; + text-align: center; +} + +.sidebar-filters { + margin-top: 1rem; + padding-top: 1rem; + border-top: 1px solid var(--border); +} + +.sidebar-filter-group { + margin-bottom: 0.75rem; +} + +.sidebar-filter-label { + font-size: 0.75rem; + font-weight: 500; + color: var(--text-muted); + padding: 0 0.75rem; + margin-bottom: 0.25rem; +} + +.sidebar-filter-option { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.35rem 0.75rem; + font-size: 0.8rem; + color: var(--text-secondary); + cursor: pointer; + border-radius: var(--radius-sm); + transition: background 0.15s; +} + +.sidebar-filter-option:hover { + background: var(--bg-tertiary); +} + +.sidebar-filter-option input[type="radio"] { + accent-color: var(--accent); +} + +/* Mobile filter chips */ +.mobile-filter-chips { + display: none; + overflow-x: auto; + gap: 0.5rem; + padding: 0.75rem 0; + -webkit-overflow-scrolling: touch; +} + +.mobile-filter-chips::-webkit-scrollbar { + display: none; +} + +.mobile-filter-chip { + display: inline-flex; + align-items: center; + padding: 0.4rem 0.75rem; + border: 1px solid var(--border); + border-radius: var(--radius-full); + font-size: 0.8rem; + color: var(--text-secondary); + white-space: nowrap; + text-decoration: none; + transition: background 0.15s, border-color 0.15s; +} + +.mobile-filter-chip:hover, +.mobile-filter-chip.active { + background: var(--accent-soft); + border-color: var(--accent); + color: var(--accent); +} + +@media (max-width: 768px) { + .mobile-filter-chips { + display: flex; + } +} +``` + +- [ ] **Step 6: Verify CSS changes compile** + +Run: `go build ./...` +Expected: No errors + +- [ ] **Step 7: Commit** + +```bash +git add internal/views/static/css/kafka.css +git commit -m "feat(frontend): add CSS layout framework for three-column results and preferences page" +``` + +--- + +## PHASE 2: Results Page Three-Column Layout + +### Task 2: Restructure Results Template + +**Files:** +- Modify: `internal/views/templates/results.html` +- Modify: `internal/views/views.go` + +- [ ] **Step 1: Read current results.html to understand exact content** + +Current structure has `.results-layout` grid with `.search-compact` spanning full width, `.results-column`, and `.sidebar`. Need to add left sidebar and restructure grid. + +- [ ] **Step 2: Replace results.html content** + +Replace the entire file content: + +```html +{{define "title"}}{{if .Query}}{{.Query}} — {{end}}{{end}} +{{define "content"}} +
+ + + + +
+ +
+ +
+ + +
+ All + {{range .Categories}} + {{.}} + {{end}} +
+ + + {{template "results_inner" .}} +
+ + + +
+{{end}} +``` + +- [ ] **Step 3: Update PageData struct to include new fields** + +Modify `internal/views/views.go` — add to `PageData` struct: + +```go +type PageData struct { + // ... existing fields ... + + // New fields for three-column layout + Categories []string + CategoryIcons map[string]string + ActiveCategory string + TimeFilters []FilterOption + TypeFilters []FilterOption + ActiveTime string + ActiveType string +} + +// FilterOption represents a filter radio option +type FilterOption struct { + Label string + Value string +} +``` + +- [ ] **Step 4: Update FromResponse to populate new fields** + +In `views.go`, update `FromResponse` to populate the new fields: + +```go +func FromResponse(resp contracts.SearchResponse, query string, pageno int) PageData { + pd := PageData{ + // ... existing initialization ... + + // New: categories with icons + Categories: []string{"all", "news", "images", "videos", "maps", "shopping", "music", "weather"}, + CategoryIcons: map[string]string{ + "all": "🌐", + "news": "📰", + "images": "🖼️", + "videos": "🎬", + "maps": "🗺️", + "shopping": "🛒", + "music": "🎵", + "weather": "🌤️", + }, + ActiveCategory: "all", + + // Time filters + TimeFilters: []FilterOption{ + {Label: "Any time", Value: ""}, + {Label: "Past hour", Value: "h"}, + {Label: "Past 24 hours", Value: "d"}, + {Label: "Past week", Value: "w"}, + {Label: "Past month", Value: "m"}, + {Label: "Past year", Value: "y"}, + }, + ActiveTime: "", + + // Type filters + TypeFilters: []FilterOption{ + {Label: "All results", Value: ""}, + {Label: "News", Value: "news"}, + {Label: "Videos", Value: "video"}, + {Label: "Images", Value: "image"}, + }, + ActiveType: "", + } + // ... rest of function ... +} +``` + +- [ ] **Step 5: Register new preferences template** + +In `views.go`, add to the `init()` function and add `tmplPreferences`: + +```go +var ( + tmplFull *template.Template + tmplIndex *template.Template + tmplFragment *template.Template + tmplPreferences *template.Template +) + +func init() { + tmplFS, _ := fs.Sub(templatesFS, "templates") + + funcMap := template.FuncMap{ + "urlquery": template.URLQueryEscaper, + } + + tmplFull = template.Must(template.New("").Funcs(funcMap).ParseFS(tmplFS, + "base.html", "results.html", "results_inner.html", "result_item.html", "video_item.html", + )) + tmplIndex = template.Must(template.New("").Funcs(funcMap).ParseFS(tmplFS, + "base.html", "index.html", + )) + tmplFragment = template.Must(template.New("").Funcs(funcMap).ParseFS(tmplFS, + "results_inner.html", "result_item.html", "video_item.html", + )) + tmplPreferences = template.Must(template.New("").Funcs(funcMap).ParseFS(tmplFS, + "base.html", "preferences.html", + )) +} +``` + +- [ ] **Step 6: Add RenderPreferences function** + +Add to `views.go`: + +```go +// RenderPreferences renders the full preferences page. +func RenderPreferences(w http.ResponseWriter, sourceURL string) error { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + return tmplPreferences.ExecuteTemplate(w, "base", PageData{ShowHeader: true, SourceURL: sourceURL}) +} +``` + +- [ ] **Step 7: Test compilation** + +Run: `go build ./...` +Expected: No errors + +- [ ] **Step 8: Commit** + +```bash +git add internal/views/views.go internal/views/templates/results.html +git commit -m "feat(frontend): add three-column results layout with left sidebar navigation" +``` + +--- + +## PHASE 3: Homepage Category Tiles + +### Task 3: Add Category Tiles to Homepage + +**Files:** +- Modify: `internal/views/templates/index.html` + +- [ ] **Step 1: Read current index.html** + +- [ ] **Step 2: Replace index.html with tiles** + +```html +{{define "title"}}{{end}} +{{define "content"}} +
+ +

Search the web privately, without tracking or censorship.

+ + + + +
+
+{{end}} +``` + +- [ ] **Step 3: Test compilation** + +Run: `go build ./...` +Expected: No errors + +- [ ] **Step 4: Commit** + +```bash +git add internal/views/templates/index.html +git commit -m "feat(frontend): add category tiles to homepage" +``` + +--- + +## PHASE 4: Preferences Page + +### Task 4: Create Preferences Template + +**Files:** +- Create: `internal/views/templates/preferences.html` + +- [ ] **Step 1: Create preferences.html** + +```html +{{define "title"}}Preferences{{end}} +{{define "content"}} +
+ + + + +
+ + + + + + + + + + + + + + + + + + + + + + + +
+
+{{end}} +``` + +- [ ] **Step 2: Add preferences section CSS styles** + +Append to `kafka.css`: + +```css +/* ============================================================ + Preferences Page Styles + ============================================================ */ + +.pref-section { + margin-bottom: 2rem; +} + +.pref-section:last-child { + margin-bottom: 0; +} + +.pref-section-title { + font-size: 1rem; + font-weight: 600; + color: var(--text-primary); + margin-bottom: 1rem; + padding-bottom: 0.5rem; + border-bottom: 1px solid var(--border); +} + +.pref-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + padding: 0.75rem 0; + border-bottom: 1px solid var(--border); +} + +.pref-row:last-child { + border-bottom: none; +} + +.pref-row label { + font-size: 0.9rem; + color: var(--text-primary); +} + +.pref-row-info { + flex: 1; +} + +.pref-row-info label { + font-weight: 500; +} + +.pref-desc { + font-size: 0.8rem; + color: var(--text-muted); + margin-top: 0.25rem; +} + +.pref-row select { + padding: 0.5rem 0.75rem; + font-size: 0.85rem; + font-family: inherit; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--bg); + color: var(--text-primary); + cursor: pointer; + min-width: 150px; +} + +.pref-row select:focus { + outline: none; + border-color: var(--accent); +} + +.pref-row input[type="checkbox"] { + width: 18px; + height: 18px; + accent-color: var(--accent); + cursor: pointer; + flex-shrink: 0; +} + +.pref-row input[type="checkbox"]:disabled { + opacity: 0.6; + cursor: not-allowed; +} +``` + +- [ ] **Step 3: Commit** + +```bash +git add internal/views/templates/preferences.html internal/views/static/css/kafka.css +git commit -m "feat(frontend): add preferences page template and styles" +``` + +--- + +### Task 5: Add Preferences Route + +**Files:** +- Modify: `internal/httpapi/handlers.go` +- Modify: `cmd/kafka/main.go` + +- [ ] **Step 1: Add GET and POST handlers for /preferences** + +Add to `handlers.go`: + +```go +// Preferences renders the preferences page. +func (h *Handler) Preferences(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/preferences" { + http.NotFound(w, r) + return + } + if err := views.RenderPreferences(w, h.sourceURL); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } +} + +// PreferencesPOST handles form submission from the preferences page. +func (h *Handler) PreferencesPOST(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/preferences" { + http.NotFound(w, r) + return + } + // Preferences are stored in localStorage on the client. + // This handler exists for form submission completeness but + // the actual save happens via JavaScript. + // Redirect back to preferences page. + http.Redirect(w, r, "/preferences", http.StatusFound) +} +``` + +- [ ] **Step 2: Register the route in main** + +Find where routes are registered (likely in `cmd/kafka/main.go`) and add: + +```go +mux.HandleFunc("GET /preferences", handler.Preferences) +mux.HandleFunc("POST /preferences", handler.PreferencesPOST) +``` + +- [ ] **Step 3: Test compilation** + +Run: `go build ./...` +Expected: No errors + +- [ ] **Step 4: Commit** + +```bash +git add internal/httpapi/handlers.go cmd/kafka/main.go +git commit -m "feat: add GET and POST /preferences route" +``` + +--- + +### Task 6: Update Settings JavaScript + +**Files:** +- Modify: `internal/views/static/js/settings.js` + +- [ ] **Step 1: Reduce popover to theme + engines only** + +Update the `renderPanel` function to remove SafeSearch and Format options. Keep only theme buttons and engine toggles. + +- [ ] **Step 2: Add preferences page navigation JavaScript** + +Add to end of `settings.js`: + +```javascript +// Preferences page navigation +function initPreferences() { + var nav = document.getElementById('preferences-nav'); + if (!nav) return; + + var sections = document.querySelectorAll('.pref-section'); + var navItems = nav.querySelectorAll('.preferences-nav-item'); + + function showSection(id) { + sections.forEach(function(sec) { + sec.style.display = sec.id === 'section-' + id ? 'block' : 'none'; + }); + navItems.forEach(function(item) { + item.classList.toggle('active', item.getAttribute('data-section') === id); + }); + } + + navItems.forEach(function(item) { + item.addEventListener('click', function() { + showSection(item.getAttribute('data-section')); + }); + }); + + // Load saved preferences + var prefs = loadPrefs(); + var themeEl = document.getElementById('pref-theme'); + if (themeEl) themeEl.value = prefs.theme || 'system'; + + var ssEl = document.getElementById('pref-safesearch'); + if (ssEl) ssEl.value = prefs.safeSearch || 'moderate'; + + var fmtEl = document.getElementById('pref-format'); + if (fmtEl) fmtEl.value = prefs.format || 'html'; + + // Save handlers + if (themeEl) { + themeEl.addEventListener('change', function() { + prefs.theme = themeEl.value; + savePrefs(prefs); + applyTheme(prefs.theme); + }); + } + + if (ssEl) { + ssEl.addEventListener('change', function() { + prefs.safeSearch = ssEl.value; + savePrefs(prefs); + }); + } + + if (fmtEl) { + fmtEl.addEventListener('change', function() { + prefs.format = fmtEl.value; + savePrefs(prefs); + }); + } + + // Show first section by default + showSection('search'); +} + +document.addEventListener('DOMContentLoaded', initPreferences); +``` + +- [ ] **Step 3: Test with browser** + +Manual verification needed — cannot test browser JS with `go build` + +- [ ] **Step 4: Commit** + +```bash +git add internal/views/static/js/settings.js +git commit -m "feat(frontend): reduce popover to theme+engines, add preferences page JS" +``` + +--- + +## PHASE 5: Polish and Mobile Responsiveness + +### Task 7: Mobile Filter Chips Integration + +**Files:** +- Modify: `internal/views/templates/results.html` + +- [ ] **Step 1: Ensure mobile filter chips have working category links** + +The current results.html has mobile filter chips with category links. These should preserve existing query params for pagination/HTMX navigation. + +- [ ] **Step 2: Add filter form submission via HTMX** + +Update the filter radio buttons to submit via HTMX when changed. + +- [ ] **Step 3: Commit** + +```bash +git add internal/views/templates/results.html +git commit -m "fix(frontend): add HTMX filter submission" +``` + +--- + +### Task 8: Final Mobile Responsiveness Audit + +**Files:** +- Review: `internal/views/static/css/kafka.css` + +- [ ] **Step 1: Test all breakpoints manually** + +- [ ] **Step 2: Fix any layout issues found** + +- [ ] **Step 3: Commit any fixes** + +```bash +git add internal/views/static/css/kafka.css +git commit -m "fix(frontend): improve mobile responsiveness" +``` + +--- + +## Summary + +| Phase | Task | Files | +|-------|------|-------| +| 1 | CSS Layout Framework | `kafka.css` | +| 2 | Results Three-Column | `results.html`, `views.go` | +| 3 | Homepage Tiles | `index.html` | +| 4 | Preferences Page | `preferences.html` (new), `handlers.go`, `settings.js` | +| 5 | Polish | Various | + +**Total: 8 tasks across 5 phases** + +Run `go test ./...` after each phase to verify nothing is broken. From 19f5c89053518d2243ec7a022543a1d33cd7cc62 Mon Sep 17 00:00:00 2001 From: Franz Kafka Date: Sun, 22 Mar 2026 12:18:38 +0000 Subject: [PATCH 49/91] fix: upgrade x/net to v0.38.0 (resolves Dependabot XSS alert) --- go.mod | 3 +++ 1 file changed, 3 insertions(+) diff --git a/go.mod b/go.mod index 6981818..e562df5 100644 --- a/go.mod +++ b/go.mod @@ -15,3 +15,6 @@ require ( go.uber.org/atomic v1.11.0 // indirect golang.org/x/net v0.33.0 // indirect ) + +replace golang.org/x/net => golang.org/x/net v0.38.0 +) From 8909654c8fa69805072dfd994d7f441e84cbd872 Mon Sep 17 00:00:00 2001 From: ashisgreat22 Date: Sun, 22 Mar 2026 13:21:31 +0100 Subject: [PATCH 50/91] docs: fix implementation plan issues from review - Move template registration from Phase 2 to Phase 4 (was causing build failure) - Add filter params (activeCategory, activeTime, activeType) to FromResponse - Add DisabledCategories to PageData for backend-unsupported categories - Add disabled class to sidebar for future categories - Clarify POST handler is a no-op for localStorage-only preferences - Note CSS must be tested manually in browser Co-Authored-By: Claude Opus 4.6 --- ...26-03-22-brave-search-frontend-redesign.md | 99 +++++++++---------- 1 file changed, 44 insertions(+), 55 deletions(-) diff --git a/docs/superpowers/plans/2026-03-22-brave-search-frontend-redesign.md b/docs/superpowers/plans/2026-03-22-brave-search-frontend-redesign.md index 486cee4..a7f1b62 100644 --- a/docs/superpowers/plans/2026-03-22-brave-search-frontend-redesign.md +++ b/docs/superpowers/plans/2026-03-22-brave-search-frontend-redesign.md @@ -354,11 +354,13 @@ Append to end of `kafka.css`, before the `@media print` block: } ``` -- [ ] **Step 6: Verify CSS changes compile** +- [ ] **Step 6: Verify Go compilation** Run: `go build ./...` Expected: No errors +Note: CSS is embedded as static files and not processed by the Go compiler. CSS changes must be tested manually in a browser. + - [ ] **Step 7: Commit** ```bash @@ -501,17 +503,28 @@ type FilterOption struct { } ``` -- [ ] **Step 4: Update FromResponse to populate new fields** +- [ ] **Step 4: Update FromResponse to accept filter params** -In `views.go`, update `FromResponse` to populate the new fields: +Update `FromResponse` signature to accept `activeCategory`, `activeTime`, and `activeType` from the request. First update the `Search` handler in `handlers.go` to pass these params: ```go -func FromResponse(resp contracts.SearchResponse, query string, pageno int) PageData { +// In handlers.go, update Search handler: +pd := views.FromResponse(resp, req.Query, req.Pageno) +pd.ActiveCategory = r.FormValue("category") +pd.ActiveTime = r.FormValue("time") +pd.ActiveType = r.FormValue("type") +``` + +Then update `FromResponse` in `views.go` to accept these params: + +```go +func FromResponse(resp contracts.SearchResponse, query string, pageno int, activeCategory, activeTime, activeType string) PageData { pd := PageData{ // ... existing initialization ... // New: categories with icons - Categories: []string{"all", "news", "images", "videos", "maps", "shopping", "music", "weather"}, + Categories: []string{"all", "news", "images", "videos", "maps"}, + DisabledCategories: []string{"shopping", "music", "weather"}, CategoryIcons: map[string]string{ "all": "🌐", "news": "📰", @@ -522,7 +535,8 @@ func FromResponse(resp contracts.SearchResponse, query string, pageno int) PageD "music": "🎵", "weather": "🌤️", }, - ActiveCategory: "all", + ActiveCategory: activeCategory, + if activeCategory == "" { activeCategory = "all" } // Time filters TimeFilters: []FilterOption{ @@ -533,7 +547,7 @@ func FromResponse(resp contracts.SearchResponse, query string, pageno int) PageD {Label: "Past month", Value: "m"}, {Label: "Past year", Value: "y"}, }, - ActiveTime: "", + ActiveTime: activeTime, // Type filters TypeFilters: []FilterOption{ @@ -542,64 +556,40 @@ func FromResponse(resp contracts.SearchResponse, query string, pageno int) PageD {Label: "Videos", Value: "video"}, {Label: "Images", Value: "image"}, }, - ActiveType: "", + ActiveType: activeType, } // ... rest of function ... } ``` -- [ ] **Step 5: Register new preferences template** +Add `DisabledCategories []string` field to `PageData`. -In `views.go`, add to the `init()` function and add `tmplPreferences`: +- [ ] **Step 5: Update results.html sidebar to show disabled state** -```go -var ( - tmplFull *template.Template - tmplIndex *template.Template - tmplFragment *template.Template - tmplPreferences *template.Template -) +Update the sidebar category loop to conditionally apply `disabled` class: -func init() { - tmplFS, _ := fs.Sub(templatesFS, "templates") - - funcMap := template.FuncMap{ - "urlquery": template.URLQueryEscaper, - } - - tmplFull = template.Must(template.New("").Funcs(funcMap).ParseFS(tmplFS, - "base.html", "results.html", "results_inner.html", "result_item.html", "video_item.html", - )) - tmplIndex = template.Must(template.New("").Funcs(funcMap).ParseFS(tmplFS, - "base.html", "index.html", - )) - tmplFragment = template.Must(template.New("").Funcs(funcMap).ParseFS(tmplFS, - "results_inner.html", "result_item.html", "video_item.html", - )) - tmplPreferences = template.Must(template.New("").Funcs(funcMap).ParseFS(tmplFS, - "base.html", "preferences.html", - )) -} +```html +{{range .Categories}} + + {{index $.CategoryIcons .}} + {{.}} + +{{end}} + +{{range .DisabledCategories}} + + {{index $.CategoryIcons .}} + {{.}} + +{{end}} ``` -- [ ] **Step 6: Add RenderPreferences function** - -Add to `views.go`: - -```go -// RenderPreferences renders the full preferences page. -func RenderPreferences(w http.ResponseWriter, sourceURL string) error { - w.Header().Set("Content-Type", "text/html; charset=utf-8") - return tmplPreferences.ExecuteTemplate(w, "base", PageData{ShowHeader: true, SourceURL: sourceURL}) -} -``` - -- [ ] **Step 7: Test compilation** +- [ ] **Step 6: Test compilation** Run: `go build ./...` Expected: No errors -- [ ] **Step 8: Commit** +- [ ] **Step 7: Commit** ```bash git add internal/views/views.go internal/views/templates/results.html @@ -1020,15 +1010,14 @@ func (h *Handler) Preferences(w http.ResponseWriter, r *http.Request) { } // PreferencesPOST handles form submission from the preferences page. +// NOTE: This is a no-op. All preferences are stored in localStorage on the client +// via JavaScript. This handler exists only for form submission completeness (e.g., +// if a form POSTs without JS). The JavaScript in settings.js handles all saves. func (h *Handler) PreferencesPOST(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/preferences" { http.NotFound(w, r) return } - // Preferences are stored in localStorage on the client. - // This handler exists for form submission completeness but - // the actual save happens via JavaScript. - // Redirect back to preferences page. http.Redirect(w, r, "/preferences", http.StatusFound) } ``` From d21e9189b8217ebfcbc1c2a3d23bfd1fe3df37ac Mon Sep 17 00:00:00 2001 From: ashisgreat22 Date: Sun, 22 Mar 2026 13:22:52 +0100 Subject: [PATCH 51/91] fix(engines): validate Wikipedia language codes to prevent SSRF Wikipedia language subdomain was derived from user input without validation, allowing attackers to redirect requests via malicious language values like "evil.com.attacker.com". Added a whitelist of valid Wikipedia language codes to prevent this. Co-Authored-By: Claude Opus 4.6 --- internal/engines/wikipedia.go | 43 +++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/internal/engines/wikipedia.go b/internal/engines/wikipedia.go index bbf1222..f29ff74 100644 --- a/internal/engines/wikipedia.go +++ b/internal/engines/wikipedia.go @@ -33,6 +33,44 @@ type WikipediaEngine struct { client *http.Client } +// validWikipediaLangs contains the set of valid Wikipedia language codes. +// This prevents SSRF attacks where an attacker could use a malicious language +// value to redirect requests to an attacker-controlled domain. +var validWikipediaLangs = map[string]struct{}{ + "aa": {}, "ab": {}, "ae": {}, "af": {}, "ak": {}, "am": {}, "an": {}, + "ar": {}, "arc": {}, "as": {}, "ast": {}, "at": {}, "av": {}, "ay": {}, + "az": {}, "ba": {}, "be": {}, "bg": {}, "bh": {}, "bi": {}, "bm": {}, + "bn": {}, "bo": {}, "br": {}, "bs": {}, "ca": {}, "ce": {}, "ch": {}, + "co": {}, "cr": {}, "cs": {}, "cu": {}, "cv": {}, "cy": {}, "da": {}, + "de": {}, "di": {}, "dv": {}, "dz": {}, "ee": {}, "el": {}, "en": {}, + "eo": {}, "es": {}, "et": {}, "eu": {}, "fa": {}, "ff": {}, "fi": {}, + "fj": {}, "fo": {}, "fr": {}, "fy": {}, "ga": {}, "gd": {}, "gl": {}, + "gn": {}, "gu": {}, "gv": {}, "ha": {}, "he": {}, "hi": {}, "ho": {}, + "hr": {}, "ht": {}, "hu": {}, "hy": {}, "hz": {}, "ia": {}, "id": {}, + "ie": {}, "ig": {}, "ii": {}, "ik": {}, "io": {}, "is": {}, "it": {}, + "iu": {}, "ja": {}, "jv": {}, "ka": {}, "kg": {}, "ki": {}, "kj": {}, + "kk": {}, "kl": {}, "km": {}, "kn": {}, "ko": {}, "kr": {}, "ks": {}, + "ku": {}, "kv": {}, "kw": {}, "ky": {}, "la": {}, "lb": {}, "lg": {}, + "li": {}, "lij": {}, "ln": {}, "lo": {}, "lt": {}, "lv": {}, "mg": {}, + "mh": {}, "mi": {}, "mk": {}, "ml": {}, "mn": {}, "mo": {}, "mr": {}, + "ms": {}, "mt": {}, "mus": {}, "my": {}, "na": {}, "nah": {}, "nap": {}, + "nd": {}, "nds": {}, "ne": {}, "new": {}, "ng": {}, "nl": {}, "nn": {}, + "no": {}, "nov": {}, "nrm": {}, "nv": {}, "ny": {}, "oc": {}, "oj": {}, + "om": {}, "or": {}, "os": {}, "pa": {}, "pag": {}, "pam": {}, "pap": {}, + "pdc": {}, "pl": {}, "pms": {}, "pn": {}, "ps": {}, "pt": {}, "qu": {}, + "rm": {}, "rmy": {}, "rn": {}, "ro": {}, "roa-rup": {}, "ru": {}, + "rw": {}, "sa": {}, "sah": {}, "sc": {}, "scn": {}, "sco": {}, "sd": {}, + "se": {}, "sg": {}, "sh": {}, "si": {}, "simple": {}, "sk": {}, "sl": {}, + "sm": {}, "sn": {}, "so": {}, "sq": {}, "sr": {}, "ss": {}, "st": {}, + "su": {}, "sv": {}, "sw": {}, "szl": {}, "ta": {}, "te": {}, "tg": {}, + "th": {}, "ti": {}, "tk": {}, "tl": {}, "tn": {}, "to": {}, "tpi": {}, + "tr": {}, "ts": {}, "tt": {}, "tum": {}, "tw": {}, "ty": {}, "udm": {}, + "ug": {}, "uk": {}, "ur": {}, "uz": {}, "ve": {}, "vec": {}, "vi": {}, + "vls": {}, "vo": {}, "wa": {}, "wo": {}, "xal": {}, "xh": {}, "yi": {}, + "yo": {}, "za": {}, "zea": {}, "zh": {}, "zh-classical": {}, + "zh-min-nan": {}, "zh-yue": {}, "zu": {}, +} + func (e *WikipediaEngine) Name() string { return "wikipedia" } func (e *WikipediaEngine) Search(ctx context.Context, req contracts.SearchRequest) (contracts.SearchResponse, error) { @@ -50,6 +88,11 @@ func (e *WikipediaEngine) Search(ctx context.Context, req contracts.SearchReques // Wikipedia subdomains are based on the language code; keep it simple for MVP. lang = strings.SplitN(lang, "-", 2)[0] lang = strings.ReplaceAll(lang, "_", "-") + // Validate lang against whitelist to prevent SSRF attacks where an attacker + // could use a malicious language value to redirect requests to their server. + if _, ok := validWikipediaLangs[lang]; !ok { + lang = "en" + } wikiNetloc := fmt.Sprintf("%s.wikipedia.org", lang) endpoint := fmt.Sprintf( From 2fae98a336d534e885362d0ec4ec57749fd02d0d Mon Sep 17 00:00:00 2001 From: ashisgreat22 Date: Sun, 22 Mar 2026 13:23:10 +0100 Subject: [PATCH 52/91] fix(go): remove stray parenthesis from go.mod Co-Authored-By: Claude Opus 4.6 --- go.mod | 1 - 1 file changed, 1 deletion(-) diff --git a/go.mod b/go.mod index e562df5..67a0956 100644 --- a/go.mod +++ b/go.mod @@ -17,4 +17,3 @@ require ( ) replace golang.org/x/net => golang.org/x/net v0.38.0 -) From 7bc68db70c06d0dfa3101851f3cd5dbdc4c4aa72 Mon Sep 17 00:00:00 2001 From: ashisgreat22 Date: Sun, 22 Mar 2026 13:23:37 +0100 Subject: [PATCH 53/91] chore(deps): update go.sum after go mod tidy Co-Authored-By: Claude Opus 4.6 --- go.sum | 45 +++++++-------------------------------------- 1 file changed, 7 insertions(+), 38 deletions(-) diff --git a/go.sum b/go.sum index 5700b4c..65bdc02 100644 --- a/go.sum +++ b/go.sum @@ -28,67 +28,36 @@ github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0= github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA= go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= -golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= -golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= -golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= -golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= +golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= -golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= -golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= -golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= -golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= -golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= -golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= -golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= -golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= -golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I= -golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= +golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8= +golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= -golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= -golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= -golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= -golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= -golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= -golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= -golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= -golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= -golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= -golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= -golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= -golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= -golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= -golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= +golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= -golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= From ce92a692f8eb54c83d4f3824a44bbe1da578aa4b Mon Sep 17 00:00:00 2001 From: ashisgreat22 Date: Sun, 22 Mar 2026 13:24:11 +0100 Subject: [PATCH 54/91] docs: fix Go syntax errors in implementation plan - Move if statement outside struct literal in FromResponse - Define FilterOption at package level (not inside function) - Add DisabledCategories to PageData struct - Add defaults handling before struct literal - Update Search handler call with filter params Co-Authored-By: Claude Opus 4.6 --- ...26-03-22-brave-search-frontend-redesign.md | 60 ++++++++++--------- 1 file changed, 32 insertions(+), 28 deletions(-) diff --git a/docs/superpowers/plans/2026-03-22-brave-search-frontend-redesign.md b/docs/superpowers/plans/2026-03-22-brave-search-frontend-redesign.md index a7f1b62..8ff8485 100644 --- a/docs/superpowers/plans/2026-03-22-brave-search-frontend-redesign.md +++ b/docs/superpowers/plans/2026-03-22-brave-search-frontend-redesign.md @@ -478,49 +478,49 @@ Replace the entire file content: {{end}} ``` -- [ ] **Step 3: Update PageData struct to include new fields** +- [ ] **Step 3: Add FilterOption struct and update PageData struct** -Modify `internal/views/views.go` — add to `PageData` struct: +Add `FilterOption` struct at package level in `views.go` (near `PageNumber` struct): ```go -type PageData struct { - // ... existing fields ... - - // New fields for three-column layout - Categories []string - CategoryIcons map[string]string - ActiveCategory string - TimeFilters []FilterOption - TypeFilters []FilterOption - ActiveTime string - ActiveType string -} - -// FilterOption represents a filter radio option +// FilterOption represents a filter radio option for the sidebar. type FilterOption struct { Label string Value string } ``` -- [ ] **Step 4: Update FromResponse to accept filter params** - -Update `FromResponse` signature to accept `activeCategory`, `activeTime`, and `activeType` from the request. First update the `Search` handler in `handlers.go` to pass these params: +Then update `PageData` struct to include new fields: ```go -// In handlers.go, update Search handler: -pd := views.FromResponse(resp, req.Query, req.Pageno) -pd.ActiveCategory = r.FormValue("category") -pd.ActiveTime = r.FormValue("time") -pd.ActiveType = r.FormValue("type") +type PageData struct { + // ... existing fields (SourceURL, Query, Pageno, etc.) ... + + // New fields for three-column layout + Categories []string + CategoryIcons map[string]string + DisabledCategories []string + ActiveCategory string + TimeFilters []FilterOption + TypeFilters []FilterOption + ActiveTime string + ActiveType string +} ``` -Then update `FromResponse` in `views.go` to accept these params: +- [ ] **Step 4: Update FromResponse signature and body** + +Update `FromResponse` signature to accept filter params and set defaults: ```go func FromResponse(resp contracts.SearchResponse, query string, pageno int, activeCategory, activeTime, activeType string) PageData { + // Set defaults + if activeCategory == "" { + activeCategory = "all" + } + pd := PageData{ - // ... existing initialization ... + // ... existing initialization (NumberOfResults, Results, etc.) ... // New: categories with icons Categories: []string{"all", "news", "images", "videos", "maps"}, @@ -536,7 +536,6 @@ func FromResponse(resp contracts.SearchResponse, query string, pageno int, activ "weather": "🌤️", }, ActiveCategory: activeCategory, - if activeCategory == "" { activeCategory = "all" } // Time filters TimeFilters: []FilterOption{ @@ -562,7 +561,12 @@ func FromResponse(resp contracts.SearchResponse, query string, pageno int, activ } ``` -Add `DisabledCategories []string` field to `PageData`. +Update the `Search` handler in `handlers.go` to pass filter params: + +```go +pd := views.FromResponse(resp, req.Query, req.Pageno, + r.FormValue("category"), r.FormValue("time"), r.FormValue("type")) +``` - [ ] **Step 5: Update results.html sidebar to show disabled state** From d071921329ae729116a89db04410f683d1ef40e0 Mon Sep 17 00:00:00 2001 From: ashisgreat22 Date: Sun, 22 Mar 2026 13:26:22 +0100 Subject: [PATCH 55/91] docs: add missing template registration step to plan - Add tmplPreferences variable to views.go var block - Initialize tmplPreferences in init() function - Add RenderPreferences function to views.go - Fix step numbering for Task 4 Co-Authored-By: Claude Opus 4.6 --- ...26-03-22-brave-search-frontend-redesign.md | 29 +++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/docs/superpowers/plans/2026-03-22-brave-search-frontend-redesign.md b/docs/superpowers/plans/2026-03-22-brave-search-frontend-redesign.md index 8ff8485..28b98a1 100644 --- a/docs/superpowers/plans/2026-03-22-brave-search-frontend-redesign.md +++ b/docs/superpowers/plans/2026-03-22-brave-search-frontend-redesign.md @@ -982,10 +982,35 @@ Append to `kafka.css`: } ``` -- [ ] **Step 3: Commit** +- [ ] **Step 3: Register preferences template in views.go** + +Add `tmplPreferences` variable and initialize it in `init()`. Also add `RenderPreferences` function: + +```go +// In views.go, add to var block: +var ( + tmplFull *template.Template + tmplIndex *template.Template + tmplFragment *template.Template + tmplPreferences *template.Template +) + +// In init(), after existing template parsing, add: +tmplPreferences = template.Must(template.New("").Funcs(funcMap).ParseFS(tmplFS, + "base.html", "preferences.html", +)) + +// Add RenderPreferences function: +func RenderPreferences(w http.ResponseWriter, sourceURL string) error { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + return tmplPreferences.ExecuteTemplate(w, "base", PageData{ShowHeader: true, SourceURL: sourceURL}) +} +``` + +- [ ] **Step 4: Commit** ```bash -git add internal/views/templates/preferences.html internal/views/static/css/kafka.css +git add internal/views/templates/preferences.html internal/views/static/css/kafka.css internal/views/views.go git commit -m "feat(frontend): add preferences page template and styles" ``` From 0af49f91b75d3e31b8efd60bb8f47df0a489d61f Mon Sep 17 00:00:00 2001 From: ashisgreat22 Date: Sun, 22 Mar 2026 13:29:39 +0100 Subject: [PATCH 56/91] feat(frontend): add CSS layout framework for three-column results and preferences page Co-Authored-By: Claude Opus 4.6 --- internal/views/static/css/kafka.css | 300 ++++++++++++++++++++++++++++ 1 file changed, 300 insertions(+) diff --git a/internal/views/static/css/kafka.css b/internal/views/static/css/kafka.css index 8ae97ea..d23ea7a 100644 --- a/internal/views/static/css/kafka.css +++ b/internal/views/static/css/kafka.css @@ -975,6 +975,306 @@ a:focus-visible { background: var(--border-focus); } +/* ============================================================ + Three-Column Results Layout + ============================================================ */ + +.results-layout { + display: grid; + grid-template-columns: 200px 1fr 240px; + gap: 2rem; + align-items: start; +} + +.results-layout .left-sidebar { + position: sticky; + top: calc(var(--header-height) + 1.5rem); + max-height: calc(100vh - var(--header-height) - 3rem); + overflow-y: auto; +} + +.results-layout .right-sidebar { + position: sticky; + top: calc(var(--header-height) + 1.5rem); + max-height: calc(100vh - var(--header-height) - 3rem); + overflow-y: auto; +} + +.results-layout .results-column { + min-width: 0; +} + +/* Tablet: hide left sidebar, two columns */ +@media (min-width: 769px) and (max-width: 1024px) { + .results-layout { + grid-template-columns: 1fr 220px; + } + .results-layout .left-sidebar { + display: none; + } +} + +/* Mobile: single column, no sidebars */ +@media (max-width: 768px) { + .results-layout { + grid-template-columns: 1fr; + } + .results-layout .left-sidebar, + .results-layout .right-sidebar { + display: none; + } +} + +/* ============================================================ + Preferences Page Layout + ============================================================ */ + +.preferences-layout { + display: grid; + grid-template-columns: 200px 1fr; + gap: 2rem; + align-items: start; + padding: 2rem 0; +} + +.preferences-nav { + position: sticky; + top: calc(var(--header-height) + 1.5rem); +} + +.preferences-nav-item { + display: flex; + align-items: center; + gap: 0.6rem; + padding: 0.6rem 0.75rem; + border-radius: var(--radius-sm); + color: var(--text-secondary); + text-decoration: none; + font-size: 0.9rem; + transition: background 0.15s, color 0.15s; + cursor: pointer; +} + +.preferences-nav-item:hover { + background: var(--bg-tertiary); + color: var(--text-primary); +} + +.preferences-nav-item.active { + background: var(--accent-soft); + color: var(--accent); + font-weight: 500; +} + +.preferences-content { + background: var(--bg); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + padding: 1.5rem; +} + +@media (max-width: 768px) { + .preferences-layout { + grid-template-columns: 1fr; + } + .preferences-nav { + position: static; + display: flex; + overflow-x: auto; + gap: 0.5rem; + padding-bottom: 0.5rem; + } + .preferences-nav-item { + white-space: nowrap; + } +} + +/* ============================================================ + Category Tiles + ============================================================ */ + +.category-tiles { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(100px, 1fr)); + gap: 1rem; + margin-top: 2rem; +} + +.category-tile { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.5rem; + padding: 1rem 0.5rem; + border-radius: var(--radius-md); + text-decoration: none; + color: var(--text-secondary); + font-size: 0.85rem; + transition: background 0.15s, color 0.15s, transform 0.15s, box-shadow 0.15s; +} + +.category-tile:hover { + background: var(--bg-tertiary); + color: var(--text-primary); + transform: translateY(-2px); + box-shadow: var(--shadow-sm); +} + +.category-tile-icon { + font-size: 1.5rem; + line-height: 1; +} + +.category-tile.disabled { + opacity: 0.5; + cursor: not-allowed; + pointer-events: none; +} + +@media (max-width: 768px) { + .category-tiles { + grid-template-columns: repeat(auto-fill, minmax(80px, 1fr)); + gap: 0.75rem; + } + .category-tile { + padding: 0.75rem 0.25rem; + font-size: 0.75rem; + } + .category-tile-icon { + font-size: 1.25rem; + } +} + +/* ============================================================ + Left Sidebar (Results Page) + ============================================================ */ + +.left-sidebar { + padding: 0; +} + +.sidebar-nav { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.sidebar-nav-title { + font-size: 0.7rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--text-muted); + padding: 0.5rem 0.75rem; + margin-top: 0.5rem; +} + +.sidebar-nav-item { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 0.75rem; + border-radius: var(--radius-sm); + color: var(--text-secondary); + text-decoration: none; + font-size: 0.875rem; + transition: background 0.15s, color 0.15s; +} + +.sidebar-nav-item:hover { + background: var(--bg-tertiary); + color: var(--text-primary); +} + +.sidebar-nav-item.active { + background: var(--accent-soft); + color: var(--accent); + font-weight: 500; +} + +.sidebar-nav-item-icon { + font-size: 1rem; + width: 20px; + text-align: center; +} + +.sidebar-filters { + margin-top: 1rem; + padding-top: 1rem; + border-top: 1px solid var(--border); +} + +.sidebar-filter-group { + margin-bottom: 0.75rem; +} + +.sidebar-filter-label { + font-size: 0.75rem; + font-weight: 500; + color: var(--text-muted); + padding: 0 0.75rem; + margin-bottom: 0.25rem; +} + +.sidebar-filter-option { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.35rem 0.75rem; + font-size: 0.8rem; + color: var(--text-secondary); + cursor: pointer; + border-radius: var(--radius-sm); + transition: background 0.15s; +} + +.sidebar-filter-option:hover { + background: var(--bg-tertiary); +} + +.sidebar-filter-option input[type="radio"] { + accent-color: var(--accent); +} + +/* Mobile filter chips */ +.mobile-filter-chips { + display: none; + overflow-x: auto; + gap: 0.5rem; + padding: 0.75rem 0; + -webkit-overflow-scrolling: touch; +} + +.mobile-filter-chips::-webkit-scrollbar { + display: none; +} + +.mobile-filter-chip { + display: inline-flex; + align-items: center; + padding: 0.4rem 0.75rem; + border: 1px solid var(--border); + border-radius: var(--radius-full); + font-size: 0.8rem; + color: var(--text-secondary); + white-space: nowrap; + text-decoration: none; + transition: background 0.15s, border-color 0.15s; +} + +.mobile-filter-chip:hover, +.mobile-filter-chip.active { + background: var(--accent-soft); + border-color: var(--accent); + color: var(--accent); +} + +@media (max-width: 768px) { + .mobile-filter-chips { + display: flex; + } +} + /* ============================================================ Print ============================================================ */ From 2e7075adf1ad01be8ad29c1d5831fe3b132a78e2 Mon Sep 17 00:00:00 2001 From: ashisgreat22 Date: Sun, 22 Mar 2026 13:33:24 +0100 Subject: [PATCH 57/91] fix(frontend): merge duplicate sidebar sticky rules --- internal/views/static/css/kafka.css | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/internal/views/static/css/kafka.css b/internal/views/static/css/kafka.css index d23ea7a..9f014e0 100644 --- a/internal/views/static/css/kafka.css +++ b/internal/views/static/css/kafka.css @@ -986,13 +986,7 @@ a:focus-visible { align-items: start; } -.results-layout .left-sidebar { - position: sticky; - top: calc(var(--header-height) + 1.5rem); - max-height: calc(100vh - var(--header-height) - 3rem); - overflow-y: auto; -} - +.results-layout .left-sidebar, .results-layout .right-sidebar { position: sticky; top: calc(var(--header-height) + 1.5rem); From 0e79b729fee5df61d185acfb69c1ef5ee45bc6e9 Mon Sep 17 00:00:00 2001 From: ashisgreat22 Date: Sun, 22 Mar 2026 13:36:09 +0100 Subject: [PATCH 58/91] feat(frontend): add three-column results layout with left sidebar navigation Co-Authored-By: Claude Opus 4.6 --- internal/httpapi/handlers.go | 3 +- internal/views/templates/results.html | 85 ++++++++++++++++++++------- internal/views/views.go | 57 +++++++++++++++++- 3 files changed, 122 insertions(+), 23 deletions(-) diff --git a/internal/httpapi/handlers.go b/internal/httpapi/handlers.go index f8db054..e27db01 100644 --- a/internal/httpapi/handlers.go +++ b/internal/httpapi/handlers.go @@ -112,7 +112,8 @@ func (h *Handler) Search(w http.ResponseWriter, r *http.Request) { } if req.Format == contracts.FormatHTML { - pd := views.FromResponse(resp, req.Query, req.Pageno) + pd := views.FromResponse(resp, req.Query, req.Pageno, + r.FormValue("category"), r.FormValue("time"), r.FormValue("type")) if err := views.RenderSearchAuto(w, r, pd); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) } diff --git a/internal/views/templates/results.html b/internal/views/templates/results.html index 7010a3a..39e7c64 100644 --- a/internal/views/templates/results.html +++ b/internal/views/templates/results.html @@ -1,32 +1,75 @@ {{define "title"}}{{if .Query}}{{.Query}} — {{end}}{{end}} {{define "content"}}
- -
- -
+ + + + +
+ +
+ +
+ + +
+ All + {{range .Categories}} + {{.}} + {{end}} +
+ + {{template "results_inner" .}}
- -
-{{end}} +{{end}} \ No newline at end of file diff --git a/internal/views/views.go b/internal/views/views.go index 4d7289c..0161d2b 100644 --- a/internal/views/views.go +++ b/internal/views/views.go @@ -50,6 +50,15 @@ type PageData struct { UnresponsiveEngines [][2]string PageNumbers []PageNumber ShowHeader bool + // New fields for three-column layout + Categories []string + CategoryIcons map[string]string + DisabledCategories []string + ActiveCategory string + TimeFilters []FilterOption + TypeFilters []FilterOption + ActiveTime string + ActiveType string } // ResultView is a template-friendly wrapper around a MainResult. @@ -73,6 +82,12 @@ type InfoboxView struct { ImgSrc string } +// FilterOption represents a filter radio option for the sidebar. +type FilterOption struct { + Label string + Value string +} + var ( tmplFull *template.Template tmplIndex *template.Template @@ -116,12 +131,52 @@ func OpenSearchXML(baseURL string) ([]byte, error) { } // FromResponse builds PageData from a search response and request params. -func FromResponse(resp contracts.SearchResponse, query string, pageno int) PageData { +func FromResponse(resp contracts.SearchResponse, query string, pageno int, activeCategory, activeTime, activeType string) PageData { + // Set defaults + if activeCategory == "" { + activeCategory = "all" + } + pd := PageData{ Query: query, Pageno: pageno, NumberOfResults: resp.NumberOfResults, UnresponsiveEngines: resp.UnresponsiveEngines, + + // New: categories with icons + Categories: []string{"all", "news", "images", "videos", "maps"}, + DisabledCategories: []string{"shopping", "music", "weather"}, + CategoryIcons: map[string]string{ + "all": "🌐", + "news": "📰", + "images": "🖼️", + "videos": "🎬", + "maps": "🗺️", + "shopping": "🛒", + "music": "🎵", + "weather": "🌤️", + }, + ActiveCategory: activeCategory, + + // Time filters + TimeFilters: []FilterOption{ + {Label: "Any time", Value: ""}, + {Label: "Past hour", Value: "h"}, + {Label: "Past 24 hours", Value: "d"}, + {Label: "Past week", Value: "w"}, + {Label: "Past month", Value: "m"}, + {Label: "Past year", Value: "y"}, + }, + ActiveTime: activeTime, + + // Type filters + TypeFilters: []FilterOption{ + {Label: "All results", Value: ""}, + {Label: "News", Value: "news"}, + {Label: "Videos", Value: "video"}, + {Label: "Images", Value: "image"}, + }, + ActiveType: activeType, } // Convert results. From bfcbd45c572a8db13ffedc2c95e0a0e9f42cc935 Mon Sep 17 00:00:00 2001 From: ashisgreat22 Date: Sun, 22 Mar 2026 13:40:16 +0100 Subject: [PATCH 59/91] fix(frontend): update FromResponse tests and fix disabled categories rendering Co-Authored-By: Claude Opus 4.6 --- internal/views/templates/results.html | 7 +++++++ internal/views/views.go | 1 - internal/views/views_test.go | 6 +++--- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/internal/views/templates/results.html b/internal/views/templates/results.html index 39e7c64..59bc525 100644 --- a/internal/views/templates/results.html +++ b/internal/views/templates/results.html @@ -11,6 +11,13 @@ {{.}} {{end}} + + {{range .DisabledCategories}} + + {{index $.CategoryIcons .}} + {{.}} + + {{end}} + + +
-{{end}} +{{end}} \ No newline at end of file From b4053b7f9894aba0ee363d6bbfc489d8a1734795 Mon Sep 17 00:00:00 2001 From: ashisgreat22 Date: Sun, 22 Mar 2026 13:47:30 +0100 Subject: [PATCH 61/91] feat(frontend): add preferences page template and styles Co-Authored-By: Claude Opus 4.6 --- internal/views/static/css/kafka.css | 83 ++++++++++ internal/views/templates/preferences.html | 191 ++++++++++++++++++++++ internal/views/views.go | 10 ++ 3 files changed, 284 insertions(+) create mode 100644 internal/views/templates/preferences.html diff --git a/internal/views/static/css/kafka.css b/internal/views/static/css/kafka.css index 9f014e0..ad094e9 100644 --- a/internal/views/static/css/kafka.css +++ b/internal/views/static/css/kafka.css @@ -1269,6 +1269,89 @@ a:focus-visible { } } +/* ============================================================ + Preferences Page Styles + ============================================================ */ + +.pref-section { + margin-bottom: 2rem; +} + +.pref-section:last-child { + margin-bottom: 0; +} + +.pref-section-title { + font-size: 1rem; + font-weight: 600; + color: var(--text-primary); + margin-bottom: 1rem; + padding-bottom: 0.5rem; + border-bottom: 1px solid var(--border); +} + +.pref-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + padding: 0.75rem 0; + border-bottom: 1px solid var(--border); +} + +.pref-row:last-child { + border-bottom: none; +} + +.pref-row label { + font-size: 0.9rem; + color: var(--text-primary); +} + +.pref-row-info { + flex: 1; +} + +.pref-row-info label { + font-weight: 500; +} + +.pref-desc { + font-size: 0.8rem; + color: var(--text-muted); + margin-top: 0.25rem; +} + +.pref-row select { + padding: 0.5rem 0.75rem; + font-size: 0.85rem; + font-family: inherit; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--bg); + color: var(--text-primary); + cursor: pointer; + min-width: 150px; +} + +.pref-row select:focus { + outline: none; + border-color: var(--accent); +} + +.pref-row input[type="checkbox"] { + width: 18px; + height: 18px; + accent-color: var(--accent); + cursor: pointer; + flex-shrink: 0; +} + +.pref-row input[type="checkbox"]:disabled { + opacity: 0.6; + cursor: not-allowed; +} + /* ============================================================ Print ============================================================ */ diff --git a/internal/views/templates/preferences.html b/internal/views/templates/preferences.html new file mode 100644 index 0000000..394c27f --- /dev/null +++ b/internal/views/templates/preferences.html @@ -0,0 +1,191 @@ +{{define "title"}}Preferences{{end}} +{{define "content"}} +
+ + + + +
+ + + + + + + + + + + + + + + + + + + + + + + +
+
+{{end}} \ No newline at end of file diff --git a/internal/views/views.go b/internal/views/views.go index ac6d4b0..2dec5a7 100644 --- a/internal/views/views.go +++ b/internal/views/views.go @@ -92,6 +92,7 @@ var ( tmplFull *template.Template tmplIndex *template.Template tmplFragment *template.Template + tmplPreferences *template.Template ) func init() { @@ -111,6 +112,9 @@ func init() { tmplFragment = template.Must(template.New("").Funcs(funcMap).ParseFS(tmplFS, "results_inner.html", "result_item.html", "video_item.html", )) + tmplPreferences = template.Must(template.New("").Funcs(funcMap).ParseFS(tmplFS, + "base.html", "preferences.html", + )) } // StaticFS returns the embedded static file system for serving CSS/JS/images. @@ -288,3 +292,9 @@ func RenderSearchAuto(w http.ResponseWriter, r *http.Request, data PageData) err return RenderSearch(w, data) } +// RenderPreferences renders the full preferences page. +func RenderPreferences(w http.ResponseWriter, sourceURL string) error { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + return tmplPreferences.ExecuteTemplate(w, "base", PageData{ShowHeader: true, SourceURL: sourceURL}) +} + From 70818558cd0bb25458e97c6f4d2b84a5a052ce23 Mon Sep 17 00:00:00 2001 From: ashisgreat22 Date: Sun, 22 Mar 2026 13:53:23 +0100 Subject: [PATCH 62/91] feat: add GET and POST /preferences route Co-Authored-By: Claude Opus 4.6 --- cmd/kafka/main.go | 2 ++ internal/httpapi/handlers.go | 23 +++++++++++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/cmd/kafka/main.go b/cmd/kafka/main.go index cdc81b5..6785ba7 100644 --- a/cmd/kafka/main.go +++ b/cmd/kafka/main.go @@ -84,6 +84,8 @@ func main() { mux.HandleFunc("/healthz", h.Healthz) mux.HandleFunc("/search", h.Search) mux.HandleFunc("/autocompleter", h.Autocompleter) + mux.HandleFunc("/preferences", h.Preferences) + mux.HandleFunc("POST /preferences", h.PreferencesPOST) mux.HandleFunc("/opensearch.xml", h.OpenSearch(cfg.Server.BaseURL)) // Serve embedded static files (CSS, JS, images). diff --git a/internal/httpapi/handlers.go b/internal/httpapi/handlers.go index e27db01..46df1d9 100644 --- a/internal/httpapi/handlers.go +++ b/internal/httpapi/handlers.go @@ -142,3 +142,26 @@ func (h *Handler) Autocompleter(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json; charset=utf-8") _ = json.NewEncoder(w).Encode(suggestions) } + +// Preferences renders the preferences page. +func (h *Handler) Preferences(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/preferences" { + http.NotFound(w, r) + return + } + if err := views.RenderPreferences(w, h.sourceURL); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } +} + +// PreferencesPOST handles form submission from the preferences page. +// NOTE: This is a no-op. All preferences are stored in localStorage on the client +// via JavaScript. This handler exists only for form submission completeness (e.g., +// if a form POSTs without JS). The JavaScript in settings.js handles all saves. +func (h *Handler) PreferencesPOST(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/preferences" { + http.NotFound(w, r) + return + } + http.Redirect(w, r, "/preferences", http.StatusFound) +} From 0afcf509c31ebdb95fdff122d3866344b6613adc Mon Sep 17 00:00:00 2001 From: ashisgreat22 Date: Sun, 22 Mar 2026 13:57:32 +0100 Subject: [PATCH 63/91] fix: use single Preferences handler with method check instead of dead POST route --- cmd/kafka/main.go | 1 - internal/httpapi/handlers.go | 20 +++++++------------- 2 files changed, 7 insertions(+), 14 deletions(-) diff --git a/cmd/kafka/main.go b/cmd/kafka/main.go index 6785ba7..3a0a80e 100644 --- a/cmd/kafka/main.go +++ b/cmd/kafka/main.go @@ -85,7 +85,6 @@ func main() { mux.HandleFunc("/search", h.Search) mux.HandleFunc("/autocompleter", h.Autocompleter) mux.HandleFunc("/preferences", h.Preferences) - mux.HandleFunc("POST /preferences", h.PreferencesPOST) mux.HandleFunc("/opensearch.xml", h.OpenSearch(cfg.Server.BaseURL)) // Serve embedded static files (CSS, JS, images). diff --git a/internal/httpapi/handlers.go b/internal/httpapi/handlers.go index 46df1d9..ce4165b 100644 --- a/internal/httpapi/handlers.go +++ b/internal/httpapi/handlers.go @@ -143,25 +143,19 @@ func (h *Handler) Autocompleter(w http.ResponseWriter, r *http.Request) { _ = json.NewEncoder(w).Encode(suggestions) } -// Preferences renders the preferences page. +// Preferences handles GET and POST for the preferences page. func (h *Handler) Preferences(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/preferences" { http.NotFound(w, r) return } + if r.Method == "POST" { + // Preferences are stored in localStorage on the client via JavaScript. + // This handler exists only for form submission completeness. + http.Redirect(w, r, "/preferences", http.StatusFound) + return + } if err := views.RenderPreferences(w, h.sourceURL); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) } } - -// PreferencesPOST handles form submission from the preferences page. -// NOTE: This is a no-op. All preferences are stored in localStorage on the client -// via JavaScript. This handler exists only for form submission completeness (e.g., -// if a form POSTs without JS). The JavaScript in settings.js handles all saves. -func (h *Handler) PreferencesPOST(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/preferences" { - http.NotFound(w, r) - return - } - http.Redirect(w, r, "/preferences", http.StatusFound) -} From 6d7e68ada17a655ef417097c71c32baec08b095a Mon Sep 17 00:00:00 2001 From: ashisgreat22 Date: Sun, 22 Mar 2026 14:00:53 +0100 Subject: [PATCH 64/91] feat(frontend): reduce popover to theme+engines, add preferences page JS --- internal/views/static/js/settings.js | 104 ++++++++++++++++----------- 1 file changed, 64 insertions(+), 40 deletions(-) diff --git a/internal/views/static/js/settings.js b/internal/views/static/js/settings.js index 762fbcb..9682e6a 100644 --- a/internal/views/static/js/settings.js +++ b/internal/views/static/js/settings.js @@ -103,28 +103,7 @@ function renderPanel(prefs) { engineToggles += ''; }); - var ssOptions = [ - { val: 'moderate', label: 'Moderate' }, - { val: 'strict', label: 'Strict' }, - { val: 'off', label: 'Off' } - ]; - var fmtOptions = [ - { val: 'html', label: 'HTML' }, - { val: 'json', label: 'JSON' }, - { val: 'csv', label: 'CSV' }, - { val: 'rss', label: 'RSS' } - ]; - var ssOptionsHtml = ''; - var fmtOptionsHtml = ''; - ssOptions.forEach(function(o) { - var sel = prefs.safeSearch === o.val ? ' selected' : ''; - ssOptionsHtml += ''; - }); - fmtOptions.forEach(function(o) { - var sel = prefs.format === o.val ? ' selected' : ''; - fmtOptionsHtml += ''; - }); - + body.innerHTML = '
' + '
Appearance
' + @@ -175,24 +154,6 @@ function renderPanel(prefs) { })(checkboxes[j])); } - // Safe search - var ssEl = panel.querySelector('#pref-safesearch'); - if (ssEl) { - ssEl.addEventListener('change', function() { - prefs.safeSearch = ssEl.value; - savePrefs(prefs); - }); - } - - // Format - var fmtEl = panel.querySelector('#pref-format'); - if (fmtEl) { - fmtEl.addEventListener('change', function() { - prefs.format = fmtEl.value; - savePrefs(prefs); - }); - } - // Close button var closeBtn = panel.querySelector('.settings-popover-close'); if (closeBtn) closeBtn.addEventListener('click', closePanel); @@ -269,3 +230,66 @@ if (document.readyState === 'loading') { } else { initSettings(); } + +// Preferences page navigation +function initPreferences() { + var nav = document.getElementById('preferences-nav'); + if (!nav) return; + + var sections = document.querySelectorAll('.pref-section'); + var navItems = nav.querySelectorAll('.preferences-nav-item'); + + function showSection(id) { + sections.forEach(function(sec) { + sec.style.display = sec.id === 'section-' + id ? 'block' : 'none'; + }); + navItems.forEach(function(item) { + item.classList.toggle('active', item.getAttribute('data-section') === id); + }); + } + + navItems.forEach(function(item) { + item.addEventListener('click', function() { + showSection(item.getAttribute('data-section')); + }); + }); + + // Load saved preferences + var prefs = loadPrefs(); + + // Theme + var themeEl = document.getElementById('pref-theme'); + if (themeEl) { + themeEl.value = prefs.theme || 'system'; + themeEl.addEventListener('change', function() { + prefs.theme = themeEl.value; + savePrefs(prefs); + applyTheme(prefs.theme); + }); + } + + // Safe search + var ssEl = document.getElementById('pref-safesearch'); + if (ssEl) { + ssEl.value = prefs.safeSearch || 'moderate'; + ssEl.addEventListener('change', function() { + prefs.safeSearch = ssEl.value; + savePrefs(prefs); + }); + } + + // Format (if exists on page) + var fmtEl = document.getElementById('pref-format'); + if (fmtEl) { + fmtEl.value = prefs.format || 'html'; + fmtEl.addEventListener('change', function() { + prefs.format = fmtEl.value; + savePrefs(prefs); + }); + } + + // Show first section by default + showSection('search'); +} + +document.addEventListener('DOMContentLoaded', initPreferences); From e18a54a41a9baead10b949c961d45fe3baeaa20f Mon Sep 17 00:00:00 2001 From: ashisgreat22 Date: Sun, 22 Mar 2026 14:05:26 +0100 Subject: [PATCH 65/91] fix(frontend): add HTMX filter submission for sidebar radio buttons Wrap sidebar time/type filters in a form with HTMX attributes so filter changes trigger partial page updates instead of full reload. Co-Authored-By: Claude Opus 4.6 --- internal/views/templates/results.html | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/internal/views/templates/results.html b/internal/views/templates/results.html index 59bc525..1e02fb9 100644 --- a/internal/views/templates/results.html +++ b/internal/views/templates/results.html @@ -20,7 +20,10 @@ {{end}} - + From 7969b724de014cf07a8fc7c4ba357bdb6d5ad0da Mon Sep 17 00:00:00 2001 From: ashisgreat22 Date: Sun, 22 Mar 2026 14:16:04 +0100 Subject: [PATCH 66/91] fix(engines): remove unsupported lookahead from Google regex Go's regexp package doesn't support Perl lookahead (?=...). Removing the unnecessary lookahead since each MjjYud div is self-contained. Co-Authored-By: Claude Opus 4.6 --- internal/engines/google.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/engines/google.go b/internal/engines/google.go index 8563829..77d8549 100644 --- a/internal/engines/google.go +++ b/internal/engines/google.go @@ -129,7 +129,7 @@ func detectGoogleSorry(resp *http.Response) bool { func parseGoogleResults(body, query string) []contracts.MainResult { var results []contracts.MainResult - mjjPattern := regexp.MustCompile(`]*class="[^"]*MjjYud[^"]*"[^>]*>(.*?)
\s*(?=]*class="[^"]*MjjYud|$)`) + mjjPattern := regexp.MustCompile(`]*class="[^"]*MjjYud[^"]*"[^>]*>(.*?)
`) matches := mjjPattern.FindAllStringSubmatch(body, -1) for i, match := range matches { From 0b381c001ffa3bef6b19a65ab0dd7ce37ad45ceb Mon Sep 17 00:00:00 2001 From: ashisgreat22 Date: Sun, 22 Mar 2026 13:28:27 +0000 Subject: [PATCH 67/91] fix(flake): simplify preConfigure --- flake.nix | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flake.nix b/flake.nix index e2521d7..ad62fd3 100644 --- a/flake.nix +++ b/flake.nix @@ -29,7 +29,7 @@ ldflags = [ "-s" "-w" ]; # Remove stale vendor directory before buildGoModule deletes it. - preConfigure = "find vendor -type f -exec chmod 666 {} \; 2>/dev/null || true; rm -rf vendor 2>/dev/null || find vendor -delete 2>/dev/null || true"; + preConfigure = "rm -rf vendor || true"; nativeCheckInputs = with pkgs; [ ]; From e2ff822847bec4a5be0717dc9e5f1ba6ec074ae9 Mon Sep 17 00:00:00 2001 From: ashisgreat22 Date: Sun, 22 Mar 2026 14:05:28 +0000 Subject: [PATCH 68/91] fix(flake): set vendorHash to auto-compute The go.mod was updated with new replace directive for golang.org/x/net. Need to recompute vendorHash. Co-Authored-By: Claude Opus 4.6 --- flake.nix | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flake.nix b/flake.nix index ad62fd3..20ae248 100644 --- a/flake.nix +++ b/flake.nix @@ -21,7 +21,7 @@ version = "0.1.0"; src = ./.; - vendorHash = "sha256-PTD4eEEkLGBCZbot6W4U+sMOpIbH2tcFSztQel7hyXI="; + vendorHash = ""; # Run: nix build .#packages.x86_64-linux.default # It will fail with the correct hash. Replace vendorHash with it. From 994d27ff7f5c7b5c37964cf134f4b4ac005051aa Mon Sep 17 00:00:00 2001 From: ashisgreat22 Date: Sun, 22 Mar 2026 15:17:03 +0000 Subject: [PATCH 69/91] fix(flake): set correct vendorHash The correct vendorHash for current go.mod is: sha256-8wlKD+33s97oorCJTfHKAgE2Xp1HKXV+bSr6z29KrKM= Co-Authored-By: Claude Opus 4.6 --- flake.nix | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flake.nix b/flake.nix index 20ae248..3552728 100644 --- a/flake.nix +++ b/flake.nix @@ -21,7 +21,7 @@ version = "0.1.0"; src = ./.; - vendorHash = ""; + vendorHash = "sha256-8wlKD+33s97oorCJTfHKAgE2Xp1HKXV+bSr6z29KrKM="; # Run: nix build .#packages.x86_64-linux.default # It will fail with the correct hash. Replace vendorHash with it. From 2d22a8cdbb636aaad40f1240b4a70877a649d61f Mon Sep 17 00:00:00 2001 From: Franz Kafka Date: Sun, 22 Mar 2026 15:12:21 +0000 Subject: [PATCH 70/91] feat: add Brave web search scraper engine New brave.go: scrapes https://search.brave.com directly. Extracts title, URL, snippet, and favicon from Brave's HTML. No API key required. Rename existing BraveAPIEngine (was BraveEngine) to avoid collision with the new scraper. API engine stays as 'braveapi', scraper as 'brave'. --- internal/engines/brave.go | 172 +++++++++++++++++++++++++++++++++++ internal/engines/braveapi.go | 6 +- internal/engines/factory.go | 3 +- internal/engines/planner.go | 4 +- 4 files changed, 179 insertions(+), 6 deletions(-) create mode 100644 internal/engines/brave.go diff --git a/internal/engines/brave.go b/internal/engines/brave.go new file mode 100644 index 0000000..cb9313d --- /dev/null +++ b/internal/engines/brave.go @@ -0,0 +1,172 @@ +package engines + +import ( + "context" + "fmt" + "io" + "net/http" + "net/url" + "regexp" + "strings" + + "github.com/metamorphosis-dev/kafka/internal/contracts" +) + +type BraveEngine struct { + client *http.Client +} + +func (e *BraveEngine) Name() string { return "brave" } + +func (e *BraveEngine) Search(ctx context.Context, req contracts.SearchRequest) (contracts.SearchResponse, error) { + if strings.TrimSpace(req.Query) == "" { + return contracts.SearchResponse{Query: req.Query}, nil + } + + start := (req.Pageno - 1) * 20 + u := fmt.Sprintf( + "https://search.brave.com/search?q=%s&offset=%d&source=web", + url.QueryEscape(req.Query), + start, + ) + + httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) + if err != nil { + return contracts.SearchResponse{}, err + } + httpReq.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36") + httpReq.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8") + httpReq.Header.Set("Accept-Language", "en-US,en;q=0.9") + + resp, err := e.client.Do(httpReq) + if err != nil { + return contracts.SearchResponse{}, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + return contracts.SearchResponse{}, fmt.Errorf("brave error: status=%d body=%q", resp.StatusCode, string(body)) + } + + body, err := io.ReadAll(io.LimitReader(resp.Body, 128*1024)) + if err != nil { + return contracts.SearchResponse{}, err + } + + results := parseBraveResults(string(body)) + return contracts.SearchResponse{ + Query: req.Query, + NumberOfResults: len(results), + Results: results, + Answers: []map[string]any{}, + Corrections: []string{}, + Infoboxes: []map[string]any{}, + Suggestions: extractBraveSuggestions(string(body)), + UnresponsiveEngines: [][2]string{}, + }, nil +} + +func parseBraveResults(body string) []contracts.MainResult { + var results []contracts.MainResult + + // Brave wraps each result in divs with data-type="web" or data-type="news". + // Pattern:
... TITLE ...
SNIPPET
+ webPattern := regexp.MustCompile(`(?s)]+data-type="web"[^>]*>(.*?)
\s*]+data-type="(web|news)"`) + matches := webPattern.FindAllStringSubmatch(body, -1) + + seen := map[string]bool{} + + for _, match := range matches { + if len(match) < 2 { + continue + } + block := match[1] + + // Extract title and URL from the result-title link. + titlePattern := regexp.MustCompile(`]+class="result-title"[^>]+href="([^"]+)"[^>]*>([^<]+)`) + titleMatch := titlePattern.FindStringSubmatch(block) + if titleMatch == nil { + continue + } + href := titleMatch[1] + title := stripTags(titleMatch[2]) + + if href == "" || !strings.HasPrefix(href, "http") { + continue + } + if seen[href] { + continue + } + seen[href] = true + + // Extract snippet. + snippet := extractBraveSnippet(block) + + // Extract favicon URL. + favicon := extractBraveFavicon(block) + + urlPtr := href + results = append(results, contracts.MainResult{ + Title: title, + URL: &urlPtr, + Content: snippet, + Thumbnail: favicon, + Engine: "brave", + Score: 1.0, + Category: "general", + Engines: []string{"brave"}, + }) + } + + return results +} + +func extractBraveSnippet(block string) string { + // Try various snippet selectors Brave uses. + patterns := []string{ + `]+class="snippet"[^>]*>(.*?)
`, + `]+class="[^"]*description[^"]*"[^>]*>(.*?)

`, + `]+class="[^"]*snippet[^"]*"[^>]*>(.*?)`, + } + + for _, pat := range patterns { + re := regexp.MustCompile(`(?s)` + pat) + m := re.FindStringSubmatch(block) + if len(m) >= 2 { + text := stripTags(m[1]) + if text != "" { + return strings.TrimSpace(text) + } + } + } + return "" +} + +func extractBraveFavicon(block string) string { + imgPattern := regexp.MustCompile(`]+class="[^"]*favicon[^"]*"[^>]+src="([^"]+)"`) + m := imgPattern.FindStringSubmatch(block) + if len(m) >= 2 { + return m[1] + } + return "" +} + +func extractBraveSuggestions(body string) []string { + var suggestions []string + // Brave suggestions appear in a dropdown or related searches section. + suggestPattern := regexp.MustCompile(`(?s)]+class="[^"]*suggestion[^"]*"[^>]*>.*?]*>([^<]+)`) + matches := suggestPattern.FindAllStringSubmatch(body, -1) + seen := map[string]bool{} + for _, m := range matches { + if len(m) < 2 { + continue + } + s := strings.TrimSpace(stripTags(m[1])) + if s != "" && !seen[s] { + seen[s] = true + suggestions = append(suggestions, s) + } + } + return suggestions +} diff --git a/internal/engines/braveapi.go b/internal/engines/braveapi.go index 8977cb2..1ae6220 100644 --- a/internal/engines/braveapi.go +++ b/internal/engines/braveapi.go @@ -33,16 +33,16 @@ import ( // BraveEngine implements the Brave Web Search API. // Required: BRAVE_API_KEY env var or config. // Optional: BRAVE_ACCESS_TOKEN to gate requests. -type BraveEngine struct { +type BraveAPIEngine struct { client *http.Client apiKey string accessGateToken string resultsPerPage int } -func (e *BraveEngine) Name() string { return "braveapi" } +func (e *BraveAPIEngine) Name() string { return "braveapi" } -func (e *BraveEngine) Search(ctx context.Context, req contracts.SearchRequest) (contracts.SearchResponse, error) { +func (e *BraveAPIEngine) Search(ctx context.Context, req contracts.SearchRequest) (contracts.SearchResponse, error) { if e == nil || e.client == nil { return contracts.SearchResponse{}, errors.New("brave engine not initialized") } diff --git a/internal/engines/factory.go b/internal/engines/factory.go index 528dcb7..68f66eb 100644 --- a/internal/engines/factory.go +++ b/internal/engines/factory.go @@ -51,12 +51,13 @@ func NewDefaultPortedEngines(client *http.Client, cfg *config.Config) map[string "wikipedia": &WikipediaEngine{client: client}, "arxiv": &ArxivEngine{client: client}, "crossref": &CrossrefEngine{client: client}, - "braveapi": &BraveEngine{ + "braveapi": &BraveAPIEngine{ client: client, apiKey: braveAPIKey, accessGateToken: braveAccessToken, resultsPerPage: 20, }, + "brave": &BraveEngine{client: client}, "qwant": &QwantEngine{ client: client, category: "web-lite", diff --git a/internal/engines/planner.go b/internal/engines/planner.go index 9616a4b..270885b 100644 --- a/internal/engines/planner.go +++ b/internal/engines/planner.go @@ -23,7 +23,7 @@ import ( "github.com/metamorphosis-dev/kafka/internal/contracts" ) -var defaultPortedEngines = []string{"wikipedia", "arxiv", "crossref", "braveapi", "qwant", "duckduckgo", "github", "reddit", "bing", "google", "youtube"} +var defaultPortedEngines = []string{"wikipedia", "arxiv", "crossref", "braveapi", "brave", "qwant", "duckduckgo", "github", "reddit", "bing", "google", "youtube"} type Planner struct { PortedSet map[string]bool @@ -122,7 +122,7 @@ func inferFromCategories(categories []string) []string { out = append(out, e) } // stable order - order := map[string]int{"wikipedia": 0, "braveapi": 1, "qwant": 2, "duckduckgo": 3, "bing": 4, "google": 5, "arxiv": 6, "crossref": 7, "github": 8, "reddit": 9, "youtube": 10} + order := map[string]int{"wikipedia": 0, "braveapi": 1, "brave": 2, "qwant": 3, "duckduckgo": 4, "bing": 5, "google": 6, "arxiv": 7, "crossref": 8, "github": 9, "reddit": 10, "youtube": 11} sortByOrder(out, order) return out } From 4b0cde91edbda48aecc1883da045ee0964f634d1 Mon Sep 17 00:00:00 2001 From: Franz Kafka Date: Sun, 22 Mar 2026 15:24:40 +0000 Subject: [PATCH 71/91] feat: 3-column layout with centered results and right column - results-layout: 3-column grid (1fr | min(768px,100%) | 300px) max-width 1400px, centered - Widen center results column to 768px max - Right column (formerly sidebar): sticky, contains knowledge panel + related searches - Knowledge panel: Wikipedia/infobox summary with optional thumbnail - Related searches: clickable links to refine the query - Empty left buffer creates balanced whitespace on large screens - Responsive: 2-col at 1000px, 1-col at 700px --- internal/views/static/css/kafka.css | 77 ++++++++++++-- internal/views/templates/results.html | 111 ++++++-------------- internal/views/templates/results_inner.html | 7 -- internal/views/views.go | 2 +- 4 files changed, 105 insertions(+), 92 deletions(-) diff --git a/internal/views/static/css/kafka.css b/internal/views/static/css/kafka.css index ad094e9..40f0f25 100644 --- a/internal/views/static/css/kafka.css +++ b/internal/views/static/css/kafka.css @@ -235,11 +235,22 @@ main { padding-top: 1.5rem; } +/* 3-column layout: empty left buffer | center results | right column + max-width 1400px, centered on page */ .results-layout { display: grid; - grid-template-columns: 1fr 220px; + grid-template-columns: 1fr min(768px, 100%) 300px; gap: 2rem; align-items: start; + max-width: 1400px; + margin: 0 auto; +} + +@media (max-width: 1000px) { + .results-layout { + grid-template-columns: 1fr 260px; + gap: 1.5rem; + } } @media (max-width: 700px) { @@ -249,7 +260,7 @@ main { } } -/* Compact search bar on results page */ +/* Compact search bar spans all columns */ .search-compact { grid-column: 1 / -1; } @@ -380,12 +391,15 @@ main { } /* ============================================================ - Sidebar + Right Column (formerly sidebar) ============================================================ */ -.sidebar { +.right-column { position: sticky; top: calc(var(--header-height) + 1.5rem); + display: flex; + flex-direction: column; + gap: 1rem; } .sidebar-card { @@ -393,18 +407,65 @@ main { border: 1px solid var(--border); border-radius: var(--radius-md); padding: 1rem; - margin-bottom: 1rem; } -.sidebar-title { - font-size: 0.75rem; +.sidebar-card-title { + font-size: 0.7rem; font-weight: 600; text-transform: uppercase; - letter-spacing: 0.05em; + letter-spacing: 0.06em; color: var(--text-muted); margin-bottom: 0.75rem; } +/* Knowledge Panel card */ +.knowledge-panel { + background: var(--bg-secondary); + border: 1px solid var(--border); + border-radius: var(--radius-md); + padding: 1rem; +} + +.knowledge-panel-title { + font-size: 1rem; + font-weight: 600; + color: var(--text-primary); + margin-bottom: 0.5rem; +} + +.knowledge-panel-content { + font-size: 0.875rem; + color: var(--text-secondary); + line-height: 1.55; +} + +.knowledge-panel-thumb { + width: 100%; + border-radius: var(--radius-sm); + margin-bottom: 0.75rem; +} + +/* Related searches */ +.related-searches { + display: flex; + flex-direction: column; + gap: 0.35rem; +} + +.related-search-link { + font-size: 0.85rem; + color: var(--accent); + text-decoration: none; + padding: 0.25rem 0; + border-radius: var(--radius-sm); + transition: color 0.15s; +} + +.related-search-link:hover { + color: var(--accent-hover); + text-decoration: underline; +} + /* Suggestions in sidebar */ .suggestion-list { display: flex; diff --git a/internal/views/templates/results.html b/internal/views/templates/results.html index 1e02fb9..74d5f93 100644 --- a/internal/views/templates/results.html +++ b/internal/views/templates/results.html @@ -1,94 +1,53 @@ {{define "title"}}{{if .Query}}{{.Query}} — {{end}}{{end}} {{define "content"}}
- - - - -
- -
- + +
+ +
- -
- All - {{range .Categories}} - {{.}} - {{end}} -
- - + +
{{template "results_inner" .}}
- -
-{{end}} \ No newline at end of file +{{end}} diff --git a/internal/views/templates/results_inner.html b/internal/views/templates/results_inner.html index 0f9450a..cca51d1 100644 --- a/internal/views/templates/results_inner.html +++ b/internal/views/templates/results_inner.html @@ -8,13 +8,6 @@ {{range .Answers}}
{{.}}
{{end}} - {{range .Infoboxes}} -
- {{if .title}}
{{.title}}
{{end}} - {{if .content}}
{{.content}}
{{end}} - {{if .img_src}}{{.title}}{{end}} -
- {{end}}
{{end}} diff --git a/internal/views/views.go b/internal/views/views.go index 2dec5a7..6f23937 100644 --- a/internal/views/views.go +++ b/internal/views/views.go @@ -110,7 +110,7 @@ func init() { "base.html", "index.html", )) tmplFragment = template.Must(template.New("").Funcs(funcMap).ParseFS(tmplFS, - "results_inner.html", "result_item.html", "video_item.html", + "results.html", "results_inner.html", "result_item.html", "video_item.html", )) tmplPreferences = template.Must(template.New("").Funcs(funcMap).ParseFS(tmplFS, "base.html", "preferences.html", From da367a1bfd5c0c8f86853ba9f664caa7389d5087 Mon Sep 17 00:00:00 2001 From: Franz Kafka Date: Sun, 22 Mar 2026 16:22:27 +0000 Subject: [PATCH 72/91] security: harden against SAST findings (criticals through mediums) Critical: - Validate baseURL/sourceURL/upstreamURL at config load time (prevents XML injection, XSS, SSRF via config/env manipulation) - Use xml.Escape for OpenSearch XML template interpolation High: - Add security headers middleware (CSP, X-Frame-Options, HSTS, etc.) - Sanitize result URLs to reject javascript:/data: schemes - Sanitize infobox img_src against dangerous URL schemes - Default CORS to deny-all (was wildcard *) Medium: - Rate limiter: X-Forwarded-For only trusted from configured proxies - Validate engine names against known registry allowlist - Add 1024-char max query length - Sanitize upstream error messages (strip raw response bodies) - Upstream client validates URL scheme (http/https only) Test updates: - Update extractIP tests for new trusted proxy behavior --- cmd/kafka/main.go | 4 +- internal/config/config.go | 29 ++++++ internal/engines/arxiv.go | 2 +- internal/engines/bing.go | 2 +- internal/engines/brave.go | 2 +- internal/engines/braveapi.go | 2 +- internal/engines/crossref.go | 2 +- internal/engines/duckduckgo.go | 2 +- internal/engines/github.go | 2 +- internal/engines/google.go | 2 +- internal/engines/qwant.go | 4 +- internal/engines/reddit.go | 2 +- internal/engines/wikipedia.go | 2 +- internal/engines/youtube.go | 4 +- internal/middleware/cors.go | 4 +- internal/middleware/ratelimit.go | 71 +++++++++++--- internal/middleware/ratelimit_global.go | 2 +- internal/middleware/ratelimit_test.go | 34 +++++-- internal/middleware/security.go | 92 ++++++++++++++++++ internal/search/request_params.go | 27 ++++++ internal/upstream/client.go | 5 +- internal/util/validate.go | 123 ++++++++++++++++++++++++ internal/views/views.go | 21 +++- 23 files changed, 399 insertions(+), 41 deletions(-) create mode 100644 internal/middleware/security.go create mode 100644 internal/util/validate.go diff --git a/cmd/kafka/main.go b/cmd/kafka/main.go index 3a0a80e..f691665 100644 --- a/cmd/kafka/main.go +++ b/cmd/kafka/main.go @@ -95,8 +95,9 @@ func main() { var subFS fs.FS = staticFS mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.FS(subFS)))) - // Apply middleware: global rate limit → burst rate limit → per-IP rate limit → CORS → handler. + // Apply middleware: global rate limit → burst rate limit → per-IP rate limit → CORS → security headers → handler. var handler http.Handler = mux + handler = middleware.SecurityHeaders(middleware.SecurityHeadersConfig{})(handler) handler = middleware.CORS(middleware.CORSConfig{ AllowedOrigins: cfg.CORS.AllowedOrigins, AllowedMethods: cfg.CORS.AllowedMethods, @@ -108,6 +109,7 @@ func main() { Requests: cfg.RateLimit.Requests, Window: cfg.RateLimitWindow(), CleanupInterval: cfg.RateLimitCleanupInterval(), + TrustedProxies: cfg.RateLimit.TrustedProxies, }, logger)(handler) handler = middleware.GlobalRateLimit(middleware.GlobalRateLimitConfig{ Requests: cfg.GlobalRateLimit.Requests, diff --git a/internal/config/config.go b/internal/config/config.go index e5d1fbb..42aac23 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -18,11 +18,13 @@ package config import ( "fmt" + "log" "os" "strings" "time" "github.com/BurntSushi/toml" + "github.com/metamorphosis-dev/kafka/internal/util" ) // Config is the top-level configuration for the kafka service. @@ -77,6 +79,7 @@ type RateLimitConfig struct { Requests int `toml:"requests"` // Max requests per window (default: 30) Window string `toml:"window"` // Time window (e.g. "1m", default: "1m") CleanupInterval string `toml:"cleanup_interval"` // Stale entry cleanup interval (default: "5m") + TrustedProxies []string `toml:"trusted_proxies"` // CIDRs allowed to set X-Forwarded-For } // GlobalRateLimitConfig holds server-wide rate limiting settings. @@ -120,9 +123,35 @@ func Load(path string) (*Config, error) { } applyEnvOverrides(cfg) + + if err := validateConfig(cfg); err != nil { + return nil, fmt.Errorf("invalid configuration: %w", err) + } + return cfg, nil } +// validateConfig checks security-critical config values at startup. +func validateConfig(cfg *Config) error { + if cfg.Server.BaseURL != "" { + if err := util.ValidatePublicURL(cfg.Server.BaseURL); err != nil { + return fmt.Errorf("server.base_url: %w", err) + } + } + if cfg.Server.SourceURL != "" { + if err := util.ValidatePublicURL(cfg.Server.SourceURL); err != nil { + return fmt.Errorf("server.source_url: %w", err) + } + } + if cfg.Upstream.URL != "" { + if err := util.ValidatePublicURL(cfg.Upstream.URL); err != nil { + return fmt.Errorf("upstream.url: %w", err) + } + log.Printf("WARNING: upstream.url SSRF protection is enabled; ensure the upstream host is not on a private network") + } + return nil +} + func defaultConfig() *Config { return &Config{ Server: ServerConfig{ diff --git a/internal/engines/arxiv.go b/internal/engines/arxiv.go index 1347562..111e3b7 100644 --- a/internal/engines/arxiv.go +++ b/internal/engines/arxiv.go @@ -76,7 +76,7 @@ func (e *ArxivEngine) Search(ctx context.Context, req contracts.SearchRequest) ( if resp.StatusCode < 200 || resp.StatusCode >= 300 { body, _ := io.ReadAll(io.LimitReader(resp.Body, 16*1024)) - return contracts.SearchResponse{}, fmt.Errorf("arxiv upstream error: status=%d body=%q", resp.StatusCode, string(body)) + return contracts.SearchResponse{}, fmt.Errorf("arxiv upstream error: status %d", resp.StatusCode) } raw, err := io.ReadAll(resp.Body) diff --git a/internal/engines/bing.go b/internal/engines/bing.go index 85c3f65..b1abbab 100644 --- a/internal/engines/bing.go +++ b/internal/engines/bing.go @@ -69,7 +69,7 @@ func (e *BingEngine) Search(ctx context.Context, req contracts.SearchRequest) (c if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) - return contracts.SearchResponse{}, fmt.Errorf("bing upstream error: status=%d body=%q", resp.StatusCode, string(body)) + return contracts.SearchResponse{}, fmt.Errorf("bing upstream error: status %d", resp.StatusCode) } contentType := resp.Header.Get("Content-Type") diff --git a/internal/engines/brave.go b/internal/engines/brave.go index cb9313d..373bc47 100644 --- a/internal/engines/brave.go +++ b/internal/engines/brave.go @@ -46,7 +46,7 @@ func (e *BraveEngine) Search(ctx context.Context, req contracts.SearchRequest) ( if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) - return contracts.SearchResponse{}, fmt.Errorf("brave error: status=%d body=%q", resp.StatusCode, string(body)) + return contracts.SearchResponse{}, fmt.Errorf("brave error: status %d", resp.StatusCode) } body, err := io.ReadAll(io.LimitReader(resp.Body, 128*1024)) diff --git a/internal/engines/braveapi.go b/internal/engines/braveapi.go index 1ae6220..6b89347 100644 --- a/internal/engines/braveapi.go +++ b/internal/engines/braveapi.go @@ -128,7 +128,7 @@ func (e *BraveAPIEngine) Search(ctx context.Context, req contracts.SearchRequest if resp.StatusCode < 200 || resp.StatusCode >= 300 { body, _ := io.ReadAll(io.LimitReader(resp.Body, 16*1024)) - return contracts.SearchResponse{}, fmt.Errorf("brave upstream error: status=%d body=%q", resp.StatusCode, string(body)) + return contracts.SearchResponse{}, fmt.Errorf("brave upstream error: status %d", resp.StatusCode) } var api struct { diff --git a/internal/engines/crossref.go b/internal/engines/crossref.go index cc33759..d911034 100644 --- a/internal/engines/crossref.go +++ b/internal/engines/crossref.go @@ -64,7 +64,7 @@ func (e *CrossrefEngine) Search(ctx context.Context, req contracts.SearchRequest if resp.StatusCode < 200 || resp.StatusCode >= 300 { body, _ := io.ReadAll(io.LimitReader(resp.Body, 16*1024)) - return contracts.SearchResponse{}, fmt.Errorf("crossref upstream error: status=%d body=%q", resp.StatusCode, string(body)) + return contracts.SearchResponse{}, fmt.Errorf("crossref upstream error: status %d", resp.StatusCode) } var api struct { diff --git a/internal/engines/duckduckgo.go b/internal/engines/duckduckgo.go index 158d483..9aa275e 100644 --- a/internal/engines/duckduckgo.go +++ b/internal/engines/duckduckgo.go @@ -64,7 +64,7 @@ func (e *DuckDuckGoEngine) Search(ctx context.Context, req contracts.SearchReque if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) - return contracts.SearchResponse{}, fmt.Errorf("duckduckgo upstream error: status=%d body=%q", resp.StatusCode, string(body)) + return contracts.SearchResponse{}, fmt.Errorf("duckduckgo upstream error: status %d", resp.StatusCode) } results, err := parseDuckDuckGoHTML(resp.Body) diff --git a/internal/engines/github.go b/internal/engines/github.go index f37cddc..13d85b8 100644 --- a/internal/engines/github.go +++ b/internal/engines/github.go @@ -67,7 +67,7 @@ func (e *GitHubEngine) Search(ctx context.Context, req contracts.SearchRequest) if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) - return contracts.SearchResponse{}, fmt.Errorf("github api error: status=%d body=%q", resp.StatusCode, string(body)) + return contracts.SearchResponse{}, fmt.Errorf("github api error: status %d", resp.StatusCode) } var data struct { diff --git a/internal/engines/google.go b/internal/engines/google.go index 77d8549..49e2dbe 100644 --- a/internal/engines/google.go +++ b/internal/engines/google.go @@ -96,7 +96,7 @@ func (e *GoogleEngine) Search(ctx context.Context, req contracts.SearchRequest) if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) - return contracts.SearchResponse{}, fmt.Errorf("google error: status=%d body=%q", resp.StatusCode, string(body)) + return contracts.SearchResponse{}, fmt.Errorf("google error: status %d", resp.StatusCode) } body, err := io.ReadAll(io.LimitReader(resp.Body, 128*1024)) diff --git a/internal/engines/qwant.go b/internal/engines/qwant.go index e15d4f2..1c4876b 100644 --- a/internal/engines/qwant.go +++ b/internal/engines/qwant.go @@ -125,7 +125,7 @@ func (e *QwantEngine) searchWebAPI(ctx context.Context, req contracts.SearchRequ if resp.StatusCode < 200 || resp.StatusCode >= 300 { body, _ := io.ReadAll(io.LimitReader(resp.Body, 16*1024)) - return contracts.SearchResponse{}, fmt.Errorf("qwant upstream error: status=%d body=%q", resp.StatusCode, string(body)) + return contracts.SearchResponse{}, fmt.Errorf("qwant upstream error: status %d", resp.StatusCode) } body, err := io.ReadAll(io.LimitReader(resp.Body, 2*1024*1024)) @@ -254,7 +254,7 @@ func (e *QwantEngine) searchWebLite(ctx context.Context, req contracts.SearchReq if resp.StatusCode < 200 || resp.StatusCode >= 300 { body, _ := io.ReadAll(io.LimitReader(resp.Body, 16*1024)) - return contracts.SearchResponse{}, fmt.Errorf("qwant lite upstream error: status=%d body=%q", resp.StatusCode, string(body)) + return contracts.SearchResponse{}, fmt.Errorf("qwant lite upstream error: status %d", resp.StatusCode) } doc, err := goquery.NewDocumentFromReader(resp.Body) diff --git a/internal/engines/reddit.go b/internal/engines/reddit.go index 788f52a..cb75cf9 100644 --- a/internal/engines/reddit.go +++ b/internal/engines/reddit.go @@ -63,7 +63,7 @@ func (e *RedditEngine) Search(ctx context.Context, req contracts.SearchRequest) if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) - return contracts.SearchResponse{}, fmt.Errorf("reddit api error: status=%d body=%q", resp.StatusCode, string(body)) + return contracts.SearchResponse{}, fmt.Errorf("reddit api error: status %d", resp.StatusCode) } var data struct { diff --git a/internal/engines/wikipedia.go b/internal/engines/wikipedia.go index f29ff74..3a65749 100644 --- a/internal/engines/wikipedia.go +++ b/internal/engines/wikipedia.go @@ -135,7 +135,7 @@ func (e *WikipediaEngine) Search(ctx context.Context, req contracts.SearchReques } if resp.StatusCode < 200 || resp.StatusCode >= 300 { body, _ := io.ReadAll(io.LimitReader(resp.Body, 16*1024)) - return contracts.SearchResponse{}, fmt.Errorf("wikipedia upstream error: status=%d body=%q", resp.StatusCode, string(body)) + return contracts.SearchResponse{}, fmt.Errorf("wikipedia upstream error: status %d", resp.StatusCode) } var api struct { diff --git a/internal/engines/youtube.go b/internal/engines/youtube.go index 5946aa4..ec0add9 100644 --- a/internal/engines/youtube.go +++ b/internal/engines/youtube.go @@ -78,7 +78,7 @@ func (e *YouTubeEngine) Search(ctx context.Context, req contracts.SearchRequest) if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) - return contracts.SearchResponse{}, fmt.Errorf("youtube api error: status=%d body=%q", resp.StatusCode, string(body)) + return contracts.SearchResponse{}, fmt.Errorf("youtube api error: status %d", resp.StatusCode) } var apiResp youtubeSearchResponse @@ -87,7 +87,7 @@ func (e *YouTubeEngine) Search(ctx context.Context, req contracts.SearchRequest) } if apiResp.Error != nil { - return contracts.SearchResponse{}, fmt.Errorf("youtube api error: %s", apiResp.Error.Message) + return contracts.SearchResponse{}, fmt.Errorf("youtube api error: code %d", apiResp.Error.Code) } results := make([]contracts.MainResult, 0, len(apiResp.Items)) diff --git a/internal/middleware/cors.go b/internal/middleware/cors.go index ee90ab0..d4ecf2a 100644 --- a/internal/middleware/cors.go +++ b/internal/middleware/cors.go @@ -42,7 +42,8 @@ type CORSConfig struct { func CORS(cfg CORSConfig) func(http.Handler) http.Handler { origins := cfg.AllowedOrigins if len(origins) == 0 { - origins = []string{"*"} + // Default: no CORS headers. Explicitly configure origins to enable. + origins = nil } methods := cfg.AllowedMethods @@ -70,6 +71,7 @@ func CORS(cfg CORSConfig) func(http.Handler) http.Handler { origin := r.Header.Get("Origin") // Determine the allowed origin for this request. + // If no origins are configured, CORS is disabled entirely — no headers are set. allowedOrigin := "" for _, o := range origins { if o == "*" { diff --git a/internal/middleware/ratelimit.go b/internal/middleware/ratelimit.go index 78774f2..8bd1123 100644 --- a/internal/middleware/ratelimit.go +++ b/internal/middleware/ratelimit.go @@ -27,10 +27,14 @@ import ( "log/slog" ) +// RateLimitConfig controls per-IP rate limiting. type RateLimitConfig struct { Requests int Window time.Duration CleanupInterval time.Duration + // TrustedProxies is a list of CIDR ranges that are allowed to set + // X-Forwarded-For / X-Real-IP. If empty, only r.RemoteAddr is used. + TrustedProxies []string } func RateLimit(cfg RateLimitConfig, logger *slog.Logger) func(http.Handler) http.Handler { @@ -53,18 +57,30 @@ func RateLimit(cfg RateLimitConfig, logger *slog.Logger) func(http.Handler) http logger = slog.Default() } + // Parse trusted proxy CIDRs. + var trustedNets []*net.IPNet + for _, cidr := range cfg.TrustedProxies { + _, network, err := net.ParseCIDR(cidr) + if err != nil { + logger.Warn("invalid trusted proxy CIDR, skipping", "cidr", cidr, "error", err) + continue + } + trustedNets = append(trustedNets, network) + } + limiter := &ipLimiter{ requests: requests, window: window, clients: make(map[string]*bucket), logger: logger, + trusted: trustedNets, } go limiter.cleanup(cleanup) return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - ip := extractIP(r) + ip := l.extractIP(r) if !limiter.allow(ip) { retryAfter := int(limiter.window.Seconds()) @@ -92,6 +108,7 @@ type ipLimiter struct { clients map[string]*bucket mu sync.Mutex logger *slog.Logger + trusted []*net.IPNet } func (l *ipLimiter) allow(ip string) bool { @@ -129,18 +146,48 @@ func (l *ipLimiter) cleanup(interval time.Duration) { } } -func extractIP(r *http.Request) string { - if xff := r.Header.Get("X-Forwarded-For"); xff != "" { - parts := strings.SplitN(xff, ",", 2) - return strings.TrimSpace(parts[0]) - } - if rip := r.Header.Get("X-Real-IP"); rip != "" { - return strings.TrimSpace(rip) +// extractIP extracts the client IP from the request. +// If trusted proxy CIDRs are configured, X-Forwarded-For is only used when +// the direct connection comes from a trusted proxy. Otherwise, only RemoteAddr is used. +func (l *ipLimiter) extractIP(r *http.Request) string { + return extractIP(r, l.trusted...) +} + +func extractIP(r *http.Request, trusted ...*net.IPNet) string { + remoteIP, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + remoteIP = r.RemoteAddr } - host, _, err := net.SplitHostPort(r.RemoteAddr) - if err != nil { - return r.RemoteAddr + // Check if the direct connection is from a trusted proxy. + isTrusted := false + if len(trusted) > 0 { + ip := net.ParseIP(remoteIP) + if ip != nil { + for _, network := range trusted { + if network.Contains(ip) { + isTrusted = true + break + } + } + } } - return host + + if isTrusted { + if xff := r.Header.Get("X-Forwarded-For"); xff != "" { + parts := strings.SplitN(xff, ",", 2) + candidate := strings.TrimSpace(parts[0]) + if net.ParseIP(candidate) != nil { + return candidate + } + } + if rip := r.Header.Get("X-Real-IP"); rip != "" { + candidate := strings.TrimSpace(rip) + if net.ParseIP(candidate) != nil { + return candidate + } + } + } + + return remoteIP } diff --git a/internal/middleware/ratelimit_global.go b/internal/middleware/ratelimit_global.go index 538c435..0bd34c5 100644 --- a/internal/middleware/ratelimit_global.go +++ b/internal/middleware/ratelimit_global.go @@ -71,7 +71,7 @@ func GlobalRateLimit(cfg GlobalRateLimitConfig, logger *slog.Logger) func(http.H w.Header().Set("Content-Type", "text/plain; charset=utf-8") w.WriteHeader(http.StatusServiceUnavailable) _, _ = w.Write([]byte("503 Service Unavailable — global rate limit exceeded\n")) - logger.Warn("global rate limit exceeded", "ip", extractIP(r)) + logger.Warn("global rate limit exceeded", "remote", r.RemoteAddr) return } diff --git a/internal/middleware/ratelimit_test.go b/internal/middleware/ratelimit_test.go index 987d014..514d985 100644 --- a/internal/middleware/ratelimit_test.go +++ b/internal/middleware/ratelimit_test.go @@ -92,9 +92,11 @@ func TestRateLimit_DifferentIPs(t *testing.T) { } func TestRateLimit_XForwardedFor(t *testing.T) { + privateNet := mustParseCIDR("10.0.0.0/8") h := RateLimit(RateLimitConfig{ - Requests: 1, - Window: 10 * time.Second, + Requests: 1, + Window: 10 * time.Second, + TrustedProxies: []string{"10.0.0.0/8"}, }, nil)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) })) @@ -143,17 +145,27 @@ func TestRateLimit_WindowExpires(t *testing.T) { } func TestExtractIP(t *testing.T) { + // Trusted proxy: loopback + loopback := mustParseCIDR("127.0.0.0/8") + privateNet := mustParseCIDR("10.0.0.0/8") + tests := []struct { name string xff string realIP string remote string + trusted []*net.IPNet expected string }{ - {"xff", "203.0.113.50, 10.0.0.1", "", "10.0.0.1:1234", "203.0.113.50"}, - {"real_ip", "", "203.0.113.50", "10.0.0.1:1234", "203.0.113.50"}, - {"remote", "", "", "1.2.3.4:5678", "1.2.3.4"}, - {"xff_over_real", "203.0.113.50", "10.0.0.1", "10.0.0.1:1234", "203.0.113.50"}, + // No trusted proxies → always use RemoteAddr. + {"no_trusted_xff", "203.0.113.50, 10.0.0.1", "", "10.0.0.1:1234", nil, "10.0.0.1"}, + {"no_trusted_real", "", "203.0.113.50", "10.0.0.1:1234", nil, "10.0.0.1"}, + {"no_trusted_remote", "", "", "1.2.3.4:5678", nil, "1.2.3.4"}, + // Trusted proxy → XFF is respected. + {"trusted_xff", "203.0.113.50, 10.0.0.1", "", "10.0.0.1:1234", []*net.IPNet{privateNet}, "203.0.113.50"}, + {"trusted_real_ip", "", "203.0.113.50", "10.0.0.1:1234", []*net.IPNet{privateNet}, "203.0.113.50"}, + // Untrusted remote → XFF ignored even if present. + {"untrusted_xff", "203.0.113.50, 10.0.0.1", "", "1.2.3.4:5678", []*net.IPNet{loopback}, "1.2.3.4"}, } for _, tt := range tests { @@ -167,9 +179,17 @@ func TestExtractIP(t *testing.T) { } req.RemoteAddr = tt.remote - if got := extractIP(req); got != tt.expected { + if got := extractIP(req, tt.trusted...); got != tt.expected { t.Errorf("extractIP() = %q, want %q", got, tt.expected) } }) } } + +func mustParseCIDR(s string) *net.IPNet { + _, network, err := net.ParseCIDR(s) + if err != nil { + panic(err) + } + return network +} diff --git a/internal/middleware/security.go b/internal/middleware/security.go new file mode 100644 index 0000000..09f3878 --- /dev/null +++ b/internal/middleware/security.go @@ -0,0 +1,92 @@ +// kafka — a privacy-respecting metasearch engine +// Copyright (C) 2026-present metamorphosis-dev +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +package middleware + +import ( + "net/http" + "strconv" + "strings" +) + +// SecurityHeadersConfig controls which security headers are set. +type SecurityHeadersConfig struct { + // FrameOptions controls X-Frame-Options. Default: "DENY". + FrameOptions string + // HSTSMaxAge controls the max-age for Strict-Transport-Security. + // Set to 0 to disable HSTS (useful for local dev). Default: 31536000 (1 year). + HSTSMaxAge int + // HSTSPreloadDomains adds "includeSubDomains; preload" to HSTS. + HSTSPreloadDomains bool + // ReferrerPolicy controls the Referrer-Policy header. Default: "no-referrer". + ReferrerPolicy string + // CSP controls Content-Security-Policy. Default: a restrictive policy. + // Set to "" to disable CSP entirely. + CSP string +} + +// SecurityHeaders returns middleware that sets standard HTTP security headers +// on every response. +func SecurityHeaders(cfg SecurityHeadersConfig) func(http.Handler) http.Handler { + frameOpts := cfg.FrameOptions + if frameOpts == "" { + frameOpts = "DENY" + } + + hstsAge := cfg.HSTSMaxAge + if hstsAge == 0 { + hstsAge = 31536000 // 1 year + } + + refPol := cfg.ReferrerPolicy + if refPol == "" { + refPol = "no-referrer" + } + + csp := cfg.CSP + if csp == "" { + csp = defaultCSP() + } + + hstsValue := "max-age=" + strconv.Itoa(hstsAge) + if cfg.HSTSPreloadDomains { + hstsValue += "; includeSubDomains; preload" + } + + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("X-Content-Type-Options", "nosniff") + w.Header().Set("X-Frame-Options", frameOpts) + w.Header().Set("Referrer-Policy", refPol) + w.Header().Set("Permissions-Policy", "camera=(), microphone=(), geolocation=()") + w.Header().Set("Content-Security-Policy", csp) + + if hstsAge > 0 { + w.Header().Set("Strict-Transport-Security", hstsValue) + } + + next.ServeHTTP(w, r) + }) + } +} + +// defaultCSP returns a restrictive Content-Security-Policy for the +// metasearch engine. +func defaultCSP() string { + return strings.Join([]string{ + "default-src 'self'", + "script-src 'self'", + "style-src 'self' 'unsafe-inline'", + "img-src 'self' https: data:", + "connect-src 'self'", + "font-src 'self'", + "frame-ancestors 'none'", + "base-uri 'self'", + "form-action 'self'", + }, "; ") +} diff --git a/internal/search/request_params.go b/internal/search/request_params.go index baad193..2e477fb 100644 --- a/internal/search/request_params.go +++ b/internal/search/request_params.go @@ -26,6 +26,28 @@ import ( var languageCodeRe = regexp.MustCompile(`^[a-z]{2,3}(-[a-zA-Z]{2})?$`) +// maxQueryLength is the maximum allowed length for the search query. +const maxQueryLength = 1024 + +// knownEngineNames is the allowlist of valid engine identifiers. +var knownEngineNames = map[string]bool{ + "wikipedia": true, "arxiv": true, "crossref": true, + "braveapi": true, "brave": true, "qwant": true, + "duckduckgo": true, "github": true, "reddit": true, + "bing": true, "google": true, "youtube": true, +} + +// validateEngines filters engine names against the known registry. +func validateEngines(engines []string) []string { + out := make([]string, 0, len(engines)) + for _, e := range engines { + if knownEngineNames[strings.ToLower(e)] { + out = append(out, strings.ToLower(e)) + } + } + return out +} + func ParseSearchRequest(r *http.Request) (SearchRequest, error) { // Supports both GET and POST and relies on form values for routing. if err := r.ParseForm(); err != nil { @@ -50,6 +72,9 @@ func ParseSearchRequest(r *http.Request) (SearchRequest, error) { if strings.TrimSpace(q) == "" { return SearchRequest{}, errors.New("missing required parameter: q") } + if len(q) > maxQueryLength { + return SearchRequest{}, errors.New("query exceeds maximum length") + } pageno := 1 if s := strings.TrimSpace(r.FormValue("pageno")); s != "" { @@ -105,6 +130,8 @@ func ParseSearchRequest(r *http.Request) (SearchRequest, error) { // engines is an explicit list of engine names. engines := splitCSV(strings.TrimSpace(r.FormValue("engines"))) + // Validate engine names against known registry to prevent injection. + engines = validateEngines(engines) // categories and category_ params mirror the webadapter parsing. // We don't validate against a registry here; we just preserve the requested values. diff --git a/internal/upstream/client.go b/internal/upstream/client.go index 2bff509..27d74b6 100644 --- a/internal/upstream/client.go +++ b/internal/upstream/client.go @@ -44,6 +44,9 @@ func NewClient(baseURL string, timeout time.Duration) (*Client, error) { if err != nil { return nil, fmt.Errorf("invalid upstream base URL: %w", err) } + if u.Scheme != "http" && u.Scheme != "https" { + return nil, fmt.Errorf("upstream URL must use http or https, got %q", u.Scheme) + } // Normalize: trim trailing slash to make URL concatenation predictable. base := strings.TrimRight(u.String(), "/") @@ -108,7 +111,7 @@ func (c *Client) SearchJSON(ctx context.Context, req contracts.SearchRequest, en } if resp.StatusCode != http.StatusOK { - return contracts.SearchResponse{}, fmt.Errorf("upstream search failed: status=%d body=%q", resp.StatusCode, string(body)) + return contracts.SearchResponse{}, fmt.Errorf("upstream search failed with status %d", resp.StatusCode) } // Decode upstream JSON into our contract types. diff --git a/internal/util/validate.go b/internal/util/validate.go new file mode 100644 index 0000000..2ea31cc --- /dev/null +++ b/internal/util/validate.go @@ -0,0 +1,123 @@ +// kafka — a privacy-respecting metasearch engine +// Copyright (C) 2026-present metamorphosis-dev +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License,// or (at your option) any later version. + +package util + +import ( + "fmt" + "net" + "net/url" + "strings" +) + +// SafeURLScheme returns true if the URL uses an acceptable scheme (http or https). +func SafeURLScheme(raw string) bool { + u, err := url.Parse(raw) + if err != nil { + return false + } + return u.Scheme == "http" || u.Scheme == "https" +} + +// IsPrivateIP returns true if the IP address is in a private, loopback, +// link-local, or otherwise non-routable range. +func IsPrivateIP(host string) bool { + // Strip port if present. + h, _, err := net.SplitHostPort(host) + if err != nil { + h = host + } + + // Resolve hostname to IPs. + ips, err := net.LookupIP(h) + if err != nil || len(ips) == 0 { + // If we can't resolve, reject to be safe. + return true + } + + for _, ip := range ips { + if isPrivateIPAddr(ip) { + return true + } + } + return false +} + +func isPrivateIPAddr(ip net.IP) bool { + privateRanges := []struct { + network *net.IPNet + }{ + // Loopback + {mustParseCIDR("127.0.0.0/8")}, + {mustParseCIDR("::1/128")}, + // RFC 1918 + {mustParseCIDR("10.0.0.0/8")}, + {mustParseCIDR("172.16.0.0/12")}, + {mustParseCIDR("192.168.0.0/16")}, + // RFC 6598 (Carrier-grade NAT) + {mustParseCIDR("100.64.0.0/10")}, + // Link-local + {mustParseCIDR("169.254.0.0/16")}, + {mustParseCIDR("fe80::/10")}, + // IPv6 unique local + {mustParseCIDR("fc00::/7")}, + // IPv4-mapped IPv6 loopback + {mustParseCIDR("::ffff:127.0.0.0/104")}, + } + + for _, r := range privateRanges { + if r.network.Contains(ip) { + return true + } + } + return false +} + +func mustParseCIDR(s string) *net.IPNet { + _, network, err := net.ParseCIDR(s) + if err != nil { + panic(fmt.Sprintf("validate: invalid CIDR %q: %v", s, err)) + } + return network +} + +// ValidatePublicURL checks that a URL is well-formed, uses http or https, +// and does not point to a private/reserved IP range. +func ValidatePublicURL(raw string) error { + u, err := url.Parse(raw) + if err != nil { + return fmt.Errorf("invalid URL: %w", err) + } + if u.Scheme != "http" && u.Scheme != "https" { + return fmt.Errorf("URL must use http or https, got %q", u.Scheme) + } + if u.Host == "" { + return fmt.Errorf("URL must have a host") + } + if IsPrivateIP(u.Host) { + return fmt.Errorf("URL points to a private or reserved address: %s", u.Host) + } + return nil +} + +// SanitizeResultURL ensures a URL is safe for rendering in an href attribute. +// It rejects javascript:, data:, vbscript: and other dangerous schemes. +func SanitizeResultURL(raw string) string { + if raw == "" { + return "" + } + u, err := url.Parse(raw) + if err != nil { + return "" + } + switch strings.ToLower(u.Scheme) { + case "http", "https", "": + return raw + default: + return "" + } +} diff --git a/internal/views/views.go b/internal/views/views.go index 6f23937..c176f81 100644 --- a/internal/views/views.go +++ b/internal/views/views.go @@ -18,6 +18,7 @@ package views import ( "embed" + "encoding/xml" "html/template" "io/fs" "net/http" @@ -25,6 +26,7 @@ import ( "strings" "github.com/metamorphosis-dev/kafka/internal/contracts" + "github.com/metamorphosis-dev/kafka/internal/util" ) //go:embed all:templates @@ -122,15 +124,20 @@ func StaticFS() (fs.FS, error) { return fs.Sub(staticFS, "static") } -// OpenSearchXML returns the OpenSearch description XML with {baseUrl} -// replaced by the provided base URL. +// OpenSearchXML returns the OpenSearch description XML with the base URL +// safely embedded via xml.EscapeText (no raw string interpolation). func OpenSearchXML(baseURL string) ([]byte, error) { tmplFS, _ := fs.Sub(templatesFS, "templates") data, err := fs.ReadFile(tmplFS, "opensearch.xml") if err != nil { return nil, err } - result := strings.ReplaceAll(string(data), "{baseUrl}", baseURL) + + var buf strings.Builder + xml.Escape(&buf, []byte(baseURL)) + escapedBaseURL := buf.String() + + result := strings.ReplaceAll(string(data), "{baseUrl}", escapedBaseURL) return []byte(result), nil } @@ -190,6 +197,12 @@ func FromResponse(resp contracts.SearchResponse, query string, pageno int, activ if r.Template == "videos" { tmplName = "video_item" } + // Sanitize URLs to prevent javascript:/data: scheme injection. + if r.URL != nil { + safe := util.SanitizeResultURL(*r.URL) + r.URL = &safe + } + r.Thumbnail = util.SanitizeResultURL(r.Thumbnail) pd.Results[i] = ResultView{MainResult: r, TemplateName: tmplName} } @@ -213,7 +226,7 @@ func FromResponse(resp contracts.SearchResponse, query string, pageno int, activ iv.Title = v } if v, ok := ib["img_src"].(string); ok { - iv.ImgSrc = v + iv.ImgSrc = util.SanitizeResultURL(v) } if iv.Title != "" || iv.Content != "" { pd.Infoboxes = append(pd.Infoboxes, iv) From b3e31236128bad9a72c92c3cd0e37b26df226ea6 Mon Sep 17 00:00:00 2001 From: Franz Kafka Date: Sun, 22 Mar 2026 16:27:49 +0000 Subject: [PATCH 73/91] security: fix build errors, add honest Google UA, sanitize error msgs - Fix config validation: upstream URLs allow private IPs (self-hosted) - Fix util.SafeURLScheme to return parsed URL - Replace spoofed GSA User-Agent with honest Kafka UA - Sanitize all engine error messages (strip response bodies) - Replace unused body reads with io.Copy(io.Discard, ...) for reuse - Fix pre-existing braveapi_test using wrong struct type - Fix ratelimit test reference to limiter variable - Update ratelimit tests for new trusted proxy behavior --- internal/config/config.go | 6 +++--- internal/engines/arxiv.go | 2 +- internal/engines/bing.go | 2 +- internal/engines/brave.go | 2 +- internal/engines/braveapi.go | 2 +- internal/engines/braveapi_test.go | 2 +- internal/engines/crossref.go | 2 +- internal/engines/duckduckgo.go | 2 +- internal/engines/github.go | 2 +- internal/engines/google.go | 22 ++++++---------------- internal/engines/qwant.go | 4 ++-- internal/engines/reddit.go | 2 +- internal/engines/wikipedia.go | 2 +- internal/engines/youtube.go | 2 +- internal/middleware/ratelimit.go | 2 +- internal/middleware/ratelimit_test.go | 2 +- internal/util/validate.go | 12 ++++++++---- 17 files changed, 32 insertions(+), 38 deletions(-) diff --git a/internal/config/config.go b/internal/config/config.go index 42aac23..f5a8b9a 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -18,7 +18,6 @@ package config import ( "fmt" - "log" "os" "strings" "time" @@ -144,10 +143,11 @@ func validateConfig(cfg *Config) error { } } if cfg.Upstream.URL != "" { - if err := util.ValidatePublicURL(cfg.Upstream.URL); err != nil { + // Validate scheme and well-formedness, but allow private IPs + // since self-hosted deployments commonly use localhost/internal addresses. + if _, err := util.SafeURLScheme(cfg.Upstream.URL); err != nil { return fmt.Errorf("upstream.url: %w", err) } - log.Printf("WARNING: upstream.url SSRF protection is enabled; ensure the upstream host is not on a private network") } return nil } diff --git a/internal/engines/arxiv.go b/internal/engines/arxiv.go index 111e3b7..2f9cca0 100644 --- a/internal/engines/arxiv.go +++ b/internal/engines/arxiv.go @@ -75,7 +75,7 @@ func (e *ArxivEngine) Search(ctx context.Context, req contracts.SearchRequest) ( defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { - body, _ := io.ReadAll(io.LimitReader(resp.Body, 16*1024)) + io.Copy(io.Discard, io.LimitReader(resp.Body, 16*1024)) return contracts.SearchResponse{}, fmt.Errorf("arxiv upstream error: status %d", resp.StatusCode) } diff --git a/internal/engines/bing.go b/internal/engines/bing.go index b1abbab..3b18f7b 100644 --- a/internal/engines/bing.go +++ b/internal/engines/bing.go @@ -68,7 +68,7 @@ func (e *BingEngine) Search(ctx context.Context, req contracts.SearchRequest) (c defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + io.Copy(io.Discard, io.LimitReader(resp.Body, 4096)) return contracts.SearchResponse{}, fmt.Errorf("bing upstream error: status %d", resp.StatusCode) } diff --git a/internal/engines/brave.go b/internal/engines/brave.go index 373bc47..da25630 100644 --- a/internal/engines/brave.go +++ b/internal/engines/brave.go @@ -45,7 +45,7 @@ func (e *BraveEngine) Search(ctx context.Context, req contracts.SearchRequest) ( defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + io.Copy(io.Discard, io.LimitReader(resp.Body, 4096)) return contracts.SearchResponse{}, fmt.Errorf("brave error: status %d", resp.StatusCode) } diff --git a/internal/engines/braveapi.go b/internal/engines/braveapi.go index 6b89347..830b010 100644 --- a/internal/engines/braveapi.go +++ b/internal/engines/braveapi.go @@ -127,7 +127,7 @@ func (e *BraveAPIEngine) Search(ctx context.Context, req contracts.SearchRequest defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { - body, _ := io.ReadAll(io.LimitReader(resp.Body, 16*1024)) + io.Copy(io.Discard, io.LimitReader(resp.Body, 16*1024)) return contracts.SearchResponse{}, fmt.Errorf("brave upstream error: status %d", resp.StatusCode) } diff --git a/internal/engines/braveapi_test.go b/internal/engines/braveapi_test.go index 13c7420..ed710ff 100644 --- a/internal/engines/braveapi_test.go +++ b/internal/engines/braveapi_test.go @@ -39,7 +39,7 @@ func TestBraveEngine_GatingAndHeader(t *testing.T) { }) client := &http.Client{Transport: transport} - engine := &BraveEngine{ + engine := &BraveAPIEngine{ client: client, apiKey: wantAPIKey, accessGateToken: wantToken, diff --git a/internal/engines/crossref.go b/internal/engines/crossref.go index d911034..79e6ab5 100644 --- a/internal/engines/crossref.go +++ b/internal/engines/crossref.go @@ -63,7 +63,7 @@ func (e *CrossrefEngine) Search(ctx context.Context, req contracts.SearchRequest defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { - body, _ := io.ReadAll(io.LimitReader(resp.Body, 16*1024)) + io.Copy(io.Discard, io.LimitReader(resp.Body, 16*1024)) return contracts.SearchResponse{}, fmt.Errorf("crossref upstream error: status %d", resp.StatusCode) } diff --git a/internal/engines/duckduckgo.go b/internal/engines/duckduckgo.go index 9aa275e..7a71ef4 100644 --- a/internal/engines/duckduckgo.go +++ b/internal/engines/duckduckgo.go @@ -63,7 +63,7 @@ func (e *DuckDuckGoEngine) Search(ctx context.Context, req contracts.SearchReque defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + io.Copy(io.Discard, io.LimitReader(resp.Body, 4096)) return contracts.SearchResponse{}, fmt.Errorf("duckduckgo upstream error: status %d", resp.StatusCode) } diff --git a/internal/engines/github.go b/internal/engines/github.go index 13d85b8..d0c9fcc 100644 --- a/internal/engines/github.go +++ b/internal/engines/github.go @@ -66,7 +66,7 @@ func (e *GitHubEngine) Search(ctx context.Context, req contracts.SearchRequest) defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + io.Copy(io.Discard, io.LimitReader(resp.Body, 4096)) return contracts.SearchResponse{}, fmt.Errorf("github api error: status %d", resp.StatusCode) } diff --git a/internal/engines/google.go b/internal/engines/google.go index 49e2dbe..cea4bd5 100644 --- a/internal/engines/google.go +++ b/internal/engines/google.go @@ -28,20 +28,10 @@ import ( "github.com/metamorphosis-dev/kafka/internal/contracts" ) -// GSA User-Agent pool — these are Google Search Appliance identifiers -// that Google trusts for enterprise search appliance traffic. -var gsaUserAgents = []string{ - "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/399.2.845414227 Mobile/15E148 Safari/604.1", - "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/406.0.862495628 Mobile/15E148 Safari/604.1", - "Mozilla/5.0 (iPhone; CPU iPhone OS 17_7_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/406.0.862495628 Mobile/15E148 Safari/604.1", - "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/406.0.862495628 Mobile/15E148 Safari/604.1", - "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/399.2.845414227 Mobile/15E148 Safari/604.1", - "Mozilla/5.0 (iPhone; CPU iPhone OS 18_5_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) GSA/406.0.862495628 Mobile/15E148 Safari/604.1", -} - -func gsaUA() string { - return gsaUserAgents[0] // deterministic for now; could rotate -} +// googleUserAgent is an honest User-Agent identifying the metasearch engine. +// Using a spoofed GSA User-Agent violates Google's Terms of Service and +// risks permanent IP blocking. +var googleUserAgent = "Kafka/0.1 (compatible; +https://github.com/metamorphosis-dev/kafka)" type GoogleEngine struct { client *http.Client @@ -70,7 +60,7 @@ func (e *GoogleEngine) Search(ctx context.Context, req contracts.SearchRequest) if err != nil { return contracts.SearchResponse{}, err } - httpReq.Header.Set("User-Agent", gsaUA()) + httpReq.Header.Set("User-Agent", googleUserAgent) httpReq.Header.Set("Accept", "*/*") httpReq.AddCookie(&http.Cookie{Name: "CONSENT", Value: "YES+"}) @@ -95,7 +85,7 @@ func (e *GoogleEngine) Search(ctx context.Context, req contracts.SearchRequest) } if resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + io.Copy(io.Discard, io.LimitReader(resp.Body, 4096)) return contracts.SearchResponse{}, fmt.Errorf("google error: status %d", resp.StatusCode) } diff --git a/internal/engines/qwant.go b/internal/engines/qwant.go index 1c4876b..7fa963b 100644 --- a/internal/engines/qwant.go +++ b/internal/engines/qwant.go @@ -124,7 +124,7 @@ func (e *QwantEngine) searchWebAPI(ctx context.Context, req contracts.SearchRequ } if resp.StatusCode < 200 || resp.StatusCode >= 300 { - body, _ := io.ReadAll(io.LimitReader(resp.Body, 16*1024)) + io.Copy(io.Discard, io.LimitReader(resp.Body, 16*1024)) return contracts.SearchResponse{}, fmt.Errorf("qwant upstream error: status %d", resp.StatusCode) } @@ -253,7 +253,7 @@ func (e *QwantEngine) searchWebLite(ctx context.Context, req contracts.SearchReq defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { - body, _ := io.ReadAll(io.LimitReader(resp.Body, 16*1024)) + io.Copy(io.Discard, io.LimitReader(resp.Body, 16*1024)) return contracts.SearchResponse{}, fmt.Errorf("qwant lite upstream error: status %d", resp.StatusCode) } diff --git a/internal/engines/reddit.go b/internal/engines/reddit.go index cb75cf9..699e7b2 100644 --- a/internal/engines/reddit.go +++ b/internal/engines/reddit.go @@ -62,7 +62,7 @@ func (e *RedditEngine) Search(ctx context.Context, req contracts.SearchRequest) defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + io.Copy(io.Discard, io.LimitReader(resp.Body, 4096)) return contracts.SearchResponse{}, fmt.Errorf("reddit api error: status %d", resp.StatusCode) } diff --git a/internal/engines/wikipedia.go b/internal/engines/wikipedia.go index 3a65749..518d994 100644 --- a/internal/engines/wikipedia.go +++ b/internal/engines/wikipedia.go @@ -134,7 +134,7 @@ func (e *WikipediaEngine) Search(ctx context.Context, req contracts.SearchReques }, nil } if resp.StatusCode < 200 || resp.StatusCode >= 300 { - body, _ := io.ReadAll(io.LimitReader(resp.Body, 16*1024)) + io.Copy(io.Discard, io.LimitReader(resp.Body, 16*1024)) return contracts.SearchResponse{}, fmt.Errorf("wikipedia upstream error: status %d", resp.StatusCode) } diff --git a/internal/engines/youtube.go b/internal/engines/youtube.go index ec0add9..0c5ff9e 100644 --- a/internal/engines/youtube.go +++ b/internal/engines/youtube.go @@ -77,7 +77,7 @@ func (e *YouTubeEngine) Search(ctx context.Context, req contracts.SearchRequest) defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + io.Copy(io.Discard, io.LimitReader(resp.Body, 4096)) return contracts.SearchResponse{}, fmt.Errorf("youtube api error: status %d", resp.StatusCode) } diff --git a/internal/middleware/ratelimit.go b/internal/middleware/ratelimit.go index 8bd1123..6f662fd 100644 --- a/internal/middleware/ratelimit.go +++ b/internal/middleware/ratelimit.go @@ -80,7 +80,7 @@ func RateLimit(cfg RateLimitConfig, logger *slog.Logger) func(http.Handler) http return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - ip := l.extractIP(r) + ip := limiter.extractIP(r) if !limiter.allow(ip) { retryAfter := int(limiter.window.Seconds()) diff --git a/internal/middleware/ratelimit_test.go b/internal/middleware/ratelimit_test.go index 514d985..8366e57 100644 --- a/internal/middleware/ratelimit_test.go +++ b/internal/middleware/ratelimit_test.go @@ -1,6 +1,7 @@ package middleware import ( + "net" "net/http" "net/http/httptest" "testing" @@ -92,7 +93,6 @@ func TestRateLimit_DifferentIPs(t *testing.T) { } func TestRateLimit_XForwardedFor(t *testing.T) { - privateNet := mustParseCIDR("10.0.0.0/8") h := RateLimit(RateLimitConfig{ Requests: 1, Window: 10 * time.Second, diff --git a/internal/util/validate.go b/internal/util/validate.go index 2ea31cc..eb7d55e 100644 --- a/internal/util/validate.go +++ b/internal/util/validate.go @@ -14,13 +14,17 @@ import ( "strings" ) -// SafeURLScheme returns true if the URL uses an acceptable scheme (http or https). -func SafeURLScheme(raw string) bool { +// SafeURLScheme validates that a URL is well-formed and uses an acceptable scheme. +// Returns the parsed URL on success, or an error. +func SafeURLScheme(raw string) (*url.URL, error) { u, err := url.Parse(raw) if err != nil { - return false + return nil, err } - return u.Scheme == "http" || u.Scheme == "https" + if u.Scheme != "http" && u.Scheme != "https" { + return nil, fmt.Errorf("URL must use http or https, got %q", u.Scheme) + } + return u, nil } // IsPrivateIP returns true if the IP address is in a private, loopback, From a316763aca88a18405a3c46bcb4e0e8d2a73445c Mon Sep 17 00:00:00 2001 From: Franz Kafka Date: Sun, 22 Mar 2026 16:38:03 +0000 Subject: [PATCH 74/91] fix(test): update CORS preflight test for deny-all default Empty CORSConfig now means no CORS headers, matching the security fix. Test explicitly configures an origin to test preflight behavior. --- internal/middleware/cors_test.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/internal/middleware/cors_test.go b/internal/middleware/cors_test.go index 4f3f6c2..f24ed65 100644 --- a/internal/middleware/cors_test.go +++ b/internal/middleware/cors_test.go @@ -51,7 +51,7 @@ func TestCORS_SpecificOrigin(t *testing.T) { } func TestCORS_Preflight(t *testing.T) { - h := CORS(CORSConfig{})(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + h := CORS(CORSConfig{AllowedOrigins: []string{"https://example.com"}})(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { t.Error("handler should not be called for preflight") })) @@ -100,6 +100,7 @@ func TestCORS_CustomMethodsAndHeaders(t *testing.T) { })(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) req := httptest.NewRequest("OPTIONS", "/search", nil) + req.Header.Set("Origin", "https://example.com") rec := httptest.NewRecorder() h.ServeHTTP(rec, req) From 2b072e4de37fb14e0a6707a78bc92926eebe31aa Mon Sep 17 00:00:00 2001 From: Franz Kafka Date: Sun, 22 Mar 2026 16:49:24 +0000 Subject: [PATCH 75/91] feat: add image search with Bing, DuckDuckGo, and Qwant engines Three new image search engines: - bing_images: Bing Images via RSS endpoint - ddg_images: DuckDuckGo Images via VQD API - qwant_images: Qwant Images via v3 search API Frontend: - Image grid layout with responsive columns - image_item template with thumbnail, title, and source metadata - Hover animations and lazy loading - Grid activates automatically when category=images Backend: - category=images routes to image engines via planner - Image engines registered in factory and engine allowlist - extractImgSrc helper for parsing thumbnail URLs from HTML - IsImageSearch flag on PageData for template layout switching --- internal/engines/bing_images.go | 123 ++++++++++++ internal/engines/ddg_images.go | 207 ++++++++++++++++++++ internal/engines/factory.go | 4 + internal/engines/html_helpers.go | 11 ++ internal/engines/planner.go | 18 +- internal/engines/qwant_images.go | 199 +++++++++++++++++++ internal/search/request_params.go | 2 + internal/views/static/css/kafka.css | 94 +++++++++ internal/views/templates/image_item.html | 15 ++ internal/views/templates/results_inner.html | 12 ++ internal/views/views.go | 6 +- 11 files changed, 687 insertions(+), 4 deletions(-) create mode 100644 internal/engines/bing_images.go create mode 100644 internal/engines/ddg_images.go create mode 100644 internal/engines/qwant_images.go create mode 100644 internal/views/templates/image_item.html diff --git a/internal/engines/bing_images.go b/internal/engines/bing_images.go new file mode 100644 index 0000000..002f947 --- /dev/null +++ b/internal/engines/bing_images.go @@ -0,0 +1,123 @@ +// kafka — a privacy-respecting metasearch engine +// Copyright (C) 2026-present metamorphosis-dev +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +package engines + +import ( + "context" + "encoding/xml" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" + + "github.com/metamorphosis-dev/kafka/internal/contracts" +) + +// BingImagesEngine searches Bing Images via their public RSS endpoint. +type BingImagesEngine struct { + client *http.Client +} + +func (e *BingImagesEngine) Name() string { return "bing_images" } + +func (e *BingImagesEngine) Search(ctx context.Context, req contracts.SearchRequest) (contracts.SearchResponse, error) { + if e == nil || e.client == nil { + return contracts.SearchResponse{}, errors.New("bing_images engine not initialized") + } + q := strings.TrimSpace(req.Query) + if q == "" { + return contracts.SearchResponse{Query: req.Query}, nil + } + + offset := (req.Pageno - 1) * 10 + endpoint := fmt.Sprintf( + "https://www.bing.com/images/search?q=%s&count=10&offset=%d&format=rss", + url.QueryEscape(q), + offset, + ) + + httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return contracts.SearchResponse{}, err + } + httpReq.Header.Set("User-Agent", "kafka/0.1 (compatible; +https://git.ashisgreat.xyz/penal-colony/kafka)") + + resp, err := e.client.Do(httpReq) + if err != nil { + return contracts.SearchResponse{}, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + io.Copy(io.Discard, io.LimitReader(resp.Body, 4096)) + return contracts.SearchResponse{}, fmt.Errorf("bing_images upstream error: status %d", resp.StatusCode) + } + + return parseBingImagesRSS(resp.Body, req.Query) +} + +// parseBingImagesRSS parses Bing's RSS image search results. +// The description field contains HTML with an tag whose src is the +// thumbnail and whose enclosing tag links to the source page. +func parseBingImagesRSS(r io.Reader, query string) (contracts.SearchResponse, error) { + type bingImageItem struct { + Title string `xml:"title"` + Link string `xml:"link"` + Descrip string `xml:"description"` + } + + type rssFeed struct { + XMLName xml.Name `xml:"rss"` + Channel struct { + Items []bingImageItem `xml:"item"` + } `xml:"channel"` + } + + var rss rssFeed + if err := xml.NewDecoder(r).Decode(&rss); err != nil { + return contracts.SearchResponse{}, fmt.Errorf("bing_images RSS parse error: %w", err) + } + + results := make([]contracts.MainResult, 0, len(rss.Channel.Items)) + for _, item := range rss.Channel.Items { + if item.Link == "" { + continue + } + + // Extract thumbnail URL from the description HTML. + thumbnail := extractImgSrc(item.Descrip) + content := stripHTML(item.Descrip) + + linkPtr := item.Link + results = append(results, contracts.MainResult{ + Template: "images", + Title: item.Title, + Content: content, + URL: &linkPtr, + Thumbnail: thumbnail, + Engine: "bing_images", + Score: 0, + Category: "images", + Engines: []string{"bing_images"}, + }) + } + + return contracts.SearchResponse{ + Query: query, + NumberOfResults: len(results), + Results: results, + Answers: []map[string]any{}, + Corrections: []string{}, + Infoboxes: []map[string]any{}, + Suggestions: []string{}, + UnresponsiveEngines: [][2]string{}, + }, nil +} diff --git a/internal/engines/ddg_images.go b/internal/engines/ddg_images.go new file mode 100644 index 0000000..5764af4 --- /dev/null +++ b/internal/engines/ddg_images.go @@ -0,0 +1,207 @@ +// kafka — a privacy-respecting metasearch engine +// Copyright (C) 2026-present metamorphosis-dev +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +package engines + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "strings" + + "github.com/metamorphosis-dev/kafka/internal/contracts" +) + +// DuckDuckGoImagesEngine searches DuckDuckGo Images via their vql API. +type DuckDuckGoImagesEngine struct { + client *http.Client +} + +func (e *DuckDuckGoImagesEngine) Name() string { return "ddg_images" } + +func (e *DuckDuckGoImagesEngine) Search(ctx context.Context, req contracts.SearchRequest) (contracts.SearchResponse, error) { + if e == nil || e.client == nil { + return contracts.SearchResponse{}, errors.New("ddg_images engine not initialized") + } + q := strings.TrimSpace(req.Query) + if q == "" { + return contracts.SearchResponse{Query: req.Query}, nil + } + + // Step 1: Get a VQD token from the initial search page. + vqd, err := e.getVQD(ctx, q) + if err != nil { + return contracts.SearchResponse{ + Query: req.Query, + UnresponsiveEngines: [][2]string{{"ddg_images", "vqd_fetch_failed"}}, + Results: []contracts.MainResult{}, + Answers: []map[string]any{}, + Corrections: []string{}, + Infoboxes: []map[string]any{}, + Suggestions: []string{}, + }, nil + } + + // Step 2: Fetch image results using the VQD token. + endpoint := fmt.Sprintf( + "https://duckduckgo.com/i.js?q=%s&kl=wt-wt&l=wt-wt&p=1&s=%d&vqd=%s", + url.QueryEscape(q), + (req.Pageno-1)*50, + url.QueryEscape(vqd), + ) + + httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return contracts.SearchResponse{}, err + } + httpReq.Header.Set("User-Agent", "kafka/0.1 (compatible; +https://git.ashisgreat.xyz/penal-colony/kafka)") + httpReq.Header.Set("Referer", "https://duckduckgo.com/") + + resp, err := e.client.Do(httpReq) + if err != nil { + return contracts.SearchResponse{}, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + io.Copy(io.Discard, io.LimitReader(resp.Body, 16*1024)) + return contracts.SearchResponse{}, fmt.Errorf("ddg_images upstream error: status %d", resp.StatusCode) + } + + body, err := io.ReadAll(io.LimitReader(resp.Body, 2*1024*1024)) + if err != nil { + return contracts.SearchResponse{}, err + } + + return parseDDGImages(body, req.Query) +} + +// getVQD fetches a VQD token from DuckDuckGo's search page. +func (e *DuckDuckGoImagesEngine) getVQD(ctx context.Context, query string) (string, error) { + endpoint := "https://duckduckgo.com/?q=" + url.QueryEscape(query) + + httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return "", err + } + httpReq.Header.Set("User-Agent", "kafka/0.1 (compatible; +https://git.ashisgreat.xyz/penal-colony/kafka)") + + resp, err := e.client.Do(httpReq) + if err != nil { + return "", err + } + defer resp.Body.Close() + + body, err := io.ReadAll(io.LimitReader(resp.Body, 512*1024)) + if err != nil { + return "", err + } + + // Extract VQD from the HTML: vqd='...' + vqd := extractVQD(string(body)) + if vqd == "" { + return "", fmt.Errorf("vqd token not found in response") + } + return vqd, nil +} + +// extractVQD extracts the VQD token from DuckDuckGo's HTML response. +func extractVQD(html string) string { + // Look for: vqd='...' or vqd="..." + for _, prefix := range []string{"vqd='", `vqd="`} { + idx := strings.Index(html, prefix) + if idx == -1 { + continue + } + start := idx + len(prefix) + end := start + for end < len(html) && html[end] != '\'' && html[end] != '"' { + end++ + } + if end > start { + return html[start:end] + } + } + return "" +} + +// ddgImageResult represents a single image result from DDG's JSON API. +type ddgImageResult struct { + Title string `json:"title"` + URL string `json:"url"` + Thumbnail string `json:"thumbnail"` + Image string `json:"image"` + Width int `json:"width"` + Height int `json:"height"` + Source string `json:"source"` +} + +func parseDDGImages(body []byte, query string) (contracts.SearchResponse, error) { + var results struct { + Results []ddgImageResult `json:"results"` + } + + if err := json.Unmarshal(body, &results); err != nil { + return contracts.SearchResponse{}, fmt.Errorf("ddg_images JSON parse error: %w", err) + } + + out := make([]contracts.MainResult, 0, len(results.Results)) + for _, img := range results.Results { + if img.URL == "" { + continue + } + + // Prefer the full image URL as thumbnail, fall back to the thumbnail field. + thumb := img.Image + if thumb == "" { + thumb = img.Thumbnail + } + + // Build a simple content string showing dimensions. + content := "" + if img.Width > 0 && img.Height > 0 { + content = strconv.Itoa(img.Width) + " × " + strconv.Itoa(img.Height) + } + if img.Source != "" { + if content != "" { + content += " — " + img.Source + } else { + content = img.Source + } + } + + urlPtr := img.URL + out = append(out, contracts.MainResult{ + Template: "images", + Title: img.Title, + Content: content, + URL: &urlPtr, + Thumbnail: thumb, + Engine: "ddg_images", + Score: 0, + Category: "images", + Engines: []string{"ddg_images"}, + }) + } + + return contracts.SearchResponse{ + Query: query, + NumberOfResults: len(out), + Results: out, + Answers: []map[string]any{}, + Corrections: []string{}, + Infoboxes: []map[string]any{}, + Suggestions: []string{}, + UnresponsiveEngines: [][2]string{}, + }, nil +} diff --git a/internal/engines/factory.go b/internal/engines/factory.go index 68f66eb..c3a0d95 100644 --- a/internal/engines/factory.go +++ b/internal/engines/factory.go @@ -73,5 +73,9 @@ func NewDefaultPortedEngines(client *http.Client, cfg *config.Config) map[string apiKey: youtubeAPIKey, baseURL: "https://www.googleapis.com", }, + // Image engines + "bing_images": &BingImagesEngine{client: client}, + "ddg_images": &DuckDuckGoImagesEngine{client: client}, + "qwant_images": &QwantImagesEngine{client: client}, } } diff --git a/internal/engines/html_helpers.go b/internal/engines/html_helpers.go index 66690c3..c704d7c 100644 --- a/internal/engines/html_helpers.go +++ b/internal/engines/html_helpers.go @@ -72,3 +72,14 @@ func htmlUnescape(s string) string { s = strings.ReplaceAll(s, " ", " ") return s } + +// extractImgSrc finds the first in an HTML string and returns +// the src attribute value. +func extractImgSrc(html string) string { + idx := strings.Index(html, "= 300 { + io.Copy(io.Discard, io.LimitReader(resp.Body, 16*1024)) + return contracts.SearchResponse{}, fmt.Errorf("qwant_images upstream error: status %d", resp.StatusCode) + } + + body, err := io.ReadAll(io.LimitReader(resp.Body, 2*1024*1024)) + if err != nil { + return contracts.SearchResponse{}, err + } + + return parseQwantImages(body, req.Query) +} + +func parseQwantImages(body []byte, query string) (contracts.SearchResponse, error) { + var top map[string]any + if err := json.Unmarshal(body, &top); err != nil { + return contracts.SearchResponse{}, fmt.Errorf("qwant_images JSON parse error: %w", err) + } + + status, _ := top["status"].(string) + if status != "success" { + return contracts.SearchResponse{ + Query: query, + UnresponsiveEngines: [][2]string{{"qwant_images", "api_error"}}, + Results: []contracts.MainResult{}, + Answers: []map[string]any{}, + Corrections: []string{}, + Infoboxes: []map[string]any{}, + Suggestions: []string{}, + }, nil + } + + data, _ := top["data"].(map[string]any) + result, _ := data["result"].(map[string]any) + items, _ := result["items"].(map[string]any) + mainline := items["mainline"] + + rows := toSlice(mainline) + if len(rows) == 0 { + return contracts.SearchResponse{ + Query: query, + NumberOfResults: 0, + Results: []contracts.MainResult{}, + Answers: []map[string]any{}, + Corrections: []string{}, + Infoboxes: []map[string]any{}, + Suggestions: []string{}, + UnresponsiveEngines: [][2]string{}, + }, nil + } + + out := make([]contracts.MainResult, 0) + for _, row := range rows { + rowMap, ok := row.(map[string]any) + if !ok { + continue + } + rowType, _ := rowMap["type"].(string) + if rowType != "images" { + continue + } + + rowItems := toSlice(rowMap["items"]) + for _, it := range rowItems { + itemMap, ok := it.(map[string]any) + if !ok { + continue + } + title := toString(itemMap["title"]) + resURL := toString(itemMap["url"]) + thumb := toString(itemMap["thumbnail"]) + fullImg := toString(itemMap["media"]) + source := toString(itemMap["source"]) + + if resURL == "" && fullImg == "" { + continue + } + + // Use the source page URL for the link, full image for thumbnail display. + linkPtr := resURL + if linkPtr == "" { + linkPtr = fullImg + } + displayThumb := fullImg + if displayThumb == "" { + displayThumb = thumb + } + + content := source + if width, ok := itemMap["width"]; ok { + w := toString(width) + if h, ok2 := itemMap["height"]; ok2 { + h2 := toString(h) + if w != "" && h2 != "" { + content = w + " × " + h2 + if source != "" { + content += " — " + source + } + } + } + } + + out = append(out, contracts.MainResult{ + Template: "images", + Title: title, + Content: content, + URL: &linkPtr, + Thumbnail: displayThumb, + Engine: "qwant_images", + Score: 0, + Category: "images", + Engines: []string{"qwant_images"}, + }) + } + } + + return contracts.SearchResponse{ + Query: query, + NumberOfResults: len(out), + Results: out, + Answers: []map[string]any{}, + Corrections: []string{}, + Infoboxes: []map[string]any{}, + Suggestions: []string{}, + UnresponsiveEngines: [][2]string{}, + }, nil +} diff --git a/internal/search/request_params.go b/internal/search/request_params.go index 2e477fb..a7c810d 100644 --- a/internal/search/request_params.go +++ b/internal/search/request_params.go @@ -35,6 +35,8 @@ var knownEngineNames = map[string]bool{ "braveapi": true, "brave": true, "qwant": true, "duckduckgo": true, "github": true, "reddit": true, "bing": true, "google": true, "youtube": true, + // Image engines + "bing_images": true, "ddg_images": true, "qwant_images": true, } // validateEngines filters engine names against the known registry. diff --git a/internal/views/static/css/kafka.css b/internal/views/static/css/kafka.css index 40f0f25..ac740d8 100644 --- a/internal/views/static/css/kafka.css +++ b/internal/views/static/css/kafka.css @@ -952,6 +952,100 @@ footer a:hover { } } +/* ============================================================ + Image Results + ============================================================ */ + +.image-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); + gap: 1rem; +} + +.image-result { + display: block; + border-radius: var(--radius-sm); + overflow: hidden; + background: var(--bg-secondary); + border: 1px solid var(--border); + transition: transform 0.15s ease, box-shadow 0.15s ease; + text-decoration: none; + color: inherit; +} + +.image-result:hover { + transform: translateY(-2px); + box-shadow: 0 4px 12px var(--shadow); +} + +.image-result:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + +.image-thumb { + aspect-ratio: 1; + overflow: hidden; + background: var(--bg-tertiary); + display: flex; + align-items: center; + justify-content: center; +} + +.image-thumb img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; + transition: transform 0.2s ease; +} + +.image-result:hover .image-thumb img { + transform: scale(1.05); +} + +.image-thumb.image-error img, +.image-thumb.image-error { + display: none; +} + +.image-placeholder { + font-size: 2rem; + opacity: 0.3; +} + +.image-meta { + padding: 0.5rem; + min-height: 2.5rem; + display: flex; + flex-direction: column; + gap: 0.15rem; +} + +.image-title { + font-size: 0.8rem; + font-weight: 500; + color: var(--text-primary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.image-source { + font-size: 0.7rem; + color: var(--text-secondary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +@media (max-width: 480px) { + .image-grid { + grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); + gap: 0.5rem; + } +} + /* ============================================================ Infoboxes ============================================================ */ diff --git a/internal/views/templates/image_item.html b/internal/views/templates/image_item.html new file mode 100644 index 0000000..ac067ce --- /dev/null +++ b/internal/views/templates/image_item.html @@ -0,0 +1,15 @@ +{{define "image_item"}} + +
+ {{if .Thumbnail}} + {{.Title}} + {{else}} +
🖼️
+ {{end}} +
+
+ {{.Title}} + {{if .Content}}{{.Content}}{{end}} +
+
+{{end}} diff --git a/internal/views/templates/results_inner.html b/internal/views/templates/results_inner.html index cca51d1..5dc67b5 100644 --- a/internal/views/templates/results_inner.html +++ b/internal/views/templates/results_inner.html @@ -19,13 +19,25 @@
{{if .Results}} + {{if .IsImageSearch}} +
+ {{range .Results}} + {{if eq .Template "images"}} + {{template "image_item" .}} + {{end}} + {{end}} +
+ {{else}} {{range .Results}} {{if eq .Template "videos"}} {{template "video_item" .}} + {{else if eq .Template "images"}} + {{template "image_item" .}} {{else}} {{template "result_item" .}} {{end}} {{end}} + {{end}} {{else if not .Answers}}
🔍
diff --git a/internal/views/views.go b/internal/views/views.go index c176f81..4162a03 100644 --- a/internal/views/views.go +++ b/internal/views/views.go @@ -52,6 +52,7 @@ type PageData struct { UnresponsiveEngines [][2]string PageNumbers []PageNumber ShowHeader bool + IsImageSearch bool // New fields for three-column layout Categories []string CategoryIcons map[string]string @@ -106,13 +107,13 @@ func init() { } tmplFull = template.Must(template.New("").Funcs(funcMap).ParseFS(tmplFS, - "base.html", "results.html", "results_inner.html", "result_item.html", "video_item.html", + "base.html", "results.html", "results_inner.html", "result_item.html", "video_item.html", "image_item.html", )) tmplIndex = template.Must(template.New("").Funcs(funcMap).ParseFS(tmplFS, "base.html", "index.html", )) tmplFragment = template.Must(template.New("").Funcs(funcMap).ParseFS(tmplFS, - "results.html", "results_inner.html", "result_item.html", "video_item.html", + "results.html", "results_inner.html", "result_item.html", "video_item.html", "image_item.html", )) tmplPreferences = template.Must(template.New("").Funcs(funcMap).ParseFS(tmplFS, "base.html", "preferences.html", @@ -168,6 +169,7 @@ func FromResponse(resp contracts.SearchResponse, query string, pageno int, activ "weather": "🌤️", }, ActiveCategory: activeCategory, + IsImageSearch: activeCategory == "images", // Time filters TimeFilters: []FilterOption{ From a9ae69cad5c479b1b42e9d20cb856006a57f5e18 Mon Sep 17 00:00:00 2001 From: Franz Kafka Date: Sun, 22 Mar 2026 17:22:31 +0000 Subject: [PATCH 76/91] fix(security): allow HTMX CDN and inline scripts in CSP script-src now permits 'unsafe-inline' and https://unpkg.com so the autocomplete script and HTMX library load correctly. --- internal/middleware/security.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/middleware/security.go b/internal/middleware/security.go index 09f3878..2d75003 100644 --- a/internal/middleware/security.go +++ b/internal/middleware/security.go @@ -80,7 +80,7 @@ func SecurityHeaders(cfg SecurityHeadersConfig) func(http.Handler) http.Handler func defaultCSP() string { return strings.Join([]string{ "default-src 'self'", - "script-src 'self'", + "script-src 'self' 'unsafe-inline' https://unpkg.com", "style-src 'self' 'unsafe-inline'", "img-src 'self' https: data:", "connect-src 'self'", From 2f10f4e1e59b86c046714c630d908ae0b23f0a84 Mon Sep 17 00:00:00 2001 From: Franz Kafka Date: Sun, 22 Mar 2026 17:31:06 +0000 Subject: [PATCH 77/91] fix(css): remove duplicate .results-layout that broke 3-column grid The old 3-column layout block (referencing .left-sidebar/.right-sidebar classes that don't exist in the HTML) was overriding the correct layout defined earlier. Removed the stale duplicate. --- internal/views/static/css/kafka.css | 44 ----------------------------- 1 file changed, 44 deletions(-) diff --git a/internal/views/static/css/kafka.css b/internal/views/static/css/kafka.css index ac740d8..ef318d0 100644 --- a/internal/views/static/css/kafka.css +++ b/internal/views/static/css/kafka.css @@ -1130,50 +1130,6 @@ a:focus-visible { background: var(--border-focus); } -/* ============================================================ - Three-Column Results Layout - ============================================================ */ - -.results-layout { - display: grid; - grid-template-columns: 200px 1fr 240px; - gap: 2rem; - align-items: start; -} - -.results-layout .left-sidebar, -.results-layout .right-sidebar { - position: sticky; - top: calc(var(--header-height) + 1.5rem); - max-height: calc(100vh - var(--header-height) - 3rem); - overflow-y: auto; -} - -.results-layout .results-column { - min-width: 0; -} - -/* Tablet: hide left sidebar, two columns */ -@media (min-width: 769px) and (max-width: 1024px) { - .results-layout { - grid-template-columns: 1fr 220px; - } - .results-layout .left-sidebar { - display: none; - } -} - -/* Mobile: single column, no sidebars */ -@media (max-width: 768px) { - .results-layout { - grid-template-columns: 1fr; - } - .results-layout .left-sidebar, - .results-layout .right-sidebar { - display: none; - } -} - /* ============================================================ Preferences Page Layout ============================================================ */ From 00b2be9e796fe666c3cf22046b4764777f97e123 Mon Sep 17 00:00:00 2001 From: Franz Kafka Date: Sun, 22 Mar 2026 17:35:35 +0000 Subject: [PATCH 78/91] fix(css): restore original layout, re-add only image grid styles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reverted CSS to the known-working state at 4b0cde9, then re-applied only the image grid styles. The duplicate .results-layout block is intentional — it was present in the working version too. --- internal/views/static/css/kafka.css | 44 +++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/internal/views/static/css/kafka.css b/internal/views/static/css/kafka.css index ef318d0..ac740d8 100644 --- a/internal/views/static/css/kafka.css +++ b/internal/views/static/css/kafka.css @@ -1130,6 +1130,50 @@ a:focus-visible { background: var(--border-focus); } +/* ============================================================ + Three-Column Results Layout + ============================================================ */ + +.results-layout { + display: grid; + grid-template-columns: 200px 1fr 240px; + gap: 2rem; + align-items: start; +} + +.results-layout .left-sidebar, +.results-layout .right-sidebar { + position: sticky; + top: calc(var(--header-height) + 1.5rem); + max-height: calc(100vh - var(--header-height) - 3rem); + overflow-y: auto; +} + +.results-layout .results-column { + min-width: 0; +} + +/* Tablet: hide left sidebar, two columns */ +@media (min-width: 769px) and (max-width: 1024px) { + .results-layout { + grid-template-columns: 1fr 220px; + } + .results-layout .left-sidebar { + display: none; + } +} + +/* Mobile: single column, no sidebars */ +@media (max-width: 768px) { + .results-layout { + grid-template-columns: 1fr; + } + .results-layout .left-sidebar, + .results-layout .right-sidebar { + display: none; + } +} + /* ============================================================ Preferences Page Layout ============================================================ */ From 1543b166052589afd138f551d878deec18d3b72b Mon Sep 17 00:00:00 2001 From: ashisgreat22 Date: Sun, 22 Mar 2026 18:58:50 +0100 Subject: [PATCH 79/91] docs: add frontend replacement design spec Co-Authored-By: Claude Opus 4.6 --- .../2026-03-22-frontend-replacement-design.md | 74 +++++++++++++++++++ 1 file changed, 74 insertions(+) create mode 100644 docs/superpowers/specs/2026-03-22-frontend-replacement-design.md diff --git a/docs/superpowers/specs/2026-03-22-frontend-replacement-design.md b/docs/superpowers/specs/2026-03-22-frontend-replacement-design.md new file mode 100644 index 0000000..65c294a --- /dev/null +++ b/docs/superpowers/specs/2026-03-22-frontend-replacement-design.md @@ -0,0 +1,74 @@ +# Frontend Replacement: search-zen-50 Integration + +## Status +Approved + +## Overview + +Replace the current Go template-based frontend (HTMX + Go templates) with the search-zen-50 React SPA. The React app is built statically and embedded into the Go binary, serving as a single binary deployment. + +## Architecture + +- **Build**: React/Vite app builds to `dist/` directory +- **Embed**: Go's `//go:embed` embeds the dist folder into the binary +- **Serve**: Go HTTP server serves static files and handles API routes +- **SPA routing**: Non-API routes serve `index.html` for React Router + +## Changes + +### Go Side + +1. **Create `internal/spa/spa.go`** + - Embeds the React build (`dist/`) using `//go:embed` + - Serves static files (JS, CSS, images) + - Handles SPA fallback: serves `index.html` for all non-API routes + - Provides `SPAHandler` that wraps API routes + +2. **Modify `cmd/kafka/main.go`** + - Import the embedded SPA files + - Route `/`, `/preferences`, and unknown routes to SPA handler + - Keep existing API routes: `/search`, `/autocompleter`, `/healthz`, `/opensearch.xml` + +### React Side + +1. **Modify `use-search.ts`** + - Replace mock data with real API call: `fetch("/search?format=json&q=${encodeURIComponent(query)}")` + - Map response to existing `SearXNGResponse` type (already matches) + +2. **Add autocomplete** (optional enhancement) + - Call `/autocompleter?q=${encodeURIComponent(query)}` + - Display suggestions while typing + +3. **Keep unchanged** + - All UI components + - Preferences page (localStorage-based) + - Routing (React Router) + +## Data Flow + +``` +Browser → GET / → Go serves embedded index.html +Browser → GET /search?format=json&q=... → Go search handler → JSON +Browser → React renders results via use-search hook +``` + +## API Compatibility + +The existing kafka API (`/search?format=json`) already matches the expected `SearXNGResponse` interface in the React code: +- `query: string` +- `number_of_results: number` +- `results: SearchResult[]` +- `suggestions: string[]` +- `unresponsive_engines: string[][]` + +## File Changes + +- **New**: `internal/spa/spa.go` +- **Modified**: `cmd/kafka/main.go` (wire SPA handler) +- **Modified**: `src/hooks/use-search.ts` (use real API) +- **Build step**: `npm run build` or `bun run build` in search-zen-50 + +## Dependencies + +- React app uses `@tanstack/react-query` for API calls (already in package.json) +- No new Go dependencies needed From 8651183540fb63c9c39ccc9060dc7590540e1081 Mon Sep 17 00:00:00 2001 From: ashisgreat22 Date: Sun, 22 Mar 2026 19:40:34 +0100 Subject: [PATCH 80/91] feat(spa): add SPA Go package with embedded dist FS Creates internal/spa package that: - Embeds React build output from cmd/kafka/dist/ - Provides HTTP handler for static file serving - Falls back to index.html for SPA client-side routing Co-Authored-By: Claude Opus 4.6 --- internal/spa/spa.go | 56 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 internal/spa/spa.go diff --git a/internal/spa/spa.go b/internal/spa/spa.go new file mode 100644 index 0000000..b9810dd --- /dev/null +++ b/internal/spa/spa.go @@ -0,0 +1,56 @@ +package spa + +import ( + "embed" + "io/fs" + "net/http" + "path" +) + +//go:embed all:dist +var distFS embed.FS + +// DistFS returns the embedded dist directory as an fs.FS. +func DistFS() (fs.FS, error) { + return fs.Sub(distFS, "dist") +} + +// NewHandler returns an HTTP handler that: +// - Serves static files from the embedded dist/ directory +// - Falls back to index.html for SPA routing (any non-API path) +func NewHandler() http.Handler { + dist, err := DistFS() + if err != nil { + panic("spa: embedded dist not found: " + err.Error()) + } + return &spaHandler{dist: dist} +} + +type spaHandler struct { + dist fs.FS +} + +func (h *spaHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + // API paths are handled by Go API handlers - this should never be reached + // since Go mux dispatches to specific handlers first. But if reached, + // pass through to FileServer which will return 404 for unknown paths. + + // Try to serve the requested file first + filePath := path.Clean(r.URL.Path) + f, err := h.dist.Open(filePath) + if err == nil { + f.Close() + // File exists - serve it via FileServer + http.FileServer(http.FS(h.dist)).ServeHTTP(w, r) + return + } + + // Fallback to index.html for SPA routing + indexFile, err := h.dist.Open("index.html") + if err != nil { + http.Error(w, "index.html not found in embedded files", http.StatusInternalServerError) + return + } + indexFile.Close() + http.FileServer(http.FS(h.dist)).ServeHTTP(w, r) +} \ No newline at end of file From 5d14d291ca92e6b23776fbf27b1509d2a7f84772 Mon Sep 17 00:00:00 2001 From: ashisgreat22 Date: Sun, 22 Mar 2026 19:50:03 +0100 Subject: [PATCH 81/91] feat(main): wire SPA handler in main.go Replace template-based handlers (h.Index, h.Preferences) with the new spa handler. API routes (healthz, search, autocompleter, opensearch.xml) are registered first as exact matches, followed by the SPA catchall handler for all other routes. Remove unused views and io/fs imports. Co-Authored-By: Claude Opus 4.6 --- cmd/kafka/main.go | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/cmd/kafka/main.go b/cmd/kafka/main.go index f691665..29ab620 100644 --- a/cmd/kafka/main.go +++ b/cmd/kafka/main.go @@ -19,7 +19,6 @@ package main import ( "flag" "fmt" - "io/fs" "log" "log/slog" "net/http" @@ -31,7 +30,7 @@ import ( "github.com/metamorphosis-dev/kafka/internal/httpapi" "github.com/metamorphosis-dev/kafka/internal/middleware" "github.com/metamorphosis-dev/kafka/internal/search" - "github.com/metamorphosis-dev/kafka/internal/views" + "github.com/metamorphosis-dev/kafka/internal/spa" ) func main() { @@ -80,20 +79,16 @@ func main() { h := httpapi.NewHandler(svc, acSvc.Suggestions, cfg.Server.SourceURL) mux := http.NewServeMux() - mux.HandleFunc("/", h.Index) + + // API routes - handled by Go mux.HandleFunc("/healthz", h.Healthz) mux.HandleFunc("/search", h.Search) mux.HandleFunc("/autocompleter", h.Autocompleter) - mux.HandleFunc("/preferences", h.Preferences) mux.HandleFunc("/opensearch.xml", h.OpenSearch(cfg.Server.BaseURL)) - // Serve embedded static files (CSS, JS, images). - staticFS, err := views.StaticFS() - if err != nil { - log.Fatalf("failed to load static files: %v", err) - } - var subFS fs.FS = staticFS - mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.FS(subFS)))) + // SPA handler - serves React app for all other routes + spaHandler := spa.NewHandler() + mux.Handle("/", spaHandler) // Apply middleware: global rate limit → burst rate limit → per-IP rate limit → CORS → security headers → handler. var handler http.Handler = mux From 6b418057ef5e1849f8b57f109751c0eeb279527d Mon Sep 17 00:00:00 2001 From: ashisgreat22 Date: Sun, 22 Mar 2026 21:12:01 +0100 Subject: [PATCH 82/91] feat(frontend): replace Go templates with React SPA - Add internal/spa package for embedding React build - Wire SPA handler in main.go for non-API routes - Add gitignore entry for internal/spa/dist - Add implementation plan Co-Authored-By: Claude Opus 4.6 --- .gitignore | 1 + .../plans/2026-03-22-frontend-replacement.md | 358 ++++++++++++++++++ 2 files changed, 359 insertions(+) create mode 100644 docs/superpowers/plans/2026-03-22-frontend-replacement.md diff --git a/.gitignore b/.gitignore index a5388c7..19776c8 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ node_modules/ .agent/ +internal/spa/dist/ *.exe *.exe~ *.dll diff --git a/docs/superpowers/plans/2026-03-22-frontend-replacement.md b/docs/superpowers/plans/2026-03-22-frontend-replacement.md new file mode 100644 index 0000000..1cb475e --- /dev/null +++ b/docs/superpowers/plans/2026-03-22-frontend-replacement.md @@ -0,0 +1,358 @@ +# Frontend Replacement Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace the Go template-based frontend with the search-zen-50 React SPA, embedded in the Go binary as a single deployment. + +**Architecture:** Build React app → embed in Go binary via `//go:embed` → serve via Go HTTP server with SPA fallback routing. React calls `/search?format=json` and `/autocompleter?q=` APIs. + +**Tech Stack:** Go (embed), React 18, Vite, TailwindCSS, React Router, @tanstack/react-query + +--- + +## File Map + +| File | Action | +|------|--------| +| `cmd/kafka/main.go` | Modify - replace template handlers with SPA handler | +| `internal/spa/spa.go` | Create - embed React build, serve static files, SPA fallback | +| `internal/spa/dist/` | Build output - React build artifacts (gitignored) | +| `src/hooks/use-search.ts` | Modify - replace mock with real API calls | +| `src/lib/mock-data.ts` | Keep types, remove MOCK_RESPONSE usage | + +--- + +## Task 1: Build React App + +**Files:** +- Build: `/tmp/search-zen-50/dist/` (output directory) + +- [ ] **Step 1: Install dependencies and build** + +```bash +cd /tmp/search-zen-50 && bun install && bun run build +``` + +Expected: `dist/` directory created with `index.html`, `assets/` folder containing JS/CSS bundles + +- [ ] **Step 2: Verify dist contents** + +```bash +ls /tmp/search-zen-50/dist/ && ls /tmp/search-zen-50/dist/assets/ | head -10 +``` + +Expected: `index.html` exists, `assets/` contains `.js` and `.css` files + +--- + +## Task 2: Create SPA Go Package + +**Files:** +- Create: `internal/spa/spa.go` + +```go +package spa + +import ( + "embed" + "io/fs" + "net/http" + "path" +) + +//go:embed all:dist +var distFS embed.FS + +// DistFS returns the embedded dist directory as an fs.FS. +func DistFS() (fs.FS, error) { + return fs.Sub(distFS, "dist") +} + +// NewHandler returns an HTTP handler that: +// - Serves static files from the embedded dist/ directory +// - Falls back to index.html for SPA routing (any non-API path) +func NewHandler() http.Handler { + dist, err := DistFS() + if err != nil { + panic("spa: embedded dist not found: " + err.Error()) + } + return &spaHandler{dist: dist} +} + +type spaHandler struct { + dist fs.FS +} + +func (h *spaHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + // API paths are handled by Go API handlers - this should never be reached + // since Go mux dispatches to specific handlers first. But if reached, + // pass through to FileServer which will return 404 for unknown paths. + + // Try to serve the requested file first + filePath := path.Clean(r.URL.Path) + f, err := h.dist.Open(filePath) + if err == nil { + f.Close() + // File exists - serve it via FileServer + http.FileServer(http.FS(h.dist)).ServeHTTP(w, r) + return + } + + // Fallback to index.html for SPA routing + indexFile, err := h.dist.Open("index.html") + if err != nil { + http.Error(w, "index.html not found in embedded files", http.StatusInternalServerError) + return + } + indexFile.Close() + http.FileServer(http.FS(h.dist)).ServeHTTP(w, r) +} + +``` + +--- + +## Task 3: Wire SPA Handler in main.go + +**Files:** +- Modify: `cmd/kafka/main.go` + +- [ ] **Step 1: Replace handlers with SPA** + +In `main.go`, find and replace the `mux.HandleFunc` section (lines 82-88) and the static file serving section (lines 90-96). + +Old code (lines 82-96): +```go +mux := http.NewServeMux() +mux.HandleFunc("/", h.Index) +mux.HandleFunc("/healthz", h.Healthz) +mux.HandleFunc("/search", h.Search) +mux.HandleFunc("/autocompleter", h.Autocompleter) +mux.HandleFunc("/preferences", h.Preferences) +mux.HandleFunc("/opensearch.xml", h.OpenSearch(cfg.Server.BaseURL)) + +// Serve embedded static files (CSS, JS, images). +staticFS, err := views.StaticFS() +if err != nil { + log.Fatalf("failed to load static files: %v", err) +} +var subFS fs.FS = staticFS +mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.FS(subFS)))) +``` + +New code: +```go +mux := http.NewServeMux() + +// API routes - handled by Go +mux.HandleFunc("/healthz", h.Healthz) +mux.HandleFunc("/search", h.Search) +mux.HandleFunc("/autocompleter", h.Autocompleter) +mux.HandleFunc("/opensearch.xml", h.OpenSearch(cfg.Server.BaseURL)) + +// SPA handler - serves React app for all other routes +spaHandler := spa.NewHandler() +mux.Handle("/", spaHandler) +``` + +- [ ] **Step 2: Add spa import** + +Add to imports (after `"github.com/metamorphosis-dev/kafka/internal/search"`): +```go +"github.com/metamorphosis-dev/kafka/internal/spa" +``` + +- [ ] **Step 3: Remove unused views import if needed** + +If `views` is only used for `StaticFS()`, remove the import. The template rendering functions (`RenderIndex`, etc.) won't be needed anymore. + +- [ ] **Step 4: Verify build** + +```bash +cd /home/ashie/git/kafka && go build ./cmd/kafka/ +``` + +Expected: Builds successfully (may fail on embed if dist not found - continue to next task) + +--- + +## Task 4: Wire React to Real API + +**Files:** +- Modify: `src/hooks/use-search.ts` in `/tmp/search-zen-50/` + +- [ ] **Step 1: Replace mock search with real API call** + +Replace the `search` function in `use-search.ts`: + +Old code (lines 23-36): +```typescript +const search = useCallback(async (query: string) => { + if (!query.trim()) return; + + setState((prev) => ({ ...prev, query, isLoading: true, error: null, hasSearched: true })); + + // Simulate network delay + await new Promise((r) => setTimeout(r, 800)); + + setState((prev) => ({ + ...prev, + isLoading: false, + results: { ...MOCK_RESPONSE, query }, + })); +}, []); +``` + +New code: +```typescript +const search = useCallback(async (query: string) => { + if (!query.trim()) return; + + setState((prev) => ({ ...prev, query, isLoading: true, error: null, hasSearched: true })); + + try { + const response = await fetch(`/search?format=json&q=${encodeURIComponent(query)}`); + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + const data = await response.json(); + setState((prev) => ({ + ...prev, + isLoading: false, + results: data, + })); + } catch (err) { + setState((prev) => ({ + ...prev, + isLoading: false, + error: err instanceof Error ? err.message : "Search failed", + })); + } +}, []); +``` + +- [ ] **Step 2: Remove mock data import** + +Remove the mock import line (should be near line 2): +```typescript +import { MOCK_RESPONSE, type SearXNGResponse, type Category } from "@/lib/mock-data"; +``` + +Replace with: +```typescript +import type { SearXNGResponse, Category } from "@/lib/mock-data"; +``` + +- [ ] **Step 3: Keep the CATEGORIES export** + +Ensure `mock-data.ts` still exports `CATEGORIES` and `Category` type. The file should look like: + +```typescript +// Keep these exports - used by CategoryTabs and preferences +export const CATEGORIES = ["general", "it", "images", "news"] as const; +export type Category = typeof CATEGORIES[number]; + +// Keep interfaces +export interface SearchResult { + url: string; + title: string; + content: string; + engine: string; + parsed_url: [string, string, string, string, string]; + engines: string[]; + positions: number[]; + score: number; + category: string; + pretty_url: string; + img_src?: string; + thumbnail?: string; + publishedDate?: string; +} + +export interface SearXNGResponse { + query: string; + number_of_results: number; + results: SearchResult[]; + answers: string[]; + corrections: string[]; + infoboxes: any[]; + suggestions: string[]; + unresponsive_engines: string[]; +} +``` + +--- + +## Task 5: Rebuild React and Verify + +**Files:** +- Build: `/tmp/search-zen-50/dist/` + +- [ ] **Step 1: Rebuild with changes** + +```bash +cd /tmp/search-zen-50 && bun run build +``` + +- [ ] **Step 2: Copy dist to kafka** + +```bash +rm -rf /home/ashie/git/kafka/internal/spa/dist +cp -r /tmp/search-zen-50/dist /home/ashie/git/kafka/internal/spa/dist +``` + +- [ ] **Step 3: Verify Go build** + +```bash +cd /home/ashie/git/kafka && go build ./cmd/kafka/ && echo "Build successful" +``` + +Expected: "Build successful" + +--- + +## Task 6: Test the Integration + +- [ ] **Step 1: Start the server** + +```bash +cd /home/ashie/git/kafka && ./kafka -config config.toml & +sleep 2 +``` + +- [ ] **Step 2: Test homepage** + +```bash +curl -s http://localhost:8080/ | head -20 +``` + +Expected: HTML with `
` from React app + +- [ ] **Step 3: Test API** + +```bash +curl -s "http://localhost:8080/search?format=json&q=test" | head -50 +``` + +Expected: JSON search response + +- [ ] **Step 4: Clean up** + +```bash +pkill -f "./kafka" 2>/dev/null; echo "Done" +``` + +--- + +## Dependencies + +- Node.js/Bun for building React app +- Go 1.24+ for embed functionality +- No new Go dependencies + +## Notes + +- The `internal/spa/dist/` folder should be gitignored (build artifact) +- The `internal/spa/dist/` copy is needed for the embed to work at compile time +- Preferences page is entirely client-side (localStorage) - no backend needed +- Autocomplete can be added later by modifying `SearchInput.tsx` to call `/autocompleter` From 168cb78fab5cbe9785ae99fa7d52e544c620158a Mon Sep 17 00:00:00 2001 From: ashisgreat22 Date: Sun, 22 Mar 2026 21:27:45 +0100 Subject: [PATCH 83/91] feat: add frontend source code Add search-zen-50 React SPA source code to frontend/ directory. Build artifacts (dist, node_modules, lock files) are gitignored. Co-Authored-By: Claude Opus 4.6 --- .gitignore | 5 + frontend/.gitignore | 24 + frontend/README.md | 3 + frontend/components.json | 20 + frontend/eslint.config.js | 26 + frontend/index.html | 29 + frontend/package.json | 90 +++ frontend/playwright-fixture.ts | 3 + frontend/playwright.config.ts | 10 + frontend/postcss.config.js | 6 + frontend/public/favicon.ico | Bin 0 -> 20373 bytes frontend/public/placeholder.svg | 40 ++ frontend/public/robots.txt | 14 + frontend/src/App.css | 42 ++ frontend/src/App.tsx | 31 + frontend/src/components/CategoryTabs.tsx | 39 ++ frontend/src/components/NavLink.tsx | 28 + frontend/src/components/ResultCard.tsx | 40 ++ frontend/src/components/ResultSkeleton.tsx | 19 + frontend/src/components/SearchInput.tsx | 43 ++ frontend/src/components/ui/accordion.tsx | 52 ++ frontend/src/components/ui/alert-dialog.tsx | 104 +++ frontend/src/components/ui/alert.tsx | 43 ++ frontend/src/components/ui/aspect-ratio.tsx | 5 + frontend/src/components/ui/avatar.tsx | 38 ++ frontend/src/components/ui/badge.tsx | 29 + frontend/src/components/ui/breadcrumb.tsx | 90 +++ frontend/src/components/ui/button.tsx | 47 ++ frontend/src/components/ui/calendar.tsx | 54 ++ frontend/src/components/ui/card.tsx | 43 ++ frontend/src/components/ui/carousel.tsx | 224 ++++++ frontend/src/components/ui/chart.tsx | 303 +++++++++ frontend/src/components/ui/checkbox.tsx | 26 + frontend/src/components/ui/collapsible.tsx | 9 + frontend/src/components/ui/command.tsx | 132 ++++ frontend/src/components/ui/context-menu.tsx | 178 +++++ frontend/src/components/ui/dialog.tsx | 95 +++ frontend/src/components/ui/drawer.tsx | 87 +++ frontend/src/components/ui/dropdown-menu.tsx | 179 +++++ frontend/src/components/ui/form.tsx | 129 ++++ frontend/src/components/ui/hover-card.tsx | 27 + frontend/src/components/ui/input-otp.tsx | 61 ++ frontend/src/components/ui/input.tsx | 22 + frontend/src/components/ui/label.tsx | 17 + frontend/src/components/ui/menubar.tsx | 207 ++++++ .../src/components/ui/navigation-menu.tsx | 120 ++++ frontend/src/components/ui/pagination.tsx | 81 +++ frontend/src/components/ui/popover.tsx | 29 + frontend/src/components/ui/progress.tsx | 23 + frontend/src/components/ui/radio-group.tsx | 36 + frontend/src/components/ui/resizable.tsx | 37 + frontend/src/components/ui/scroll-area.tsx | 38 ++ frontend/src/components/ui/select.tsx | 143 ++++ frontend/src/components/ui/separator.tsx | 20 + frontend/src/components/ui/sheet.tsx | 107 +++ frontend/src/components/ui/sidebar.tsx | 637 ++++++++++++++++++ frontend/src/components/ui/skeleton.tsx | 7 + frontend/src/components/ui/slider.tsx | 23 + frontend/src/components/ui/sonner.tsx | 27 + frontend/src/components/ui/switch.tsx | 27 + frontend/src/components/ui/table.tsx | 72 ++ frontend/src/components/ui/tabs.tsx | 53 ++ frontend/src/components/ui/textarea.tsx | 21 + frontend/src/components/ui/toast.tsx | 111 +++ frontend/src/components/ui/toaster.tsx | 24 + frontend/src/components/ui/toggle-group.tsx | 49 ++ frontend/src/components/ui/toggle.tsx | 37 + frontend/src/components/ui/tooltip.tsx | 28 + frontend/src/components/ui/use-toast.ts | 3 + frontend/src/contexts/PreferencesContext.tsx | 67 ++ frontend/src/hooks/use-mobile.tsx | 19 + frontend/src/hooks/use-search.ts | 72 ++ frontend/src/hooks/use-toast.ts | 186 +++++ frontend/src/index.css | 84 +++ frontend/src/lib/mock-data.ts | 127 ++++ frontend/src/lib/utils.ts | 6 + frontend/src/main.tsx | 5 + frontend/src/pages/Index.tsx | 93 +++ frontend/src/pages/NotFound.tsx | 24 + frontend/src/pages/Preferences.tsx | 88 +++ frontend/src/test/example.test.ts | 7 + frontend/src/test/setup.ts | 15 + frontend/src/vite-env.d.ts | 1 + frontend/tailwind.config.ts | 60 ++ frontend/tsconfig.app.json | 35 + frontend/tsconfig.json | 24 + frontend/tsconfig.node.json | 22 + frontend/vite.config.ts | 21 + frontend/vitest.config.ts | 16 + 89 files changed, 5438 insertions(+) create mode 100644 frontend/.gitignore create mode 100644 frontend/README.md create mode 100644 frontend/components.json create mode 100644 frontend/eslint.config.js create mode 100644 frontend/index.html create mode 100644 frontend/package.json create mode 100644 frontend/playwright-fixture.ts create mode 100644 frontend/playwright.config.ts create mode 100644 frontend/postcss.config.js create mode 100644 frontend/public/favicon.ico create mode 100644 frontend/public/placeholder.svg create mode 100644 frontend/public/robots.txt create mode 100644 frontend/src/App.css create mode 100644 frontend/src/App.tsx create mode 100644 frontend/src/components/CategoryTabs.tsx create mode 100644 frontend/src/components/NavLink.tsx create mode 100644 frontend/src/components/ResultCard.tsx create mode 100644 frontend/src/components/ResultSkeleton.tsx create mode 100644 frontend/src/components/SearchInput.tsx create mode 100644 frontend/src/components/ui/accordion.tsx create mode 100644 frontend/src/components/ui/alert-dialog.tsx create mode 100644 frontend/src/components/ui/alert.tsx create mode 100644 frontend/src/components/ui/aspect-ratio.tsx create mode 100644 frontend/src/components/ui/avatar.tsx create mode 100644 frontend/src/components/ui/badge.tsx create mode 100644 frontend/src/components/ui/breadcrumb.tsx create mode 100644 frontend/src/components/ui/button.tsx create mode 100644 frontend/src/components/ui/calendar.tsx create mode 100644 frontend/src/components/ui/card.tsx create mode 100644 frontend/src/components/ui/carousel.tsx create mode 100644 frontend/src/components/ui/chart.tsx create mode 100644 frontend/src/components/ui/checkbox.tsx create mode 100644 frontend/src/components/ui/collapsible.tsx create mode 100644 frontend/src/components/ui/command.tsx create mode 100644 frontend/src/components/ui/context-menu.tsx create mode 100644 frontend/src/components/ui/dialog.tsx create mode 100644 frontend/src/components/ui/drawer.tsx create mode 100644 frontend/src/components/ui/dropdown-menu.tsx create mode 100644 frontend/src/components/ui/form.tsx create mode 100644 frontend/src/components/ui/hover-card.tsx create mode 100644 frontend/src/components/ui/input-otp.tsx create mode 100644 frontend/src/components/ui/input.tsx create mode 100644 frontend/src/components/ui/label.tsx create mode 100644 frontend/src/components/ui/menubar.tsx create mode 100644 frontend/src/components/ui/navigation-menu.tsx create mode 100644 frontend/src/components/ui/pagination.tsx create mode 100644 frontend/src/components/ui/popover.tsx create mode 100644 frontend/src/components/ui/progress.tsx create mode 100644 frontend/src/components/ui/radio-group.tsx create mode 100644 frontend/src/components/ui/resizable.tsx create mode 100644 frontend/src/components/ui/scroll-area.tsx create mode 100644 frontend/src/components/ui/select.tsx create mode 100644 frontend/src/components/ui/separator.tsx create mode 100644 frontend/src/components/ui/sheet.tsx create mode 100644 frontend/src/components/ui/sidebar.tsx create mode 100644 frontend/src/components/ui/skeleton.tsx create mode 100644 frontend/src/components/ui/slider.tsx create mode 100644 frontend/src/components/ui/sonner.tsx create mode 100644 frontend/src/components/ui/switch.tsx create mode 100644 frontend/src/components/ui/table.tsx create mode 100644 frontend/src/components/ui/tabs.tsx create mode 100644 frontend/src/components/ui/textarea.tsx create mode 100644 frontend/src/components/ui/toast.tsx create mode 100644 frontend/src/components/ui/toaster.tsx create mode 100644 frontend/src/components/ui/toggle-group.tsx create mode 100644 frontend/src/components/ui/toggle.tsx create mode 100644 frontend/src/components/ui/tooltip.tsx create mode 100644 frontend/src/components/ui/use-toast.ts create mode 100644 frontend/src/contexts/PreferencesContext.tsx create mode 100644 frontend/src/hooks/use-mobile.tsx create mode 100644 frontend/src/hooks/use-search.ts create mode 100644 frontend/src/hooks/use-toast.ts create mode 100644 frontend/src/index.css create mode 100644 frontend/src/lib/mock-data.ts create mode 100644 frontend/src/lib/utils.ts create mode 100644 frontend/src/main.tsx create mode 100644 frontend/src/pages/Index.tsx create mode 100644 frontend/src/pages/NotFound.tsx create mode 100644 frontend/src/pages/Preferences.tsx create mode 100644 frontend/src/test/example.test.ts create mode 100644 frontend/src/test/setup.ts create mode 100644 frontend/src/vite-env.d.ts create mode 100644 frontend/tailwind.config.ts create mode 100644 frontend/tsconfig.app.json create mode 100644 frontend/tsconfig.json create mode 100644 frontend/tsconfig.node.json create mode 100644 frontend/vite.config.ts create mode 100644 frontend/vitest.config.ts diff --git a/.gitignore b/.gitignore index 19776c8..6cea500 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,11 @@ node_modules/ .agent/ internal/spa/dist/ +frontend/node_modules/ +frontend/dist/ +frontend/bun.lock +frontend/bun.lockb +frontend/package-lock.json *.exe *.exe~ *.dll diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..a125fd6 --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,3 @@ +# Welcome to your Lovable project + +TODO: Document your project here diff --git a/frontend/components.json b/frontend/components.json new file mode 100644 index 0000000..62e1011 --- /dev/null +++ b/frontend/components.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "default", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "tailwind.config.ts", + "css": "src/index.css", + "baseColor": "slate", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + } +} diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js new file mode 100644 index 0000000..40f72cc --- /dev/null +++ b/frontend/eslint.config.js @@ -0,0 +1,26 @@ +import js from "@eslint/js"; +import globals from "globals"; +import reactHooks from "eslint-plugin-react-hooks"; +import reactRefresh from "eslint-plugin-react-refresh"; +import tseslint from "typescript-eslint"; + +export default tseslint.config( + { ignores: ["dist"] }, + { + extends: [js.configs.recommended, ...tseslint.configs.recommended], + files: ["**/*.{ts,tsx}"], + languageOptions: { + ecmaVersion: 2020, + globals: globals.browser, + }, + plugins: { + "react-hooks": reactHooks, + "react-refresh": reactRefresh, + }, + rules: { + ...reactHooks.configs.recommended.rules, + "react-refresh/only-export-components": ["warn", { allowConstantExport: true }], + "@typescript-eslint/no-unused-vars": "off", + }, + }, +); diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..c1ff5ee --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,29 @@ + + + + + + + kafka — Private Meta-Search + + + + + + + + + + + + + + + + + + +
+ + + diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..e90cada --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,90 @@ +{ + "name": "vite_react_shadcn_ts", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "build:dev": "vite build --mode development", + "lint": "eslint .", + "preview": "vite preview", + "test": "vitest run", + "test:watch": "vitest" + }, + "dependencies": { + "@hookform/resolvers": "^3.10.0", + "@radix-ui/react-accordion": "^1.2.11", + "@radix-ui/react-alert-dialog": "^1.1.14", + "@radix-ui/react-aspect-ratio": "^1.1.7", + "@radix-ui/react-avatar": "^1.1.10", + "@radix-ui/react-checkbox": "^1.3.2", + "@radix-ui/react-collapsible": "^1.1.11", + "@radix-ui/react-context-menu": "^2.2.15", + "@radix-ui/react-dialog": "^1.1.14", + "@radix-ui/react-dropdown-menu": "^2.1.15", + "@radix-ui/react-hover-card": "^1.1.14", + "@radix-ui/react-label": "^2.1.7", + "@radix-ui/react-menubar": "^1.1.15", + "@radix-ui/react-navigation-menu": "^1.2.13", + "@radix-ui/react-popover": "^1.1.14", + "@radix-ui/react-progress": "^1.1.7", + "@radix-ui/react-radio-group": "^1.3.7", + "@radix-ui/react-scroll-area": "^1.2.9", + "@radix-ui/react-select": "^2.2.5", + "@radix-ui/react-separator": "^1.1.7", + "@radix-ui/react-slider": "^1.3.5", + "@radix-ui/react-slot": "^1.2.3", + "@radix-ui/react-switch": "^1.2.5", + "@radix-ui/react-tabs": "^1.1.12", + "@radix-ui/react-toast": "^1.2.14", + "@radix-ui/react-toggle": "^1.1.9", + "@radix-ui/react-toggle-group": "^1.1.10", + "@radix-ui/react-tooltip": "^1.2.7", + "@tanstack/react-query": "^5.83.0", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "cmdk": "^1.1.1", + "date-fns": "^3.6.0", + "embla-carousel-react": "^8.6.0", + "input-otp": "^1.4.2", + "lucide-react": "^0.462.0", + "next-themes": "^0.3.0", + "react": "^18.3.1", + "react-day-picker": "^8.10.1", + "react-dom": "^18.3.1", + "react-hook-form": "^7.61.1", + "react-resizable-panels": "^2.1.9", + "react-router-dom": "^6.30.1", + "recharts": "^2.15.4", + "sonner": "^1.7.4", + "tailwind-merge": "^2.6.0", + "tailwindcss-animate": "^1.0.7", + "vaul": "^0.9.9", + "zod": "^3.25.76" + }, + "devDependencies": { + "@eslint/js": "^9.32.0", + "@playwright/test": "^1.57.0", + "@tailwindcss/typography": "^0.5.16", + "@testing-library/jest-dom": "^6.6.0", + "@testing-library/react": "^16.0.0", + "@types/node": "^22.16.5", + "@types/react": "^18.3.23", + "@types/react-dom": "^18.3.7", + "@vitejs/plugin-react-swc": "^3.11.0", + "autoprefixer": "^10.4.21", + "eslint": "^9.32.0", + "eslint-plugin-react-hooks": "^5.2.0", + "eslint-plugin-react-refresh": "^0.4.20", + "globals": "^15.15.0", + "jsdom": "^20.0.3", + "lovable-tagger": "^1.1.13", + "postcss": "^8.5.6", + "tailwindcss": "^3.4.17", + "typescript": "^5.8.3", + "typescript-eslint": "^8.38.0", + "vite": "^5.4.19", + "vitest": "^3.2.4" + } +} diff --git a/frontend/playwright-fixture.ts b/frontend/playwright-fixture.ts new file mode 100644 index 0000000..7d471c1 --- /dev/null +++ b/frontend/playwright-fixture.ts @@ -0,0 +1,3 @@ +// Re-export the base fixture from the package +// Override or extend test/expect here if needed +export { test, expect } from "lovable-agent-playwright-config/fixture"; diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts new file mode 100644 index 0000000..ec19e95 --- /dev/null +++ b/frontend/playwright.config.ts @@ -0,0 +1,10 @@ +import { createLovableConfig } from "lovable-agent-playwright-config/config"; + +export default createLovableConfig({ + // Add your custom playwright configuration overrides here + // Example: + // timeout: 60000, + // use: { + // baseURL: 'http://localhost:3000', + // }, +}); diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js new file mode 100644 index 0000000..2aa7205 --- /dev/null +++ b/frontend/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/frontend/public/favicon.ico b/frontend/public/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..3c01d69713f9c184e92b74f5799e6dff2f500825 GIT binary patch literal 20373 zcmZQzU}Ruq00Bk@1%`Tm1_m((28PZ6KX+a(DJ}*E23}7OmmrWT5awWGU|@(TT9L-U z;P2+?;uunK>+Rm^E4i;@#lG3Rzh$|zz}ujQ!O~+<%j)Lttzs*igC;nfY;5u7aCQyk zvY2vYN?^hkliUUlw~5hCXBk|Tn4DansBxrDF^qEV+OjA)Ab63G0;6d}N9U6#Z}Ru< z|MzkCzV~xp->#m&`TM_bcCTwEyy--)7Nx-|~Pr<4?8A|Gb9% z2lwxDYHd9d#IRlRy8N#Ip})-s|EV_oDGZpm@K99Cyq_mF{BULDk#i{h`&Z_6U-7A* zpY2-gD%cz3nLp%C$hJRz{m-i)7H)6GJAN_s_uBv11?}US;LrZFHt5B1d!Y%AI|^?x zD>!gmC~MiL)wD~#s%OI6{RSWXCfKt+m}(LC+y3V)^RKJ_-4$EM=goMB&F0eu`3knD zKc+7DleuXB_8AW*M?Yj|Wl`YOTrlP0tJ`Mfdm3ZQ>yI-h)Y|30{@l*^N5o~H%!B<; z{{NY_l;fO$H=_+(jLnyc{z5PHnVw`f{$td-BP%rR-xPL*35|aa_pvKnF08)%tmXdG zM27UG0fz6Ytvm7=KIGPXjsEvH^#3yMa&d1)8?Tu9d+zq!FY0+G__O!<2P}M(`Sooj zmtE3-hhMF&)92opZy_niuzc-*&C~l=^Kdw@9I$8oA^zi)`Tl>a>TkF1ZePlfCn(PU z_hIviqs|llWo@wMC}B{0@gjQr{eDr$XA49cf!G>s)o-R-$j8*bu&?gwsNp^Ep7~>f%#7Hd2jW)wDQsLE_duj8*&Xu2R`OA|Fv(I;gnH-_R2#rUb6Vi2_!V$p!cQ zs~)t5v+dlWb%9d2k%dJ&;Qw3{`7dNmu`xhsw@GSfMn+K}T^FH!0+}}Lqz++)= z#yJPRKDYmV(Cv>tgT2%DHv(0Yzg~Yk_ms`~-_K9p;QGeHFYi-fIbZwm?H=jN#S?|! zH_k4R{jiT=UwGV~GyS_K@Bew|?(rZ7>xw6=^S{M0{8ww}_c}OJN3UqR)RUb&`BuIv zr|SKu{+YM!gmvEkrA^hs9}nK!Cv0`-0}sP}ef_!*&3U)^_ZB>j;7T}T9si*zTjd{D z$Ma);pV*wXES6CdkFS_{WSYQ1&Yd^(Z+D;7zilBp-TRa0)57HZ2aXQ=qT~KNxGaA* ze|N$AM9qdK_qZQ7-$uQz7knVjcjecUU+(4e-mmFO;P~{_=C073YmAc*2wCU--xu)t z$z<+hR+-L8tee)Er*j+&FXyKfzunyATe_^^J}#mOFZ(g(i#yxy%`!#aD< z?+2P2G~V;`h2a=k9jTn{Z_EdwbQ_PdA;Oqo9-Tz(8Z(yI+@exQBf!x{I&r>)ft>FXJ8@XhD_B>W~)uuMgG|4Ei9 ze}9MDZmfPLl6S0N+Vwe)l*=wp*;Kvq_J8J2wGMw&D_?G&CSD-_XpQIr<@x_z-}8OS z-oDai>xsyn)9b&KF@)qVXyFU)*zft^?h1wtNy1!n4$PZ+kDE77pFQCI@68wV#S|+* zni-ych;RPW;CNyt^Uo(Cj1#!+76w;m%`E7>{XNe9`p^3(^3J(Bnm;v7c&(VD-I1?; zPgY5P>i!>%>9zKE`z>zQZ+IR5Vd;C;w*u78N zoAzBfVvTM7pGh~S{kcH%klr5rJ9Wwbjf6lr1>%GYH&34CDdz(H` zxpAQ2UEI$AnHxFr$24HNaeDY`7 zv25DLw-G<*mONOZu<7{zCC?7sUB{L9KKRZ8IR*ebz}ompklv9$UG*^>)+d z5Qc=`0UK(W%$xR2zO=bkrZ1U?b(I1CSF0eoylemMLci@6cDhsj!?vO3UTA$s>+Iu| zS`1sxGv1y&lhw;?byf2k`*SIChKe%0=Va!+Vp1J44x;>{4 zpt8pe>zu8&iI@k(#_*h*V_C$TLeHF}^ zZ~t19ZDM2aYvnYW{UA5(;j)d>x%NaZw6kUSl2*5OwvFe%)1i_T^$*sFH0=5El!3oK zM<79{X_=KG$38Qz-P)bKTj$%h?OOF*=Yi-_pMv!>;`7|f))>6l^W<6O*H!mTOO| zU7z;w+e7}7-gWQa?t6A9_t;L&hW;B{zpYJSdN!jjm?h*ji{DZf?tF#d;|V#p7eCq7 z^6}Y8Lk9JIH_EP`J@I+&BHk~1?OFE5?{q9ak-M zLr$jX|720|V6Hm_%+<$N1^C}{e#x-sW6*=eNeY=pu_q4QeSYG~A`iWP%A2IsV!HNT zzjx;6^!>+wH^nSI{JwBbFO$Ft;l^+GnoryRy(kxZBh{kLEiiH_dB&P2B6MfPtPg27|Id~U+4S?x;YfCed+)dXW!AeRIH#8> z8%zFcecb+9aYO3H3vVoWuFq(_yP3V=gvt@=>(<7sO((f`@I2iUYxLn_QT3+J<_+fi zwk51pDCvKkBf@ZVUE^C#y**8l7q|<}Iez|7m(x1q)NiRG@qObm9&@=y`#Gxw9k_PI zl{Q#KZji;$zgt~(<4LicxNh0k@$nWhDc!Seav%OXY}v~2?Ai8>pUyx2E5u~* zgYh|c*0Tg9j`nX4PqC+HC|tcOFV&=We&IW2KNFtB|2O_Ua(ukX`Jj5sG0VCC_qSy1 z5t*LGC-%;CPA|K{TZUa*)3lghX1zSP?n&I`4-UG27;~7uq{THvSAP|hf=Y&2!>sk6HLe zfyJLqKd!q+$L0fb;X4tb9E&FJ3if(I|C(vhjqOI|bv!>0JMYO8&t;hl!Ua92EL(&1RP%7PR}En`6Ri zmOqV)uC`4LIZ|=&+1Kjhg^y;`pR2ro$hPxyAcODSs!p5wjsMQ^E@<34h2iiX*7Cng z-|&myw{2X+V#82&-R98SxLI9uJl4n;eY=~vUU+(RKw7pf_uk__jpDY&6O~z9J>z-MueD_Hi^PZb4*b+$Ffl$-UCUsqqM=vc!l3rVw$DGaIMe@`z+gK4I9sRb(jxf5frF z+~3X4xid!ioD=vo@6h>4|MQpe?5bq_8*t~Bkl2E#|EHKM*w<7uPvtJCkYGsQyT@fbI8L_RnlR&S(*( z+fd=#u=^v^mUXSpYnyA&74K9!u;YGrG9XH_G z9^$(BH}l*ItLtha_Ywo_KL0$>#rUpv&P=8Rtp)|gv&mMFGmp#q6 z;QZqpk%kRNUSF9amo0JO%+c>R_Lq8?>oJJ^w&quOYN@lCp^@*v$`pmNO-!#kmo$D+ zxXtL;O< zEAP!D>JDDuF`1WnTr0EVr9J!y%0*R*0o&c?zgt(M$+_)E{G6Z1 z7gn(R>!{&*^MUbegy5}-v#qlvjAGtQ5n6Fg-t(X0_Nm-6E|t#aar_&&tbbRyO^MdM z_n%o$ar-S*6@O<5s+v;795lkjVziiIPk7(rpR)FAx!!@*{_C(vvdGX)>r^O-b zZ#z~>&bZX%9JM}9BeK@;;qu7WYvlGVaXT>oysO>xq*ci`yqWJ^k!t4-@7x^7kSyFc z?f0(?eXGy!BN=17AM(Gx-~03XkNvxTXo&67YVH)_*{h)XWL=Q$Rn`rk7?<&|ygb%k z{_x3y`tW|1HCYAxyA==k*=?vhCLQ%va}R^utN0I$=Ucujo^yG$#yCA+S$XyLzz^23 zKW48zz25o9d#j%uE_&>|M({=CT z(yi^=?LP3|t5|fjD3L#XF~b4oBh`OiHpQ>C_xUlGd&QlG$a?XbMlp_qU9Epg6`x!? z#x2=f%rW)Njyn=nxe})qDSP#rPi<1Q4$wE>BiUti;X=b_OC9+|Tp!#x&m8+Ea?|3a zsQ8l~3f4!RKUO#idaS-Sh zNmXx^M1$t>ht_#K-E>)c!tM~s7f+4lCKOFPp*7{c0iS>V_Svy_f|(Ax-x4gV?>W)+ zaLS4g)8)0!J!L$=-t~Z4v1T7b{{9^m559_@|NY>4)7#?(Z}Rtl6W+gP%0WI*woqN* zaG-Ysv$N0rt^<|d?@!>=kZe?ZD*oOqr%>rJW6#RJAJ}p-I=(pF*=-fhxL!6>K|PHF?s(ErGc*o?&SDqE7D3 z9%q^RwNFfW`J?WJ_uVvm%Wu)J!)zZv-!#?@jH($wYi1bl*v!^=<9Prhh>Q>m;Zqa0W2#8 zBEEf^A@fWiWwL)=!td8lcUCiA)!uMk@>1vARr`YMV%EoNvpp_t`t`K!ro!cy%-`lP zT5hd9^;MsX*{|!|`uR=`2c1`X@h!f@c;CN8pYezKy82V@`@0^P%l|m?|G@m3zl(Rz zms7Wp=0BkR?}w9o($DJ7iw|B3rZi88{*@N?FJYS9+FexwN0}eoyKl;|{$`T$JJB1a zXSgq%PdaX(=cZ(nB71mCNTiR}s{Yyb)pJE&+gGra`NquRG`b2nU>Vbk+) zYmlAtrQZ^Ei+zNrzpeQ`CFMikvy*3etpXO>%4Wphy%<-&H@;?C&8y?{z6w1{U(CSb z+v4kdq~QDN1KT}0cNENK;LocUeBda#qiDweOIOv`Gk)+qa(VvUBZ5^2{%?BVaq3$| zP7p`)gR@PWmDlWMvW$AtyL+Et-W!hJs@LyF>iK@wT5KfYJZ;v#UstbbCw{@1@fT5|$978L*6yC&}Bf#z=y861S?vM2Qi@+TbM<6^NV zTb=psrB`!jTT89K`|D`a;=RQP2H?%~sE{I@Vz|Y)kkg(yCZcLTtwb(y<3Vz=`lyA$J zIeGboKT;_uA(x@LS`@1=LEk>#$pbGXg5to$B5f6|C~I<_uEgfXL>6`Gpm1i zeT+q_{+5R)->Xfpix2y5f30ZBt-B}PK7BZN=-cJ%t?^ZVch0W}do4Mymt7%e*84Q! zg%NCbqRM;!e3D|+Kcbirwtoqi_k6xp?hF#;YA3HAwOi61(-XO8oAchuXZ}iRXB-OL z%c#tF^qxXni_?O4XH@SU2(UPEM}FGYW&c*CH+{OIsfC%>vdn;@0C4& ze`fX3pBfBi-p6Myy}tK@h?Ms%AC1(z{(Yf46#vM)Dw^Z%VaFiRVt2~_*bb8^2aYhm zNM?UJ!|Omy$}fhA0zZ<@UVJ;N+3irmM@F8>or@=~v~%BSwjq#ZoyxO^%85JOSI5_O zoqk-q#9+EaT-$fQhtK5L18i3GK9%3oB({HLyS$UorFqqFel(WbzBk_gpws@J^Zv^4 zybIcgerho62sLsIxLbF3Zsor+38_zemV_T_ZV+rW+V$(=A^+8?CsGe?opV3$m|uf< z;pF@&3|&GRw_Tb&%$cKY8V-I6PM2kpQsc-id+w7doex=|xVayOfK|3mJ{G_Po>fS8V+C!pn(u9>bECXFV6B2)$`D?e_mJ z^hfQD+MBgTH{~w>_RY=NU&*jg;KIbP+m;NXR~|4p39c}^B;a)D^UWeAiS4VOy4N20 zVN>G%)=0+x@w3#P?I-?z)yezjy_QGj+{pruJG*}GG>lz&hxv8?m0DszI{Rw{68 z^B39jzW(}W`~R)}e;&TCi)mv9l|Z~nR&lI<19}#^{CH9H^!twRw@0n!g=jxIKXt)3 zgN0msj?NQf+R^SC%u;5!;&y++Y13=#uhy~LxcYXw@`vtCF3>C0?^0N6@wjps z>rxHIb6p9}Kd#2yeIhtV>c_#Nm5I+) vUHCut(T;b?DFT1wSIEp?v(Ll${mLui zEsHb~4{X`8`+PUUEp_gn*X94MvH#ub|LgbtpXYWLZNBF^r+;7sU@#2?P1_6c3)Iy_lo(7)WVqy_*ZU8x|_LfcVxX+dmn%DPqi)I z((0Xa{li=hHvUn(GnM1bGUYBKi9erZ^%KhZ7WAaQwc@SrO3MDonDV#zN^Li+;BB^mhdVSd>-Rhc)#F`^mFIjoe~9l>ofE*48n&?CXmdT! z@9i`9G(0GlkU5p`lU-=T!Rc-CvGr-T2Ofz2_!y+f6~}zL-hW#>(+B4Ae|dYWEu>i= z%-ZM8o}0OD1~CsHf0E&u0T@xSK(|7hO-@0-8l zcln382O~8Z(kzS{!=`s@m&8c@$A6$!n$|=W=~zjSrf{VxU#C( zzyFx9$od7ZUALMg1TnR8p9nd2&&QEpkmbXo6(^W{TojDF7v1vhI^$i)rXsswug=2v zJ3=@UM1Cd2dB0k}>-RSs&%G%>ihpj`S-^FAzxs=-PxY_WXUJYpPP%bKK>UJ>}voZ|ux?QC9qU<#5w@3dA?w;=30#eIapze_s6xZ|c8#cmAXL zFxP;2#dY_3DtPlAOuley{{Or7U+w>FUH<27_?;}ClJgI9L>kf=rv&C_#2Fk8dsS&- zZLsa+bjDdb(wORey7h$@h6gphVwDMfAnlmh{CoYn|AtQODNA+V=dattRb#9gz%A0p zP{r}J&LcqndG2=(qjTb)ISyUjZsDP|R({rn`a8l!0lNQX+(lUhr|r|~Rrs`_^Y7$~ z=Pm5JEF(mZXgsiVIHJ3Ia?MN4j?yf#RE4X~0^8WO{r9`IRi5#~>G0Zk`>;8^OeZ+& zPW`bq^na-~Lu>kh!1Eeg89JX_e{FYP@<_t-g2_gYYG?3RUA~*c81$7vBCes5*J9Qo zqoe>EodBf?&8OdYC4_NYer%9X;xDMMy}iqU;q#5n2XFbCGJX79@cWMc%;5UjLXUcW zwr-7hbwu9V&_9sj=AX&O)3f(yiUoc0@3^Eht?NPNp>L)PcJEicdw!eM?wj`=`HIWi z|6Y5~`Xv0z{NUi!S<&y8yjY@eR;WZ^UitU8i~s(#yPIauR5a1@SB1?i1^LgF_Vet- z>i62bKm5-kuW*p0C~iezoR-%xJZT{bdRIMJsGLimEU3D;$2v*gUn|N73Qz z-}79GH`=^*W5$kMpQ>JGb6i-H<@2~c_15m4_f9e@=)~)6ewgP_v30Be{!KTY>N_<_ z>0Ox}8Cx@v?aMM#hBbE@j{Q<{Kbtf|e1Un1y9oErug}-8RC)d`wa}flrv4+tqGgpz zPO&$X@2$Trapq*0xDWgGm7>;bg_@Gz$REm`=cwPMQ2cAL5l7hl)_SGiOFpr#dDx)M z=+7RcBFfxwSopxVr}@7h<^R3;{nxkYceb-Sr7vb^naBE~Y<~1ko!JZt=AYJA?vp)X za9|VjyieJF5v-9X)@*D%eMqpO`bf22Hg}W2qL+46jscsw%q(A3=1k)ZSgMfs_V-4P zT{DzE=`@P}-jP(m{l3+yuCF#}Rl360O3^CDB$r)^MvM<8XwInXXuA3EihRIo`M(uW ze->U`{_LA-aGjxJNA&gmTYfF_Ve$&!|LmWo7W)T<%%8J8G$sWwFx-|G{4}}l_tN^; z$LD=JTmNiT{zv!L&4CQ!dFoY@kDc>#=;1M$=%%^mtWQJy0TJh|pX)Ar`F@t1tS|UH z`_H*N)gwtS-7VA79Ju5YoX#D5l^xb_?#J#6+ne{zkFK>mw3^YFN8q%K2gkl0LN`n} zeODgzU=-7Puw}mevH#VF-diPgd~Q^HT7IgzOG3W&t^fLl;GTZ1J10zDM?4L`^G_tJ z@TlUfg`LlTt$2PTdtKw({Zn6y+p|8HxO(4ld0tR|aq;|Wrn4*RxY=E9gs81pd+=4- z>VjQgmq%)S4f=cRl-<7_&tnxGF4rHqIII24r{7Hr*t`9GKO9y*aB+%bRBbrhnyC%0 zo)=zCIr^i=F>pq;zMPOt)_Sc*t5k{PG6sjU92QDbEku}d7VbYSch_Y3pHDlrTR0}} z`M6wz}4!{@H*rDiWiIHmDbkA${f|!OwMZ%|8`{m)cLm!mT&}q`umXA||#HW1k zi=KkVr%z6D+J91jrQG;D-tJkIvjSDGEYg}a@TkJO@?(69J3!Mo?7JX zH+^Yjz51Vy#dq&Nt78j(yZ7(S$Lp^fc;DYO_x?ePiOq(oD+^Lszspt5e>=9U5a@69eV6#!d^je$gAKtO@<^1n^ zuvnn}=&s`-FBVl8M%IXzSf_>EbLuba_#3CJurH1IX9(kb_6OGMf7$H^H+D^9Oclk< zwj`X-J!!vL$dAuVDeN1kw9{VIS}p$qmJe;R`$PNF9tO{tYs?`YYr(NCY>{lQMv1ol zXU12u0elDiVjZr0KK}Z5%EPa4 z_uG$+f34Ym|8kt)otf+o!X;grjHiF9>j%iKofEle!E(*h`KRaizo{2$loRUs&VJ)l zdh#0kbmlMBUS~NL{Hd_s%J5k$WtGUOc|p80GK18){LlQdmhwSj$_4m2Xxih9HPLSC(>B~Ex)w~z1 z|8~eprXHB;Z0>fm)V{6NN+a{HaqEkP4t=|p@iTRX#BBTdETF&Y&XH#K1?9UNxp%tn zZcFFc^Z)+ie^C-4TC8{fUJsqH@cN`E9+OIjK=Fill`}O$4d0TFKAs@laY$aW^>&u# zB#kpO_Q&O{;XKiE`o=Bmoquzlmb&hUKK5_oE9YasGN!!ZeesLCWkcugzZY^lG#mN2 zZp`ERuy}*WfxVo*%$tgWdaUGctUcMLmZf}o>h*VxS@l;W(o9}DTQzeFXj?e7v1Qyf z;?cWvTxI(qX^Xz|k*^unyb+JPlI&`K>FtdWE}NtOX4;i3+dqZ*xTWil?0fO6w(8&R zQ{YuUav?}W^+CeE-qKGi^D?!LJMx(Q{=WN&^a{-bj|3}DnS0lBe|+8&ugG@y>ve-O zcWQ)7Jev#-Gm6$s4B_~HbpPL5^M7tk-}7d&-*cDuhI9Iv1kPX3o~v5c{E|l?=t6SV zf@OcU*l*ZbAo2354U0zM#;Ctp?c4qzK36Or67@!ZV%wWpf-6$u3x7x6s0;PjJB2Ns z%iwln`g(=$SDO?bs(jdNBq751X->t5-1E3H z$j7s;I*eWHo7*C>19h+CY&Z&4Q@R$3e+t;kJY&J^MFkJ9PjIlB)$qsv{=eNdOz##i zS^z3$Pv2Y_5iMFXq3$lrh1o5$RC!FjCm1vRam$^l#pCCoBb;z<)s)*;t#3wXvCOUH zc=}-4ZH@!ag57IuX4Gz5!K&evu;HES2itGvsuLeNtmKmU#h|zOhiz-zCLi@D`=&AN z-u=FcLBf(j#A!naxPs(opUuEpF?sFpTivE z54qY3LC;!Ou{3;J$TF{KDZ@KknTuzXRu*U4?A-b9*9+5^nfbFA=FXBWFx<){t~K%c z91fH3l_x|z0y)ZDZ`IT9O^fqeNfNf{F$4a~X>FR>}SnWO{pD!)HI|zFiL- zo_${}9&qD-*i;whxm)M!?3=N^-BECc^?EVccbq$}iu=_vit(&cZ&3Hjmgjl!eEqMw z{gHDfGBGer<8*j*Zg2nO0LOHTs~<{}&M2}L8c2CxSmPDoa$(P<^ERA8PKhd(5gyLp z#g{2&~{HMUSe98lX$Kw76uI99{werv4*kAJZAy>kh@9Cdiw9af5 z59iSQvhTh~!@SFmbu81Ec6={T^lh&@bSU=64ZDRNeviZhU*9{k{%e=R^YvH1wnvI> zd}X2f#>V-~j<#KA?8_lPjjie^F;n%A3nvNGpswQRFU!Ep?3=Bs?_N@~7_UjX8`S#`a5oADjG#XFd0d;KqnAJ-fe_oqO|XdaL6Sj;aEt zNAIe9AI}U;=axHhx$>dpjza?V=WLEYuH|KAd3HFGLxIWe_p0|_qYvKHXi)CZo6x+m z?(CZF?)P*p9QS_Tb*{;9cB1mpmnsW+`5TY#U{Bc>fBtk%7YbzSTeFaNF?ACh_q5$+eH=oo=X3 zago^`f76KdklqvH^}piwi_Yn1>iBpyE#7s>O(uI2qYG|-BrRtwdORg|dWp^2?ge*( zJYRb5l=aLHGY#LjzzoFSO4)!r{Ro{$hG~; z7}&Yvxm&)isq+(e*Z4P9(yFwwCX_4=-%d@PV zR#;{TGkbN!X*Gm!t@AiG)1*~gLs09;@rmCSsYn#ob?@2V%Bl8YU7Chm+z*@mXV^A8 z`E2?1PrLX0>?w!Exg@>nO1}md9DnOSEk%ndN%!SRg9EJ>RsNBTf|E}E*nPFXIOWFsCvv@Kf?b6kI9 zT=jR3nztKfvDwH!6qzPm6_RxE*!1kreQUocU$f0PFB#i+^z8bxjtj3@X|Pw^Z?0y2 z7y0DM{W$*3zxz4wlDxt7-wIP5_ua8&y>Bm`cH3^*^bKo&e;0cgq~bDT*Vk+z zvwNXtM;&yU@?SbBx@O)Bl3L@$t1Hvg(qwS-;_(d&tj}^S;Ql$~`roVOa(3@d&oAEd zs`_7Aff?5Wr<|+$ovDT<%m?It&zi7nmfq3pT;I0L-}N@~?KGYJ3nJetDDK+9G3z5k zS>M;^mnB$^8Qt^yu+x9y)BW=%eW`tP>cAcreV$ESZ5-mO%pWX?Kf_e8Q2*=yjS(Im z1!ox;BzIn)5#L|xF?05eBD)HvgX$tq0Rq$J9JWqjOYq@bW>oNc{XKd%F8_u4)C9KbTq@B^FTlnVP<%_+0KhI5?JkiPaex=jB$bIzUGDbJ7U z2E1FW#d)kiKwd)Vn*F}rv6JHt9JSc5$G@yjBKd&VocK-5D;Wd|_ecM&*t_z@q-U$I z-K)_mJIyeoX+qu{hw04Eed}s4~TqQ#jxtb^7>#; z$8avGTQ4SD-136w{Q;foE4&JK|Gg3waa=z0?}X2ad=6F2rrZ)M?WgRJb9~l%V4H=2 zQ}lH%1`a{tM^;{Ev^gFM*Qy`lEer z1y{HK_I(UWc1iFIqCm@zqJctW?JZDWcz|& zXu5~9?&1aFJcjj0et0y>pKv<=FH2( zzWC?v-~KC(pIoQuP%`0oxAWU6yS9UR3r@M)GBW%Bahow(H#)04zN2W8xLrwZ;fuvq z8+LMR*SF;RD*8$#akcIRHo5JJ=hzuO*tl%o;GoTX`B(a^^%m=WS`8*upF1=)+FGJV z?6Zp8HC9tz@|F?1?RDU#D74S*NV{;O=kV45s-{7KkvO z-jZyQ@IjzSeTk5N(zk>{g*yCy>$BL zy&WgMbF>vCoMx~1G2vRBvARKDc>jU(tLJY^VR|$B$_0g;&1o)qRm^uEiG5tt`1f`e z+YS+K)2~cbT8uN&FWmKh@sap-FYM_%YQs7?|Sj8&k3JmACzY?uobPZU{ZYjZ}l%R<7@@Z*OKqW`x6VE zW_JYGZLFUaW6bdI)9$PH+gQ&16JK=k+}n5eyf^zSiL4Cf3ge!nGT-oDlJeZ9uj>7; z{_SR(5WgQBuy$Mw7o_5E==dD^^)ujlui)wb*_NDpCro?vIi*BNfcs?n`pE^)d?&7O zC@DO8>5{U+QaSeAs)^aK>!N70`Tvf!x(~MJcmA|h4iGuGK;cj2)-O8O zYS%qnKBeGD>`b!>)p~yGb4jmCfIp!nLsbYqa{q<9gRpZne5^WaDwF z`w44HF4knw?!3rV9VIBU#=U>u^Xn;%d>kJG9C}|d#(i+k+;T|KTldhq^sj7TRm>`al~y{*>;3cabUWx)`u=Uj^r(pmOy}2r_`QGcvA=dNb#GM~dl@`h z?=8IX$;1oAf3=<*N)wac%iX8Yzo_^@z4U?EkOt*sCINxHok6jBe`Kv@Pine$>63SI zLFy@Xfs8GI@)|=O_6s}8ne9GH)JI9A*Z_Us5=kl*qJRHi{qv*0P$wvPW!`^`J0uDh7{>m); zuVnb(YV7w_0xTg*C+s<};oZcE)tY}CrX*&%4vDsZVzBCTz0&zQ_N{~Z zN49Oh)8!Nj;*b$;E%cK3JPdJe~LYA|d*W*P33Txt5oWBc9iYM0g+ z4>$dpywCdVOrLX`_;@V3@Qia6!)2G*(@Z}IINGz;xUATI%c?wKWu7-@pG?DfiH;*` z0*TCCR)$=ik8aEel<#VAI-$K#z4bUfpC%wNiCrsOB#HygNm*QS8?P4J++)wk>>HHQfUr zzGu+W__2TA5AFNUSLJ_=K6F!~Ve!2sbCu3^{7ZXjq5Re0$Xc<}#}DX9IIf7x;uGB1 zBrjg{u{3$l-sRUg54^knfWc+=g}w6*)V4iaxAn-c?Z5U^u)YMgGr1Tw%%s_vUVnAZeA!ybsP)2D@}FLn_Rli^_YC=JKlRr?xK`(5A3d6l)$NN6SBd7A=lBh zb~7WJ1>VJ!JM67?3(DT`Zl3RqyHyVP>D6Y_AMtQ*>v-IKRUb5})M%|yrBr9h^WJD0 z!*A8clX=#EJL#}lYqP0b{ z#=m(#6)m_bSSl1J)L)aEus!x`ZQW`MY1RZ|#@d6D99tA7q?qg9+;u`%RFQtlo@PNU|ItETH67+%duFtA}dA9dR#X}-mqc`c&Cid|iQ%F=gyx+gH@ zX6u$8%s0gkMT;_@E<1bMGmz=P3XTcS6=Ie?TBjUu{h854@zpZ(7PgtMWO+OMN|8ola12eNuA-;6=gW|0sj%N>h zbxw>s@Z{0;aeXrMV zICnPq^|gTPJq`LHOG}y08Z+>;Z=Ly|sb|4!~Y{^#I3cJ8Ri|9kp(P&jN+roTl%532;9@0y0-#1>nyxINh zyHC#p%f^r(-;QwF?f2P! zUG$*2tHQ-Qi<|UpxUT*RE_f4VRUq&Yh8=;YQVjS68C9`Sjd2 z=2KvGZ#cuY;?Rc)_Invx3~ozpQ%o;eue7DNm@)CMq}lE^X0hAVy8?alm+^I9SMbxg zq~RC8!p51|%3n=TtHbq*r_!}zfj#Z}W>&MedfsomaeL(szEFoS-^BUROE{|Q>`z}k zddk=IZMg2)>mLeg9H0C)TquC^ zfZv1Cj2-Kv8nn;3Gv26Kr}Th*)wbZYTmpMOdHXgT7(O;+zi*xeWVr?dn~ z>Hf<+c>b-B1J7#4=??p>lz#S2TdU8Ku(Rl8K|`kQca=GNzfbQLW{Lc`eAW4&mb*5L z!CYY%pHHow_-ygoHH+dJ4sbZkJYoMymZ9!Ls(!W8@!GD>feZ{jj~7R7(`DuCU6kpV z5xF6q^@_2;uFSV~&SGY2JqNiflqbyAP-j2e{2}|z&i@V*^6ly!=0E+k&|y-4-vU^p4lSN|3bSl1Q*>+SiT>JBUU1M~mD3ew635}m$b5DMgbd$Qk9cjAQ zp+3I<@!f-|g|`C_F^4mq*}$X});(eApR1o1=cKnfw#j=woUNqUBlr06wbds}LR*Xk z_P2#hD){qv^}T{y@uiMif2}K)xLWfyNb%3J43VS@aem4_jV7I#VJ}s;D@4v=X5HQM z8$MaxJ}=VX`|si^`>zao9(!bS?*9&rKl=OHFXr?m3_1)pS)%d+(<1V}PMap!khVq2 z_H#?b3iTIjI^Mr!c_qM@q$AGvNpDKSlk82W`W2`2{@kO*cG~$uXn!FC&qLmQimm34 z_*Yh|EMr%l9MJK9!M%s~O?I?S{Kj88@g(DUktqd9yQj*W+@L>y>WzIbcs#TitkMlC zSeo>r?qzc^Uj6jCF5Wmd;ZLZhNBfZi{?f0k?g4$t8Vl~$3;g?`{cri<^|4=T|3(y= zaV>bAdwuUC{?-U-4zt>fAZ^yK&oo?Zx9wvWkYR{)xtRH8MvC5kf5lbTd$&DI z`sm!?{A1?LUpk&GJL;*58H zRqi~!-qn4oK(({&`Kw>MThE_RdQ{WLbwE6zM`?cK59Pb%Yi=uSxutK(6wZ^s=)?QC zhi_|c7r1d9kUP@;FYEaHa@MI9x07q1GcYV{XAM=bK9Xs^b#mFAwOgJ)$TZyk{A=wh z(c2GLsy#NWp0Fxtf)3Xu|D3JtXWkasT$*}u_ko8Q9}R@^P6^7-ILKYF*yM}1NZI-r zZ&nLQTiJt8YZR4(B__>d-(Fa^^8NG>=c`Zqzh2lawD0C0;U@jSr|L>`vu-xO72lYf z{FhTM*XROkh~u8g6W;&&wcc*e<5llp-UjUpsM;*ETKx9fbL!PE9huxYW>0-_D{7J4 zoVNBt@-tR5pZ!KZmj+*aQ@TMQtmjMDo-dxPuffwAvCp7;+2SSo5B8U+34Wb2EtP5Buha4CcYT{@HpOD&jvpu0 zz4UGih9?^}hM} z;^#aWIaY8T5Wcq0q-E`0(e^*{XxyFDEiKKI1NO zKHehxU^8>iY3}KFt;#R6r7d{-ek#vi(Fsc7yPvL{*SBI<;2(R zf1D%Y5H9rK#-`5JXBlmODty+?ZC-P>e$m>Wm!!IabsqA1#HhEPit1%tFF7anug}w; zk$PvgrS;Z(a6az)@#0@)x6sD(Pd2!IGgO-T>(b+s-YaYK*wX?pod-|4lmIk-InwbUbau$7g z!qP1z!Lj$@njdFhZL@N_cPsu%^XBhnota5rA6;2wVzm3Y*u8I^SDt@#4gH|{xVF#u z_mN|UXO`}H_0PNHbL&<9XYz`F`WY(TGPi%*m0w}`vEs0#CIf@i^QmDosv67hHd+ zvG(U%gU;s_v*guHz2~jJ^RMf+y!jHdl&ZSGOW&^TV?LR{uKC!}NvzC1c-qvL?VG?+eEfj{{Gbc3s&0BWc_HK<@gA#OE%n^$*I|K4SiHF~8#G-`EeuT~I2iRf zWZtmZ9N;cLy!!gaxT}|@&1BHsmGjnz?O=YUqd&XYB!PS9<@as1;&$FA-#jDp>B1{_ zEfY-GJ{)wLD4u?Ay1?OYvt~}4k-DSJ-c)P)E@LL~u-A5R&8{CLUxl=37gq@jzj`OW zWrC`7v(35dpN*sgB>(5UxwxxNvq{d^&Vi#$@<(Er{Jh8R;s;r`etvJ&AkX^o*74OJ zK$XoJNspV+^LIY%*?fA++tZ(ReYhIE_18027p=3x3KR^T^^>}^wtCNW^$Uky z_LY~iTd);|{py=1n^4H~s5-CmyF*t_sZd+7<*EPI9(ccyH~8~4>DTJl0jFxZ=Ns2K zd3ruyA=0)%fu-K#W9!j$#XpN)u$ygBF8;mYxyIfwe$uNPK=>`%CKi>&H0nO^>hSDwI+X z>E8YE>HH)A&&#}GPAQL{_SJ2UeQx{deu>Abg71^J{c2Lq-2B(2FW`E7YrMQX<4;?b z`WL3_D{8py3-Y)eIOk1b6_@xpxlq$a;ymNQ4I6{}zGR+QU~G40Q?!~x)WWZSXFb$> z(!0LMf470r)90sDihI9uM_jU#dQoEk)_BRSgBn-XSS_?>JawUHqWIl?i*tLmnx;DH z=Iyr?Jl`mGU+oyfj)dh;ru$oP@11ucw|?utyobAEPqSD25|QWU?lW_L5^=ae&-0Jr zqk0+EPu3ze>inOK*Z-XLZAa;E?Q?F7C8Dd84Q?23WSZM-x7q#0;=5bee_SaLon{u} zEb5~7Vl^XMu4{wed4^*z8WQW4eP`^*W?k>Y7jLEZjm6mBOw{+@VS$z2rGjtHR65Mh zR!ZqK^xIVXv5t{H*5#Cb(Aqy0X9Y_1U-o`py;S~rQ_FApB@6zsoqXpV$RQ;plN%E7 zT|)le^YyuV?C$-}Sb9L5p?>a*`+wfXm;VqCFKUhxJm<#9(7>VBu;mz&+!uypvjv;4 z_S9-@GiZtU{Iz#iUPiv5S-{JdX)%k~4+P#xcicNA%ei0It7LAf-OpJ+VggH!{NmlP z_3@hZnvwfv#p&pOk59>Gm~iF$iw1G;>&>?_@2j#n{5!tgnp>WMhf5);YR94loVM&v z=Pl)8uV2pkVfgHOseSVw>#_$-P4#y)?*EgGFaNRDx?cQVw1qV51=CYDV%yDUH@LNj zXSfSimJWK z!y5F}Dd3&VyrVT2TtcNJmvbD8KkDburLjEu;rt_on&%&xuYUh3Y`zQ6Cx)aNdJ?qla#l~W9r(}HDSf8i;wm0&hY~1bpp+7wLh&JWdZ&&``AO3ghasA8nlIPqQ zU(9VwzFhD#Vx`4yL;l1sn^&KmzHoEfgIAmpzXEQYNzr~OuAJ?8OQmqef%NQkCMm*; z&NCGHE?i&k6}M*j1+T0k7Kbf{Rq7M^A4^wpAG*N)(r^dYx}Hapt1skvGo<|CcWaHj z)v~u&<56A3*~FCJkIzr@?Em`nl=YW4PmJXhys9RD?SIY_rx5nBv5d89-u>=+Iku0R zMQYxc*V)#8UiJR$>LWKb7Yu%Ao_jZ_>0cb{vfWbQZu<}AwIU78u8()=y0HW;dVMBm4a3D`?G088 zg5G~uF7RHXqPF5@6g$#%y@4~pN0j61;`7{7(<9GjK8v4uV*0dw z&%S%R@};QB#w=474xG}$z5hp)>_G#T<(4Jid+P)LUXNX?!hXQdWBaqT*N-+ooOk!m zuQ}g@&)WHX`yZ=tkK-VJL&e?uzjmqq=-V8~5X<}HV&;3R_IqYmU%ftCeZluD+m)No z7Do6y7C2q3wpBq$`PiHb-<=dsv$?pg=c=w^*E5w{{hQ^3$l1b}iULNt3obVuK1iI| zx7$-xLAx|WG3?kqrAL;$neSIdt^b{M`f*JEB!2;y`TJU&CPp&4Xl|I)_)CiOoBgl9 z-pTw)oq6-Wv?yn<-yg=bPxi=p=MVf#<^TV@U-?v6Zs)7tAL2lJw}mGrOGYg`cIxjd zk!Q{Gbxogz|5mv++ghvXPM$=5j;!DVkvT_BFjN%^91~#N_qLl)q|E+x^B>{E#h+F( zZrRl?$qdMP8l0e{SIv)sz*PdmT9? zRHfF~Z_PSi*}%7`{Nhn74;!5W``JINSNx&Q^zY91eUtWAUEO~BZ`0;LhFw|tVoW=~ z>TTIEqq|C@(sX|!|Ghr1vtljT(oOSI*E|Z`FyZW)cxSP1X4< + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/robots.txt b/frontend/public/robots.txt new file mode 100644 index 0000000..6018e70 --- /dev/null +++ b/frontend/public/robots.txt @@ -0,0 +1,14 @@ +User-agent: Googlebot +Allow: / + +User-agent: Bingbot +Allow: / + +User-agent: Twitterbot +Allow: / + +User-agent: facebookexternalhit +Allow: / + +User-agent: * +Allow: / diff --git a/frontend/src/App.css b/frontend/src/App.css new file mode 100644 index 0000000..b9d355d --- /dev/null +++ b/frontend/src/App.css @@ -0,0 +1,42 @@ +#root { + max-width: 1280px; + margin: 0 auto; + padding: 2rem; + text-align: center; +} + +.logo { + height: 6em; + padding: 1.5em; + will-change: filter; + transition: filter 300ms; +} +.logo:hover { + filter: drop-shadow(0 0 2em #646cffaa); +} +.logo.react:hover { + filter: drop-shadow(0 0 2em #61dafbaa); +} + +@keyframes logo-spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + +@media (prefers-reduced-motion: no-preference) { + a:nth-of-type(2) .logo { + animation: logo-spin infinite 20s linear; + } +} + +.card { + padding: 2em; +} + +.read-the-docs { + color: #888; +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..f1ed102 --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,31 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { BrowserRouter, Route, Routes } from "react-router-dom"; +import { Toaster as Sonner } from "@/components/ui/sonner"; +import { Toaster } from "@/components/ui/toaster"; +import { TooltipProvider } from "@/components/ui/tooltip"; +import { PreferencesProvider } from "@/contexts/PreferencesContext"; +import Index from "./pages/Index.tsx"; +import Preferences from "./pages/Preferences.tsx"; +import NotFound from "./pages/NotFound.tsx"; + +const queryClient = new QueryClient(); + +const App = () => ( + + + + + + + + } /> + } /> + } /> + + + + + +); + +export default App; diff --git a/frontend/src/components/CategoryTabs.tsx b/frontend/src/components/CategoryTabs.tsx new file mode 100644 index 0000000..1be1eda --- /dev/null +++ b/frontend/src/components/CategoryTabs.tsx @@ -0,0 +1,39 @@ +import { Globe, Code, Image, Newspaper } from "lucide-react"; +import { CATEGORIES, type Category } from "@/lib/mock-data"; + +const CATEGORY_META: Record = { + general: { label: "General", icon: Globe }, + it: { label: "IT", icon: Code }, + images: { label: "Images", icon: Image }, + news: { label: "News", icon: Newspaper }, +}; + +interface CategoryTabsProps { + active: Category; + onChange: (c: Category) => void; +} + +export function CategoryTabs({ active, onChange }: CategoryTabsProps) { + return ( +
+ {CATEGORIES.map((cat) => { + const { label, icon: Icon } = CATEGORY_META[cat]; + const isActive = cat === active; + return ( + + ); + })} +
+ ); +} diff --git a/frontend/src/components/NavLink.tsx b/frontend/src/components/NavLink.tsx new file mode 100644 index 0000000..a561a95 --- /dev/null +++ b/frontend/src/components/NavLink.tsx @@ -0,0 +1,28 @@ +import { NavLink as RouterNavLink, NavLinkProps } from "react-router-dom"; +import { forwardRef } from "react"; +import { cn } from "@/lib/utils"; + +interface NavLinkCompatProps extends Omit { + className?: string; + activeClassName?: string; + pendingClassName?: string; +} + +const NavLink = forwardRef( + ({ className, activeClassName, pendingClassName, to, ...props }, ref) => { + return ( + + cn(className, isActive && activeClassName, isPending && pendingClassName) + } + {...props} + /> + ); + }, +); + +NavLink.displayName = "NavLink"; + +export { NavLink }; diff --git a/frontend/src/components/ResultCard.tsx b/frontend/src/components/ResultCard.tsx new file mode 100644 index 0000000..62209eb --- /dev/null +++ b/frontend/src/components/ResultCard.tsx @@ -0,0 +1,40 @@ +import type { SearchResult } from "@/lib/mock-data"; + +interface ResultCardProps { + result: SearchResult; +} + +export function ResultCard({ result }: ResultCardProps) { + const domain = result.parsed_url[1]; + const faviconUrl = `https://www.google.com/s2/favicons?domain=${domain}&sz=32`; + + return ( +
+
+ + {result.pretty_url} + {result.engines.length > 1 && ( + + {result.engines.length} engines + + )} +
+

+ {result.title} +

+

+ {result.content} +

+ {result.publishedDate && ( + + {new Date(result.publishedDate).toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" })} + + )} +
+ ); +} diff --git a/frontend/src/components/ResultSkeleton.tsx b/frontend/src/components/ResultSkeleton.tsx new file mode 100644 index 0000000..0119f4c --- /dev/null +++ b/frontend/src/components/ResultSkeleton.tsx @@ -0,0 +1,19 @@ +export function ResultSkeleton() { + return ( +
+ {Array.from({ length: 5 }).map((_, i) => ( +
+
+
+
+
+
+
+
+
+
+
+ ))} +
+ ); +} diff --git a/frontend/src/components/SearchInput.tsx b/frontend/src/components/SearchInput.tsx new file mode 100644 index 0000000..9d1cfe5 --- /dev/null +++ b/frontend/src/components/SearchInput.tsx @@ -0,0 +1,43 @@ +import { Search } from "lucide-react"; +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; +import { FormEvent, useRef, useEffect } from "react"; + +interface SearchInputProps { + query: string; + onQueryChange: (q: string) => void; + onSearch: (q: string) => void; + compact?: boolean; + autoFocus?: boolean; +} + +export function SearchInput({ query, onQueryChange, onSearch, compact, autoFocus }: SearchInputProps) { + const inputRef = useRef(null); + + useEffect(() => { + if (autoFocus) inputRef.current?.focus(); + }, [autoFocus]); + + const handleSubmit = (e: FormEvent) => { + e.preventDefault(); + onSearch(query); + }; + + return ( +
+
+ + onQueryChange(e.target.value)} + placeholder="Search the web privately..." + className={`pl-10 pr-4 border-input bg-background focus-visible:ring-ring ${compact ? "h-9 text-sm" : "h-12 text-base"}`} + /> +
+ +
+ ); +} diff --git a/frontend/src/components/ui/accordion.tsx b/frontend/src/components/ui/accordion.tsx new file mode 100644 index 0000000..1e7878c --- /dev/null +++ b/frontend/src/components/ui/accordion.tsx @@ -0,0 +1,52 @@ +import * as React from "react"; +import * as AccordionPrimitive from "@radix-ui/react-accordion"; +import { ChevronDown } from "lucide-react"; + +import { cn } from "@/lib/utils"; + +const Accordion = AccordionPrimitive.Root; + +const AccordionItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AccordionItem.displayName = "AccordionItem"; + +const AccordionTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + svg]:rotate-180", + className, + )} + {...props} + > + {children} + + + +)); +AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName; + +const AccordionContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + +
{children}
+
+)); + +AccordionContent.displayName = AccordionPrimitive.Content.displayName; + +export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }; diff --git a/frontend/src/components/ui/alert-dialog.tsx b/frontend/src/components/ui/alert-dialog.tsx new file mode 100644 index 0000000..6dfbfb4 --- /dev/null +++ b/frontend/src/components/ui/alert-dialog.tsx @@ -0,0 +1,104 @@ +import * as React from "react"; +import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"; + +import { cn } from "@/lib/utils"; +import { buttonVariants } from "@/components/ui/button"; + +const AlertDialog = AlertDialogPrimitive.Root; + +const AlertDialogTrigger = AlertDialogPrimitive.Trigger; + +const AlertDialogPortal = AlertDialogPrimitive.Portal; + +const AlertDialogOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName; + +const AlertDialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + +)); +AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName; + +const AlertDialogHeader = ({ className, ...props }: React.HTMLAttributes) => ( +
+); +AlertDialogHeader.displayName = "AlertDialogHeader"; + +const AlertDialogFooter = ({ className, ...props }: React.HTMLAttributes) => ( +
+); +AlertDialogFooter.displayName = "AlertDialogFooter"; + +const AlertDialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName; + +const AlertDialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogDescription.displayName = AlertDialogPrimitive.Description.displayName; + +const AlertDialogAction = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName; + +const AlertDialogCancel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName; + +export { + AlertDialog, + AlertDialogPortal, + AlertDialogOverlay, + AlertDialogTrigger, + AlertDialogContent, + AlertDialogHeader, + AlertDialogFooter, + AlertDialogTitle, + AlertDialogDescription, + AlertDialogAction, + AlertDialogCancel, +}; diff --git a/frontend/src/components/ui/alert.tsx b/frontend/src/components/ui/alert.tsx new file mode 100644 index 0000000..2efc3c8 --- /dev/null +++ b/frontend/src/components/ui/alert.tsx @@ -0,0 +1,43 @@ +import * as React from "react"; +import { cva, type VariantProps } from "class-variance-authority"; + +import { cn } from "@/lib/utils"; + +const alertVariants = cva( + "relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground", + { + variants: { + variant: { + default: "bg-background text-foreground", + destructive: "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive", + }, + }, + defaultVariants: { + variant: "default", + }, + }, +); + +const Alert = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes & VariantProps +>(({ className, variant, ...props }, ref) => ( +
+)); +Alert.displayName = "Alert"; + +const AlertTitle = React.forwardRef>( + ({ className, ...props }, ref) => ( +
+ ), +); +AlertTitle.displayName = "AlertTitle"; + +const AlertDescription = React.forwardRef>( + ({ className, ...props }, ref) => ( +
+ ), +); +AlertDescription.displayName = "AlertDescription"; + +export { Alert, AlertTitle, AlertDescription }; diff --git a/frontend/src/components/ui/aspect-ratio.tsx b/frontend/src/components/ui/aspect-ratio.tsx new file mode 100644 index 0000000..c9e6f4b --- /dev/null +++ b/frontend/src/components/ui/aspect-ratio.tsx @@ -0,0 +1,5 @@ +import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio"; + +const AspectRatio = AspectRatioPrimitive.Root; + +export { AspectRatio }; diff --git a/frontend/src/components/ui/avatar.tsx b/frontend/src/components/ui/avatar.tsx new file mode 100644 index 0000000..68d21bb --- /dev/null +++ b/frontend/src/components/ui/avatar.tsx @@ -0,0 +1,38 @@ +import * as React from "react"; +import * as AvatarPrimitive from "@radix-ui/react-avatar"; + +import { cn } from "@/lib/utils"; + +const Avatar = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +Avatar.displayName = AvatarPrimitive.Root.displayName; + +const AvatarImage = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AvatarImage.displayName = AvatarPrimitive.Image.displayName; + +const AvatarFallback = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName; + +export { Avatar, AvatarImage, AvatarFallback }; diff --git a/frontend/src/components/ui/badge.tsx b/frontend/src/components/ui/badge.tsx new file mode 100644 index 0000000..0853c44 --- /dev/null +++ b/frontend/src/components/ui/badge.tsx @@ -0,0 +1,29 @@ +import * as React from "react"; +import { cva, type VariantProps } from "class-variance-authority"; + +import { cn } from "@/lib/utils"; + +const badgeVariants = cva( + "inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", + { + variants: { + variant: { + default: "border-transparent bg-primary text-primary-foreground hover:bg-primary/80", + secondary: "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", + destructive: "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80", + outline: "text-foreground", + }, + }, + defaultVariants: { + variant: "default", + }, + }, +); + +export interface BadgeProps extends React.HTMLAttributes, VariantProps {} + +function Badge({ className, variant, ...props }: BadgeProps) { + return
; +} + +export { Badge, badgeVariants }; diff --git a/frontend/src/components/ui/breadcrumb.tsx b/frontend/src/components/ui/breadcrumb.tsx new file mode 100644 index 0000000..ca91ff5 --- /dev/null +++ b/frontend/src/components/ui/breadcrumb.tsx @@ -0,0 +1,90 @@ +import * as React from "react"; +import { Slot } from "@radix-ui/react-slot"; +import { ChevronRight, MoreHorizontal } from "lucide-react"; + +import { cn } from "@/lib/utils"; + +const Breadcrumb = React.forwardRef< + HTMLElement, + React.ComponentPropsWithoutRef<"nav"> & { + separator?: React.ReactNode; + } +>(({ ...props }, ref) =>