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
This commit is contained in:
Franz Kafka 2026-03-21 17:40:05 +00:00
parent 3caf702c4f
commit 4ec600f6c0
6 changed files with 51 additions and 0 deletions

View file

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

View file

@ -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.

View file

@ -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.

View file

@ -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") {

View file

@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<OpenSearchDescription xmlns="http://a9.com/-/spec/opensearch/1.1/">
<ShortName>gosearch</ShortName>
<Description>A privacy-respecting, open metasearch engine</Description>
<InputEncoding>UTF-8</InputEncoding>
<OutputEncoding>UTF-8</OutputEncoding>
<LongName>gosearch — Privacy-respecting metasearch</LongName>
<Image width="16" height="16" type="image/svg+xml">/static/img/favicon.svg</Image>
<Contact>https://git.ashisgreat.xyz/penal-colony/gosearch</Contact>
<Url type="text/html" method="GET" template="{baseUrl}/search?q={searchTerms}&amp;format=html">
<Param name="pageno" value="{startPage?}" />
<Param name="language" value="{language?}" />
</Url>
<Url type="application/json" method="GET" template="{baseUrl}/search?q={searchTerms}&amp;format=json" />
<Query role="example" searchTerms="golang" />
</OpenSearchDescription>

View file

@ -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{