1 Commits
Author SHA1 Message Date
sneak 6e652a223a Commit Alpine.js to the repo instead of unpacking it at build time (closes #163)
check / check (push) Canceled after 0s
static/js/alpine.min.js is now committed: package/dist/cdn.min.js from
the @alpinejs/csp 3.14.9 npm package, byte for byte, taken once from the
package file that was in 3p/. The build no longer unpacks it, so
script/assets, the assets make target, its Dockerfile steps and the
ignore entries for the file are gone, and so is the package file.
Prettier and ESLint both ignore the file, so it stays as published.
Comments and the README now describe the committed file.

Model: opus-5-5
2026-10-26 03:46:37 +00:00
8 changed files with 50 additions and 189 deletions
+3 -2
View File
@@ -204,8 +204,9 @@ RUN version="$(make version VERSION="$VERSION")"; \
esac; \ esac; \
fi fi
# The Makefile's build target builds with cgo off, so the binary is # Builds through the Makefile's build target, which builds with cgo off, so
# statically linked and runs on the Alpine runtime image below. # the binary is statically linked and runs on the Alpine runtime image below
# as it is.
RUN make build VERSION="$VERSION" RUN make build VERSION="$VERSION"
# Runtime stage # Runtime stage
+21 -21
View File
@@ -47,7 +47,7 @@ identifier of its own to the request, so make your targets tolerate a duplicate.
## Getting Started ## Getting Started
You need the Go version named in `go.mod`. You need the Go version named in `go.mod`, and Docker for the checks.
```bash ```bash
git clone https://git.eeqj.de/sneak/webhooker.git git clone https://git.eeqj.de/sneak/webhooker.git
@@ -57,8 +57,9 @@ DATA_DIR=../webhooker-data make dev
``` ```
`DATA_DIR` is where webhooker keeps its databases. It defaults to `DATA_DIR` is where webhooker keeps its databases. It defaults to
`/var/lib/webhooker`, so point it at a directory you can write to. `make build` `/var/lib/webhooker`, so point it at a directory you can write to. On a host
writes the program to `bin/webhooker`. Running that file starts webhooker. without Docker, `make build` writes the program to `bin/webhooker`, and running
that file starts webhooker.
The first start creates the `admin` account and prints its password once, in a The first start creates the `admin` account and prints its password once, in a
ruled box on standard output: ruled box on standard output:
@@ -106,9 +107,9 @@ sooner after 24 hours without use, unless `SESSION_IDLE_TIMEOUT` says otherwise.
On the webhook list at `/hooks`, **New Webhook** asks for **Name**, an optional On the webhook list at `/hooks`, **New Webhook** asks for **Name**, an optional
**Description** and **Retention (days)**. Events older than the retention period **Description** and **Retention (days)**. Events older than the retention period
are deleted, along with their deliveries. The default is 30 days. `0` keeps are deleted, along with their deliveries. The default is 30 days. `0` keeps
events forever. Filling in **HTTP target URL** creates an `http` target with the events forever. The form also has an **HTTP target URL** field and an
webhook. Ticking **Archive** creates an archive target. Every new webhook gets **Archive** checkbox; each creates that target with the webhook. Every new
one entrypoint, described as `Default entrypoint`. webhook gets one entrypoint, described as `Default entrypoint`.
### Entrypoint URLs ### Entrypoint URLs
@@ -197,9 +198,9 @@ Read it with `docker logs` and the name upaas gave the container.
webhooker speaks plain HTTP. It does not handle TLS, the encryption behind webhooker speaks plain HTTP. It does not handle TLS, the encryption behind
HTTPS: it holds no certificate and accepts no HTTPS connections. The reverse HTTPS: it holds no certificate and accepts no HTTPS connections. The reverse
proxy holds the certificate and reaches webhooker over a private address. A bare proxy holds the certificate and reaches webhooker over a private address. A bare
host is a host without Docker. On a bare host the private address is loopback host is a host without Docker. On a bare host that is loopback, the address only
(`127.0.0.1`), which only this host can reach. For the image it is the Docker this host can reach. For the image it is the Docker bridge, the private network
bridge, the private network Docker puts containers on. Docker puts containers on.
Four things must be right. Four things must be right.
@@ -370,13 +371,13 @@ writes it. The reverse proxy's access log does hold it, in the request path.
### Sign-in limits ### Sign-in limits
From the fifth failure within a minute, each failure is answered `429`. Only The password is checked first, and only a failed sign-in is counted. A correct
failures are counted. A correct password is never answered `429`. Failures are password is never answered `429`. Failures are counted per client and submitted
counted per client and submitted username. At most two password checks run at username. From the fifth failure within a minute, each failure is answered
once; a request that gets no turn within 5 seconds is answered `503`. So `429`. At most two password checks run at once; a request that gets no turn
guessing speed is bounded by the limit of two checks at a time, not by the within 5 seconds is answered `503`. So guessing speed is bounded by the limit of
failure count. Choose a long random `admin` password, or rate-limit two checks at a time, not by the failure count. Choose a long random `admin`
`POST /pages/login` at the reverse proxy. password, or rate-limit `POST /pages/login` at the reverse proxy.
### Delivery destinations ### Delivery destinations
@@ -432,11 +433,10 @@ sends them on in the background, and SQLite holds everything in between.
The receiver stores the event and one pending delivery per active target in one The receiver stores the event and one pending delivery per active target in one
database write, then queues the deliveries. Background workers run them and database write, then queues the deliveries. Background workers run them and
record each attempt. At start, a recovery routine resumes every delivery left record each attempt. At start, a recovery routine queues again every delivery
`pending` or `retrying`. It then runs every minute. Each run resumes every left `pending` or `retrying`. It then runs every minute. Each run sends again a
`retrying` delivery that the engine is not already handling. It also resumes a `retrying` delivery the engine is not working on, and a `pending` one that has
`pending` delivery that has sat untouched for 15 minutes. So a stop or a crash sat untouched for 15 minutes. So a stop or a crash loses no delivery.
loses no delivery.
The main database holds configuration. Each webhook has its own event database, The main database holds configuration. Each webhook has its own event database,
so writes for one webhook never wait on another webhook's database. The program so writes for one webhook never wait on another webhook's database. The program
-6
View File
@@ -96,12 +96,6 @@ func (s *Handlers) WebhookStatsForTest(webhookID string) *WebhookStats {
return s.loadWebhookStats(webhookID, entrypoints, targets) return s.loadWebhookStats(webhookID, entrypoints, targets)
} }
// RelativeTimeForTest exposes relativeTime for use in the
// handlers_test package.
func RelativeTimeForTest(at, now time.Time) string {
return relativeTime(at, now)
}
// FinishedByTargetForTest exposes finishedByTarget for use in the // FinishedByTargetForTest exposes finishedByTarget for use in the
// handlers_test package. // handlers_test package.
func FinishedByTargetForTest( func FinishedByTargetForTest(
+5 -54
View File
@@ -15,9 +15,6 @@ const (
longWindow = 24 * time.Hour longWindow = 24 * time.Hour
) )
// day is the largest unit relativeTime writes.
const day = 24 * time.Hour
// percent turns a fraction into a percentage. // percent turns a fraction into a percentage.
const percent = 100 const percent = 100
@@ -38,12 +35,8 @@ type WebhookStats struct {
InProgress int64 InProgress int64
// LastEventAt is when the newest event arrived, or nil when none // LastEventAt is when the newest event arrived, or nil when none
// has. Retention does not change it. LastEvent is how long ago // has. Retention does not change it.
// that was, as relativeTime writes it, and LastEventUTC the full
// time; both are empty when no event has arrived.
LastEventAt *time.Time LastEventAt *time.Time
LastEvent string
LastEventUTC string
Last10Minutes RecentWindow Last10Minutes RecentWindow
Last24Hours RecentWindow Last24Hours RecentWindow
@@ -88,42 +81,6 @@ func (w RecentWindow) FailurePercent() string {
) )
} }
// relativeTime is how long before now the time at was, in whole
// seconds, written as its two largest units out of d, h, m and s:
// "59s", "1m1s", "23h59m", "3d4h". A second unit of zero is left off,
// as in "5m". A time after now is "0s".
func relativeTime(at, now time.Time) string {
elapsed := max(now.Sub(at), 0)
days := int64(elapsed / day)
hours := int64(elapsed % day / time.Hour)
minutes := int64(elapsed % time.Hour / time.Minute)
seconds := int64(elapsed % time.Minute / time.Second)
switch {
case days > 0:
return twoUnits(days, "d", hours, "h")
case hours > 0:
return twoUnits(hours, "h", minutes, "m")
case minutes > 0:
return twoUnits(minutes, "m", seconds, "s")
default:
return fmt.Sprintf("%ds", seconds)
}
}
// twoUnits writes a count of a larger unit followed by a count of the
// next smaller one, leaving the smaller off when it is zero.
func twoUnits(
larger int64, largerUnit string, smaller int64, smallerUnit string,
) string {
if smaller == 0 {
return fmt.Sprintf("%d%s", larger, largerUnit)
}
return fmt.Sprintf("%d%s%d%s", larger, largerUnit, smaller, smallerUnit)
}
// loadWebhookStats gathers the figures for the statistics pane from the // loadWebhookStats gathers the figures for the statistics pane from the
// webhook's entrypoints and targets, as the page has already loaded // webhook's entrypoints and targets, as the page has already loaded
// them, and from its event database. It returns nil, and logs why, when // them, and from its event database. It returns nil, and logs why, when
@@ -182,7 +139,7 @@ func (h *Handlers) loadWebhookStats(
func readEventStats( func readEventStats(
db *gorm.DB, now time.Time, stats *WebhookStats, db *gorm.DB, now time.Time, stats *WebhookStats,
) error { ) error {
err := readTotals(db, now, stats) err := readTotals(db, stats)
if err != nil { if err != nil {
return err return err
} }
@@ -212,9 +169,9 @@ func readEventStats(
} }
// readTotals fills in the lifetime and within-retention figures, and // readTotals fills in the lifetime and within-retention figures, and
// when the last event arrived and how long before now that was, from // when the last event arrived, from the running totals: the events'
// the running totals: the events' row, and the targets' rows summed. // row, and the targets' rows summed.
func readTotals(db *gorm.DB, now time.Time, stats *WebhookStats) error { func readTotals(db *gorm.DB, stats *WebhookStats) error {
var events database.EventTotals var events database.EventTotals
err := db.Take(&events).Error err := db.Take(&events).Error
@@ -233,12 +190,6 @@ func readTotals(db *gorm.DB, now time.Time, stats *WebhookStats) error {
stats.WithinRetention.Events = events.Events - events.EventsRemoved stats.WithinRetention.Events = events.Events - events.EventsRemoved
stats.LastEventAt = events.LastEventAt stats.LastEventAt = events.LastEventAt
if events.LastEventAt != nil {
stats.LastEvent = relativeTime(*events.LastEventAt, now)
stats.LastEventUTC =
events.LastEventAt.UTC().Format(time.DateTime) + " UTC"
}
for _, t := range targets { for _, t := range targets {
stats.Lifetime.Deliveries += t.Deliveries stats.Lifetime.Deliveries += t.Deliveries
stats.Lifetime.Failures += t.Failed stats.Lifetime.Failures += t.Failed
+17 -62
View File
@@ -265,13 +265,6 @@ func statsPane(t *testing.T, page string) string {
return strings.Join(strings.Fields(pane), " ") return strings.Join(strings.Fields(pane), " ")
} }
// statsLastEventShown matches the pane's last event for an event that
// arrived at at: a figure with the full time in UTC as its tooltip.
func statsLastEventShown(at time.Time) string {
return `Last event</span>\s*<span [^>]*title="` +
at.UTC().Format(time.DateTime) + ` UTC">[^<]+</span>`
}
// assertStatsTargets checks, for the history seedStatsHistory builds, // assertStatsTargets checks, for the history seedStatsHistory builds,
// each target's totals and its deliveries finished in the last 24 // each target's totals and its deliveries finished in the last 24
// hours. The first target has ten deliveries and the replay, the // hours. The first target has ten deliveries and the replay, the
@@ -307,17 +300,14 @@ func assertStatsPaneAfterPrune(
) { ) {
t.Helper() t.Helper()
page := renderSourceDetailPage(t, h, sess, hist.webhook.ID) pane := statsPane(t, renderSourceDetailPage(t, h, sess, hist.webhook.ID))
pane := statsPane(t, page) lastEvent := hist.newest.CreatedAt.UTC().Format("2006-01-02 15:04:05 UTC")
// The newest event arrived moments ago, so the last event is a assert.Contains(t, pane, "Entrypoints 12 (11 active) "+
// few seconds ago. "Targets 6 (2 active) "+
assert.Regexp(t, `Entrypoints 12 \(11 active\) `+ "Deliveries in progress 13 "+
`Targets 6 \(2 active\) `+ "Last event "+lastEvent+" "+
`Deliveries in progress 13 `+ "Retention 14 days")
`Last event \d+s `+
`Retention 14 days`, pane)
assert.Regexp(t, statsLastEventShown(hist.newest.CreatedAt), page)
assert.Contains(t, pane, "Lifetime Within retention "+ assert.Contains(t, pane, "Lifetime Within retention "+
"Events 10 9 "+ "Events 10 9 "+
"Deliveries 21 18 "+ "Deliveries 21 18 "+
@@ -450,14 +440,15 @@ func TestWebhookStats_LastEventSurvivesPruningEveryEvent(t *testing.T) {
require.NotNil(t, stats.LastEventAt) require.NotNil(t, stats.LastEventAt)
assert.True(t, arrived.Equal(*stats.LastEventAt)) assert.True(t, arrived.Equal(*stats.LastEventAt))
assert.Regexp(t, statsLastEventShown(arrived), pane := statsPane(t, renderSourceDetailPage(t, h, sess, wh.ID))
renderSourceDetailPage(t, h, sess, wh.ID)) assert.Contains(t, pane,
"Last event "+arrived.UTC().Format("2006-01-02 15:04:05 UTC"))
} }
// TestWebhookStats_LastEventInUTC checks that the tooltip of the pane's // TestWebhookStats_LastEventInUTC checks that the pane shows when the
// last event gives the time in UTC, as the event list does, when the // last event arrived in UTC, as the event list does, when the time was
// time was stored in another zone, as it is on a host whose local time // stored in another zone, as it is on a host whose local time is not
// is not UTC. // UTC.
func TestWebhookStats_LastEventInUTC(t *testing.T) { func TestWebhookStats_LastEventInUTC(t *testing.T) {
t.Parallel() t.Parallel()
@@ -483,9 +474,8 @@ func TestWebhookStats_LastEventInUTC(t *testing.T) {
require.NoError(t, database.AddEventTotals(webhookDB, require.NoError(t, database.AddEventTotals(webhookDB,
database.EventTotals{Events: 1, LastEventAt: &arrived})) database.EventTotals{Events: 1, LastEventAt: &arrived}))
assert.Regexp(t, pane := statsPane(t, renderSourceDetailPage(t, h, sess, wh.ID))
`Last event</span>\s*<span [^>]*title="2026-03-05 03:30:00 UTC">`, assert.Contains(t, pane, "Last event 2026-03-05 03:30:00 UTC")
renderSourceDetailPage(t, h, sess, wh.ID))
} }
// TestWebhookStats_PaneShowsRetentionPeriod checks that the statistics // TestWebhookStats_PaneShowsRetentionPeriod checks that the statistics
@@ -551,11 +541,8 @@ func TestWebhookStats_WebhookWithNoEvents(t *testing.T) {
assert.Equal(t, &handlers.WebhookStats{}, h.WebhookStatsForTest(wh.ID)) assert.Equal(t, &handlers.WebhookStats{}, h.WebhookStatsForTest(wh.ID))
assert.Equal(t, "—", handlers.RecentWindow{}.FailurePercent()) assert.Equal(t, "—", handlers.RecentWindow{}.FailurePercent())
page := renderSourceDetailPage(t, h, sess, wh.ID) pane := statsPane(t, renderSourceDetailPage(t, h, sess, wh.ID))
pane := statsPane(t, page)
assert.Contains(t, pane, "Last event none") assert.Contains(t, pane, "Last event none")
assert.Regexp(t, `Last event</span>\s*<span class="[^"]*">none</span>`,
page, "a last event of none has a tooltip")
assert.Contains(t, pane, "Failure percentage — —") assert.Contains(t, pane, "Failure percentage — —")
assert.False(t, dbMgr.DBExists(wh.ID)) assert.False(t, dbMgr.DBExists(wh.ID))
} }
@@ -580,35 +567,3 @@ func TestRecentWindow_FailurePercent(t *testing.T) {
assert.Equal(t, tt.want, tt.window.FailurePercent(), tt.window) assert.Equal(t, tt.want, tt.window.FailurePercent(), tt.window)
} }
} }
// TestRelativeTime pins how the pane writes the time since the last
// event, at each boundary between its units.
func TestRelativeTime(t *testing.T) {
t.Parallel()
now := time.Date(2026, time.October, 26, 12, 0, 0, 0, time.UTC)
tests := []struct {
ago time.Duration
want string
}{
{0, "0s"},
{59900 * time.Millisecond, "59s"},
{60 * time.Second, "1m"},
{61 * time.Second, "1m1s"},
{3599 * time.Second, "59m59s"},
{3600 * time.Second, "1h"},
{3660 * time.Second, "1h1m"},
{86399 * time.Second, "23h59m"},
{86400 * time.Second, "1d"},
{90000 * time.Second, "1d1h"},
{400*24*time.Hour + 3*time.Hour, "400d3h"},
// An event time ahead of the server clock.
{-90 * time.Second, "0s"},
}
for _, tt := range tests {
assert.Equal(t, tt.want,
handlers.RelativeTimeForTest(now.Add(-tt.ago), now), tt.ago)
}
}
+1 -38
View File
@@ -61,8 +61,7 @@ const (
// event log in a headless browser, served by the real router and so // event log in a headless browser, served by the real router and so
// under the real Content-Security-Policy, and checks that the pages' // under the real Content-Security-Policy, and checks that the pages'
// Alpine.js directives, the copy control and the delete prompts work, // Alpine.js directives, the copy control and the delete prompts work,
// that a target's row shows its delivery counts, and that the // and that a target's row shows its delivery counts.
// Statistics block shows how long ago the last event arrived.
func TestAlpineRunsUnderTheSecurityPolicy(t *testing.T) { func TestAlpineRunsUnderTheSecurityPolicy(t *testing.T) {
t.Parallel() t.Parallel()
@@ -98,7 +97,6 @@ func TestAlpineRunsUnderTheSecurityPolicy(t *testing.T) {
checkCopy(ctx, t, page) checkCopy(ctx, t, page)
checkEntrypointEdit(ctx, t, page, page+"/events") checkEntrypointEdit(ctx, t, page, page+"/events")
checkRecentEvents(ctx, t, page) checkRecentEvents(ctx, t, page)
checkStatsLastEvent(ctx, t, env, webhook.ID, page)
checkArchiveChoice(ctx, t, srv.URL+"/hooks/new", page) checkArchiveChoice(ctx, t, srv.URL+"/hooks/new", page)
checkNewWebhookTargets(ctx, t, env, srv.URL+"/hooks/new") checkNewWebhookTargets(ctx, t, env, srv.URL+"/hooks/new")
checkRefusedNewWebhook(ctx, t, srv.URL+"/hooks/new") checkRefusedNewWebhook(ctx, t, srv.URL+"/hooks/new")
@@ -833,41 +831,6 @@ func checkRecentEvents(ctx context.Context, t *testing.T, url string) {
"the event's own page does not show its body") "the event's own page does not show its body")
} }
// checkStatsLastEvent records that the last event of the webhook
// webhookID arrived 3 days, 4 hours and 30 minutes ago, loads its page,
// and checks that the Statistics block shows the last event as "3d4h"
// with the full time in UTC as its tooltip.
func checkStatsLastEvent(
ctx context.Context, t *testing.T, env *testEnv, webhookID, url string,
) {
t.Helper()
webhookDB, err := env.dbMgr.GetDB(webhookID)
require.NoError(t, err)
ago := 3*24*time.Hour + 4*time.Hour + 30*time.Minute
arrived := time.Now().Add(-ago)
require.NoError(t, database.AddEventTotals(webhookDB,
database.EventTotals{LastEventAt: &arrived}))
figure := `//span[text()="Last event"]` +
`/following-sibling::span[text()="3d4h"]`
var (
title string
found bool
)
require.NoError(t, chromedp.Run(ctx, loadPage(url)))
require.True(t, shown(ctx, figure),
`the Statistics block does not show the last event as "3d4h"`)
require.NoError(t, chromedp.Run(ctx, chromedp.AttributeValue(
figure, "title", &title, &found, chromedp.BySearch,
)))
assert.Equal(t, arrived.UTC().Format(time.DateTime)+" UTC", title,
"the last event's tooltip is not the full time in UTC")
}
// checkEventLog loads the event log and checks that of its events only // checkEventLog loads the event log and checks that of its events only
// the newest, eventID, starts expanded: its row says so and its caret is // the newest, eventID, starts expanded: its row says so and its caret is
// turned up, and the log's last event, lastEventID, starts collapsed. In // turned up, and the log's last event, lastEventID, starts collapsed. In
+1
View File
@@ -13,6 +13,7 @@ import (
// TestBaseTemplateScriptsAreServed walks every /s/ script the base // TestBaseTemplateScriptsAreServed walks every /s/ script the base
// template loads on each page and fetches it through the real router. // template loads on each page and fetches it through the real router.
// This is the check that each page gets the JavaScript it asks for.
func TestBaseTemplateScriptsAreServed(t *testing.T) { func TestBaseTemplateScriptsAreServed(t *testing.T) {
t.Parallel() t.Parallel()
+1 -5
View File
@@ -22,11 +22,7 @@
</div> </div>
<div> <div>
<span class="text-gray-500">Last event</span> <span class="text-gray-500">Last event</span>
{{if .LastEvent}} <span class="font-medium text-gray-900">{{with .LastEventAt}}{{.UTC.Format "2006-01-02 15:04:05 UTC"}}{{else}}none{{end}}</span>
<span class="font-medium text-gray-900" title="{{.LastEventUTC}}">{{.LastEvent}}</span>
{{else}}
<span class="font-medium text-gray-900">none</span>
{{end}}
</div> </div>
<div> <div>
<span class="text-gray-500">Retention</span> <span class="text-gray-500">Retention</span>