package server
import (
"context"
"fmt"
"html/template"
"io"
"log"
"log/slog"
"net/http"
"net/url"
"os"
"path"
"strconv"
"time"
"searchix/frontend"
"searchix/internal/config"
search "searchix/internal/index"
"searchix/internal/options"
"github.com/blevesearch/bleve/v2"
sentryhttp "github.com/getsentry/sentry-go/http"
"github.com/osdevisnot/sorvor/pkg/livereload"
"github.com/pkg/errors"
"github.com/shengyanli1982/law"
)
type HTTPError struct {
Error error
Message string
Code int
}
const jsSnippet = template.HTML(livereload.JsSnippet) // #nosec G203
type VersionInfo struct {
ShortSHA string
CommitSHA string
}
type TemplateData struct {
Sources map[string]*config.Source
Source config.Source
Query string
Results bool
SourceResult *bleve.SearchResult
ExtraHeadHTML template.HTML
Version VersionInfo
Code int
Message string
}
type ResultData[T options.NixOption] struct {
TemplateData
Query string
ResultsPerPage int
Results *search.Result
Prev string
Next string
}
var versionInfo = &VersionInfo{
ShortSHA: config.ShortSHA,
CommitSHA: config.CommitSHA,
}
var templates TemplateCollection
func applyDevModeOverrides(cfg *config.Config) {
if len(cfg.Web.ContentSecurityPolicy.ScriptSrc) == 0 {
cfg.Web.ContentSecurityPolicy.ScriptSrc = cfg.Web.ContentSecurityPolicy.DefaultSrc
}
cfg.Web.ContentSecurityPolicy.ScriptSrc = append(
cfg.Web.ContentSecurityPolicy.ScriptSrc,
"'unsafe-inline'",
)
}
func NewMux(
cfg *config.Config,
index *search.ReadIndex,
liveReload bool,
) (*http.ServeMux, error) {
var err error
sentryHandler := sentryhttp.New(sentryhttp.Options{
Repanic: true,
})
templates, err = loadTemplates()
if err != nil {
log.Panicf("could not load templates: %v", err)
}
errorHandler := createErrorHandler(cfg)
top := http.NewServeMux()
mux := http.NewServeMux()
mux.HandleFunc("/{$}", func(w http.ResponseWriter, r *http.Request) {
indexData := TemplateData{
ExtraHeadHTML: cfg.Web.ExtraHeadHTML,
Sources: cfg.Importer.Sources,
Version: *versionInfo,
}
w.Header().Add("Cache-Control", "max-age=86400")
err := templates["index"].Execute(w, indexData)
if err != nil {
errorHandler(w, r, err.Error(), http.StatusInternalServerError)
}
})
const searchTimeout = 1 * time.Second
mux.HandleFunc("/options/{source}/search", func(w http.ResponseWriter, r *http.Request) {
sourceKey := r.PathValue("source")
source := cfg.Importer.Sources[sourceKey]
if source == nil {
errorHandler(w, r, "Source not found", http.StatusNotFound)
return
}
ctx, cancel := context.WithTimeout(r.Context(), searchTimeout)
defer cancel()
if r.URL.Query().Has("query") {
qs := r.URL.Query().Get("query")
pg := r.URL.Query().Get("page")
var page uint64 = 1
if pg != "" {
page, err = strconv.ParseUint(pg, 10, 64)
if err != nil || page == 0 {
errorHandler(w, r, "Bad query string", http.StatusBadRequest)
}
}
results, err := index.Search(ctx, sourceKey, qs, (page-1)*search.ResultsPerPage)
if err != nil {
if err == context.DeadlineExceeded {
errorHandler(w, r, "Search timed out", http.StatusInternalServerError)
return
}
slog.Error("search error", "error", err)
errorHandler(w, r, err.Error(), http.StatusInternalServerError)
}
tdata := ResultData[options.NixOption]{
TemplateData: TemplateData{
ExtraHeadHTML: cfg.Web.ExtraHeadHTML,
Source: *source,
Sources: cfg.Importer.Sources,
Version: *versionInfo,
},
ResultsPerPage: search.ResultsPerPage,
Query: qs,
Results: results,
}
hits := uint64(len(results.Hits))
if results.Total > hits {
q, err := url.ParseQuery(r.URL.RawQuery)
if err != nil {
errorHandler(w, r, "Query string error", http.StatusBadRequest)
return
}
if page*search.ResultsPerPage > results.Total {
errorHandler(w, r, "Not found", http.StatusNotFound)
return
}
if page*search.ResultsPerPage < results.Total {
q.Set("page", strconv.FormatUint(page+1, 10))
tdata.Next = "search?" + q.Encode()
}
if page > 1 {
p := page - 1
if p == 1 {
q.Del("page")
} else {
q.Set("page", strconv.FormatUint(p, 10))
}
tdata.Prev = "search?" + q.Encode()
}
}
w.Header().Add("Cache-Control", "max-age=300")
w.Header().Add("Vary", "Fetch")
if r.Header.Get("Fetch") == "true" {
w.Header().Add("Content-Type", "text/html; charset=utf-8")
err = templates["options"].ExecuteTemplate(w, "results", tdata)
} else {
err = templates["options"].Execute(w, tdata)
}
if err != nil {
slog.Error("template error", "template", "options", "error", err)
errorHandler(w, r, err.Error(), http.StatusInternalServerError)
}
} else {
sourceResult, err := index.GetSource(ctx, sourceKey)
if err != nil {
errorHandler(w, r, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Add("Cache-Control", "max-age=14400")
err = templates["search"].Execute(w, TemplateData{
ExtraHeadHTML: cfg.Web.ExtraHeadHTML,
Sources: cfg.Importer.Sources,
Source: *source,
SourceResult: sourceResult,
Version: *versionInfo,
})
if err != nil {
errorHandler(w, r, err.Error(), http.StatusInternalServerError)
return
}
}
})
mux.HandleFunc(
"/options/{source}/opensearch.xml",
func(w http.ResponseWriter, r *http.Request) {
type openSearchData struct {
BaseURL string
Source *config.Source
}
sourceKey := r.PathValue("source")
source := cfg.Importer.Sources[sourceKey]
if source == nil {
errorHandler(w, r, "Source not found", http.StatusNotFound)
return
}
w.Header().Add("Cache-Control", "max-age=604800")
w.Header().Set("Content-Type", "application/opensearchdescription+xml")
err := templates["opensearch.xml"].ExecuteTemplate(
w,
"opensearch.xml.gotmpl",
openSearchData{
BaseURL: cfg.Web.BaseURL.String(),
Source: source,
},
)
if err != nil {
// no errorHandler; HTML does not make sense here
http.Error(
w,
fmt.Sprintf("Template render error: %v", err),
http.StatusInternalServerError,
)
}
},
)
fs := http.FileServer(http.FS(frontend.Files))
mux.HandleFunc("/static/", func(w http.ResponseWriter, r *http.Request) {
w.Header().Add("Cache-Control", "max-age=86400")
fs.ServeHTTP(w, r)
})
if liveReload {
applyDevModeOverrides(cfg)
cfg.Web.ExtraHeadHTML = jsSnippet
liveReload := livereload.New()
liveReload.Start()
top.Handle("/livereload", liveReload)
fw, err := NewFileWatcher()
if err != nil {
return nil, errors.WithMessage(err, "could not create file watcher")
}
err = fw.AddRecursive(path.Join("frontend"))
if err != nil {
return nil, errors.WithMessage(err, "could not add directory to file watcher")
}
go fw.Start(func(filename string) {
slog.Debug(fmt.Sprintf("got filename %s", filename))
if path.Ext(filename) == ".gotmpl" {
templates, err = loadTemplates()
if err != nil {
slog.Error(fmt.Sprintf("could not reload templates: %v", err))
}
}
liveReload.Reload()
})
}
var logWriter io.Writer
if cfg.Web.Environment == "production" {
logWriter = law.NewWriteAsyncer(os.Stdout, nil)
} else {
logWriter = os.Stdout
}
top.Handle("/",
AddHeadersMiddleware(
sentryHandler.Handle(
wrapHandlerWithLogging(mux, wrappedHandlerOptions{
defaultHostname: cfg.Web.BaseURL.Hostname(),
logger: logWriter,
}),
),
cfg,
),
)
// no logging, no sentry
top.HandleFunc("/health", func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
})
return top, nil
}