diff --git a/.gitignore b/.gitignore index 17a53c5..33077df 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,7 @@ postgres-data/ server !backend/server .DS_Store +node_modules/ +package.json +package-lock.json +.prettierrc diff --git a/README.md b/README.md index cc96f2f..2c60912 100644 --- a/README.md +++ b/README.md @@ -184,6 +184,18 @@ You can configure a custom timestamp format for hiSHtory via `hishtory config-se +
+Web UI for sharing
+ +If you'd like to temporarily allow someone else to search your shell history, you can start a web server via `hishtory start-web-ui`. This will expose a basic (password-protected) web UI on port `8000` where they can query your history: + +![demo showing the web UI searching for git](https://raw.githubusercontent.com/ddworken/hishtory/master/backend/web/landing/www/img/webui.png) + +Note that this uses [HTTP Basic Auth](https://en.wikipedia.org/wiki/Basic_access_authentication), so the credentials are sent over your local network via HTTP. + +
+ +
Customizing the install folder
diff --git a/backend/web/landing/www/img/webui.png b/backend/web/landing/www/img/webui.png new file mode 100644 index 0000000..e20b417 Binary files /dev/null and b/backend/web/landing/www/img/webui.png differ diff --git a/client/client_test.go b/client/client_test.go index adc9dd6..1f0eb5a 100644 --- a/client/client_test.go +++ b/client/client_test.go @@ -4,6 +4,8 @@ import ( "bytes" "encoding/json" "fmt" + "io" + "net/http" "os" "os/exec" "path" @@ -2959,4 +2961,44 @@ func TestAugmentedIsOfflineError(t *testing.T) { require.True(t, lib.IsOfflineError(ctx, fmt.Errorf("unchecked error type"))) } +func TestWebUi(t *testing.T) { + markTestForSharding(t, 13) + defer testutils.BackupAndRestore(t)() + tester := zshTester{} + installHishtory(t, tester, "") + + // Run a few commands to search for + tester.RunInteractiveShell(t, `echo foobar`) + + // Start the server + require.NoError(t, tester.RunInteractiveShellBackground(t, `hishtory start-web-ui`)) + time.Sleep(time.Second) + defer tester.RunInteractiveShell(t, `killall hishtory`) + + // And check that the server seems to be returning valid data + req, err := http.NewRequest("GET", "http://localhost:8000?q=foobar", nil) + require.NoError(t, err) + req.SetBasicAuth("hishtory", hctx.GetConf(hctx.MakeContext()).UserSecret) + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + require.Equal(t, 200, resp.StatusCode) + defer resp.Body.Close() + respBody, err := io.ReadAll(resp.Body) + require.NoError(t, err) + require.Contains(t, string(respBody), "echo foobar") + + // And that it rejects requests without auth + resp, err = http.Get("http://localhost:8000?q=foobar") + require.NoError(t, err) + require.Equal(t, 401, resp.StatusCode) + + // And requests with incorrect auth + req, err = http.NewRequest("GET", "http://localhost:8000?q=foobar", nil) + require.NoError(t, err) + req.SetBasicAuth("hishtory", "wrong-password") + resp, err = http.DefaultClient.Do(req) + require.NoError(t, err) + require.Equal(t, 401, resp.StatusCode) +} + // TODO: somehow test/confirm that hishtory works even if only bash/only zsh is installed diff --git a/client/cmd/webui.go b/client/cmd/webui.go new file mode 100644 index 0000000..8607c32 --- /dev/null +++ b/client/cmd/webui.go @@ -0,0 +1,23 @@ +package cmd + +import ( + "os" + + "github.com/ddworken/hishtory/client/hctx" + "github.com/ddworken/hishtory/client/lib" + "github.com/ddworken/hishtory/client/webui" + "github.com/spf13/cobra" +) + +var webUiCmd = &cobra.Command{ + Use: "start-web-ui", + Short: "Serve a basic web UI for interacting with your shell history", + Run: func(cmd *cobra.Command, args []string) { + lib.CheckFatalError(webui.StartWebUiServer(hctx.MakeContext())) + os.Exit(1) + }, +} + +func init() { + rootCmd.AddCommand(webUiCmd) +} diff --git a/client/webui/templates/webui.html b/client/webui/templates/webui.html new file mode 100644 index 0000000..c49aec6 --- /dev/null +++ b/client/webui/templates/webui.html @@ -0,0 +1,76 @@ +
+
+
+

hiSHtory

+

Your shell history in context, synced, and queryable

+
+
+
+ + + +
+ +{{ block "resultsTable.html" . }} +
+ + + + {{ range .ColumnNames }} + + {{ end }} + + + + {{ range .SearchResults }} + + {{ range . }} + + {{ end }} + + {{ end }} + +
{{ . }}
{{ . }}
+
+{{ end }} + + + + + diff --git a/client/webui/webui.go b/client/webui/webui.go new file mode 100644 index 0000000..d5ed468 --- /dev/null +++ b/client/webui/webui.go @@ -0,0 +1,151 @@ +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() +} diff --git a/go.mod b/go.mod index 2e2532f..c6a2f4b 100644 --- a/go.mod +++ b/go.mod @@ -148,6 +148,7 @@ require ( github.com/google/go-querystring v1.1.0 // indirect github.com/google/gofuzz v1.2.0 // indirect github.com/google/pprof v0.0.0-20211214055906-6f57359322fd // indirect + github.com/google/safehtml v0.1.0 // indirect github.com/google/trillian v1.5.0 // indirect github.com/googleapis/gnostic v0.5.5 // indirect github.com/gorilla/websocket v1.4.2 // indirect diff --git a/go.sum b/go.sum index e7738e3..a6cb311 100644 --- a/go.sum +++ b/go.sum @@ -766,6 +766,8 @@ github.com/google/pprof v0.0.0-20211214055906-6f57359322fd h1:1FjCyPC+syAzJ5/2S8 github.com/google/pprof v0.0.0-20211214055906-6f57359322fd/go.mod h1:KgnwoLYCZ8IQu3XUZ8Nc/bM9CCZFOyjUNOSygVozoDg= github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= github.com/google/rpmpack v0.0.0-20191226140753-aa36bfddb3a0/go.mod h1:RaTPr0KUf2K7fnZYLNDrr8rxAamWs3iNywJLtQ2AzBg= +github.com/google/safehtml v0.1.0 h1:EwLKo8qawTKfsi0orxcQAZzu07cICaBeFMegAU9eaT8= +github.com/google/safehtml v0.1.0/go.mod h1:L4KWwDsUJdECRAEpZoBn3O64bQaywRscowZjJAzjHnU= github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= github.com/google/subcommands v1.0.1/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk= github.com/google/trillian v1.3.14-0.20210409160123-c5ea3abd4a41/go.mod h1:1dPv0CUjNQVFEDuAUFhZql16pw/VlPgaX8qj+g5pVzQ=