Load Alpine's CSP build so the UI's directives run (closes #371)
check / check (push) Waiting to run
check / check (push) Waiting to run
The pages' Content-Security-Policy forbids eval, which the standard Alpine.js build needs, so no directive ran: add forms showed open and events never collapsed. 3p/ now holds the @alpinejs/csp 3.14.9 tarball instead, and every directive in templates/ names a property or method of a component registered in static/js/app.js, as that build requires. The policy is unchanged. A headless Chromium test in internal/server loads the webhook page and the event log under the real headers. The Dockerfile's test stage installs chromium; where it is missing the test skips. Model: opus-5-5
This commit is contained in:
Binary file not shown.
Binary file not shown.
+3
-2
@@ -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
|
||||
|
||||
|
||||
@@ -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-<version>.tgz`, check it
|
||||
`https://registry.npmjs.org/@alpinejs/csp/-/csp-<version>.tgz`, check it
|
||||
against the `dist.integrity` hash listed at
|
||||
`https://registry.npmjs.org/alpinejs/<version>`, replace the tarball in `3p/`
|
||||
with it, update its file name in `script/assets`, and run `make check`.
|
||||
`https://registry.npmjs.org/@alpinejs/csp/<version>`, replace the tarball in
|
||||
`3p/` with it as `alpinejs-csp-<version>.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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
+5
-4
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
{{define "navbar"}}
|
||||
<nav class="app-bar" x-data="{ open: false }">
|
||||
<nav class="app-bar" x-data="collapsible">
|
||||
<div class="max-w-6xl mx-auto flex justify-between items-center">
|
||||
<div class="flex items-center gap-3">
|
||||
<a href="/" class="text-xl font-medium text-gray-900 hover:text-primary-600 transition-colors">Webhooker</a>
|
||||
</div>
|
||||
|
||||
<!-- Mobile menu button -->
|
||||
<button @click="open = !open" class="md:hidden p-2 rounded-md text-gray-500 hover:bg-gray-100">
|
||||
<button @click="toggle" class="md:hidden p-2 rounded-md text-gray-500 hover:bg-gray-100">
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path x-show="!open" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/>
|
||||
<path x-show="closed" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/>
|
||||
<path x-show="open" x-cloak stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
{{define "title"}}{{.Webhook.Name}} - Webhooker{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
<div class="max-w-6xl mx-auto px-6 py-8" x-data="{ showAddEntrypoint: false, showAddTarget: false }">
|
||||
<div class="max-w-6xl mx-auto px-6 py-8">
|
||||
<div class="mb-6">
|
||||
<a href="/sources" class="text-sm text-primary-600 hover:text-primary-700">← Back to webhooks</a>
|
||||
<div class="flex justify-between items-center mt-2">
|
||||
@@ -26,10 +26,10 @@
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<!-- Entrypoints -->
|
||||
<div class="card">
|
||||
<div class="card" x-data="collapsible">
|
||||
<div class="p-4 border-b border-gray-200 flex justify-between items-center">
|
||||
<h2 class="text-lg font-medium text-gray-900">Entrypoints</h2>
|
||||
<button @click="showAddEntrypoint = !showAddEntrypoint" class="btn-text text-sm">
|
||||
<button @click="toggle" class="btn-text text-sm">
|
||||
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/>
|
||||
</svg>
|
||||
@@ -38,7 +38,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Add entrypoint form -->
|
||||
<div x-show="showAddEntrypoint" x-cloak class="p-4 bg-gray-50 border-b border-gray-200">
|
||||
<div x-show="open" x-cloak class="p-4 bg-gray-50 border-b border-gray-200">
|
||||
<form method="POST" action="/source/{{.Webhook.ID}}/entrypoints" class="flex gap-2">
|
||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||
<input type="text" name="description" placeholder="Description (optional)" class="input text-sm flex-1">
|
||||
@@ -85,10 +85,10 @@
|
||||
</div>
|
||||
|
||||
<!-- Targets -->
|
||||
<div class="card">
|
||||
<div class="card" x-data="collapsible">
|
||||
<div class="p-4 border-b border-gray-200 flex justify-between items-center">
|
||||
<h2 class="text-lg font-medium text-gray-900">Targets</h2>
|
||||
<button @click="showAddTarget = !showAddTarget" class="btn-text text-sm">
|
||||
<button @click="toggle" class="btn-text text-sm">
|
||||
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/>
|
||||
</svg>
|
||||
@@ -97,42 +97,42 @@
|
||||
</div>
|
||||
|
||||
<!-- Add target form -->
|
||||
<div x-show="showAddTarget" x-cloak class="p-4 bg-gray-50 border-b border-gray-200">
|
||||
<form method="POST" action="/source/{{.Webhook.ID}}/targets" x-data="{ targetType: 'http' }" class="space-y-3">
|
||||
<div x-show="open" x-cloak class="p-4 bg-gray-50 border-b border-gray-200">
|
||||
<form method="POST" action="/source/{{.Webhook.ID}}/targets" x-data="targetForm" class="space-y-3">
|
||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||
<div class="flex gap-2">
|
||||
<input type="text" name="name" placeholder="Target name" required class="input text-sm flex-1">
|
||||
<select name="type" x-model="targetType" class="input text-sm w-32">
|
||||
<select name="type" @change="chooseType" class="input text-sm w-32">
|
||||
<option value="http">HTTP</option>
|
||||
<option value="slack">Slack</option>
|
||||
<option value="database">Database</option>
|
||||
<option value="log">Log</option>
|
||||
</select>
|
||||
</div>
|
||||
<div x-show="targetType === 'http'">
|
||||
<input type="url" name="url" placeholder="https://example.com/webhook" :disabled="targetType !== 'http'" class="input text-sm">
|
||||
<div x-show="isHttp">
|
||||
<input type="url" name="url" placeholder="https://example.com/webhook" :disabled="notHttp" class="input text-sm">
|
||||
</div>
|
||||
<div x-show="targetType === 'http'">
|
||||
<textarea name="headers" rows="3" placeholder="Authorization: Bearer ..." :disabled="targetType !== 'http'" class="input text-sm"></textarea>
|
||||
<div x-show="isHttp">
|
||||
<textarea name="headers" rows="3" placeholder="Authorization: Bearer ..." :disabled="notHttp" class="input text-sm"></textarea>
|
||||
<p class="text-xs text-gray-500 mt-1">Optional request headers, one <code>Name: value</code> per line, sent with every delivery.</p>
|
||||
</div>
|
||||
<div x-show="targetType === 'http'" class="flex gap-2 items-center">
|
||||
<div x-show="isHttp" class="flex gap-2 items-center">
|
||||
<label class="text-sm text-gray-700">Timeout (seconds, blank = default):</label>
|
||||
<input type="number" name="timeout" min="0" max="300" :disabled="targetType !== 'http'" class="input text-sm w-24">
|
||||
<input type="number" name="timeout" min="0" max="300" :disabled="notHttp" class="input text-sm w-24">
|
||||
</div>
|
||||
<div x-show="targetType === 'http'">
|
||||
<div x-show="isHttp">
|
||||
<div class="flex gap-2 items-center">
|
||||
<label class="text-sm text-gray-700">Max retries:</label>
|
||||
<input type="number" name="max_retries" value="0" min="0" max="20" class="input text-sm w-24">
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 mt-1">This is the total number of delivery attempts, not retries on top of the first: a value of 3 makes three attempts in all. 0 means a single attempt with no retries and no circuit breaker.</p>
|
||||
</div>
|
||||
<div x-show="targetType === 'slack'">
|
||||
<input type="url" name="url" placeholder="https://hooks.slack.com/services/..." :disabled="targetType !== 'slack'" class="input text-sm">
|
||||
<div x-show="isSlack">
|
||||
<input type="url" name="url" placeholder="https://hooks.slack.com/services/..." :disabled="notSlack" class="input text-sm">
|
||||
<p class="text-xs text-gray-500 mt-1">Slack or Mattermost incoming webhook URL. Payloads are pretty-printed in code blocks.</p>
|
||||
</div>
|
||||
<div x-show="targetType === 'database'">
|
||||
<input type="text" name="expiry" placeholder="never" :disabled="targetType !== 'database'" class="input text-sm">
|
||||
<div x-show="isDatabase">
|
||||
<input type="text" name="expiry" placeholder="never" :disabled="notDatabase" class="input text-sm">
|
||||
<p class="text-xs text-gray-500 mt-1">Archive expiry: "never" (default) keeps rows forever, or a duration like "720h" prunes older rows.</p>
|
||||
</div>
|
||||
<button type="submit" class="btn-primary text-sm">Add Target</button>
|
||||
|
||||
@@ -23,8 +23,8 @@
|
||||
<div class="card">
|
||||
<div class="divide-y divide-gray-100">
|
||||
{{range .Events}}
|
||||
<div class="p-4" x-data="{ open: false }">
|
||||
<div class="flex items-center justify-between cursor-pointer" @click="open = !open">
|
||||
<div class="p-4" x-data="collapsible">
|
||||
<div class="flex items-center justify-between cursor-pointer" @click="toggle">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="badge-info">{{.Method}}</span>
|
||||
<span class="text-sm font-mono text-gray-700">{{.ID}}</span>
|
||||
@@ -43,7 +43,7 @@
|
||||
</span>
|
||||
{{end}}
|
||||
<span class="text-xs text-gray-400">{{.CreatedAt.Format "2006-01-02 15:04:05"}}</span>
|
||||
<svg class="w-4 h-4 text-gray-400 transition-transform" :class="{ 'rotate-180': open }" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<svg class="w-4 h-4 text-gray-400 transition-transform" :class="caretClass" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
|
||||
</svg>
|
||||
</div>
|
||||
@@ -71,8 +71,8 @@
|
||||
<h3 class="text-xs font-medium uppercase tracking-wide text-gray-500">Deliveries</h3>
|
||||
<div class="mt-2 divide-y divide-gray-200">
|
||||
{{range .Deliveries}}
|
||||
<div class="py-2" x-data="{ attempts: false }">
|
||||
<div class="flex items-center justify-between cursor-pointer" @click="attempts = !attempts">
|
||||
<div class="py-2" x-data="collapsible">
|
||||
<div class="flex items-center justify-between cursor-pointer" @click="toggle">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-sm text-gray-700">{{.Target.DisplayName}}</span>
|
||||
<span class="text-xs {{if eq .Status "delivered"}}text-green-600{{else if eq .Status "failed"}}text-red-600{{else if eq .Status "retrying"}}text-yellow-600{{else}}text-gray-400{{end}}">{{.Status}}</span>
|
||||
@@ -86,13 +86,13 @@
|
||||
</form>
|
||||
{{end}}
|
||||
<span class="text-xs text-gray-400">{{.AttemptCount}} attempt{{if ne .AttemptCount 1}}s{{end}}</span>
|
||||
<svg class="w-3 h-3 text-gray-400 transition-transform" :class="{ 'rotate-180': attempts }" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<svg class="w-3 h-3 text-gray-400 transition-transform" :class="caretClass" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div x-show="attempts" x-cloak class="mt-2 space-y-2">
|
||||
<div x-show="open" x-cloak class="mt-2 space-y-2">
|
||||
{{if .AttemptsOmitted}}
|
||||
<p class="text-xs text-gray-500">{{.AttemptsOmitted}} attempt{{if ne .AttemptsOmitted 1}}s{{end}} omitted between the first and last shown.</p>
|
||||
{{end}}
|
||||
|
||||
Reference in New Issue
Block a user