scope/main.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)
})
}