feat: build Go-based SearXNG-compatible search service
Implement an API-first Go rewrite with local engine adapters, upstream fallback, and Nix-based tooling so searches can run without matching the original UI while preserving response compatibility. Made-with: Cursor
This commit is contained in:
parent
7783367c71
commit
dc44837219
32 changed files with 3330 additions and 0 deletions
206
internal/search/request_params.go
Normal file
206
internal/search/request_params.go
Normal file
|
|
@ -0,0 +1,206 @@
|
|||
package search
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
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.
|
||||
if err := r.ParseForm(); err != nil {
|
||||
return SearchRequest{}, errors.New("invalid request: cannot parse form")
|
||||
}
|
||||
|
||||
format := strings.ToLower(r.FormValue("format"))
|
||||
switch OutputFormat(format) {
|
||||
case FormatJSON, FormatCSV, FormatRSS:
|
||||
default:
|
||||
// MVP: treat everything else as json, except `html` which we accept for compatibility.
|
||||
if format == string(FormatHTML) {
|
||||
// accepted, but not implemented by the server yet
|
||||
} else {
|
||||
format = string(FormatJSON)
|
||||
}
|
||||
}
|
||||
|
||||
q := r.FormValue("q")
|
||||
if strings.TrimSpace(q) == "" {
|
||||
return SearchRequest{}, errors.New("missing required parameter: q")
|
||||
}
|
||||
|
||||
pageno := 1
|
||||
if s := strings.TrimSpace(r.FormValue("pageno")); s != "" {
|
||||
n, err := strconv.Atoi(s)
|
||||
if err != nil || n < 1 {
|
||||
return SearchRequest{}, errors.New("invalid parameter: pageno")
|
||||
}
|
||||
pageno = n
|
||||
}
|
||||
|
||||
// MVP defaults.
|
||||
safesearch := 0
|
||||
if s := strings.TrimSpace(r.FormValue("safesearch")); s != "" {
|
||||
n, err := strconv.Atoi(s)
|
||||
if err != nil || n < 0 || n > 2 {
|
||||
return SearchRequest{}, errors.New("invalid parameter: safesearch")
|
||||
}
|
||||
safesearch = n
|
||||
}
|
||||
|
||||
var timeRange *string
|
||||
if tr := strings.TrimSpace(r.FormValue("time_range")); tr != "" && tr != "None" {
|
||||
switch tr {
|
||||
case "day", "week", "month", "year":
|
||||
tt := tr
|
||||
timeRange = &tt
|
||||
default:
|
||||
return SearchRequest{}, errors.New("invalid parameter: time_range")
|
||||
}
|
||||
}
|
||||
|
||||
var timeoutLimit *float64
|
||||
if s := strings.TrimSpace(r.FormValue("timeout_limit")); s != "" && s != "None" {
|
||||
v, err := strconv.ParseFloat(s, 64)
|
||||
if err != nil || v <= 0 {
|
||||
return SearchRequest{}, errors.New("invalid parameter: timeout_limit")
|
||||
}
|
||||
timeoutLimit = &v
|
||||
}
|
||||
|
||||
language := strings.TrimSpace(r.FormValue("language"))
|
||||
if language == "" {
|
||||
language = "auto"
|
||||
}
|
||||
switch language {
|
||||
case "auto", "all":
|
||||
// ok
|
||||
default:
|
||||
if !languageCodeRe.MatchString(language) {
|
||||
return SearchRequest{}, errors.New("invalid parameter: language")
|
||||
}
|
||||
}
|
||||
|
||||
// engines is an explicit list of engine names.
|
||||
engines := splitCSV(strings.TrimSpace(r.FormValue("engines")))
|
||||
|
||||
// categories and category_<name> params mirror SearXNG's 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 != "" {
|
||||
for _, cat := range splitCSV(catsParam) {
|
||||
catSet[cat] = true
|
||||
}
|
||||
}
|
||||
for k, v := range r.Form {
|
||||
if !strings.HasPrefix(k, "category_") {
|
||||
continue
|
||||
}
|
||||
category := strings.TrimPrefix(k, "category_")
|
||||
if category == "" {
|
||||
continue
|
||||
}
|
||||
val := ""
|
||||
if len(v) > 0 {
|
||||
val = strings.TrimSpace(v[0])
|
||||
}
|
||||
if val == "" || val != "off" {
|
||||
catSet[category] = true
|
||||
} else {
|
||||
delete(catSet, category)
|
||||
}
|
||||
}
|
||||
categories := make([]string, 0, len(catSet))
|
||||
for c := range catSet {
|
||||
categories = append(categories, c)
|
||||
}
|
||||
if len(categories) == 0 {
|
||||
categories = []string{"general"}
|
||||
}
|
||||
|
||||
// Parse engine_data-<engine>-<key>=<value> parameters.
|
||||
engineData := map[string]map[string]string{}
|
||||
for k, v := range r.Form {
|
||||
if !strings.HasPrefix(k, "engine_data-") {
|
||||
continue
|
||||
}
|
||||
parts := strings.SplitN(k, "-", 3) // engine_data-<engine>-<key>
|
||||
if len(parts) != 3 {
|
||||
continue
|
||||
}
|
||||
engine := parts[1]
|
||||
key := parts[2]
|
||||
// For HTML forms, r.Form[k] can contain multiple values; keep first.
|
||||
val := ""
|
||||
if len(v) > 0 {
|
||||
val = v[0]
|
||||
}
|
||||
if _, ok := engineData[engine]; !ok {
|
||||
engineData[engine] = map[string]string{}
|
||||
}
|
||||
engineData[engine][key] = val
|
||||
}
|
||||
|
||||
accessToken := parseAccessToken(r)
|
||||
|
||||
return SearchRequest{
|
||||
Format: OutputFormat(format),
|
||||
Query: q,
|
||||
Pageno: pageno,
|
||||
Safesearch: safesearch,
|
||||
TimeRange: timeRange,
|
||||
TimeoutLimit: timeoutLimit,
|
||||
Language: language,
|
||||
Engines: engines,
|
||||
Categories: categories,
|
||||
EngineData: engineData,
|
||||
AccessToken: accessToken,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func splitCSV(s string) []string {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
raw := strings.Split(s, ",")
|
||||
out := make([]string, 0, len(raw))
|
||||
for _, item := range raw {
|
||||
item = strings.TrimSpace(item)
|
||||
if item == "" {
|
||||
continue
|
||||
}
|
||||
out = append(out, item)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func parseAccessToken(r *http.Request) string {
|
||||
// Supported sources (first non-empty wins):
|
||||
// - `Authorization: Bearer <token>`
|
||||
// - `X-Search-Token` / `X-Brave-Access-Token`
|
||||
// - `token` form value
|
||||
if auth := r.Header.Get("Authorization"); auth != "" {
|
||||
const prefix = "Bearer "
|
||||
if len(auth) > len(prefix) && auth[:len(prefix)] == prefix {
|
||||
return strings.TrimSpace(auth[len(prefix):])
|
||||
}
|
||||
}
|
||||
|
||||
if v := strings.TrimSpace(r.Header.Get("X-Search-Token")); v != "" {
|
||||
return v
|
||||
}
|
||||
if v := strings.TrimSpace(r.Header.Get("X-Brave-Access-Token")); v != "" {
|
||||
return v
|
||||
}
|
||||
|
||||
if v := strings.TrimSpace(r.FormValue("token")); v != "" {
|
||||
return v
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
Loading…
Add table
Add a link
Reference in a new issue