From 4ec600f6c0b9c4f7422f496277f82d385f9e22ec Mon Sep 17 00:00:00 2001 From: Franz Kafka Date: Sat, 21 Mar 2026 17:40:05 +0000 Subject: [PATCH] feat: add OpenSearch XML endpoint - Serve /opensearch.xml with configurable base URL - Browsers can now add gosearch as a search engine from the address bar - Configurable via [server] base_url or BASE_URL env var - XML template embedded in the binary via go:embed - Added base_url to config.example.toml --- cmd/searxng-go/main.go | 1 + config.example.toml | 5 +++++ internal/config/config.go | 4 ++++ internal/httpapi/handlers.go | 13 +++++++++++++ internal/views/templates/opensearch.xml | 16 ++++++++++++++++ internal/views/views.go | 12 ++++++++++++ 6 files changed, 51 insertions(+) create mode 100644 internal/views/templates/opensearch.xml diff --git a/cmd/searxng-go/main.go b/cmd/searxng-go/main.go index eb19226..f9ce301 100644 --- a/cmd/searxng-go/main.go +++ b/cmd/searxng-go/main.go @@ -63,6 +63,7 @@ func main() { mux.HandleFunc("/", h.Index) mux.HandleFunc("/healthz", h.Healthz) mux.HandleFunc("/search", h.Search) + mux.HandleFunc("/opensearch.xml", h.OpenSearch(cfg.Server.BaseURL)) // Serve embedded static files (CSS, JS, images). staticFS, err := views.StaticFS() diff --git a/config.example.toml b/config.example.toml index 825f093..b7ad762 100644 --- a/config.example.toml +++ b/config.example.toml @@ -9,6 +9,11 @@ port = 8080 # HTTP timeout for engine and upstream calls (env: HTTP_TIMEOUT) http_timeout = "10s" +# Public base URL for OpenSearch XML (env: BASE_URL) +# Set this so browsers can add gosearch as a search engine. +# Example: "https://search.example.com" +base_url = "" + [upstream] # URL of an upstream SearXNG 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 0e14351..b790200 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -22,6 +22,7 @@ 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") } type UpstreamConfig struct { @@ -158,6 +159,9 @@ func applyEnvOverrides(cfg *Config) { if v := os.Getenv("RATE_LIMIT_CLEANUP_INTERVAL"); v != "" { cfg.RateLimit.CleanupInterval = v } + if v := os.Getenv("BASE_URL"); v != "" { + cfg.Server.BaseURL = v + } } // HTTPTimeout parses the configured timeout string into a time.Duration. diff --git a/internal/httpapi/handlers.go b/internal/httpapi/handlers.go index 9df2a25..5dbd579 100644 --- a/internal/httpapi/handlers.go +++ b/internal/httpapi/handlers.go @@ -33,6 +33,19 @@ func (h *Handler) Index(w http.ResponseWriter, r *http.Request) { } } +// OpenSearch serves the OpenSearch description XML. +func (h *Handler) OpenSearch(baseURL string) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + xml, err := views.OpenSearchXML(baseURL) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/opensearchdescription+xml; charset=utf-8") + w.Write(xml) + } +} + func (h *Handler) Search(w http.ResponseWriter, r *http.Request) { // For HTML format with no query, redirect to homepage. if r.FormValue("q") == "" && (r.FormValue("format") == "" || r.FormValue("format") == "html") { diff --git a/internal/views/templates/opensearch.xml b/internal/views/templates/opensearch.xml new file mode 100644 index 0000000..596edcb --- /dev/null +++ b/internal/views/templates/opensearch.xml @@ -0,0 +1,16 @@ + + + gosearch + A privacy-respecting, open metasearch engine + UTF-8 + UTF-8 + gosearch — Privacy-respecting metasearch + /static/img/favicon.svg + https://git.ashisgreat.xyz/penal-colony/gosearch + + + + + + + diff --git a/internal/views/views.go b/internal/views/views.go index 387b1e7..2626703 100644 --- a/internal/views/views.go +++ b/internal/views/views.go @@ -80,6 +80,18 @@ func StaticFS() (fs.FS, error) { return fs.Sub(staticFS, "static") } +// OpenSearchXML returns the OpenSearch description XML with {baseUrl} +// replaced by the provided base URL. +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) + return []byte(result), nil +} + // FromResponse builds PageData from a search response and request params. func FromResponse(resp contracts.SearchResponse, query string, pageno int) PageData { pd := PageData{