hishtory/client/webui/webui.go
David Dworken 3b62e9d464
Add web UI for querying history from the browser (#180)
As requested in #176 and #147 

* Add initail version of a web UI for querying history from the browser

* Rename webui command

* Add basic test for the web UI

* Add README for the web UI

* Add basic auth for the web server

* Add status code when panic-ing
2024-08-11 12:29:36 -07:00

152 lines
4.5 KiB
Go

package webui
import (
"context"
"crypto/subtle"
"embed"
"fmt"
"net"
"net/http"
"net/url"
"os"
"html/template"
"github.com/ddworken/hishtory/client/data"
"github.com/ddworken/hishtory/client/hctx"
"github.com/ddworken/hishtory/client/lib"
"github.com/google/uuid"
)
//go:embed templates
var templateFiles embed.FS
type webUiData struct {
SearchQuery string
SearchResults [][]string
ColumnNames []string
}
func getTableRowsForDisplay(ctx context.Context, searchQuery string) ([][]string, error) {
results, err := lib.Search(ctx, hctx.GetDb(ctx), searchQuery, 100)
if err != nil {
return nil, err
}
return buildTableRows(ctx, results)
}
func htmx_resultsTable(w http.ResponseWriter, r *http.Request) {
searchQuery := r.URL.Query().Get("q")
tableRows, err := getTableRowsForDisplay(r.Context(), searchQuery)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
panic(err)
}
w.Header().Add("Content-Type", "text/html")
w.Header().Add("HX-Replace-Url", getNewUrl(r, searchQuery))
err = getTemplates().ExecuteTemplate(w, "resultsTable.html", webUiData{
SearchQuery: searchQuery,
SearchResults: tableRows,
ColumnNames: hctx.GetConf(r.Context()).DisplayedColumns,
})
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
panic(err)
}
}
func getNewUrl(r *http.Request, searchQuery string) string {
urlStr := r.Header.Get("Hx-Current-Url")
if urlStr == "" {
// In this function we purposefully want to silence any errors since updating the URL is non-critical, so
// we always return an empty string rather than handling the error.
return ""
}
url, err := url.Parse(urlStr)
if err != nil {
return ""
}
q := url.Query()
q.Set("q", searchQuery)
url.RawQuery = q.Encode()
return url.String()
}
func webuiHandler(w http.ResponseWriter, r *http.Request) {
searchQuery := r.URL.Query().Get("q")
tableRows, err := getTableRowsForDisplay(r.Context(), searchQuery)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
panic(err)
}
w.Header().Add("Content-Type", "text/html")
err = getTemplates().ExecuteTemplate(w, "webui.html", webUiData{
SearchQuery: searchQuery,
SearchResults: tableRows,
ColumnNames: hctx.GetConf(r.Context()).DisplayedColumns,
})
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
panic(err)
}
}
func getTemplates() *template.Template {
return template.Must(template.ParseFS(templateFiles, "templates/*"))
}
func buildTableRows(ctx context.Context, entries []*data.HistoryEntry) ([][]string, error) {
columnNames := hctx.GetConf(ctx).DisplayedColumns
ret := make([][]string, 0)
for _, entry := range entries {
row, err := lib.BuildTableRow(ctx, columnNames, *entry, func(s string) string { return s })
if err != nil {
return nil, err
}
ret = append(ret, row)
}
return ret, nil
}
func withBasicAuth(expectedUsername, expectedPassword string) func(h http.Handler) http.Handler {
return func(h http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
username, password, hasCreds := r.BasicAuth()
if !hasCreds || !secureStringEquals(username, expectedUsername) || !secureStringEquals(password, expectedPassword) {
w.Header().Add("WWW-Authenticate", "Basic realm=\"User Visible Realm\"")
w.WriteHeader(401)
return
}
h.ServeHTTP(w, r)
})
}
}
func secureStringEquals(s1, s2 string) bool {
return subtle.ConstantTimeCompare([]byte(s1), []byte(s2)) == 1
}
func StartWebUiServer(ctx context.Context) error {
username := "hishtory"
// Note that uuid.NewRandom() uses crypto/rand and returns a UUID with 122 bits of security
password := uuid.Must(uuid.NewRandom()).String()
if os.Getenv("HISHTORY_TEST") != "" {
// For testing, we also support having the password be the secret key. This is still mostly secure, but
// it has the risk of the secret key being exposed over HTTP. It also means that the password doesn't
// rotate with each server instance. This is why we don't prefer this normally, but as a test-only method
// this is still plenty secure.
password = hctx.GetConf(ctx).UserSecret
}
http.Handle("/", withBasicAuth(username, password)(http.HandlerFunc(webuiHandler)))
http.Handle("/htmx/results-table", withBasicAuth(username, password)(http.HandlerFunc(htmx_resultsTable)))
server := http.Server{
BaseContext: func(l net.Listener) context.Context { return ctx },
Addr: ":8000",
}
fmt.Printf("Starting web server on %s...\n", server.Addr)
fmt.Printf("Username: %s\nPassword: %s\n", username, password)
return server.ListenAndServe()
}