You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
323 lines
8.1 KiB
323 lines
8.1 KiB
/* |
|
* Scope - A simple and minimal metasearch engine |
|
* Copyright (C) 2021 Arsen Musayelyan |
|
* |
|
* 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 <https://www.gnu.org/licenses/>. |
|
*/ |
|
|
|
package main |
|
|
|
import ( |
|
"embed" |
|
"fmt" |
|
"html/template" |
|
"io/fs" |
|
"net" |
|
"net/http" |
|
"os" |
|
"strconv" |
|
"strings" |
|
"time" |
|
|
|
"github.com/DataHenHQ/useragent" |
|
"github.com/Masterminds/sprig" |
|
"github.com/rs/zerolog" |
|
"github.com/rs/zerolog/log" |
|
"github.com/spf13/viper" |
|
"go.arsenm.dev/scope/internal/cards" |
|
"go.arsenm.dev/scope/search/web" |
|
) |
|
|
|
//go:embed templates |
|
var templates embed.FS |
|
|
|
//go:embed static |
|
var static embed.FS |
|
|
|
var engines = map[string]web.Engine{ |
|
"google": &web.Google{}, |
|
"ddg": &web.DDG{}, |
|
"bing": &web.Bing{}, |
|
} |
|
|
|
var funcs = template.FuncMap{ |
|
"html": func(s string) template.HTML { |
|
return template.HTML(s) |
|
}, |
|
} |
|
|
|
// TmplContext represents context passed to a template |
|
type TmplContext struct { |
|
BaseContext |
|
Results []*web.Result |
|
Keyword string |
|
Page int |
|
Card cards.Card |
|
} |
|
|
|
// ErrTmplContext represents context passed to an error template |
|
type ErrTmplContext struct { |
|
BaseContext |
|
Error string |
|
ErrExists bool |
|
Message string |
|
Status int |
|
} |
|
|
|
// BaseContext is the common context between all templates |
|
type BaseContext struct { |
|
LoadTime time.Duration |
|
} |
|
|
|
// Config returns a viper config value |
|
func (bc BaseContext) Config(key string) interface{} { |
|
return viper.Get(key) |
|
} |
|
|
|
func init() { |
|
// Set logger |
|
log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr}) |
|
|
|
// Set config options |
|
viper.AddConfigPath(".") |
|
viper.AddConfigPath("$HOME/.config") |
|
viper.AddConfigPath("/etc") |
|
viper.SetConfigName("scope") |
|
viper.SetConfigType("toml") |
|
|
|
// Read configuration |
|
viper.WatchConfig() |
|
viper.SetEnvKeyReplacer(strings.NewReplacer(".", "_")) |
|
viper.SetEnvPrefix("scope") |
|
if err := viper.ReadInConfig(); err != nil { |
|
log.Fatal().Err(err).Msg("Error reading config") |
|
} |
|
viper.AutomaticEnv() |
|
} |
|
|
|
func main() { |
|
// Get embedded templates directory |
|
tmplRoot, err := fs.Sub(templates, "templates") |
|
if err != nil { |
|
log.Fatal().Err(err).Msg("Error getting embedded templates directory") |
|
} |
|
|
|
// Create new template for results |
|
resultTmpl, err := template.New("base.html"). |
|
Funcs(sprig.FuncMap()). |
|
Funcs(funcs). |
|
ParseFS(tmplRoot, "result.html", "base.html") |
|
if err != nil { |
|
log.Fatal().Err(err).Msg("Error parsing result template") |
|
} |
|
|
|
// Create new template for home page |
|
homeTmpl, err := template.New("base.html"). |
|
Funcs(sprig.FuncMap()). |
|
ParseFS(tmplRoot, "home.html", "base.html") |
|
if err != nil { |
|
log.Fatal().Err(err).Msg("Error parsing home template") |
|
} |
|
|
|
// Create new template for home page |
|
errorTmpl, err := template.New("base.html"). |
|
Funcs(sprig.FuncMap()). |
|
ParseFS(tmplRoot, "error.html", "base.html") |
|
if err != nil { |
|
log.Fatal().Err(err).Msg("Error parsing error template") |
|
} |
|
|
|
// Create new template for home page |
|
opensearchTmpl, err := template.New("opensearch.xml"). |
|
ParseFS(tmplRoot, "opensearch.xml") |
|
if err != nil { |
|
log.Fatal().Err(err).Msg("Error parsing opensearch template") |
|
} |
|
|
|
// GET / (Home page) |
|
http.HandleFunc("/", func(res http.ResponseWriter, req *http.Request) { |
|
start := time.Now() |
|
homeTmpl.Execute(res, TmplContext{ |
|
BaseContext: BaseContext{ |
|
LoadTime: time.Since(start), |
|
}, |
|
}) |
|
}) |
|
|
|
// GET /search (Results page) |
|
http.HandleFunc("/search", func(res http.ResponseWriter, req *http.Request) { |
|
// Get request start time |
|
start := time.Now() |
|
|
|
// Get queryParams parameters |
|
queryParams := req.URL.Query() |
|
// Get parameter "q" |
|
query := queryParams.Get("q") |
|
|
|
// If no keyword |
|
if query == "" { |
|
// Redirect to homepage |
|
http.Redirect(res, req, "/", http.StatusFound) |
|
return |
|
} |
|
|
|
// Get parameter "page" |
|
pageNumStr := queryParams.Get("page") |
|
// Attempt to convert to integer, otherwise assume 0 |
|
pageNum, err := strconv.Atoi(pageNumStr) |
|
if err != nil { |
|
pageNum = 0 |
|
} |
|
|
|
// Get random user agent |
|
randUA, _ := useragent.Desktop() |
|
|
|
var cardCh chan cards.Card |
|
if viper.GetBool("search.cards.enabled") { |
|
cardCh = make(chan cards.Card, 1) |
|
// Create channel for instant answer |
|
if pageNum == 0 { |
|
go func() { |
|
// Get matching card for query |
|
card := cards.GetCard(query, randUA) |
|
|
|
// Run query |
|
err := card.RunQuery() |
|
if err != nil || !card.Returned() { |
|
cardCh <- nil |
|
} |
|
|
|
// Send card to channel |
|
cardCh <- card |
|
}() |
|
} else { |
|
cardCh <- nil |
|
} |
|
} |
|
|
|
// Search web using all specified engines |
|
results, err := web.Search( |
|
web.Options{ |
|
Keyword: query, |
|
UserAgent: randUA, |
|
Page: pageNum, |
|
}, |
|
getEngines(viper.GetStringSlice("search.engines"))..., |
|
) |
|
if err != nil { |
|
httpErr(res, http.StatusInternalServerError, err, "Error performing search", start, errorTmpl) |
|
return |
|
} |
|
|
|
// Create new template context |
|
tmplCtx := TmplContext{ |
|
BaseContext: BaseContext{ |
|
LoadTime: time.Since(start), |
|
}, |
|
Results: results, |
|
Keyword: query, |
|
Page: pageNum, |
|
} |
|
|
|
// If cards are enabled in config |
|
if viper.GetBool("search.cards.enabled") { |
|
// Set card to response |
|
tmplCtx.Card = <-cardCh |
|
} |
|
|
|
// Execute result template for search response |
|
err = resultTmpl.Execute(res, tmplCtx) |
|
if err != nil { |
|
httpErr(res, http.StatusInternalServerError, err, "Error executing result template", start, errorTmpl) |
|
return |
|
} |
|
}) |
|
|
|
// GET /static/{path} (Static file server, 12 day cache) |
|
http.Handle("/static/", maxAgeHandler(time.Hour*24*12, http.FileServer(http.FS(static)))) |
|
|
|
// GET /opensearch (OpenSearch description file) |
|
http.HandleFunc("/opensearch", func(res http.ResponseWriter, req *http.Request) { |
|
opensearchTmpl.Execute(res, TmplContext{}) |
|
}) |
|
|
|
// Join address and port from config |
|
addr := net.JoinHostPort(viper.GetString("server.addr"), viper.GetString("server.port")) |
|
|
|
// Log server starting with address |
|
log.Info().Str("address", addr).Msg("Starting HTTP server") |
|
// Start server |
|
if err := http.ListenAndServe(addr, nil); err != nil { |
|
log.Fatal().Err(err).Msg("Error while serving") |
|
} |
|
} |
|
|
|
func httpErr(res http.ResponseWriter, statusCode int, err error, msg string, start time.Time, errTmpl *template.Template) { |
|
if err != nil { |
|
// Log error with message |
|
log.Error().Err(err).Msg(msg) |
|
// Write status code to connection |
|
res.WriteHeader(statusCode) |
|
// Execute error template with error |
|
errTmpl.Execute(res, ErrTmplContext{ |
|
BaseContext: BaseContext{ |
|
LoadTime: time.Since(start), |
|
}, |
|
Error: err.Error(), |
|
ErrExists: true, |
|
Message: msg, |
|
Status: statusCode, |
|
}) |
|
} else { |
|
// Log error without message |
|
log.Error().Msg(msg) |
|
// Write status code to connection |
|
res.WriteHeader(statusCode) |
|
// Execute error template without error |
|
errTmpl.Execute(res, ErrTmplContext{ |
|
BaseContext: BaseContext{ |
|
LoadTime: time.Since(start), |
|
}, |
|
ErrExists: false, |
|
Message: msg, |
|
Status: statusCode, |
|
}) |
|
} |
|
} |
|
|
|
// Get search endine from names |
|
func getEngines(names []string) []web.Engine { |
|
var out []web.Engine |
|
// For every provided name |
|
for _, name := range names { |
|
// If engine with that name does not exist, skip |
|
engine, ok := engines[name] |
|
if !ok { |
|
continue |
|
} |
|
// Add endine to output |
|
out = append(out, engine) |
|
} |
|
return out |
|
} |
|
|
|
// maxAgeHandler adds a cache header to the response with the duration provided |
|
func maxAgeHandler(d time.Duration, h http.Handler) http.Handler { |
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
|
// Set Cache-Control header |
|
w.Header().Add("Cache-Control", fmt.Sprintf("max-age=%d, public, must-revalidate, proxy-revalidate", int(d.Seconds()))) |
|
// Pass to next handler |
|
h.ServeHTTP(w, r) |
|
}) |
|
}
|
|
|