about summary refs log tree commit diff stats
path: root/internal/server/mux.go
diff options
context:
space:
mode:
authorAlan Pearce2024-05-11 21:00:01 +0200
committerAlan Pearce2024-05-11 21:00:01 +0200
commit6ea867dfa9043e9f9beedaabc195a2657254bdc8 (patch)
tree11b4913becf9c7fabe6f72afacd1b9d738046996 /internal/server/mux.go
parentc232a6c46c59200236d1e1bea6b170859cda1005 (diff)
downloadsearchix-6ea867dfa9043e9f9beedaabc195a2657254bdc8.tar.lz
searchix-6ea867dfa9043e9f9beedaabc195a2657254bdc8.tar.zst
searchix-6ea867dfa9043e9f9beedaabc195a2657254bdc8.zip
refactor: split http.Server and http.ServeMux creation
Diffstat (limited to 'internal/server/mux.go')
-rw-r--r--internal/server/mux.go292
1 files changed, 292 insertions, 0 deletions
diff --git a/internal/server/mux.go b/internal/server/mux.go
new file mode 100644
index 0000000..ee7daed
--- /dev/null
+++ b/internal/server/mux.go
@@ -0,0 +1,292 @@
+package server
+
+import (
+	"context"
+	"fmt"
+	"html/template"
+	"io"
+	"log"
+	"log/slog"
+	"net/http"
+	"net/url"
+	"os"
+	"path"
+	"slices"
+	"strconv"
+	"time"
+
+	"searchix/frontend"
+	cfg "searchix/internal/config"
+	"searchix/internal/importer"
+	"searchix/internal/options"
+	"searchix/internal/search"
+
+	"github.com/blevesearch/bleve/v2"
+	"github.com/getsentry/sentry-go"
+	sentryhttp "github.com/getsentry/sentry-go/http"
+	"github.com/osdevisnot/sorvor/pkg/livereload"
+	"github.com/pkg/errors"
+	"github.com/shengyanli1982/law"
+)
+
+var config *cfg.Config
+
+type Config struct {
+	Environment   string     `conf:"default:development"`
+	LiveReload    bool       `conf:"default:false,flag:live"`
+	ListenAddress string     `conf:"default:localhost"`
+	Port          string     `conf:"default:3000,short:p"`
+	BaseURL       cfg.URL    `conf:"default:http://localhost:3000,short:b"`
+	ConfigFile    string     `conf:"short:c"`
+	LogLevel      slog.Level `conf:"default:INFO"`
+	SentryDSN     string
+}
+
+type HTTPError struct {
+	Error   error
+	Message string
+	Code    int
+}
+
+const jsSnippet = template.HTML(livereload.JsSnippet) // #nosec G203
+
+type TemplateData struct {
+	LiveReload   template.HTML
+	Sources      map[string]*importer.Source
+	Source       importer.Source
+	Query        string
+	Results      bool
+	SourceResult *bleve.SearchResult
+}
+
+type ResultData[T options.NixOption] struct {
+	TemplateData
+	Query          string
+	ResultsPerPage int
+	Results        *search.Result
+	Prev           string
+	Next           string
+}
+
+func applyDevModeOverrides(config *cfg.Config) {
+	config.CSP.ScriptSrc = slices.Insert(config.CSP.ScriptSrc, 0, "'unsafe-inline'")
+	config.CSP.ConnectSrc = slices.Insert(config.CSP.ConnectSrc, 0, "'self'")
+}
+
+func NewMux(runtimeConfig *Config) (*http.ServeMux, error) {
+	var err error
+	config, err = cfg.GetConfig(runtimeConfig.ConfigFile)
+	if err != nil {
+		return nil, errors.WithMessage(err, "error parsing configuration file")
+	}
+
+	slog.Debug("loading index")
+	index, err := search.Open(config.DataPath)
+	slog.Debug("loaded index")
+	if err != nil {
+		log.Fatalf("could not open search index, error: %#v", err)
+	}
+
+	err = sentry.Init(sentry.ClientOptions{
+		EnableTracing:    true,
+		TracesSampleRate: 1.0,
+		Dsn:              runtimeConfig.SentryDSN,
+		Environment:      runtimeConfig.Environment,
+	})
+	if err != nil {
+		return nil, errors.WithMessage(err, "could not set up sentry")
+	}
+	defer sentry.Flush(2 * time.Second)
+	sentryHandler := sentryhttp.New(sentryhttp.Options{
+		Repanic: true,
+	})
+
+	templates, err := loadTemplates()
+	if err != nil {
+		log.Panicf("could not load templates: %v", err)
+	}
+
+	top := http.NewServeMux()
+	mux := http.NewServeMux()
+	indexData := TemplateData{
+		LiveReload: jsSnippet,
+		Sources:    config.Sources,
+	}
+	mux.HandleFunc("/{$}", func(w http.ResponseWriter, _ *http.Request) {
+		err := templates["index"].ExecuteTemplate(w, "index.gotmpl", indexData)
+		if err != nil {
+			http.Error(w, err.Error(), http.StatusInternalServerError)
+		}
+	})
+
+	const getSourceTimeout = 1 * time.Second
+	mux.HandleFunc("/options/{source}/search", func(w http.ResponseWriter, r *http.Request) {
+		sourceKey := r.PathValue("source")
+
+		source := config.Sources[sourceKey]
+		if source == nil {
+			http.Error(w, "Source not found", http.StatusNotFound)
+
+			return
+		}
+
+		ctx, cancel := context.WithTimeout(r.Context(), getSourceTimeout)
+		defer cancel()
+
+		sourceResult, err := index.GetSource(ctx, sourceKey)
+		if err != nil {
+			http.Error(w, err.Error(), http.StatusInternalServerError)
+
+			return
+		}
+
+		err = templates["search"].Execute(w, TemplateData{
+			LiveReload:   jsSnippet,
+			Sources:      config.Sources,
+			Source:       *source,
+			SourceResult: sourceResult,
+		})
+		if err != nil {
+			http.Error(w, err.Error(), http.StatusInternalServerError)
+
+			return
+		}
+	})
+
+	timeout := 1 * time.Second
+	mux.HandleFunc("/options/{source}/results", func(w http.ResponseWriter, r *http.Request) {
+		sourceKey := r.PathValue("source")
+		ctx, cancel := context.WithTimeoutCause(r.Context(), timeout, errors.New("timeout"))
+		defer cancel()
+		source := config.Sources[sourceKey]
+		if source == nil {
+			http.Error(w, "Unknown source", http.StatusNotFound)
+
+			return
+		}
+
+		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 {
+				http.Error(w, "Bad query string", http.StatusBadRequest)
+			}
+		}
+		results, err := index.Search(ctx, sourceKey, qs, (page-1)*search.ResultsPerPage)
+		if err != nil {
+			if err == context.DeadlineExceeded {
+				http.Error(w, "Search timed out", http.StatusInternalServerError)
+
+				return
+			}
+			slog.Error("search error", "error", err)
+			http.Error(w, err.Error(), http.StatusInternalServerError)
+		}
+
+		tdata := ResultData[options.NixOption]{
+			TemplateData: TemplateData{
+				LiveReload: jsSnippet,
+				Source:     *source,
+				Sources:    config.Sources,
+			},
+			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 {
+				http.Error(w, "Query string error", http.StatusBadRequest)
+
+				return
+			}
+
+			if page*search.ResultsPerPage > results.Total {
+				http.Error(w, "Not found", http.StatusNotFound)
+
+				return
+			}
+
+			if page*search.ResultsPerPage < results.Total {
+				q.Set("page", strconv.FormatUint(page+1, 10))
+				tdata.Next = "results?" + q.Encode()
+			}
+
+			if page > 1 {
+				p := page - 1
+				if p == 1 {
+					q.Del("page")
+				} else {
+					q.Set("page", strconv.FormatUint(p, 10))
+				}
+				tdata.Prev = "results?" + q.Encode()
+			}
+		}
+
+		if r.Header.Get("Fetch") == "true" {
+			w.Header().Add("Content-Type", "text/html; charset=utf-8")
+			err = templates["options"].ExecuteTemplate(w, "options.gotmpl", tdata)
+		} else {
+			err = templates["options"].ExecuteTemplate(w, "index.gotmpl", tdata)
+		}
+		if err != nil {
+			slog.Error("template error", "template", "options", "error", err)
+			http.Error(w, err.Error(), http.StatusInternalServerError)
+		}
+	})
+
+	mux.Handle("/static/", http.FileServer(http.FS(frontend.Files)))
+
+	if runtimeConfig.LiveReload {
+		applyDevModeOverrides(config)
+		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 runtimeConfig.Environment == "production" {
+		logWriter = law.NewWriteAsyncer(os.Stdout, nil)
+	} else {
+		logWriter = os.Stdout
+	}
+	top.Handle("/",
+		AddHeadersMiddleware(
+			sentryHandler.Handle(
+				wrapHandlerWithLogging(mux, wrappedHandlerOptions{
+					defaultHostname: runtimeConfig.BaseURL.Hostname(),
+					logger:          logWriter,
+				}),
+			),
+			config,
+		),
+	)
+	// no logging, no sentry
+	top.HandleFunc("/health", func(w http.ResponseWriter, _ *http.Request) {
+		w.WriteHeader(http.StatusOK)
+	})
+
+	return top, nil
+}