diff --git a/3p/alpinejs-3.14.9.tgz b/3p/alpinejs-3.14.9.tgz deleted file mode 100644 index 4d31381..0000000 Binary files a/3p/alpinejs-3.14.9.tgz and /dev/null differ diff --git a/3p/alpinejs-csp-3.14.9.tgz b/3p/alpinejs-csp-3.14.9.tgz new file mode 100644 index 0000000..f35c940 Binary files /dev/null and b/3p/alpinejs-csp-3.14.9.tgz differ diff --git a/Dockerfile b/Dockerfile index d9c5bc7..13c2ecf 100644 --- a/Dockerfile +++ b/Dockerfile @@ -38,8 +38,9 @@ FROM golang:1.26.1-bookworm@sha256:4465644228bc2857a954b092167e12aa59c006a349228 COPY --from=lint /src/go.sum /dev/null # jq is a runtime dependency of script/ci-mark-superseded, which the test -# suite executes. -RUN apt-get update && apt-get install -y --no-install-recommends make curl ca-certificates jq && rm -rf /var/lib/apt/lists/* +# suite executes. chromium runs the browser test in internal/server, which +# skips where it is not installed. +RUN apt-get update && apt-get install -y --no-install-recommends make curl ca-certificates jq chromium && rm -rf /var/lib/apt/lists/* WORKDIR /build diff --git a/README.md b/README.md index 9af5688..f14c490 100644 --- a/README.md +++ b/README.md @@ -1255,9 +1255,20 @@ We provide: ## Third-party browser assets -The web UI serves one third-party script, Alpine.js. Its npm package tarball -is committed as `3p/alpinejs-3.14.9.tgz`, byte for byte as the npm registry -publishes it. It is a dependency, not this repo's build output, so +The web UI serves one third-party script, Alpine.js, in its CSP build: the npm +package `@alpinejs/csp`. The pages' Content-Security-Policy forbids eval, which +the standard `alpinejs` build needs to run the expressions written in the +markup. The CSP build runs no expressions, so every Alpine directive in +`templates/` only names a property or method of a component registered in +`static/js/app.js`: `x-data="collapsible"` and `@click="toggle"`, never +`x-data="{ open: false }"` or `@click="open = !open"`. A browser test in +`internal/server` loads the pages under the real policy and fails on any +directive that does not work. It needs `chromium` on `PATH` and skips without +it; the Dockerfile installs it, so the image build always runs it. + +The package's tarball is committed as `3p/alpinejs-csp-3.14.9.tgz`, byte for +byte as the npm registry publishes it. It is a dependency, not this repo's +build output, so `REPO_POLICIES.md`'s rule against committed build artifacts does not apply. The directory is `3p/` rather than `vendor/` because Go treats a root `vendor/` directory as its module vendor directory. @@ -1270,10 +1281,11 @@ nothing downloads Alpine.js. The extracted file is not committed, and `.dockerignore` keeps any host copy out of the build context. To move to a new version: download -`https://registry.npmjs.org/alpinejs/-/alpinejs-.tgz`, check it +`https://registry.npmjs.org/@alpinejs/csp/-/csp-.tgz`, check it against the `dist.integrity` hash listed at -`https://registry.npmjs.org/alpinejs/`, replace the tarball in `3p/` -with it, update its file name in `script/assets`, and run `make check`. +`https://registry.npmjs.org/@alpinejs/csp/`, replace the tarball in +`3p/` with it as `alpinejs-csp-.tgz`, update its file name in +`script/assets`, and run `make check`. ## Rationale @@ -2753,7 +2765,7 @@ imports. The entry point is `cmd/webhooker/main.go`. ``` webhooker/ ├── 3p/ -│ └── alpinejs-3.14.9.tgz # Alpine.js npm package, extracted by make assets +│ └── alpinejs-csp-3.14.9.tgz # Alpine.js CSP build npm package, extracted by make assets ├── cmd/webhooker/ │ └── main.go # Entry point: subcommand dispatch; no args locks DATA_DIR and wires fx ├── internal/ @@ -2846,8 +2858,8 @@ webhooker/ │ ├── css/input.css # Tailwind input, source for tailwind.css (make css) │ ├── css/tailwind.css # Generated stylesheet the pages load │ ├── css/style.css # Older hand-written stylesheet, no longer loaded -│ ├── js/app.js # Progressive-enhancement copy-to-clipboard -│ └── js/alpine.min.js # Alpine.js, extracted from 3p/ by make assets, not committed +│ ├── js/app.js # Copy-to-clipboard, and the Alpine.js components +│ └── js/alpine.min.js # Alpine.js CSP build, extracted from 3p/ by make assets, not committed ├── templates/ # Go HTML templates (base, login, sources, etc.) ├── script/ # Scripts to Rule Them All entrypoints ├── Dockerfile # Three stages: lint, test+build, Alpine runtime diff --git a/go.mod b/go.mod index 3fbd1d2..cb503b0 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,8 @@ go 1.26.1 require ( github.com/99designs/basicauth-go v0.0.0-20230316000542-bf6f9cbbf0f8 + github.com/chromedp/cdproto v0.0.0-20260922220944-a19bff23514f + github.com/chromedp/chromedp v0.16.0 github.com/getsentry/sentry-go v0.25.0 github.com/go-chi/chi v1.5.5 github.com/go-chi/cors v1.2.1 @@ -28,8 +30,13 @@ require ( require ( github.com/beorn7/perks v1.0.1 // indirect github.com/cespare/xxhash/v2 v2.2.0 // indirect + github.com/chromedp/sysutil v1.1.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/dustin/go-humanize v1.0.1 // indirect + github.com/go-json-experiment/json v0.0.0-20260820222146-c27c302e5fc3 // indirect + github.com/gobwas/httphead v0.1.0 // indirect + github.com/gobwas/pool v0.2.1 // indirect + github.com/gobwas/ws v1.4.0 // indirect github.com/gorilla/securecookie v1.1.2 // indirect github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect @@ -50,7 +57,7 @@ require ( go.uber.org/zap v1.23.0 // indirect golang.org/x/mod v0.17.0 // indirect golang.org/x/sync v0.14.0 // indirect - golang.org/x/sys v0.37.0 // indirect + golang.org/x/sys v0.47.0 // indirect golang.org/x/text v0.25.0 // indirect golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d // indirect google.golang.org/protobuf v1.31.0 // indirect diff --git a/go.sum b/go.sum index d2d615e..8c96a5e 100644 --- a/go.sum +++ b/go.sum @@ -6,6 +6,12 @@ github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/chromedp/cdproto v0.0.0-20260922220944-a19bff23514f h1:8PK9FM4bE0C8GMoWBW5lVsef3U7sPICjDg6JqngyYhk= +github.com/chromedp/cdproto v0.0.0-20260922220944-a19bff23514f/go.mod h1:3v4FIp5njIUyPDvqXsxEOxnB34lijG0up98/5kM1KaE= +github.com/chromedp/chromedp v0.16.0 h1:rOO4deOm4CbZgBCa8mD9g2rDyIoNs0BkgvNrlbp5ouk= +github.com/chromedp/chromedp v0.16.0/go.mod h1:rbuGKFT1vMcFcFqKfPIO1GpX/N+2s8onm2qMxZLbU5U= +github.com/chromedp/sysutil v1.1.0 h1:PUFNv5EcprjqXZD9nJb9b/c9ibAbxiYo4exNWZyipwM= +github.com/chromedp/sysutil v1.1.0/go.mod h1:WiThHUdltqCNKGc4gaU50XgYjwjYIhKWoHGPTUfWTJ8= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -23,6 +29,14 @@ github.com/go-chi/httprate v0.15.0 h1:j54xcWV9KGmPf/X4H32/aTH+wBlrvxL7P+SdnRqxh5 github.com/go-chi/httprate v0.15.0/go.mod h1:rzGHhVrsBn3IMLYDOZQsSU4fJNWcjui4fWKJcCId1R4= github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA= github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og= +github.com/go-json-experiment/json v0.0.0-20260820222146-c27c302e5fc3 h1:UADEEmDKgfXbtnGJZ97beY5XLo9ZechG1nlU4KnRrkE= +github.com/go-json-experiment/json v0.0.0-20260820222146-c27c302e5fc3/go.mod h1:tphK2c80bpPhMOI4v6bIc2xWywPfbqi1Z06+RcrMkDg= +github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU= +github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM= +github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og= +github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw= +github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs= +github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc= github.com/gofrs/flock v0.13.0 h1:95JolYOvGMqeH31+FC7D2+uULf6mG61mEZ/A8dRYMzw= github.com/gofrs/flock v0.13.0/go.mod h1:jxeyy9R1auM5S6JYDBhDt+E2TCo7DkratH4Pgi8P+Z0= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= @@ -55,12 +69,16 @@ github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80 h1:6Yzfa6GP0rIo/kULo2bwGEkFvCePZ3qHDDTC3/J9Swo= +github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-sqlite3 v1.14.17 h1:mCRHCLDUBXgpKAqIKsaAaAsrAlbkeomtRFKXh2L6YIM= github.com/mattn/go-sqlite3 v1.14.17/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 h1:jWpvCLoY8Z/e3VKvlsiIGKtc+UG6U5vzxaoagmhXfyg= github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0/go.mod h1:QUyp042oQthUoa9bqDv0ER0wrtXnBruoNd7aNjkbP+k= +github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde h1:x0TT0RDC7UhAVbbWWBzr41ElhJx5tXPWkIHA2HWPRuw= +github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0= github.com/pingcap/errors v0.11.4 h1:lFuQV/oaUMGcD2tqt+01ROSmJs75VG1ToEOkZIZ4nE4= github.com/pingcap/errors v0.11.4/go.mod h1:Oi8TUi2kEtXXLMJk9l1cGmz20kV3TaQ0usTwv5KuLY8= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= @@ -111,8 +129,8 @@ golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ= golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= -golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4= golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA= golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg= diff --git a/internal/server/alpine_browser_test.go b/internal/server/alpine_browser_test.go new file mode 100644 index 0000000..e237ea6 --- /dev/null +++ b/internal/server/alpine_browser_test.go @@ -0,0 +1,276 @@ +package server_test + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "os/exec" + "slices" + "strings" + "sync" + "testing" + "time" + + "github.com/chromedp/cdproto/network" + "github.com/chromedp/cdproto/runtime" + "github.com/chromedp/chromedp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const ( + // browserTimeout bounds everything one test does in the browser. + browserTimeout = 60 * time.Second + + // settleTimeout bounds the wait for an element to show or hide. + settleTimeout = 5 * time.Second +) + +// TestAlpineRunsUnderTheSecurityPolicy loads the webhook page and the +// event log in headless Chromium, served by the real router and so +// under the real Content-Security-Policy, and checks that the pages' +// Alpine.js directives work. +func TestAlpineRunsUnderTheSecurityPolicy(t *testing.T) { + t.Parallel() + + ctx, consoleProblems := startBrowser(t) + + env := newTestEnv(t) + srv := httptest.NewServer(env.router) + t.Cleanup(srv.Close) + + userID, _ := env.seedUser(t, "browser", "browser-password") + webhook := env.seedWebhook(t, userID) + event := env.seedEvent(t, webhook.ID, `{"hello":"browser"}`) + + require.NoError(t, chromedp.Run( + ctx, setCookies(srv.URL, env.authCookies(t, userID, "browser")), + )) + + checkAddForms(ctx, t, srv.URL+"/source/"+webhook.ID) + checkTargetType(ctx, t) + checkEventToggle( + ctx, t, srv.URL+"/source/"+webhook.ID+"/logs", event.ID, + ) + + assert.Empty( + t, consoleProblems(), + "the pages printed console warnings, errors or exceptions", + ) +} + +// startBrowser starts headless Chromium for one test. It returns the +// context that drives it, and a function listing every console warning +// or error and every uncaught exception its pages raised: that is how +// Alpine.js reports an expression it cannot run. +// +// The test is skipped when chromium is not on PATH. The Dockerfile's +// test stage installs it, so the image build always runs this check. +func startBrowser(t *testing.T) (context.Context, func() []string) { + t.Helper() + + path, err := exec.LookPath("chromium") + if err != nil { + t.Skipf("chromium is not installed: %v", err) + } + + allocCtx, cancelAlloc := chromedp.NewExecAllocator( + t.Context(), + append( + chromedp.DefaultExecAllocatorOptions[:], + chromedp.ExecPath(path), + // The image build runs tests as root, where Chromium's + // sandbox cannot start. + chromedp.NoSandbox, + )..., + ) + t.Cleanup(cancelAlloc) + + ctx, cancel := chromedp.NewContext(allocCtx) + t.Cleanup(cancel) + + ctx, cancelTimeout := context.WithTimeout(ctx, browserTimeout) + t.Cleanup(cancelTimeout) + + var ( + mu sync.Mutex + problems []string + ) + + chromedp.ListenTarget(ctx, func(ev any) { + var problem string + + switch ev := ev.(type) { + case *runtime.EventConsoleAPICalled: + if ev.Type != runtime.APITypeWarning && + ev.Type != runtime.APITypeError { + return + } + + args := make([]string, 0, len(ev.Args)) + for _, arg := range ev.Args { + args = append(args, string(arg.Value)) + } + + problem = strings.Join(args, " ") + case *runtime.EventExceptionThrown: + problem = ev.ExceptionDetails.Error() + default: + return + } + + mu.Lock() + defer mu.Unlock() + + problems = append(problems, problem) + }) + + return ctx, func() []string { + mu.Lock() + defer mu.Unlock() + + return slices.Clone(problems) + } +} + +// setCookies gives the browser the cookies for the server at base. +func setCookies(base string, cookies []*http.Cookie) chromedp.ActionFunc { + return chromedp.ActionFunc(func(ctx context.Context) error { + for _, c := range cookies { + err := network.SetCookie(c.Name, c.Value). + WithURL(base). + Do(ctx) + if err != nil { + return fmt.Errorf("set cookie %s: %w", c.Name, err) + } + } + + return nil + }) +} + +// loadPage opens url and waits for Alpine.js to start, which it does +// by removing every x-cloak attribute. Until then x-cloak hides the +// elements Alpine would hide, so a check made earlier proves nothing. +func loadPage(url string) chromedp.Tasks { + return chromedp.Tasks{ + chromedp.Navigate(url), + chromedp.WaitNotPresent("[x-cloak]", chromedp.ByQuery), + } +} + +// shown waits up to settleTimeout for the first element matching a CSS +// selector to be rendered, and reports whether it was. The wait is +// needed because Alpine.js shows an element on the next animation +// frame, not at once. +func shown(ctx context.Context, selector string) bool { + ctx, cancel := context.WithTimeout(ctx, settleTimeout) + defer cancel() + + return chromedp.Run( + ctx, chromedp.WaitVisible(selector, chromedp.ByQuery), + ) == nil +} + +// hidden is shown's opposite: it waits for the element to be hidden. +func hidden(ctx context.Context, selector string) bool { + ctx, cancel := context.WithTimeout(ctx, settleTimeout) + defer cancel() + + return chromedp.Run( + ctx, chromedp.WaitNotVisible(selector, chromedp.ByQuery), + ) == nil +} + +// click clicks the first element matching an XPath expression. +func click(ctx context.Context, t *testing.T, xpath string) { + t.Helper() + + require.NoError(t, chromedp.Run( + ctx, chromedp.Click(xpath, chromedp.BySearch), + )) +} + +// checkAddForms loads a webhook page and checks that each section's add +// form stays hidden until the Add button beside its heading is clicked. +func checkAddForms(ctx context.Context, t *testing.T, url string) { + t.Helper() + + require.NoError(t, chromedp.Run(ctx, loadPage(url))) + + sections := []struct{ heading, form string }{ + {"Entrypoints", `form[action$="/entrypoints"]`}, + {"Targets", `form[action$="/targets"]`}, + } + + for _, s := range sections { + assert.Truef( + t, hidden(ctx, s.form), + "%s: the add form shows before Add is clicked", s.heading, + ) + + click(ctx, t, `//h2[text()="`+s.heading+ + `"]/following-sibling::button`) + + assert.Truef( + t, shown(ctx, s.form), + "%s: the add form stays hidden when Add is clicked", s.heading, + ) + } +} + +// checkTargetType chooses Slack in the open add target form and checks +// what the form would then submit: one url field, the Slack one, and +// not the HTTP url, headers or timeout, which are hidden and disabled. +func checkTargetType(ctx context.Context, t *testing.T) { + t.Helper() + + const ( + chooseSlack = `(() => { + const type = document.querySelector('select[name="type"]'); + type.value = "slack"; + type.dispatchEvent(new Event("change")); + })()` + submitted = `[...new FormData( + document.querySelector('form[action$="/targets"]')).keys()]` + ) + + var fields []string + + require.NoError(t, chromedp.Run( + ctx, + chromedp.Evaluate(chooseSlack, nil), + chromedp.Evaluate(submitted, &fields), + )) + + assert.Equal( + t, + []string{"csrf_token", "name", "type", "max_retries", "url"}, + fields, + "with Slack chosen, the HTTP fields must not be submitted", + ) +} + +// checkEventToggle loads the event log and checks that clicking an +// event's row expands it and clicking again collapses it. +func checkEventToggle( + ctx context.Context, t *testing.T, url, eventID string, +) { + t.Helper() + + // The row shows the event's ID; its Resubmit form is in the part + // that expands. + row := `//span[text()="` + eventID + `"]` + expanded := `form[action$="/resubmit"]` + + require.NoError(t, chromedp.Run(ctx, loadPage(url))) + + assert.True(t, hidden(ctx, expanded), "the event starts expanded") + + click(ctx, t, row) + assert.True(t, shown(ctx, expanded), "clicking the event does not expand it") + + click(ctx, t, row) + assert.True(t, hidden(ctx, expanded), "clicking it again does not collapse it") +} diff --git a/script/assets b/script/assets index 69db60d..54e4185 100755 --- a/script/assets +++ b/script/assets @@ -1,15 +1,16 @@ #!/bin/sh # script/assets: extract Alpine.js from its npm package tarball, committed -# in 3p/, to static/js/alpine.min.js, where go:embed reads it. The -# extracted file is not committed. script/test, make build and make dev run -# this first. +# in 3p/, to static/js/alpine.min.js, where go:embed reads it. The package +# is @alpinejs/csp, Alpine's build for pages whose Content-Security-Policy +# forbids eval. The extracted file is not committed. script/test, make +# build and make dev run this first. set -eu ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" main() { cd "$ROOT" - tar -xzOf 3p/alpinejs-3.14.9.tgz package/dist/cdn.min.js \ + tar -xzOf 3p/alpinejs-csp-3.14.9.tgz package/dist/cdn.min.js \ >static/js/alpine.min.js } diff --git a/static/js/app.js b/static/js/app.js index ee4f7fd..b94b12f 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -57,3 +57,62 @@ init(); } })(); + +// Alpine.js components. +// +// The pages' Content-Security-Policy forbids eval, so the UI loads +// Alpine's CSP build, which cannot run expressions written in the +// markup: a directive in templates/ may only name a property or method, +// and each x-data names a component registered here. This script runs +// before Alpine, whose script tag is deferred, so this listener is in +// place when Alpine starts. +document.addEventListener("alpine:init", function () { + "use strict"; + + // Something a click shows and hides: the mobile menu, an add form, + // an event in the event log, a delivery's attempts. + window.Alpine.data("collapsible", function () { + return { + open: false, + toggle() { + this.open = !this.open; + }, + get closed() { + return !this.open; + }, + // Turns a downward caret up while open. + get caretClass() { + return { "rotate-180": this.open }; + }, + }; + }); + + // The add target form. Only the chosen type's fields show, and the + // others are disabled so that the form does not submit them. + window.Alpine.data("targetForm", function () { + return { + targetType: "http", + chooseType(event) { + this.targetType = event.target.value; + }, + get isHttp() { + return this.targetType === "http"; + }, + get isSlack() { + return this.targetType === "slack"; + }, + get isDatabase() { + return this.targetType === "database"; + }, + get notHttp() { + return !this.isHttp; + }, + get notSlack() { + return !this.isSlack; + }, + get notDatabase() { + return !this.isDatabase; + }, + }; + }); +}); diff --git a/templates/navbar.html b/templates/navbar.html index edd5573..90840fb 100644 --- a/templates/navbar.html +++ b/templates/navbar.html @@ -1,14 +1,14 @@ {{define "navbar"}} -