/* * 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 . */ package main import ( "embed" "fmt" "html/template" "io/fs" "net" "net/http" "os" "path" "path/filepath" "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) }) }