324 lines
8.1 KiB
Go
324 lines
8.1 KiB
Go
/*
|
|
* 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)
|
|
})
|
|
}
|