mirror of
https://github.com/ddworken/hishtory.git
synced 2025-06-24 05:51:38 +02:00
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
This commit is contained in:
parent
0d0a690224
commit
52a4fbc96b
4
.gitignore
vendored
4
.gitignore
vendored
@ -5,3 +5,7 @@ postgres-data/
|
|||||||
server
|
server
|
||||||
!backend/server
|
!backend/server
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
node_modules/
|
||||||
|
package.json
|
||||||
|
package-lock.json
|
||||||
|
.prettierrc
|
||||||
|
12
README.md
12
README.md
@ -184,6 +184,18 @@ You can configure a custom timestamp format for hiSHtory via `hishtory config-se
|
|||||||
|
|
||||||
</blockquote></details>
|
</blockquote></details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Web UI for sharing</summary><blockquote>
|
||||||
|
|
||||||
|
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:
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
</blockquote></details>
|
||||||
|
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary>Customizing the install folder</summary><blockquote>
|
<summary>Customizing the install folder</summary><blockquote>
|
||||||
|
|
||||||
|
BIN
backend/web/landing/www/img/webui.png
Normal file
BIN
backend/web/landing/www/img/webui.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 291 KiB |
@ -4,6 +4,8 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path"
|
"path"
|
||||||
@ -2959,4 +2961,44 @@ func TestAugmentedIsOfflineError(t *testing.T) {
|
|||||||
require.True(t, lib.IsOfflineError(ctx, fmt.Errorf("unchecked error type")))
|
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
|
// TODO: somehow test/confirm that hishtory works even if only bash/only zsh is installed
|
||||||
|
23
client/cmd/webui.go
Normal file
23
client/cmd/webui.go
Normal file
@ -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)
|
||||||
|
}
|
76
client/webui/templates/webui.html
Normal file
76
client/webui/templates/webui.html
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
<div class="p-3 mb-2 bg-secondary text-white">
|
||||||
|
<div class="jumbotron jumbotron-fluid">
|
||||||
|
<div class="container">
|
||||||
|
<h1 class="display-4">hiSHtory</h1>
|
||||||
|
<p class="lead">Your shell history in context, synced, and queryable</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav class="navbar navbar-light bg-light">
|
||||||
|
<form class="form-inline my-2 my-lg-0 w-100" style="display:flex">
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
name="q"
|
||||||
|
id="search-input"
|
||||||
|
autocomplete="off"
|
||||||
|
placeholder="Search Query"
|
||||||
|
value="{{ .SearchQuery }}"
|
||||||
|
class="form-control mr-sm-2"
|
||||||
|
hx-get="/htmx/results-table"
|
||||||
|
hx-params="*"
|
||||||
|
hx-trigger="input changed delay:500ms, search"
|
||||||
|
hx-target="#search-results"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
id="search-button"
|
||||||
|
class="btn btn-outline-success my-2 my-sm-0 btn-light"
|
||||||
|
type="submit"
|
||||||
|
>
|
||||||
|
Search
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<hr />
|
||||||
|
|
||||||
|
{{ block "resultsTable.html" . }}
|
||||||
|
<div id="search-results" class="table-responsive">
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr class="table-info">
|
||||||
|
{{ range .ColumnNames }}
|
||||||
|
<th scope="col">{{ . }}</th>
|
||||||
|
{{ end }}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{{ range .SearchResults }}
|
||||||
|
<tr class="table-light">
|
||||||
|
{{ range . }}
|
||||||
|
<td>{{ . }}</td>
|
||||||
|
{{ end }}
|
||||||
|
</tr>
|
||||||
|
{{ end }}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
||||||
|
|
||||||
|
|
||||||
|
<link
|
||||||
|
href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css"
|
||||||
|
rel="stylesheet"
|
||||||
|
integrity="sha384-T3c6CoIi6uLrA9TneNEoa7RxnatzjcDSCmG1MXxSR1GAsXEV/Dwwykc2MPK8M2HN"
|
||||||
|
crossorigin="anonymous"
|
||||||
|
/>
|
||||||
|
<script
|
||||||
|
src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"
|
||||||
|
integrity="sha384-C6RzsynM9kWDrMNeT87bh95OGNyZPhcTNXj1NW7RuBCsyN/o0jlpcV8Qyq46cDfL"
|
||||||
|
crossorigin="anonymous"
|
||||||
|
></script>
|
||||||
|
<script
|
||||||
|
src="https://cdn.jsdelivr.net/npm/htmx.org@1.9.10/dist/htmx.min.js"
|
||||||
|
integrity="sha256-s73PXHQYl6U2SLEgf/8EaaDWGQFCm6H26I+Y69hOZp4="
|
||||||
|
crossorigin="anonymous"
|
||||||
|
></script>
|
151
client/webui/webui.go
Normal file
151
client/webui/webui.go
Normal file
@ -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()
|
||||||
|
}
|
1
go.mod
1
go.mod
@ -148,6 +148,7 @@ require (
|
|||||||
github.com/google/go-querystring v1.1.0 // indirect
|
github.com/google/go-querystring v1.1.0 // indirect
|
||||||
github.com/google/gofuzz v1.2.0 // indirect
|
github.com/google/gofuzz v1.2.0 // indirect
|
||||||
github.com/google/pprof v0.0.0-20211214055906-6f57359322fd // 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/google/trillian v1.5.0 // indirect
|
||||||
github.com/googleapis/gnostic v0.5.5 // indirect
|
github.com/googleapis/gnostic v0.5.5 // indirect
|
||||||
github.com/gorilla/websocket v1.4.2 // indirect
|
github.com/gorilla/websocket v1.4.2 // indirect
|
||||||
|
2
go.sum
2
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/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/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
|
||||||
github.com/google/rpmpack v0.0.0-20191226140753-aa36bfddb3a0/go.mod h1:RaTPr0KUf2K7fnZYLNDrr8rxAamWs3iNywJLtQ2AzBg=
|
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/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/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=
|
github.com/google/trillian v1.3.14-0.20210409160123-c5ea3abd4a41/go.mod h1:1dPv0CUjNQVFEDuAUFhZql16pw/VlPgaX8qj+g5pVzQ=
|
||||||
|
Loading…
x
Reference in New Issue
Block a user