Author SHA1 Message Date
clawbot 09b2115677 Say what each action did in a one-line notice (closes #383)
check / check (push) Waiting to run
Every action on the webhook pages, and signing out, redirects with a
fixed notice code in the URL, and the page it lands on shows one line
saying what was done. noticeFor maps codes to fixed text and an
unknown code shows nothing, so nothing from the URL is echoed. One
partial, templates/notice.html, shows the line under the navbar on
every page; the error page shows none.

Replay and resubmit use the same codes and partial in place of their
own query parameters and event log banners.

Model: opus-5-5
2026-10-02 06:48:09 +00:00
clawbot cb7bafab17 Derive the image's version from the .git in the build context (closes #366)
check / check (push) Waiting to run
upaas uploads its clone as a tar context, which .dockerignore does not filter, so its builds already carried .git; the unknown default of the VERSION build arg is what stamped them. The image now derives its version itself: ARG VERSION has no default, so make build falls back to script/version, which runs git describe on the copied .git; a VERSION build arg still wins.

.dockerignore sends .git without its config in a directory context and no longer leaves out tracked files, which would mark the tree -dirty. The builder installs git, trusts /build as a safe.directory, and fails when .git is present but the version comes out unknown. A shallow single-branch clone stamps its short commit.

Model: opus-5-5
2026-10-02 08:41:20 +02:00
clawbot 2416528b77 Render admin page errors in the normal layout (closes #382)
check / check (push) Waiting to run
Every 400, 403, 404 and 500 on an admin page now answers with an error page in the normal layout: the status, one fixed line explaining it, and a link back to the webhook list, or to sign-in when nobody is signed in. Unknown paths reach it through the router's not-found handler, a refused form token through the CSRF middleware, and a panic through a recoverer each admin page route group installs first. Status codes are unchanged, and the page always sends Cache-Control: no-store.

If the error page fails to render, the answer is the same status in plain text; if it panics, the answer is a 500. The receiver, the healthcheck and /metrics keep their plain answers.

Model: opus-5-5
2026-10-02 08:15:12 +02:00
41 changed files with 1152 additions and 940 deletions
+10 -4
View File
@@ -1,14 +1,20 @@
# .git is sent so the build can derive the version it stamps into the binary
# (script/version). Its config, which can hold a remote URL carrying a
# credential and which `git describe` does not need, is left out of a
# directory context. A context sent as a tar is not filtered by this file, so
# it carries .git/config unless its sender leaves it out.
.git/config
# No tracked file may be listed here: git in the build would see it as
# deleted and mark the version -dirty.
#
# .ci-fingerprint is deliberately NOT excluded: it is the CI cache barrier
# that keeps the check stages from replaying a cached pass. See the lint
# stage of the Dockerfile.
.git/
bin/
# Extracted from 3p/ by `make assets` inside the build; a host copy is not
# needed. The tarball in 3p/ must stay in the context.
static/js/alpine.min.js
*.md
LICENSE
.editorconfig
.env
.env.*
*.db
+7 -13
View File
@@ -12,9 +12,8 @@ jobs:
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 2024-10-23
with:
# The fingerprint step below needs history to find the last commit
# that touched the Docker build context, and the superseded-status
# step needs it to walk ancestors (it aborts on a shallow clone).
# The superseded-status step needs history to walk ancestors (it
# aborts on a shallow clone).
fetch-depth: 0
- name: Mark superseded run statuses
@@ -28,16 +27,11 @@ jobs:
run: script/ci-mark-superseded
- name: Fingerprint the build context
# `.dockerignore` keeps docs out of the build context, so a docs-only
# commit legitimately replays the whole image from cache and stays
# cheap. Every other commit writes a new fingerprint into the context,
# which invalidates the `COPY . .` layer of both check stages: a
# commit that was never linted, formatted-checked, tested and built
# cannot report success from cache.
run: |
set -eu
fp="$(git log -1 --format=%H -- . ':!*.md' ':!LICENSE' ':!.editorconfig')"
printf '%s\n' "${fp:-$GITHUB_SHA}" > .ci-fingerprint
# Writes the hash of the commit being checked into the context, which
# invalidates the `COPY . .` layer of both check stages: a commit
# that was never linted, format-checked, tested and built cannot
# report success from cache.
run: git rev-parse HEAD > .ci-fingerprint
- name: Build Docker image (runs make check)
run: script/cibuild
+22 -9
View File
@@ -12,8 +12,8 @@ WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
# Copy source code. In CI the context also carries .ci-fingerprint, whose
# value changes with every commit that touches the build context (see
# Copy source code. In CI the context also carries .ci-fingerprint, which
# holds the hash of the commit being checked (see
# .gitea/workflows/check.yml). That invalidates this layer, so the checks
# below cannot report success by replaying a cached pass. Do not add it to
# .dockerignore.
@@ -38,8 +38,13 @@ 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. git is what script/version derives the version with.
RUN apt-get update && apt-get install -y --no-install-recommends make curl ca-certificates jq git && rm -rf /var/lib/apt/lists/*
# A build context sent as a tar archive keeps its files' owners, and git
# refuses to read a checkout owned by another user. Trust this one
# whoever owns it.
RUN git config --system --add safe.directory /build
WORKDIR /build
@@ -55,14 +60,22 @@ COPY . .
# from its tarball in 3p/.
RUN make test
# Version stamped into the binary. .dockerignore excludes .git/, so
# nothing in this stage can derive it: script/docker resolves it on the
# host and passes it in. The default is what a bare `docker build .`
# with no --build-arg gets, and it names no tag the tree may not be at.
# Version stamped into the binary: the VERSION build arg when one is
# given, otherwise what script/version derives from the .git the build
# context carries, so any `docker build .` of a clone stamps its commit.
# With neither, as from a source tarball, it is "unknown".
#
# Declared here, below the test step, so a changed version does not
# invalidate its cached layer.
ARG VERSION=unknown
ARG VERSION
# A context that carries .git must not stamp "unknown": that means git is
# missing here or could not read the checkout, and the image could not be
# traced back to its commit.
RUN if [ -d .git ] && [ "$(make version VERSION="$VERSION")" = unknown ]; then \
echo "version is unknown although the build context carries .git" >&2; \
exit 1; \
fi
RUN make build VERSION="$VERSION"
+4 -4
View File
@@ -4,12 +4,12 @@
.DEFAULT_GOAL := check
# Version stamped into the binary. Derived from git by script/version;
# override it (`make build VERSION=v1.2.3`) where git metadata is
# unavailable, which is how the Dockerfile passes its build arg in.
# override it (`make build VERSION=v1.2.3`) to stamp a given value, which is
# how the Dockerfile passes its build arg in.
VERSION ?= $(shell script/version)
# An empty override (`make build VERSION=`, or a `--build-arg VERSION=`
# landing on the Dockerfile's `make build VERSION="$VERSION"`) means unset,
# An empty override (`make build VERSION=`, or the Dockerfile's `make build
# VERSION="$VERSION"` when no VERSION build arg was given) means unset,
# exactly as it does in script/version -- stamping "" would leave the binary
# reporting no version and the footer back on its "dev" fallback. `override`
# is required: a plain assignment loses to the command-line definition it
+40 -31
View File
@@ -1133,13 +1133,29 @@ build itself.
| Uncommitted changes | the above with a `-dirty` suffix |
| No git metadata | `unknown` |
`unknown` is what a source tarball or a `docker build .` with no
`--build-arg VERSION=...` reports. `.dockerignore` excludes `.git/`, so
the build context carries no git metadata and the image cannot derive
the version itself: `script/docker` (and so `make docker`) resolves it
on the host and passes it in as the `VERSION` build arg. A build that
reports `unknown` is a build nobody told what it was; it is not a
failure, but it cannot be traced back to a commit.
The image derives it the same way, from the `.git` that the build
context carries, so any `docker build .` of a clone, with no build
arguments, stamps the commit it was built from; a shallow clone of one
branch has no tags and stamps the short SHA. `.dockerignore` must
therefore leave out neither `.git` nor any tracked file, which git in
the build would see as deleted, marking the version `-dirty`. It does
leave `.git/config`, which can hold a remote URL carrying a credential
and which `git describe` does not need, out of a directory context. A
context sent as a tar is not filtered by `.dockerignore`, so it carries
`.git/config` unless its sender leaves it out; for upaas, that is
https://git.eeqj.de/sneak/upaas/issues/274. git in the build
reads the checkout whoever owns its files, since a context sent as a tar
archive keeps the sender's owners and git otherwise refuses a checkout
owned by another user. A `VERSION` build arg (`--build-arg VERSION=...`)
takes precedence; `script/docker` (and so `make docker`) passes the one
`script/version` resolves on the host. The image build fails if its
context carries `.git` and the version still comes out `unknown`, which
means git is missing from the build or could not read the checkout.
`unknown` is what a source tarball, or a `docker build` with no `.git`
in its context and no `VERSION` build arg, reports. A build that reports
`unknown` is a build nobody told what it was; it is not a failure, but
it cannot be traced back to a commit.
`make version` prints what the current checkout would stamp, and
`make build VERSION=v1.2.3` overrides it. An empty override — from
@@ -1752,7 +1768,7 @@ retries) is individually logged for full observability.
#### EventTotals and TargetTotals
Running counts in each event database, read by the statistics pane at the
top of the webhook page and by the webhook list. `EventTotals` is one row:
top of the webhook page. `EventTotals` is one row:
| Field | Type | Description |
| ---------------- | --------- | ----------- |
@@ -1784,14 +1800,6 @@ target. Its failure percentage for a window is the deliveries that became
`failed` in it out of all that became `delivered` or `failed` in it, and
a dash when none did.
The webhook list at `/hooks` shows three of the pane's figures for each
webhook: its events within retention and its last event, both from
`EventTotals`, and its deliveries that failed in the last 24 hours,
counted with the pane's query. It opens each webhook's event database once
(the handle stays open) and runs those two reads there, so its cost grows
with the number of webhooks and, for each, with the deliveries that
finished in the last 24 hours, never with the events stored.
#### Event-tier indexes
These indexes on the per-webhook event databases are declared in the model
@@ -1799,7 +1807,7 @@ tags, so `AutoMigrate` creates them on a fresh database:
| Table | Columns | Serves |
| ------------------ | --------------------------- | ------ |
| `deliveries` | `status`, `deleted_at`, `finished_at`, `target_id` | Startup recovery, the retry and pending sweeps every 60 seconds and the queue-depth sampler every 30 seconds, which select deliveries by status, and the webhook page's statistics and the webhook list, which count each target's deliveries by status and when they finished |
| `deliveries` | `status`, `deleted_at`, `finished_at`, `target_id` | Startup recovery, the retry and pending sweeps every 60 seconds and the queue-depth sampler every 30 seconds, which select deliveries by status, and the webhook page's statistics, which count each target's deliveries by status and when they finished |
| `deliveries` | `event_id`, `deleted_at` | The event log, which loads each event's deliveries, and retention, which counts and deletes the deliveries of expired events |
| `delivery_results` | `delivery_id`, `deleted_at` | The event log, which loads the attempts of a page's deliveries, and retention, which deletes the attempts of expired events |
| `events` | `deleted_at`, `created_at` | The webhook page's statistics, which count recent events |
@@ -2992,6 +3000,12 @@ local record instead of nothing. What that placement gives up is
recovery of a panic in the six entries above it, none of which does
more than set a header or start a timer.
Each admin page route group (`/pages`, `/user/*`, `/hooks`,
`/hook/*`) starts with its own **Recoverer** and, if `SENTRY_DSN` is
set, its own **Sentry** error reporting. That Recoverer answers a panic
with the `500` error page in the normal layout; the global one keeps
the plain-text `500` for every other route.
Additionally, form endpoints (`/pages`, `/user/*`, `/hooks`,
`/hook/*`) apply a **MaxBodySize** middleware that limits
POST/PUT/PATCH request bodies to 1 MB. It is registered ahead of the
@@ -3235,8 +3249,9 @@ version is fixed independently of the compiler's:
rebuilds the binary with `CGO_ENABLED=1` and static linking so it
runs on musl. Both builds go through `make build`, the relink adding
its `-extldflags` via `GO_LDFLAGS`, so neither can drop the `-X` that
stamps the version. The version arrives as the `VERSION` build arg,
since the context has no `.git` (see
stamps the version. The version is the `VERSION` build arg if one is
given, otherwise derived from the `.git` in the context, and the
stage fails if a context with `.git` would stamp `unknown` (see
[Version stamping](#version-stamping)).
3. **Runtime stage** (`alpine:3.21`) — copies the static binary and
`deploy/docker-entrypoint.sh`, creates the `/var/lib/webhooker`
@@ -3268,19 +3283,13 @@ A layer cache lets `docker build .` exit 0 in seconds with the lint and
test stages replayed rather than executed, which would make a green
check meaningless. The `check` workflow therefore writes
`.ci-fingerprint` into the build context before building. Its value is
the hash of the last commit that touched the build context, so:
the hash of the commit being checked, so every commit, docs-only ones
and a squash merge whose tree matches an already-built branch included,
gets a new fingerprint, invalidates the `COPY . .` layer of both check
stages, and really runs `make fmt-check`, `golangci-lint`, `make test`,
and `make build`. A run that reports success ran them.
- Any commit that changes code (including a squash merge whose tree
matches an already-built branch) gets a new fingerprint, invalidates
the `COPY . .` layer of both check stages, and really runs
`make fmt-check`, `golangci-lint`, `make test`, and `make build`. A
run that reports success ran them.
- A docs-only commit leaves the fingerprint unchanged — `.dockerignore`
excludes `*.md`, `LICENSE` and `.editorconfig` from the context
anyway — so the image replays from cache and costs seconds.
The module download layer sits above `COPY . .` and stays cached either
way.
The module download layer sits above `COPY . .` and stays cached.
A separate workflow step, run before the fingerprint is written, covers
a second way the gate lied: Gitea cancels an in-flight run when a newer
+9 -25
View File
@@ -74,7 +74,7 @@ func (h *Handlers) HandleLoginSubmit() http.HandlerFunc {
err := r.ParseForm()
if err != nil {
h.log.Error("failed to parse form", "error", err)
http.Error(w, "Bad request", http.StatusBadRequest)
h.renderError(w, r, http.StatusBadRequest)
return
}
@@ -212,11 +212,7 @@ func (h *Handlers) authenticateUser(
valid, err := database.VerifyPassword(password, user.Password)
if err != nil {
h.log.Error("failed to verify password", "error", err)
http.Error(
w, "Internal server error",
http.StatusInternalServerError,
)
h.serverError(w, r, "failed to verify password", err)
return user, err
}
@@ -288,24 +284,14 @@ func (h *Handlers) createAuthenticatedSession(
) error {
oldSess, err := h.session.Get(r)
if err != nil {
h.log.Error("failed to get session", "error", err)
http.Error(
w, "Internal server error",
http.StatusInternalServerError,
)
h.serverError(w, r, "failed to get session", err)
return err
}
sess, err := h.session.Regenerate(r, w, oldSess)
if err != nil {
h.log.Error(
"failed to regenerate session", "error", err,
)
http.Error(
w, "Internal server error",
http.StatusInternalServerError,
)
h.serverError(w, r, "failed to regenerate session", err)
return err
}
@@ -314,11 +300,7 @@ func (h *Handlers) createAuthenticatedSession(
err = h.session.Save(r, w, sess)
if err != nil {
h.log.Error("failed to save session", "error", err)
http.Error(
w, "Internal server error",
http.StatusInternalServerError,
)
h.serverError(w, r, "failed to save session", err)
return err
}
@@ -351,7 +333,9 @@ func (h *Handlers) HandleLogout() http.HandlerFunc {
)
}
// Redirect to login page
http.Redirect(w, r, "/pages/login", http.StatusSeeOther)
http.Redirect(
w, r, withNotice("/pages/login", signedOut),
http.StatusSeeOther,
)
}
}
+26 -64
View File
@@ -11,72 +11,37 @@ import (
"sneak.berlin/go/webhooker/internal/delivery"
)
// replayOutcomeParam is the query parameter the replay POST redirects
// with and the event log page reads its banner from.
const replayOutcomeParam = "replay"
// replayOutcomeCode is the outcome of a replay POST. The redirect
// carries one of these fixed codes rather than a message, so nothing a
// client submits can reach the rendered page through it.
type replayOutcomeCode string
// The outcomes of a replay POST, as the notice codes its redirect
// carries. noticeFor holds the line each one shows.
const (
// replayQueued reports that a new delivery was created and handed
// to the delivery engine.
replayQueued replayOutcomeCode = "queued"
replayQueued noticeCode = "replay-queued"
// replayTargetDeleted reports a target that once existed and has
// since been deleted. Deletes are soft and deliveries carry no
// foreign key to the target row, so the history survives its
// target and this is the ordinary case for an old event.
replayTargetDeleted replayOutcomeCode = "target-deleted"
replayTargetDeleted noticeCode = "replay-target-deleted"
// replayTargetMissing reports a target id that names no row at
// all, deleted or otherwise.
replayTargetMissing replayOutcomeCode = "target-missing"
replayTargetMissing noticeCode = "replay-target-missing"
// replayTargetInactive reports a target the operator has
// deactivated. A deactivated target receives no new deliveries, so
// a replay to it would be a delivery they switched off.
replayTargetInactive replayOutcomeCode = "target-inactive"
replayTargetInactive noticeCode = "replay-target-inactive"
// replayNotTerminal reports a delivery the engine has not finished
// with.
replayNotTerminal replayOutcomeCode = "not-terminal"
replayNotTerminal noticeCode = "replay-not-terminal"
// replayInFlight reports that an earlier replay of this event to
// this target is still running.
replayInFlight replayOutcomeCode = "in-flight"
replayInFlight noticeCode = "replay-in-flight"
)
// replayOutcome returns the banner the event log page shows for an
// outcome code, and whether the replay was queued. An unrecognised
// code yields no banner.
func replayOutcome(code string) (string, bool) {
switch replayOutcomeCode(code) {
case replayQueued:
return "Replay queued: a new delivery was created against " +
"the target's current configuration.", true
case replayTargetDeleted:
return "Not replayed: the target this delivery was for has " +
"been deleted. Recreate the target, then replay.", false
case replayTargetMissing:
return "Not replayed: the target this delivery was for no " +
"longer exists.", false
case replayTargetInactive:
return "Not replayed: the target this delivery was for is " +
"deactivated. Activate it, then replay.", false
case replayNotTerminal:
return "Not replayed: this delivery has not finished yet.",
false
case replayInFlight:
return "Not replayed: a delivery of this event to this " +
"target is already in flight.", false
default:
return "", false
}
}
// HandleDeliveryReplay re-sends a finished delivery's event to its
// target.
//
@@ -105,9 +70,7 @@ func (h *Handlers) HandleDeliveryReplay() http.HandlerFunc {
// middleware, which runs before CSRF parses the form.
err := r.ParseForm()
if err != nil {
http.Error(
w, "Bad request", http.StatusBadRequest,
)
h.renderError(w, r, http.StatusBadRequest)
return
}
@@ -124,14 +87,14 @@ func (h *Handlers) replayDelivery(
webhook database.Webhook,
) {
if !h.dbMgr.DBExists(webhook.ID) {
http.NotFound(w, r)
h.renderError(w, r, http.StatusNotFound)
return
}
webhookDB, err := h.dbMgr.GetDB(webhook.ID)
if err != nil {
h.serverError(w, "failed to get webhook database", err)
h.serverError(w, r, "failed to get webhook database", err)
return
}
@@ -142,14 +105,14 @@ func (h *Handlers) replayDelivery(
}
if !original.Status.Terminal() {
h.finishReplay(w, r, webhook, replayNotTerminal)
redirectToEventLog(w, r, webhook, replayNotTerminal)
return
}
target, code := h.replayTarget(webhook.ID, original.TargetID)
if target == nil {
h.finishReplay(w, r, webhook, code)
redirectToEventLog(w, r, webhook, code)
return
}
@@ -173,7 +136,7 @@ func (h *Handlers) loadReplaySource(
&original, "id = ?", chi.URLParam(r, "deliveryID"),
).Error
if err != nil {
http.NotFound(w, r)
h.renderError(w, r, http.StatusNotFound)
return nil, false
}
@@ -195,14 +158,14 @@ func (h *Handlers) queueReplay(
)
if err != nil {
h.serverError(
w, "failed to count in-flight deliveries", err,
w, r, "failed to count in-flight deliveries", err,
)
return
}
if inFlight > 0 {
h.finishReplay(w, r, webhook, replayInFlight)
redirectToEventLog(w, r, webhook, replayInFlight)
return
}
@@ -212,7 +175,7 @@ func (h *Handlers) queueReplay(
err = webhookDB.
First(&event, "id = ?", original.EventID).Error
if err != nil {
h.serverError(w, "failed to load event for replay", err)
h.serverError(w, r, "failed to load event for replay", err)
return
}
@@ -222,7 +185,7 @@ func (h *Handlers) queueReplay(
)
if err != nil {
h.serverError(
w, "failed to create replay delivery", err,
w, r, "failed to create replay delivery", err,
)
return
@@ -240,7 +203,7 @@ func (h *Handlers) queueReplay(
"delivery_id", task.DeliveryID,
)
h.finishReplay(w, r, webhook, replayQueued)
redirectToEventLog(w, r, webhook, replayQueued)
}
// replayTarget loads the delivery's target as it stands now.
@@ -253,7 +216,7 @@ func (h *Handlers) queueReplay(
// with the returned code saying why.
func (h *Handlers) replayTarget(
webhookID, targetID string,
) (*database.Target, replayOutcomeCode) {
) (*database.Target, noticeCode) {
var target database.Target
err := h.db.DB().Unscoped().Where(
@@ -363,17 +326,16 @@ func replayBody(body string) *string {
return &body
}
// finishReplay redirects back to the event log the replay was
// triggered from, carrying the outcome code the page turns into a
// banner and the page number the form submitted.
func (h *Handlers) finishReplay(
// redirectToEventLog redirects a replay or resubmit back to the event
// log it was triggered from, carrying the outcome as its notice and
// the page number the form submitted.
func redirectToEventLog(
w http.ResponseWriter,
r *http.Request,
webhook database.Webhook,
code replayOutcomeCode,
code noticeCode,
) {
dest := "/hook/" + webhook.ID + "/events?" +
replayOutcomeParam + "=" + string(code)
dest := withNotice("/hook/"+webhook.ID+"/events", code)
// The page is read from the form rather than the query string:
// this is a POST, and its query string is what logs and Referer
+8 -8
View File
@@ -212,7 +212,7 @@ func TestHandleDeliveryReplay_AppendsDeliveryAndLeavesOriginal(
require.Equal(t, http.StatusSeeOther, w.Code)
assert.Equal(
t,
"/hook/"+wh.ID+"/events?replay=queued",
"/hook/"+wh.ID+"/events?notice=replay-queued",
w.Header().Get("Location"),
)
@@ -362,7 +362,7 @@ func TestHandleDeliveryReplay_RefusesDeletedTarget(t *testing.T) {
require.Equal(t, http.StatusSeeOther, w.Code)
assert.Equal(
t,
"/hook/"+wh.ID+"/events?replay=target-deleted",
"/hook/"+wh.ID+"/events?notice=replay-target-deleted",
w.Header().Get("Location"),
)
@@ -390,7 +390,7 @@ func TestHandleDeliveryReplay_RefusesDeletedTarget(t *testing.T) {
require.Equal(t, http.StatusSeeOther, missing.Code)
assert.Equal(
t,
"/hook/"+wh.ID+"/events?replay=target-missing",
"/hook/"+wh.ID+"/events?notice=replay-target-missing",
missing.Header().Get("Location"),
)
}
@@ -431,7 +431,7 @@ func TestHandleDeliveryReplay_RefusesWhileEarlierReplayInFlight(
require.Equal(t, http.StatusSeeOther, first.Code)
require.Equal(
t,
"/hook/"+wh.ID+"/events?replay=queued",
"/hook/"+wh.ID+"/events?notice=replay-queued",
first.Header().Get("Location"),
)
@@ -439,7 +439,7 @@ func TestHandleDeliveryReplay_RefusesWhileEarlierReplayInFlight(
require.Equal(t, http.StatusSeeOther, second.Code)
assert.Equal(
t,
"/hook/"+wh.ID+"/events?replay=in-flight",
"/hook/"+wh.ID+"/events?notice=replay-in-flight",
second.Header().Get("Location"),
)
@@ -465,7 +465,7 @@ func TestHandleDeliveryReplay_RefusesWhileEarlierReplayInFlight(
require.Equal(t, http.StatusSeeOther, pending.Code)
assert.Equal(
t,
"/hook/"+wh.ID+"/events?replay=not-terminal",
"/hook/"+wh.ID+"/events?notice=replay-not-terminal",
pending.Header().Get("Location"),
)
}
@@ -509,7 +509,7 @@ func TestHandleSourceLogs_RendersReplayControlAndBanner(t *testing.T) {
assert.Contains(t, body, ">Replay<")
refused := renderSourceLogsPageWithQuery(
t, h, sess, wh.ID, "?replay=target-deleted",
t, h, sess, wh.ID, "?notice=replay-target-deleted",
)
assert.Contains(t, refused, "alert-error")
@@ -517,7 +517,7 @@ func TestHandleSourceLogs_RendersReplayControlAndBanner(t *testing.T) {
// An outcome code nobody issued renders no banner at all.
unknown := renderSourceLogsPageWithQuery(
t, h, sess, wh.ID, "?replay=made-up",
t, h, sess, wh.ID, "?notice=made-up",
)
assert.NotContains(t, unknown, "alert-error")
+53
View File
@@ -0,0 +1,53 @@
package handlers_test
import (
"context"
"html/template"
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/assert"
"sneak.berlin/go/webhooker/internal/handlers"
)
// TestErrorPage_RenderFailureKeepsStatus proves that an error page
// which cannot render answers with the status it was reporting, as
// plain text, and is not attempted again: a page whose own render
// fails reaches the error page, and the error page failing as well
// ends there with the 500.
func TestErrorPage_RenderFailureKeepsStatus(t *testing.T) {
t.Parallel()
var h *handlers.Handlers
app := newTestApp(t, &h)
app.RequireStart()
t.Cleanup(app.RequireStop)
// .Status is an int, so asking it for a field fails the render.
failing := `{{.Status.Missing}}`
h.AddTemplateForTest("error.html", template.Must(
template.New("error").Parse(failing),
))
h.AddTemplateForTest("failing.html", template.Must(
template.New("failing").Parse(`{{.Data.Missing}}`),
))
req := httptest.NewRequestWithContext(
context.Background(), http.MethodGet, "/", nil,
)
w := httptest.NewRecorder()
h.HandleErrorPage(http.StatusNotFound).ServeHTTP(w, req)
assert.Equal(t, http.StatusNotFound, w.Code)
assert.Equal(t, "Not Found\n", w.Body.String())
w = httptest.NewRecorder()
h.RenderTemplateForTest(w, req, "failing.html", 0)
assert.Equal(t, http.StatusInternalServerError, w.Code)
assert.Equal(t, "Internal Server Error\n", w.Body.String())
}
+5 -5
View File
@@ -52,7 +52,7 @@ func (h *Handlers) HandleEventBodyDownload() http.HandlerFunc {
// steered by a client.
eventID, err := uuid.Parse(chi.URLParam(r, "eventID"))
if err != nil {
http.NotFound(w, r)
h.renderError(w, r, http.StatusNotFound)
return
}
@@ -103,21 +103,21 @@ func (h *Handlers) serveEventBody(
eventID string,
) {
if !h.dbMgr.DBExists(webhook.ID) {
http.NotFound(w, r)
h.renderError(w, r, http.StatusNotFound)
return
}
webhookDB, err := h.dbMgr.GetDB(webhook.ID)
if err != nil {
h.serverError(w, "failed to get webhook database", err)
h.serverError(w, r, "failed to get webhook database", err)
return
}
body, found, err := eventBody(webhookDB, webhook.ID, eventID)
if err != nil {
h.serverError(w, "failed to read event body", err)
h.serverError(w, r, "failed to read event body", err)
return
}
@@ -130,7 +130,7 @@ func (h *Handlers) serveEventBody(
// row and the whole body is served, or it does not and the
// response is a clean 404.
if !found {
http.NotFound(w, r)
h.renderError(w, r, http.StatusNotFound)
return
}
+13 -62
View File
@@ -3,7 +3,6 @@ package handlers
import (
"errors"
"net/http"
"strconv"
"github.com/go-chi/chi"
"github.com/google/uuid"
@@ -11,43 +10,19 @@ import (
"sneak.berlin/go/webhooker/internal/database"
)
// resubmitOutcomeParam is the query parameter the resubmit POST
// redirects with and the event log page reads its banner from.
const resubmitOutcomeParam = "resubmit"
// resubmitOutcomeCode is the outcome of a resubmit POST. The redirect
// carries one of these fixed codes rather than a message, so nothing a
// client submits can reach the rendered page through it.
type resubmitOutcomeCode string
// The outcomes of a resubmit POST, as the notice codes its redirect
// carries. noticeFor holds the line each one shows.
const (
// resubmitQueued reports that a new event was stored and its
// deliveries handed to the delivery engine.
resubmitQueued resubmitOutcomeCode = "queued"
resubmitQueued noticeCode = "resubmit-queued"
// resubmitNoTargets reports a source with no active targets. The
// new event is stored either way, exactly as a received event
// with no targets is.
resubmitNoTargets resubmitOutcomeCode = "no-targets"
resubmitNoTargets noticeCode = "resubmit-no-targets"
)
// resubmitOutcome returns the banner the event log page shows for an
// outcome code, and whether the resubmit was queued. An unrecognised
// code yields no banner.
func resubmitOutcome(code string) (string, bool) {
switch resubmitOutcomeCode(code) {
case resubmitQueued:
return "Resubmitted: a new event was created from the stored " +
"one and queued to every active target.", true
case resubmitNoTargets:
return "Resubmitted: a new event was created, but this " +
"source has no active targets, so nothing was queued.",
true
default:
return "", false
}
}
// resubmitSource is the stored event a resubmit copies. Its body is
// read as bytes rather than as a string so the copy is byte-identical
// to what was received, whatever the payload's encoding.
@@ -99,7 +74,7 @@ func (h *Handlers) HandleEventResubmit() http.HandlerFunc {
// middleware, which runs before CSRF parses the form.
err := r.ParseForm()
if err != nil {
http.Error(w, "Bad request", http.StatusBadRequest)
h.renderError(w, r, http.StatusBadRequest)
return
}
@@ -120,20 +95,20 @@ func (h *Handlers) resubmitEvent(
// alphabet rather than from the request.
eventID, err := uuid.Parse(chi.URLParam(r, "eventID"))
if err != nil {
http.NotFound(w, r)
h.renderError(w, r, http.StatusNotFound)
return
}
if !h.dbMgr.DBExists(webhook.ID) {
http.NotFound(w, r)
h.renderError(w, r, http.StatusNotFound)
return
}
webhookDB, err := h.dbMgr.GetDB(webhook.ID)
if err != nil {
h.serverError(w, "failed to get webhook database", err)
h.serverError(w, r, "failed to get webhook database", err)
return
}
@@ -147,7 +122,7 @@ func (h *Handlers) resubmitEvent(
webhookDB, webhook.ID, eventID.String(),
)
if err != nil {
h.serverError(w, "failed to load event to resubmit", err)
h.serverError(w, r, "failed to load event to resubmit", err)
return
}
@@ -155,7 +130,7 @@ func (h *Handlers) resubmitEvent(
// A miss is a 404 whether the event was reaped, belongs to
// another webhook, or never existed.
if !found {
http.NotFound(w, r)
h.renderError(w, r, http.StatusNotFound)
return
}
@@ -207,7 +182,7 @@ func (h *Handlers) queueResubmit(
// inactive one is skipped rather than refused.
targets, err := h.loadActiveTargets(webhook.ID)
if err != nil {
h.serverError(w, "failed to query targets", err)
h.serverError(w, r, "failed to query targets", err)
return
}
@@ -225,7 +200,7 @@ func (h *Handlers) queueResubmit(
targets,
)
if err != nil {
h.serverError(w, "failed to store resubmitted event", err)
h.serverError(w, r, "failed to store resubmitted event", err)
return
}
@@ -245,29 +220,5 @@ func (h *Handlers) queueResubmit(
code = resubmitNoTargets
}
h.finishResubmit(w, r, webhook, code)
}
// finishResubmit redirects back to the event log the resubmit was
// triggered from, carrying the outcome code the page turns into a
// banner and the page number the form submitted.
func (h *Handlers) finishResubmit(
w http.ResponseWriter,
r *http.Request,
webhook database.Webhook,
code resubmitOutcomeCode,
) {
dest := "/hook/" + webhook.ID + "/events?" +
resubmitOutcomeParam + "=" + string(code)
// The page is read from the form rather than the query string:
// this is a POST, and its query string is what logs and Referer
// headers record.
if page := pageOrFirst(
r.PostFormValue("page"),
); page > 1 {
dest += "&page=" + strconv.Itoa(page)
}
http.Redirect(w, r, dest, http.StatusSeeOther)
redirectToEventLog(w, r, webhook, code)
}
+4 -4
View File
@@ -154,7 +154,7 @@ func TestHandleEventResubmit_DeliversToTargetCreatedAfterTheEvent(
require.Equal(t, http.StatusSeeOther, w.Code)
assert.Equal(
t,
"/hook/"+wh.ID+"/events?resubmit=queued",
"/hook/"+wh.ID+"/events?notice=resubmit-queued",
w.Header().Get("Location"),
)
@@ -282,7 +282,7 @@ func TestHandleEventResubmit_IsRepeatable(t *testing.T) {
require.Equal(t, http.StatusSeeOther, w.Code)
assert.Equal(
t,
"/hook/"+wh.ID+"/events?resubmit=queued",
"/hook/"+wh.ID+"/events?notice=resubmit-queued",
w.Header().Get("Location"),
"a resubmit must not be refused while an earlier "+
"one is in flight",
@@ -436,7 +436,7 @@ func TestHandleEventResubmit_SkipsInactiveTarget(t *testing.T) {
require.Equal(t, http.StatusSeeOther, w.Code)
assert.Equal(
t,
"/hook/"+wh.ID+"/events?resubmit=queued",
"/hook/"+wh.ID+"/events?notice=resubmit-queued",
w.Header().Get("Location"),
"an inactive target is skipped, not an error",
)
@@ -482,7 +482,7 @@ func TestHandleEventResubmit_NoActiveTargetsStillStoresEvent(
require.Equal(t, http.StatusSeeOther, w.Code)
assert.Equal(
t,
"/hook/"+wh.ID+"/events?resubmit=no-targets",
"/hook/"+wh.ID+"/events?notice=resubmit-no-targets",
w.Header().Get("Location"),
)
+12 -2
View File
@@ -1,9 +1,11 @@
package handlers
import (
"context"
"html/template"
"log/slog"
"net/http"
"net/http/httptest"
"time"
"gorm.io/gorm"
@@ -65,7 +67,7 @@ func (s *Handlers) LoadEventLogViewsForTest(
page int,
) []EventLogView {
views, _, _ := s.loadEventsWithDeliveries(
w, webhook, nil, page,
w, newRequestForTest(), webhook, nil, page,
)
return views
@@ -94,6 +96,14 @@ func FinishedByTargetForTest(
return finishedByTarget(webhookDB, since)
}
// newRequestForTest is the request the helpers here pass on for
// callers that have none: it is used only to render the error page.
func newRequestForTest() *http.Request {
return httptest.NewRequestWithContext(
context.Background(), http.MethodGet, "/", nil,
)
}
// AddTemplateForTest registers a template under a page name so that
// the handlers_test package can drive the render path with a
// template of its own.
@@ -147,5 +157,5 @@ func (s *Handlers) BuildDatabaseTargetConfigForTest(
w http.ResponseWriter,
expiry string,
) (string, error) {
return s.buildDatabaseTargetConfig(w, expiry)
return s.buildDatabaseTargetConfig(w, newRequestForTest(), expiry)
}
+102 -23
View File
@@ -94,10 +94,10 @@ type Handlers struct {
// parsePageTemplate parses a page-specific template set from the
// embedded FS. Each page template is combined with the shared
// base, htmlheader, and navbar templates, and with any further files
// the page includes. The page file must be listed first so that its
// root action ({{template "base" .}}) becomes the template set's entry
// point.
// base, htmlheader, navbar and notice templates, and with any further
// files the page includes. The page file must be listed first so that
// its root action ({{template "base" .}}) becomes the template set's
// entry point.
func parsePageTemplate(
pageFile string, included ...string,
) *template.Template {
@@ -106,6 +106,7 @@ func parsePageTemplate(
"base.html",
"htmlheader.html",
"navbar.html",
"notice.html",
}, included...)
return template.Must(
@@ -142,6 +143,7 @@ func New(
"source_edit.html": parsePageTemplate("source_edit.html"),
"source_logs.html": parsePageTemplate("source_logs.html"),
"target_edit.html": parsePageTemplate("target_edit.html"),
"error.html": parsePageTemplate("error.html"),
}
lc.Append(fx.Hook{
@@ -153,6 +155,16 @@ func New(
return s, nil
}
// HandleErrorPage returns a handler that answers every request with
// the error page for status. The router uses it for unknown paths, the
// CSRF middleware for a refused form, and each admin page route
// group's recoverer for a panic.
func (s *Handlers) HandleErrorPage(status int) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
s.renderError(w, r, status)
}
}
func (s *Handlers) respondJSON(
w http.ResponseWriter,
_ *http.Request,
@@ -170,15 +182,78 @@ func (s *Handlers) respondJSON(
}
}
// serverError logs an error and sends a 500 response.
// serverError logs an error and answers with the 500 error page.
func (s *Handlers) serverError(
w http.ResponseWriter, msg string, err error,
w http.ResponseWriter, r *http.Request, msg string, err error,
) {
s.log.Error(msg, "error", err)
http.Error(
w, "Internal server error",
http.StatusInternalServerError,
)
s.renderError(w, r, http.StatusInternalServerError)
}
// renderError answers with status and the error page: the normal
// layout, one fixed line explaining the status, and a link back to the
// webhook list, or to sign-in when nobody is signed in.
//
// It renders the page itself rather than through renderTemplate,
// whose own failure comes here. If the error page cannot render
// either, the answer is the same status in plain text: never a second
// attempt, and never a different status.
func (s *Handlers) renderError(
w http.ResponseWriter,
r *http.Request,
status int,
) {
// The page names the signed-in user, and some error pages are
// served outside the routes where NoCache runs.
w.Header().Set("Cache-Control", "no-store")
// No notice: one would say an action worked above a page saying
// the request failed.
data := s.pageData(r, map[string]any{
"Status": status,
"StatusText": http.StatusText(status),
"Message": errorPageText(status),
}, nil)
var buf bytes.Buffer
err := s.templates["error.html"].Execute(&buf, data)
if err != nil {
s.log.Error("failed to render error page", "error", err)
http.Error(w, http.StatusText(status), status)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(status)
_, err = buf.WriteTo(w)
if err != nil {
s.log.Error("failed to write error page", "error", err)
}
}
// errorPageText is the line the error page shows for status. It is
// fixed per status, so the page tells the reader no more than the
// plain-text answers it replaced did.
func errorPageText(status int) string {
switch status {
case http.StatusBadRequest:
return "The request could not be read."
case http.StatusForbidden:
return "The request was refused. If it came from a form " +
"left open for a long time, reload the page and try " +
"again."
case http.StatusNotFound:
return "There is nothing here. It may have been deleted, " +
"or the address may be wrong."
case http.StatusServiceUnavailable:
return "The server is busy. Please try again in a moment."
default: // http.StatusInternalServerError
return "Something went wrong on the server. Please try " +
"again."
}
}
// UserInfo represents user information for templates
@@ -192,6 +267,7 @@ type templateDataWrapper struct {
User *UserInfo
CSRFToken string
Version string
Notice *notice
Data any
}
@@ -231,14 +307,20 @@ func (s *Handlers) renderTemplate(
"template not found",
"template", pageTemplate,
)
http.Error(
w, "Internal server error",
http.StatusInternalServerError,
)
s.renderError(w, r, http.StatusInternalServerError)
return
}
s.executeTemplate(w, r, tmpl, s.pageData(r, data, noticeFor(r)))
}
// pageData adds the fields the shared layout renders to a page's own
// data. The layout shows the notice, when there is one, above the
// page.
func (s *Handlers) pageData(
r *http.Request, data any, pageNotice *notice,
) any {
userInfo := s.getUserInfo(r)
csrfToken := middleware.CSRFToken(r)
@@ -252,19 +334,18 @@ func (s *Handlers) renderTemplate(
m["User"] = userInfo
m["CSRFToken"] = csrfToken
m["Version"] = version
s.executeTemplate(w, tmpl, m)
m["Notice"] = pageNotice
return
return m
}
wrapper := templateDataWrapper{
return templateDataWrapper{
User: userInfo,
CSRFToken: csrfToken,
Version: version,
Notice: pageNotice,
Data: data,
}
s.executeTemplate(w, tmpl, wrapper)
}
// executeTemplate renders the template into a buffer and writes to
@@ -277,6 +358,7 @@ func (s *Handlers) renderTemplate(
// this reason.
func (s *Handlers) executeTemplate(
w http.ResponseWriter,
r *http.Request,
tmpl *template.Template,
data any,
) {
@@ -287,10 +369,7 @@ func (s *Handlers) executeTemplate(
s.log.Error(
"failed to execute template", "error", err,
)
http.Error(
w, "Internal server error",
http.StatusInternalServerError,
)
s.renderError(w, r, http.StatusInternalServerError)
return
}
+6 -2
View File
@@ -307,10 +307,14 @@ func TestRenderTemplateMidRenderErrorSendsNoPartialBody(t *testing.T) {
t, http.StatusInternalServerError, w.Code,
"a failed render must report a 500",
)
assert.Equal(
t, "Internal server error\n", w.Body.String(),
assert.NotContains(
t, w.Body.String(), partialPageMarker,
"the response must carry no part of the aborted page",
)
assert.Contains(
t, w.Body.String(), "500 Internal Server Error",
"a failed render must answer with the error page",
)
}
func TestBuildDatabaseTargetConfig_Valid(t *testing.T) {
+109
View File
@@ -0,0 +1,109 @@
package handlers
import "net/http"
// noticeParam is the query parameter an action's redirect carries its
// notice code in.
const noticeParam = "notice"
// noticeCode names one of the fixed lines noticeFor knows. An action
// redirects with the code rather than the line, so nothing a client
// puts in the URL reaches the page: a code noticeFor does not know
// shows nothing.
type noticeCode string
// The codes of the actions on the webhook pages and of signing out.
// Replay's and resubmit's are beside those actions, with the reasons
// each can be refused.
const (
webhookCreated noticeCode = "webhook-created"
webhookSaved noticeCode = "webhook-saved"
webhookDeleted noticeCode = "webhook-deleted"
entrypointAdded noticeCode = "entrypoint-added"
entrypointDeleted noticeCode = "entrypoint-deleted"
entrypointActivated noticeCode = "entrypoint-activated"
entrypointDeactivated noticeCode = "entrypoint-deactivated"
targetAdded noticeCode = "target-added"
targetSaved noticeCode = "target-saved"
targetDeleted noticeCode = "target-deleted"
targetActivated noticeCode = "target-activated"
targetDeactivated noticeCode = "target-deactivated"
signedOut noticeCode = "signed-out"
)
// notice is the line templates/notice.html shows above a page to say
// what an action did.
type notice struct {
Text string
// Failed shows the line as an error: the action was refused.
Failed bool
}
// noticeFor returns the notice the request's URL names, or nil when it
// names none or an unknown code.
func noticeFor(r *http.Request) *notice {
n, ok := map[noticeCode]notice{
webhookCreated: {Text: "Webhook created."},
webhookSaved: {Text: "Webhook saved."},
webhookDeleted: {Text: "Webhook deleted."},
entrypointAdded: {Text: "Entrypoint added."},
entrypointDeleted: {Text: "Entrypoint deleted."},
entrypointActivated: {Text: "Entrypoint activated."},
entrypointDeactivated: {Text: "Entrypoint deactivated."},
targetAdded: {Text: "Target added."},
targetSaved: {Text: "Target saved."},
targetDeleted: {Text: "Target deleted."},
targetActivated: {Text: "Target activated."},
targetDeactivated: {Text: "Target deactivated."},
signedOut: {Text: "Signed out."},
replayQueued: {
Text: "Replay queued: a new delivery was created " +
"against the target's current configuration.",
},
replayTargetDeleted: {
Text: "Not replayed: the target this delivery was for " +
"has been deleted. Recreate the target, then replay.",
Failed: true,
},
replayTargetMissing: {
Text: "Not replayed: the target this delivery was for " +
"no longer exists.",
Failed: true,
},
replayTargetInactive: {
Text: "Not replayed: the target this delivery was for " +
"is deactivated. Activate it, then replay.",
Failed: true,
},
replayNotTerminal: {
Text: "Not replayed: this delivery has not finished yet.",
Failed: true,
},
replayInFlight: {
Text: "Not replayed: a delivery of this event to this " +
"target is already in flight.",
Failed: true,
},
resubmitQueued: {
Text: "Resubmitted: a new event was created from the " +
"stored one and queued to every active target.",
},
resubmitNoTargets: {
Text: "Resubmitted: a new event was created, but this " +
"source has no active targets, so nothing was queued.",
},
}[noticeCode(r.URL.Query().Get(noticeParam))]
if !ok {
return nil
}
return &n
}
// withNotice returns path with code added as its notice.
func withNotice(path string, code noticeCode) string {
return path + "?" + noticeParam + "=" + string(code)
}
+16 -28
View File
@@ -1,7 +1,6 @@
package handlers
import (
"context"
"net/http"
"github.com/go-chi/chi"
@@ -37,14 +36,14 @@ func (h *Handlers) HandlePasswordChange() http.HandlerFunc {
err := r.ParseForm()
if err != nil {
h.log.Error("failed to parse form", "error", err)
http.Error(w, "Bad request", http.StatusBadRequest)
h.renderError(w, r, http.StatusBadRequest)
return
}
successMessage, errorMessage, handled := h.applyPasswordChange(
r.Context(),
w,
r,
sessionUsername,
// PostFormValue, not FormValue: the credential must
// come from the body, never from the query string.
@@ -66,12 +65,12 @@ func (h *Handlers) HandlePasswordChange() http.HandlerFunc {
// applyPasswordChange verifies the current password and, on success,
// persists a fresh hash for the user, reusing the same helpers that
// bootstrap the admin user. It returns the success and error messages
// to display on the profile page. On an internal failure it writes a
// 500 response itself and returns handled=false, signalling the caller
// to display on the profile page. On an internal failure it writes the
// error page itself and returns handled=false, signalling the caller
// to stop without re-rendering the page.
func (h *Handlers) applyPasswordChange(
ctx context.Context,
w http.ResponseWriter,
r *http.Request,
username, currentPassword, newPassword, confirmPassword string,
) (string, string, bool) {
// This endpoint verifies one password and hashes another, at
@@ -79,15 +78,10 @@ func (h *Handlers) applyPasswordChange(
// endpoint uses. The bound is per hash, not per endpoint: leaving
// this path outside it would leave a hole in it. The slot is held
// across both hashes.
release, ok := h.mw.BeginPasswordVerification(ctx)
release, ok := h.mw.BeginPasswordVerification(r.Context())
if !ok {
h.log.Warn("password verification capacity exhausted")
http.Error(
w,
"The server is busy verifying credentials. "+
"Please try again.",
http.StatusServiceUnavailable,
)
h.renderError(w, r, http.StatusServiceUnavailable)
return "", "", false
}
@@ -103,7 +97,7 @@ func (h *Handlers) applyPasswordChange(
).First(&user).Error
if err != nil {
h.serverError(
w, "failed to load user for password change", err,
w, r, "failed to load user for password change", err,
)
return "", "", false
@@ -113,7 +107,7 @@ func (h *Handlers) applyPasswordChange(
currentPassword, user.Password,
)
if err != nil {
h.serverError(w, "failed to verify password", err)
h.serverError(w, r, "failed to verify password", err)
return "", "", false
}
@@ -132,7 +126,7 @@ func (h *Handlers) applyPasswordChange(
hashedPassword, err := database.HashPassword(newPassword)
if err != nil {
h.serverError(w, "failed to hash new password", err)
h.serverError(w, r, "failed to hash new password", err)
return "", "", false
}
@@ -141,7 +135,7 @@ func (h *Handlers) applyPasswordChange(
"password", hashedPassword,
).Error
if err != nil {
h.serverError(w, "failed to update password", err)
h.serverError(w, r, "failed to update password", err)
return "", "", false
}
@@ -162,7 +156,7 @@ func (h *Handlers) profileOwnerOrDeny(
) (string, string, bool) {
requestedUsername := chi.URLParam(r, "username")
if requestedUsername == "" {
http.NotFound(w, r)
h.renderError(w, r, http.StatusNotFound)
return "", "", false
}
@@ -172,7 +166,7 @@ func (h *Handlers) profileOwnerOrDeny(
// unexpected retrieval error.
sess, err := h.session.Get(r)
if err != nil {
h.serverError(w, "failed to get session", err)
h.serverError(w, r, "failed to get session", err)
return "", "", false
}
@@ -180,10 +174,7 @@ func (h *Handlers) profileOwnerOrDeny(
sessionUsername, ok := h.session.GetUsername(sess)
if !ok {
h.log.Error("authenticated session missing username")
http.Error(
w, "Internal server error",
http.StatusInternalServerError,
)
h.renderError(w, r, http.StatusInternalServerError)
return "", "", false
}
@@ -191,17 +182,14 @@ func (h *Handlers) profileOwnerOrDeny(
sessionUserID, ok := h.session.GetUserID(sess)
if !ok {
h.log.Error("authenticated session missing user ID")
http.Error(
w, "Internal server error",
http.StatusInternalServerError,
)
h.renderError(w, r, http.StatusInternalServerError)
return "", "", false
}
// Only allow users to act on their own profile.
if requestedUsername != sessionUsername {
http.Error(w, "Forbidden", http.StatusForbidden)
h.renderError(w, r, http.StatusForbidden)
return "", "", false
}
+4 -2
View File
@@ -128,7 +128,9 @@ func TestUserRoute_Unauthenticated_RedirectedByMiddleware(t *testing.T) {
var sess *session.Session
app := newTestApp(t, &log, &cfg, &sess)
var h *handlers.Handlers
app := newTestApp(t, &log, &cfg, &sess, &h)
app.RequireStart()
t.Cleanup(app.RequireStop)
@@ -139,7 +141,7 @@ func TestUserRoute_Unauthenticated_RedirectedByMiddleware(t *testing.T) {
router := chi.NewRouter()
router.Route("/user/{username}", func(r chi.Router) {
r.Use(mw.CSRF())
r.Use(mw.CSRF(h.HandleErrorPage(http.StatusForbidden)))
r.Use(mw.RequireAuth())
r.Get("/", func(w http.ResponseWriter, _ *http.Request) {
handlerReached = true
+3 -1
View File
@@ -411,7 +411,9 @@ func TestHandleSourceDelete_RemovesConfigAndEventDatabase(
h.HandleSourceDelete().ServeHTTP(w, req)
require.Equal(t, http.StatusSeeOther, w.Code)
assert.Equal(t, "/hooks", w.Header().Get("Location"))
assert.Equal(
t, "/hooks?notice=webhook-deleted", w.Header().Get("Location"),
)
assert.Equal(
t, int64(0),
-317
View File
@@ -1,317 +0,0 @@
package handlers_test
import (
"net/http"
"net/http/httptest"
"regexp"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/handlers"
"sneak.berlin/go/webhooker/internal/logger"
"sneak.berlin/go/webhooker/internal/session"
)
// failedHighlight is how the list marks a number of failed deliveries
// that is not zero.
const failedHighlight = `class="font-medium text-red-600"`
// listWebhook adds a webhook with the given name, owned by the test
// user.
func listWebhook(
t *testing.T, db *database.Database, name string,
) *database.Webhook {
t.Helper()
wh := &database.Webhook{UserID: deleteTestUserID, Name: name}
require.NoError(t, db.DB().Omit(clause.Associations).Create(wh).Error)
return wh
}
// renderWebhookList runs the real webhook list handler as the test user
// and returns the rendered page.
func renderWebhookList(
t *testing.T, h *handlers.Handlers, sess *session.Session,
) string {
t.Helper()
cookies := authenticatedCookies(
t, sess, deleteTestUserID, deleteTestUsername,
)
w := httptest.NewRecorder()
h.HandleSourceList().ServeHTTP(
w, getRequest(t, "/hooks", cookies, nil),
)
require.Equal(t, http.StatusOK, w.Code)
return w.Body.String()
}
// listCard returns one webhook's entry in a rendered webhook list, its
// markup as rendered and its text with the markup taken out and each
// run of space made one space.
func listCard(t *testing.T, page, webhookID string) (string, string) {
t.Helper()
_, card, found := strings.Cut(page, `href="/hook/`+webhookID+`"`)
require.True(t, found, "the list has no entry for %s", webhookID)
card, _, _ = strings.Cut(card, "</a>")
text := regexp.MustCompile(`<[^>]*>`).ReplaceAllString(card, " ")
return card, strings.Join(strings.Fields(text), " ")
}
// receiveEvents posts the given number of events to an entrypoint
// through the real receiver, and returns the webhook's event database
// and its events, oldest first.
func receiveEvents(
t *testing.T,
h *handlers.Handlers,
dbMgr *database.WebhookDBManager,
webhookID, path string,
count int,
) (*gorm.DB, []database.Event) {
t.Helper()
router := receiverRouter(h)
for range count {
require.Equal(t, http.StatusOK, postReceiver(t, router, path))
}
webhookDB, err := dbMgr.GetDB(webhookID)
require.NoError(t, err)
events := listEvents(t, webhookDB)
require.Len(t, events, count)
return webhookDB, events
}
// seedFailingWebhook adds a webhook with two entrypoints, one inactive,
// and four targets, one inactive. Three events each reach the three
// active targets. Two deliveries failed in the last 24 hours, one 30
// hours ago, and one was delivered. It returns the webhook and its
// newest event.
func seedFailingWebhook(
t *testing.T,
h *handlers.Handlers,
db *database.Database,
dbMgr *database.WebhookDBManager,
) (*database.Webhook, database.Event) {
t.Helper()
wh := listWebhook(t, db, "failing")
path := statsEntrypoint(t, db, wh.ID, true)
statsEntrypoint(t, db, wh.ID, false)
first := seedTarget(t, db, wh.ID, database.TargetTypeLog)
second := seedTarget(t, db, wh.ID, database.TargetTypeLog)
seedTarget(t, db, wh.ID, database.TargetTypeLog)
inactive := seedTarget(t, db, wh.ID, database.TargetTypeLog)
require.NoError(t, db.DB().Model(inactive).
Update("active", false).Error)
webhookDB, events := receiveEvents(t, h, dbMgr, wh.ID, path, 3)
now := time.Now()
statsFinish(t, webhookDB,
statsDelivery(t, webhookDB, events[0].ID, first.ID),
database.DeliveryStatusFailed, now.Add(-30*time.Hour))
statsFinish(t, webhookDB,
statsDelivery(t, webhookDB, events[1].ID, first.ID),
database.DeliveryStatusFailed, now.Add(-time.Hour))
statsFinish(t, webhookDB,
statsDelivery(t, webhookDB, events[2].ID, first.ID),
database.DeliveryStatusFailed, now.Add(-time.Minute))
statsFinish(t, webhookDB,
statsDelivery(t, webhookDB, events[2].ID, second.ID),
database.DeliveryStatusDelivered, now.Add(-time.Minute))
return wh, events[2]
}
// seedHealthyWebhook adds a webhook with one entrypoint and one target,
// both active, and two events, both delivered. It returns the webhook
// and its newest event.
func seedHealthyWebhook(
t *testing.T,
h *handlers.Handlers,
db *database.Database,
dbMgr *database.WebhookDBManager,
) (*database.Webhook, database.Event) {
t.Helper()
wh := listWebhook(t, db, "healthy")
path := statsEntrypoint(t, db, wh.ID, true)
target := seedTarget(t, db, wh.ID, database.TargetTypeLog)
webhookDB, events := receiveEvents(t, h, dbMgr, wh.ID, path, 2)
for _, ev := range events {
statsFinish(t, webhookDB,
statsDelivery(t, webhookDB, ev.ID, target.ID),
database.DeliveryStatusDelivered, time.Now())
}
return wh, events[1]
}
// lastEventText is how the list shows the arrival of an event.
func lastEventText(ev database.Event) string {
return ev.CreatedAt.UTC().Format("2006-01-02 15:04:05 UTC")
}
// TestSourceList_ShowsActivityOfEachWebhook checks the figures the list
// shows for a webhook with recent failures, a healthy one, a new one
// that has received no event, and one without an event database.
func TestSourceList_ShowsActivityOfEachWebhook(t *testing.T) {
t.Parallel()
var (
h *handlers.Handlers
sess *session.Session
db *database.Database
dbMgr *database.WebhookDBManager
)
app := newTestApp(t, &h, &sess, &db, &dbMgr)
app.RequireStart()
t.Cleanup(app.RequireStop)
failing, failingNewest := seedFailingWebhook(t, h, db, dbMgr)
healthy, healthyNewest := seedHealthyWebhook(t, h, db, dbMgr)
// Creating a webhook creates its event database.
fresh := listWebhook(t, db, "fresh")
require.NoError(t, dbMgr.CreateDB(fresh.ID))
quiet := listWebhook(t, db, "quiet")
page := renderWebhookList(t, h, sess)
card, text := listCard(t, page, failing.ID)
assert.Contains(t, text, "2 entrypoints, 1 inactive "+
"4 targets, 1 inactive "+
"3 events within retention "+
"Last event "+lastEventText(failingNewest)+" "+
"2 failed deliveries in the last 24 hours")
assert.Contains(t, card,
failedHighlight+">2 failed deliveries in the last 24 hours<")
card, text = listCard(t, page, healthy.ID)
assert.Contains(t, text, "1 entrypoint "+
"1 target "+
"2 events within retention "+
"Last event "+lastEventText(healthyNewest)+" "+
"0 failed deliveries in the last 24 hours")
assert.NotContains(t, text, "inactive")
assert.NotContains(t, card, failedHighlight)
card, text = listCard(t, page, fresh.ID)
assert.Contains(t, text, "0 entrypoints "+
"0 targets "+
"0 events within retention "+
"No events yet "+
"0 failed deliveries in the last 24 hours")
assert.NotContains(t, card, failedHighlight)
card, text = listCard(t, page, quiet.ID)
assert.Contains(t, text, "0 entrypoints "+
"0 targets "+
"0 events within retention "+
"No events yet "+
"0 failed deliveries in the last 24 hours")
assert.NotContains(t, card, failedHighlight)
assert.False(t, dbMgr.DBExists(quiet.ID),
"showing the list must not create an event database")
}
// TestSourceList_CountsOnlyEventsWithinRetention checks that once
// retention has removed one of a webhook's three events, the list
// counts the two still stored.
func TestSourceList_CountsOnlyEventsWithinRetention(t *testing.T) {
t.Parallel()
var (
h *handlers.Handlers
sess *session.Session
db *database.Database
dbMgr *database.WebhookDBManager
log *logger.Logger
)
app := newTestApp(t, &h, &sess, &db, &dbMgr, &log)
app.RequireStart()
t.Cleanup(app.RequireStop)
wh := &database.Webhook{
UserID: deleteTestUserID, Name: "pruned", RetentionDays: 14,
}
require.NoError(t, db.DB().Omit(clause.Associations).Create(wh).Error)
path := statsEntrypoint(t, db, wh.ID, true)
webhookDB, events := receiveEvents(t, h, dbMgr, wh.ID, path, 3)
statsAge(t, webhookDB, events[0].ID, time.Now().Add(-15*24*time.Hour))
statsPrune(t, db, dbMgr, log, webhookDB)
require.Len(t, listEvents(t, webhookDB), 2)
_, text := listCard(t, renderWebhookList(t, h, sess), wh.ID)
assert.Contains(t, text,
"1 entrypoint 0 targets 2 events within retention")
}
// TestSourceList_UnreadableEventDatabase checks that a webhook whose
// event database cannot be read says so in its entry instead of
// showing zeros, and that the rest of the list is still shown.
func TestSourceList_UnreadableEventDatabase(t *testing.T) {
t.Parallel()
var (
h *handlers.Handlers
sess *session.Session
db *database.Database
dbMgr *database.WebhookDBManager
)
app := newTestApp(t, &h, &sess, &db, &dbMgr)
app.RequireStart()
t.Cleanup(app.RequireStop)
broken := listWebhook(t, db, "broken")
statsEntrypoint(t, db, broken.ID, true)
brokenDB, err := dbMgr.GetDB(broken.ID)
require.NoError(t, err)
require.NoError(t,
brokenDB.Migrator().DropTable(&database.EventTotals{}))
quiet := listWebhook(t, db, "quiet")
page := renderWebhookList(t, h, sess)
_, text := listCard(t, page, broken.ID)
assert.Contains(t, text,
"1 entrypoint 0 targets The event figures could not be read.")
assert.NotContains(t, text, "events")
assert.NotContains(t, text, "failed")
_, text = listCard(t, page, quiet.ID)
assert.Contains(t, text, "No events yet")
}
+107 -227
View File
@@ -3,12 +3,10 @@ package handlers
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"slices"
"strconv"
"strings"
"time"
"github.com/go-chi/chi"
"github.com/google/uuid"
@@ -22,20 +20,9 @@ import (
type WebhookListItem struct {
database.Webhook
EntrypointCount int
InactiveEntrypointCount int
TargetCount int
InactiveTargetCount int
// EventCount is how many events the webhook holds, LastEventAt
// when the newest arrived (nil before the first), and
// FailedLast24Hours how many of its deliveries failed in the last
// 24 hours. When the webhook's event database could not be read,
// EventsUnreadable is set and these three are not known.
EventCount int64
LastEventAt *time.Time
FailedLast24Hours int64
EventsUnreadable bool
EntrypointCount int64
TargetCount int64
EventCount int64
}
// errMissingURL signals that a required URL was not provided.
@@ -162,29 +149,12 @@ func (h *Handlers) HandleSourceList() http.HandlerFunc {
"user_id = ?", userID,
).Order("created_at DESC").Find(&webhooks).Error
if err != nil {
h.log.Error(
"failed to list webhooks", "error", err,
)
http.Error(
w, "Internal server error",
http.StatusInternalServerError,
)
h.serverError(w, r, "failed to list webhooks", err)
return
}
items, err := h.buildWebhookListItems(webhooks)
if err != nil {
h.log.Error(
"failed to list webhooks", "error", err,
)
http.Error(
w, "Internal server error",
http.StatusInternalServerError,
)
return
}
items := h.buildWebhookListItems(webhooks)
data := map[string]any{
"Webhooks": items,
@@ -194,115 +164,36 @@ func (h *Handlers) HandleSourceList() http.HandlerFunc {
}
}
// buildWebhookListItems builds the list's entry for each webhook. It
// fails when the main database cannot be read. A webhook whose event
// database cannot be read is marked on its own entry, and the error is
// logged.
// buildWebhookListItems builds list items with counts.
func (h *Handlers) buildWebhookListItems(
webhooks []database.Webhook,
) ([]WebhookListItem, error) {
) []WebhookListItem {
items := make([]WebhookListItem, len(webhooks))
since := time.Now().Add(-longWindow)
for i := range webhooks {
item := &items[i]
item.Webhook = webhooks[i]
items[i].Webhook = webhooks[i]
var err error
h.db.DB().Model(&database.Entrypoint{}).Where(
"webhook_id = ?", webhooks[i].ID,
).Count(&items[i].EntrypointCount)
item.EntrypointCount, item.InactiveEntrypointCount, err =
h.countWithInactive(&database.Entrypoint{}, item.ID)
if err != nil {
return nil, err
}
h.db.DB().Model(&database.Target{}).Where(
"webhook_id = ?", webhooks[i].ID,
).Count(&items[i].TargetCount)
item.TargetCount, item.InactiveTargetCount, err =
h.countWithInactive(&database.Target{}, item.ID)
if err != nil {
return nil, err
}
// Opening an event database that does not exist would create
// it, and it would hold nothing to count.
if !h.dbMgr.DBExists(item.ID) {
continue
}
err = h.readListEventFigures(item, since)
if err != nil {
h.log.Error(
"failed to read webhook list figures",
"webhook_id", item.ID,
"error", err,
if h.dbMgr.DBExists(webhooks[i].ID) {
webhookDB, err := h.dbMgr.GetDB(
webhooks[i].ID,
)
item.EventsUnreadable = true
if err == nil {
webhookDB.Model(
&database.Event{},
).Count(&items[i].EventCount)
}
}
}
return items, nil
}
// countWithInactive returns how many entrypoints or targets, as model
// says, a webhook has, and how many of them are inactive.
func (h *Handlers) countWithInactive(
model any, webhookID string,
) (int, int, error) {
var active []bool
err := h.db.DB().Model(model).
Where("webhook_id = ?", webhookID).
Pluck("active", &active).Error
if err != nil {
return 0, 0, fmt.Errorf(
"reading active flags of webhook %s: %w", webhookID, err,
)
}
inactive := 0
for _, a := range active {
if !a {
inactive++
}
}
return len(active), inactive, nil
}
// readListEventFigures fills in the figures the list shows from the
// webhook's event database, with the statistics pane's own queries:
// the event count and last arrival from the event totals row, and the
// deliveries that failed since the given time from the deliveries'
// status index.
func (h *Handlers) readListEventFigures(
item *WebhookListItem, since time.Time,
) error {
webhookDB, err := h.dbMgr.GetDB(item.ID)
if err != nil {
return err
}
var totals database.EventTotals
err = webhookDB.Take(&totals).Error
if err != nil {
return fmt.Errorf("reading event totals: %w", err)
}
item.EventCount = totals.Events - totals.EventsRemoved
item.LastEventAt = totals.LastEventAt
byTarget, err := finishedByTarget(webhookDB, since)
if err != nil {
return err
}
for _, f := range byTarget {
item.FailedLast24Hours += f.Failed
}
return nil
return items
}
// HandleSourceCreate shows the form to create a new webhook.
@@ -352,9 +243,7 @@ func (h *Handlers) HandleSourceCreateSubmit() http.HandlerFunc {
// middleware, which runs before CSRF parses the form.
err := r.ParseForm()
if err != nil {
http.Error(
w, "Bad request", http.StatusBadRequest,
)
h.renderError(w, r, http.StatusBadRequest)
return
}
@@ -414,7 +303,7 @@ func (h *Handlers) createWebhookWithEntrypoint(
err := h.commitWebhook(webhook)
if err != nil {
h.serverError(w, "failed to create webhook", err)
h.serverError(w, r, "failed to create webhook", err)
return
}
@@ -433,7 +322,8 @@ func (h *Handlers) createWebhookWithEntrypoint(
)
http.Redirect(
w, r, "/hook/"+webhook.ID, http.StatusSeeOther,
w, r, withNotice("/hook/"+webhook.ID, webhookCreated),
http.StatusSeeOther,
)
}
@@ -491,7 +381,7 @@ func (h *Handlers) HandleSourceDetail() http.HandlerFunc {
"id = ? AND user_id = ?", sourceID, userID,
).First(&webhook).Error
if err != nil {
http.NotFound(w, r)
h.renderError(w, r, http.StatusNotFound)
return
}
@@ -523,7 +413,7 @@ func (h *Handlers) renderSourceDetail(
if h.dbMgr.DBExists(webhook.ID) {
webhookDB, err := h.dbMgr.GetDB(webhook.ID)
if err != nil {
h.serverError(w, "failed to get webhook database", err)
h.serverError(w, r, "failed to get webhook database", err)
return
}
@@ -532,7 +422,7 @@ func (h *Handlers) renderSourceDetail(
webhookDB, webhook.ID, singleHTTPTargetID(targets),
)
if err != nil {
h.serverError(w, "failed to load recent events", err)
h.serverError(w, r, "failed to load recent events", err)
return
}
@@ -586,7 +476,7 @@ func (h *Handlers) HandleSourceEdit() http.HandlerFunc {
"id = ? AND user_id = ?", sourceID, userID,
).First(&webhook).Error
if err != nil {
http.NotFound(w, r)
h.renderError(w, r, http.StatusNotFound)
return
}
@@ -621,7 +511,7 @@ func (h *Handlers) HandleSourceEditSubmit() http.HandlerFunc {
"id = ? AND user_id = ?", sourceID, userID,
).First(&webhook).Error
if err != nil {
http.NotFound(w, r)
h.renderError(w, r, http.StatusNotFound)
return
}
@@ -630,9 +520,7 @@ func (h *Handlers) HandleSourceEditSubmit() http.HandlerFunc {
// middleware, which runs before CSRF parses the form.
err = r.ParseForm()
if err != nil {
http.Error(
w, "Bad request", http.StatusBadRequest,
)
h.renderError(w, r, http.StatusBadRequest)
return
}
@@ -686,13 +574,14 @@ func (h *Handlers) applyWebhookEdit(
err := h.db.DB().Save(webhook).Error
if err != nil {
h.serverError(w, "failed to update webhook", err)
h.serverError(w, r, "failed to update webhook", err)
return
}
http.Redirect(
w, r, "/hook/"+webhook.ID, http.StatusSeeOther,
w, r, withNotice("/hook/"+webhook.ID, webhookSaved),
http.StatusSeeOther,
)
}
@@ -716,7 +605,7 @@ func (h *Handlers) HandleSourceDelete() http.HandlerFunc {
"id = ? AND user_id = ?", sourceID, userID,
).First(&webhook).Error
if err != nil {
http.NotFound(w, r)
h.renderError(w, r, http.StatusNotFound)
return
}
@@ -743,7 +632,7 @@ func (h *Handlers) deleteWebhookResources(
// be removed by hand; deleted history cannot be recovered.
err := h.commitWebhookDeletion(&webhook)
if err != nil {
h.serverError(w, "failed to delete webhook", err)
h.serverError(w, r, "failed to delete webhook", err)
return
}
@@ -769,13 +658,15 @@ func (h *Handlers) deleteWebhookResources(
// redirecting as though everything succeeded: the file
// needs removing by hand, and the logged error names it.
h.serverError(
w, "failed to delete webhook event database", err,
w, r, "failed to delete webhook event database", err,
)
return
}
http.Redirect(w, r, "/hooks", http.StatusSeeOther)
http.Redirect(
w, r, withNotice("/hooks", webhookDeleted), http.StatusSeeOther,
)
}
// commitWebhookDeletion soft-deletes a webhook's entrypoints,
@@ -913,7 +804,7 @@ func (h *Handlers) ownedWebhook(
"id = ? AND user_id = ?", sourceID, userID,
).First(&webhook).Error
if err != nil {
http.NotFound(w, r)
h.renderError(w, r, http.StatusNotFound)
return database.Webhook{}, false
}
@@ -935,7 +826,7 @@ func (h *Handlers) HandleSourceLogs() http.HandlerFunc {
// Without the map every delivery renders through a
// zero redactor, so failing the page is the only
// safe answer.
h.serverError(w, "failed to load targets", err)
h.serverError(w, r, "failed to load targets", err)
return
}
@@ -943,7 +834,7 @@ func (h *Handlers) HandleSourceLogs() http.HandlerFunc {
page := h.parsePage(r)
evts, total, ok := h.loadEventsWithDeliveries(
w, webhook, targets, page,
w, r, webhook, targets, page,
)
if !ok {
return
@@ -954,31 +845,16 @@ func (h *Handlers) HandleSourceLogs() http.HandlerFunc {
totalPages++
}
// The banner a replay or resubmit POST redirected back
// with. The message comes from a fixed set keyed by the
// outcome code, never from the query string itself.
replayMsg, replayOK := replayOutcome(
r.URL.Query().Get(replayOutcomeParam),
)
resubmitMsg, resubmitOK := resubmitOutcome(
r.URL.Query().Get(resubmitOutcomeParam),
)
data := map[string]any{
tmplKeyWebhook: &webhook,
"Events": evts,
"ReplayMessage": replayMsg,
"ReplayQueued": replayOK,
"ResubmitMessage": resubmitMsg,
"ResubmitQueued": resubmitOK,
"Page": page,
"TotalPages": totalPages,
"TotalEvents": total,
"HasPrev": page > 1,
"HasNext": page < totalPages,
"PrevPage": page - 1,
"NextPage": page + 1,
tmplKeyWebhook: &webhook,
"Events": evts,
"Page": page,
"TotalPages": totalPages,
"TotalEvents": total,
"HasPrev": page > 1,
"HasNext": page < totalPages,
"PrevPage": page - 1,
"NextPage": page + 1,
}
h.renderTemplate(w, r, "source_logs.html", data)
@@ -1053,6 +929,7 @@ func (h *Handlers) parsePage(r *http.Request) int {
// caller must then render nothing further.
func (h *Handlers) loadEventsWithDeliveries(
w http.ResponseWriter,
r *http.Request,
webhook database.Webhook,
targetMap map[string]eventLogTarget,
page int,
@@ -1066,7 +943,7 @@ func (h *Handlers) loadEventsWithDeliveries(
webhookDB, err := h.dbMgr.GetDB(webhook.ID)
if err != nil {
h.serverError(
w, "failed to get webhook database", err,
w, r, "failed to get webhook database", err,
)
return nil, 0, false
@@ -1103,7 +980,7 @@ func (h *Handlers) loadEventsWithDeliveries(
)
if err != nil {
h.serverError(
w, "failed to load delivery attempts", err,
w, r, "failed to load delivery attempts", err,
)
return nil, 0, false
@@ -1112,7 +989,7 @@ func (h *Handlers) loadEventsWithDeliveries(
resubmits, err := resubmitCounts(webhookDB, eventIDs)
if err != nil {
h.serverError(
w, "failed to count event resubmissions", err,
w, r, "failed to count event resubmissions", err,
)
return nil, 0, false
@@ -1335,7 +1212,7 @@ func (h *Handlers) HandleEntrypointCreate() http.HandlerFunc {
"id = ? AND user_id = ?", sourceID, userID,
).First(&webhook).Error
if err != nil {
http.NotFound(w, r)
h.renderError(w, r, http.StatusNotFound)
return
}
@@ -1344,9 +1221,7 @@ func (h *Handlers) HandleEntrypointCreate() http.HandlerFunc {
// middleware, which runs before CSRF parses the form.
err = r.ParseForm()
if err != nil {
http.Error(
w, "Bad request", http.StatusBadRequest,
)
h.renderError(w, r, http.StatusBadRequest)
return
}
@@ -1362,13 +1237,14 @@ func (h *Handlers) HandleEntrypointCreate() http.HandlerFunc {
err = h.db.DB().Create(entrypoint).Error
if err != nil {
h.serverError(w, "failed to create entrypoint", err)
h.serverError(w, r, "failed to create entrypoint", err)
return
}
http.Redirect(
w, r, "/hook/"+webhook.ID, http.StatusSeeOther,
w, r, withNotice("/hook/"+webhook.ID, entrypointAdded),
http.StatusSeeOther,
)
}
}
@@ -1393,7 +1269,7 @@ func (h *Handlers) HandleTargetCreate() http.HandlerFunc {
"id = ? AND user_id = ?", sourceID, userID,
).First(&webhook).Error
if err != nil {
http.NotFound(w, r)
h.renderError(w, r, http.StatusNotFound)
return
}
@@ -1402,9 +1278,7 @@ func (h *Handlers) HandleTargetCreate() http.HandlerFunc {
// middleware, which runs before CSRF parses the form.
err = r.ParseForm()
if err != nil {
http.Error(
w, "Bad request", http.StatusBadRequest,
)
h.renderError(w, r, http.StatusBadRequest)
return
}
@@ -1475,13 +1349,14 @@ func (h *Handlers) processTargetCreate(
err = h.db.DB().Create(target).Error
if err != nil {
h.serverError(w, "failed to create target", err)
h.serverError(w, r, "failed to create target", err)
return
}
http.Redirect(
w, r, "/hook/"+webhook.ID, http.StatusSeeOther,
w, r, withNotice("/hook/"+webhook.ID, targetAdded),
http.StatusSeeOther,
)
}
@@ -1569,7 +1444,7 @@ func (h *Handlers) buildTargetConfig(
case database.TargetTypeSlack:
return h.buildSlackTargetConfig(w, r, in.URL)
case database.TargetTypeDatabase:
return h.buildDatabaseTargetConfig(w, in.Expiry)
return h.buildDatabaseTargetConfig(w, r, in.Expiry)
case database.TargetTypeLog:
return "", nil
default:
@@ -1619,7 +1494,7 @@ func (h *Handlers) buildHTTPTargetConfig(
return "", err
}
return marshalTargetConfig(w, delivery.HTTPTargetConfig{
return h.marshalTargetConfig(w, r, delivery.HTTPTargetConfig{
URL: in.URL,
Headers: headers,
Timeout: timeout,
@@ -1641,7 +1516,7 @@ func (h *Handlers) buildSlackTargetConfig(
return "", err
}
return marshalTargetConfig(w, delivery.SlackTargetConfig{
return h.marshalTargetConfig(w, r, delivery.SlackTargetConfig{
WebhookURL: targetURL,
})
}
@@ -1706,16 +1581,14 @@ func (h *Handlers) validateTargetURL(
// marshalTargetConfig serialises a target configuration for storage,
// writing a 500 itself if it cannot.
func marshalTargetConfig(
func (h *Handlers) marshalTargetConfig(
w http.ResponseWriter,
r *http.Request,
cfg any,
) (string, error) {
configBytes, err := json.Marshal(cfg)
if err != nil {
http.Error(
w, "Internal server error",
http.StatusInternalServerError,
)
h.serverError(w, r, "failed to encode target config", err)
return "", err
}
@@ -1731,6 +1604,7 @@ func marshalTargetConfig(
// expiry yields an empty config (the keep-forever default).
func (h *Handlers) buildDatabaseTargetConfig(
w http.ResponseWriter,
r *http.Request,
expiry string,
) (string, error) {
expiry = strings.TrimSpace(expiry)
@@ -1749,8 +1623,8 @@ func (h *Handlers) buildDatabaseTargetConfig(
return "", err
}
return marshalTargetConfig(
w, map[string]any{"expiry": expiry},
return h.marshalTargetConfig(
w, r, map[string]any{"expiry": expiry},
)
}
@@ -1760,6 +1634,7 @@ func (h *Handlers) HandleEntrypointDelete() http.HandlerFunc {
"entrypointID", &database.Entrypoint{},
"failed to delete entrypoint",
nil,
entrypointDeleted,
)
}
@@ -1772,18 +1647,21 @@ func (h *Handlers) HandleTargetDelete() http.HandlerFunc {
"targetID", &database.Target{},
"failed to delete target",
h.evictArchiveWriterIfUnused,
targetDeleted,
)
}
// deleteChildResource returns a handler that deletes a child
// resource (entrypoint or target) belonging to a webhook. The
// optional afterDelete hook runs with the webhook's id once the
// delete has succeeded, before the redirect.
// delete has succeeded, before the redirect, which carries done as
// its notice.
func (h *Handlers) deleteChildResource(
idParam string,
model any,
errMsg string,
afterDelete func(webhookID string),
done noticeCode,
) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
userID, ok := h.getUserID(r)
@@ -1804,7 +1682,7 @@ func (h *Handlers) deleteChildResource(
"id = ? AND user_id = ?", sourceID, userID,
).First(&webhook).Error
if err != nil {
http.NotFound(w, r)
h.renderError(w, r, http.StatusNotFound)
return
}
@@ -1814,11 +1692,7 @@ func (h *Handlers) deleteChildResource(
childID, webhook.ID,
).Delete(model)
if result.Error != nil {
h.log.Error(errMsg, "error", result.Error)
http.Error(
w, "Internal server error",
http.StatusInternalServerError,
)
h.serverError(w, r, errMsg, result.Error)
return
}
@@ -1829,7 +1703,7 @@ func (h *Handlers) deleteChildResource(
http.Redirect(
w, r,
"/hook/"+webhook.ID,
withNotice("/hook/"+webhook.ID, done),
http.StatusSeeOther,
)
}
@@ -1840,7 +1714,7 @@ func (h *Handlers) deleteChildResource(
func (h *Handlers) HandleEntrypointToggle() http.HandlerFunc {
return h.toggleChildResource(
"entrypointID",
func(webhookID, childID string) error {
func(webhookID, childID string) (bool, error) {
var ep database.Entrypoint
err := h.db.DB().Where(
@@ -1848,14 +1722,15 @@ func (h *Handlers) HandleEntrypointToggle() http.HandlerFunc {
childID, webhookID,
).First(&ep).Error
if err != nil {
return err
return false, err
}
ep.Active = !ep.Active
return h.db.DB().Save(&ep).Error
return ep.Active, h.db.DB().Save(&ep).Error
},
"failed to toggle entrypoint",
entrypointActivated, entrypointDeactivated,
)
}
@@ -1863,7 +1738,7 @@ func (h *Handlers) HandleEntrypointToggle() http.HandlerFunc {
func (h *Handlers) HandleTargetToggle() http.HandlerFunc {
return h.toggleChildResource(
"targetID",
func(webhookID, childID string) error {
func(webhookID, childID string) (bool, error) {
var tgt database.Target
err := h.db.DB().Where(
@@ -1871,23 +1746,27 @@ func (h *Handlers) HandleTargetToggle() http.HandlerFunc {
childID, webhookID,
).First(&tgt).Error
if err != nil {
return err
return false, err
}
tgt.Active = !tgt.Active
return h.db.DB().Save(&tgt).Error
return tgt.Active, h.db.DB().Save(&tgt).Error
},
"failed to toggle target",
targetActivated, targetDeactivated,
)
}
// toggleChildResource returns a handler that toggles the active
// state of a child resource belonging to a webhook.
// state of a child resource belonging to a webhook. toggleFn returns
// the new state, and the redirect carries activated or deactivated as
// its notice to match.
func (h *Handlers) toggleChildResource(
idParam string,
toggleFn func(webhookID, childID string) error,
toggleFn func(webhookID, childID string) (bool, error),
errMsg string,
activated, deactivated noticeCode,
) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
userID, ok := h.getUserID(r)
@@ -1908,25 +1787,26 @@ func (h *Handlers) toggleChildResource(
"id = ? AND user_id = ?", sourceID, userID,
).First(&webhook).Error
if err != nil {
http.NotFound(w, r)
h.renderError(w, r, http.StatusNotFound)
return
}
err = toggleFn(webhook.ID, childID)
active, err := toggleFn(webhook.ID, childID)
if err != nil {
h.log.Error(errMsg, "error", err)
http.Error(
w, "Internal server error",
http.StatusInternalServerError,
)
h.serverError(w, r, errMsg, err)
return
}
done := deactivated
if active {
done = activated
}
http.Redirect(
w, r,
"/hook/"+webhook.ID,
withNotice("/hook/"+webhook.ID, done),
http.StatusSeeOther,
)
}
+5 -6
View File
@@ -88,9 +88,7 @@ func (h *Handlers) HandleTargetEditSubmit() http.HandlerFunc {
// middleware, which runs before CSRF parses the form.
err := r.ParseForm()
if err != nil {
http.Error(
w, "Bad request", http.StatusBadRequest,
)
h.renderError(w, r, http.StatusBadRequest)
return
}
@@ -157,13 +155,14 @@ func (h *Handlers) applyTargetEdit(
err = h.db.DB().Save(target).Error
if err != nil {
h.serverError(w, "failed to update target", err)
h.serverError(w, r, "failed to update target", err)
return
}
http.Redirect(
w, r, "/hook/"+webhook.ID, http.StatusSeeOther,
w, r, withNotice("/hook/"+webhook.ID, targetSaved),
http.StatusSeeOther,
)
}
@@ -220,7 +219,7 @@ func (h *Handlers) ownedTarget(
chi.URLParam(r, "targetID"), webhook.ID,
).First(&target).Error
if err != nil {
http.NotFound(w, r)
h.renderError(w, r, http.StatusNotFound)
return database.Webhook{}, nil, false
}
+16 -3
View File
@@ -88,14 +88,14 @@ func (h *Handlers) processWebhookRequest(
headersJSON, err := json.Marshal(r.Header)
if err != nil {
h.serverError(w, "failed to serialize headers", err)
h.receiverError(w, "failed to serialize headers", err)
return
}
targets, err := h.loadActiveTargets(entrypoint.WebhookID)
if err != nil {
h.serverError(w, "failed to query targets", err)
h.receiverError(w, "failed to query targets", err)
return
}
@@ -196,7 +196,7 @@ func (h *Handlers) createAndDeliverEvent(
targets,
)
if err != nil {
h.serverError(w, "failed to store webhook event", err)
h.receiverError(w, "failed to store webhook event", err)
return
}
@@ -204,6 +204,19 @@ func (h *Handlers) createAndDeliverEvent(
h.finishWebhookResponse(w, event, entrypoint, tasks)
}
// receiverError logs an error and answers the sender with a plain-text
// 500. The receiver's answers are for programs, so it never sends the
// error page the web UI uses.
func (h *Handlers) receiverError(
w http.ResponseWriter, msg string, err error,
) {
h.log.Error(msg, "error", err)
http.Error(
w, "Internal server error",
http.StatusInternalServerError,
)
}
// eventSource carries the fields a new event is built from. The
// receiver fills it from the live request; the resubmit handler fills
// it from a stored event. Both then go through createAndFanOut, so an
+5 -3
View File
@@ -19,7 +19,7 @@ func CSRFToken(r *http.Request) string {
// key to sign a CSRF cookie and validates a masked token submitted via
// the "csrf_token" form field (or the "X-CSRF-Token" header) on
// POST/PUT/PATCH/DELETE requests. Requests with an invalid or missing
// token receive a 403 Forbidden response.
// token are logged and answered by forbidden, which must write the 403.
//
// The middleware detects the client-facing transport protocol
// per-request via reqtls.IsTLS, the single TLS predicate the session
@@ -36,7 +36,9 @@ func CSRFToken(r *http.Request) string {
// Two gorilla/csrf instances are maintained — one with Secure cookies
// (for TLS) and one without (for plaintext HTTP) — because the
// csrf.Secure option is set at creation time, not per-request.
func (m *Middleware) CSRF() func(http.Handler) http.Handler {
func (m *Middleware) CSRF(
forbidden http.Handler,
) func(http.Handler) http.Handler {
csrfErrorHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// CSRF is registered ahead of RequireAuth on every route
// group that uses it, so this WARN is reachable by an
@@ -57,7 +59,7 @@ func (m *Middleware) CSRF() func(http.Handler) http.Handler {
"remote_addr", r.RemoteAddr,
"reason", csrf.FailureReason(r),
)
http.Error(w, "Forbidden - invalid CSRF token", http.StatusForbidden)
forbidden.ServeHTTP(w, r)
})
key := m.session.GetKey()
+15 -9
View File
@@ -18,6 +18,12 @@ import (
// csrfCookieName is the gorilla/csrf cookie name.
const csrfCookieName = "_gorilla_csrf"
// forbidden stands in for the error page the server hands CSRF to
// answer a refused request with.
func forbidden(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusForbidden)
}
// csrfGetToken performs a GET request through the CSRF middleware
// and returns the token and cookies.
func csrfGetToken(
@@ -98,7 +104,7 @@ func TestCSRF_GETSetsToken(t *testing.T) {
var gotToken string
handler := m.CSRF()(http.HandlerFunc(
handler := m.CSRF(http.HandlerFunc(forbidden))(http.HandlerFunc(
func(_ http.ResponseWriter, r *http.Request) {
gotToken = middleware.CSRFToken(r)
},
@@ -120,7 +126,7 @@ func TestCSRF_POSTWithValidToken(t *testing.T) {
t.Parallel()
m, _ := testMiddleware(t, config.EnvironmentDev)
csrfMW := m.CSRF()
csrfMW := m.CSRF(http.HandlerFunc(forbidden))
getReq := httptest.NewRequestWithContext(
context.Background(),
@@ -152,7 +158,7 @@ func csrfPOSTWithoutTokenTest(
t.Helper()
m, _ := testMiddleware(t, env)
csrfMW := m.CSRF()
csrfMW := m.CSRF(http.HandlerFunc(forbidden))
// GET to establish the CSRF cookie
getHandler := csrfMW(http.HandlerFunc(
@@ -209,7 +215,7 @@ func TestCSRF_POSTWithInvalidToken(t *testing.T) {
t.Parallel()
m, _ := testMiddleware(t, config.EnvironmentDev)
csrfMW := m.CSRF()
csrfMW := m.CSRF(http.HandlerFunc(forbidden))
// GET to establish the CSRF cookie
getHandler := csrfMW(http.HandlerFunc(
@@ -265,7 +271,7 @@ func TestCSRF_GETDoesNotValidate(t *testing.T) {
var called bool
handler := m.CSRF()(http.HandlerFunc(
handler := m.CSRF(http.HandlerFunc(forbidden))(http.HandlerFunc(
func(_ http.ResponseWriter, _ *http.Request) {
called = true
},
@@ -328,7 +334,7 @@ func csrfTookStrictPath(
t.Helper()
m, _ := testMiddleware(t, env)
csrfMW := m.CSRF()
csrfMW := m.CSRF(http.HandlerFunc(forbidden))
newReq := func(method string) *http.Request {
r := httptest.NewRequestWithContext(
@@ -477,7 +483,7 @@ func TestCSRF_ProdMode_PlaintextHTTP_POSTWithValidToken(
t.Parallel()
m, _ := testMiddleware(t, config.EnvironmentProd)
csrfMW := m.CSRF()
csrfMW := m.CSRF(http.HandlerFunc(forbidden))
getReq := httptest.NewRequestWithContext(
context.Background(),
@@ -517,7 +523,7 @@ func TestCSRF_ProdMode_BehindProxy_POSTWithValidToken(
t.Parallel()
m, _ := testMiddleware(t, config.EnvironmentProd)
csrfMW := m.CSRF()
csrfMW := m.CSRF(http.HandlerFunc(forbidden))
getReq := httptest.NewRequestWithContext(
context.Background(),
@@ -562,7 +568,7 @@ func TestCSRF_ProdMode_DirectTLS_POSTWithValidToken(
t.Parallel()
m, _ := testMiddleware(t, config.EnvironmentProd)
csrfMW := m.CSRF()
csrfMW := m.CSRF(http.HandlerFunc(forbidden))
getReq := httptest.NewRequestWithContext(
context.Background(),
+3 -1
View File
@@ -260,7 +260,9 @@ func logSites() map[string]logSite {
) http.Handler {
t.Helper()
return m.CSRF()(unreachable(t))
return m.CSRF(http.HandlerFunc(forbidden))(
unreachable(t),
)
},
send: postNoToken,
wantStatus: http.StatusForbidden,
+37 -3
View File
@@ -109,7 +109,8 @@ func (w *recoverResponseWriter) Unwrap() http.ResponseWriter {
// Recoverer returns middleware that turns a handler panic into one
// structured ERROR record and a 500, rather than a dropped
// connection.
// connection. The 500 is page when page is not nil, and plain text
// when it is nil or when page panics before writing anything.
//
// It replaces chi's middleware.Recoverer, which does neither on a
// current Go release. chi v1.5.5's pretty-printer scans the stack for
@@ -136,9 +137,13 @@ func (w *recoverResponseWriter) Unwrap() http.ResponseWriter {
//
// Unlike http.Error on its own, it deletes any Set-Cookie the handler
// set before panicking, because a request that failed must not hand
// the client a credential; every other header is left to http.Error.
// the client a credential. It touches no other header: when page
// answers, every other header the handler set goes out with it, apart
// from any page sets itself; otherwise they are left to http.Error.
// See https://git.eeqj.de/sneak/webhooker/issues/193.
func (s *Middleware) Recoverer() func(http.Handler) http.Handler {
func (s *Middleware) Recoverer(
page http.Handler,
) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(
w http.ResponseWriter,
@@ -171,6 +176,14 @@ func (s *Middleware) Recoverer() func(http.Handler) http.Handler {
rw.Header().Del("Set-Cookie")
if page != nil {
s.servePage(rw, r, page)
}
if rw.committed {
return
}
http.Error(
rw,
http.StatusText(
@@ -185,6 +198,27 @@ func (s *Middleware) Recoverer() func(http.Handler) http.Handler {
}
}
// servePage answers with page. A panic in page itself is logged and
// recovered here, so the Recoverer can still send its plain 500.
func (s *Middleware) servePage(
w http.ResponseWriter,
r *http.Request,
page http.Handler,
) {
defer func() {
rvr := recover()
if rvr != nil {
s.log.Error("error page panic",
"panic", logfield.Truncate(
fmt.Sprint(rvr), maxPanicValueBytes,
),
)
}
}()
page.ServeHTTP(w, r)
}
// logPanic writes the record. Every field it can grow is truncated to
// a fixed budget, so MaxPanicLogLineBytes holds.
//
+58 -2
View File
@@ -76,7 +76,7 @@ func newRecovererProbe(
// Logging outside so the recovered 500 is the status it records.
router.Use(chimw.RequestID)
router.Use(m.Logging())
router.Use(m.Recoverer())
router.Use(m.Recoverer(nil))
router.Get("/probe", handler)
serverErrors := new(bytes.Buffer)
@@ -637,7 +637,7 @@ func TestRecovererKeepsResponseControllerWorking(t *testing.T) {
m, _ := capturingMiddleware(t)
handler := m.Recoverer()(http.HandlerFunc(
handler := m.Recoverer(nil)(http.HandlerFunc(
func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte("chunk"))
@@ -672,3 +672,59 @@ func TestRecovererKeepsResponseControllerWorking(t *testing.T) {
assert.Equal(t, http.StatusOK, resp.StatusCode)
assert.Equal(t, "chunk", string(body))
}
// TestRecovererAnswersWithThePage covers a recoverer given a page:
// the panic is logged as before, and the 500 is that page.
func TestRecovererAnswersWithThePage(t *testing.T) {
t.Parallel()
m, logs := capturingMiddleware(t)
page := http.HandlerFunc(
func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte("the error page"))
},
)
w := httptest.NewRecorder()
m.Recoverer(page)(http.HandlerFunc(panicProbe)).ServeHTTP(
w, httptest.NewRequestWithContext(
t.Context(), http.MethodGet, "/", nil,
),
)
assert.Equal(t, http.StatusInternalServerError, w.Code)
assert.Equal(t, "the error page", w.Body.String())
assert.Contains(t, logs.String(), `"msg":"handler panic"`)
assert.Contains(t, logs.String(), panicMarker)
}
// TestRecovererFallsBackWhenThePagePanics covers a page that panics
// before writing anything: both panics are logged, and the client
// still gets the plain 500.
func TestRecovererFallsBackWhenThePagePanics(t *testing.T) {
t.Parallel()
m, logs := capturingMiddleware(t)
const pagePanic = "QQERRORPAGEPANICQQ"
page := http.HandlerFunc(
func(http.ResponseWriter, *http.Request) {
panic(pagePanic)
},
)
w := httptest.NewRecorder()
m.Recoverer(page)(http.HandlerFunc(panicProbe)).ServeHTTP(
w, httptest.NewRequestWithContext(
t.Context(), http.MethodGet, "/", nil,
),
)
assert.Equal(t, http.StatusInternalServerError, w.Code)
assert.Equal(t, "Internal Server Error\n", w.Body.String())
assert.Contains(t, logs.String(), panicMarker)
assert.Contains(t, logs.String(), pagePanic)
}
+238
View File
@@ -0,0 +1,238 @@
package server_test
import (
"context"
"net/http"
"net/http/httptest"
"net/url"
"strconv"
"testing"
"github.com/getsentry/sentry-go"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sneak.berlin/go/webhooker/internal/config"
"sneak.berlin/go/webhooker/internal/server"
)
// The link back the error page offers: to the webhook list for a
// signed-in user, to sign-in for anyone else.
const (
backToWebhooks = `<a href="/hooks" class="btn-secondary">` +
`Back to webhooks</a>`
backToSignIn = `<a href="/pages/login" class="btn-primary">` +
`Sign in</a>`
)
// assertErrorPage checks that w is the error page for status, in the
// normal layout, offering link.
func assertErrorPage(
t *testing.T,
w *httptest.ResponseRecorder,
status int,
link string,
) {
t.Helper()
body := w.Body.String()
assert.Equal(t, status, w.Code)
assert.Equal(
t, "text/html; charset=utf-8", w.Header().Get("Content-Type"),
)
assert.Equal(t, "no-store", w.Header().Get("Cache-Control"))
assert.Contains(t, body, `<nav class="app-bar"`)
assert.Contains(
t, body, strconv.Itoa(status)+" "+http.StatusText(status),
)
assert.Contains(t, body, link)
}
func TestErrorPage_DeletedWebhook(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
userID, _ := env.seedUser(t, "owner", "somepassword")
cookies := env.authCookies(t, userID, "owner")
wh := env.seedWebhook(t, userID)
require.NoError(t, env.db.DB().Delete(wh).Error)
w := env.get("/hook/"+wh.ID, cookies)
assertErrorPage(t, w, http.StatusNotFound, backToWebhooks)
}
func TestErrorPage_DeletedTarget(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
userID, _ := env.seedUser(t, "owner", "somepassword")
cookies := env.authCookies(t, userID, "owner")
wh := env.seedWebhook(t, userID)
tgt := env.seedTarget(t, wh.ID)
require.NoError(t, env.db.DB().Delete(tgt).Error)
w := env.get(
"/hook/"+wh.ID+"/targets/"+tgt.ID+"/edit", cookies,
)
assertErrorPage(t, w, http.StatusNotFound, backToWebhooks)
}
// TestErrorPage_ShowsNoNotice pins that a notice code in the URL of a
// page that fails is not shown above the error.
func TestErrorPage_ShowsNoNotice(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
userID, _ := env.seedUser(t, "owner", "somepassword")
cookies := env.authCookies(t, userID, "owner")
w := env.get("/hook/no-such-webhook?notice=webhook-saved", cookies)
assertErrorPage(t, w, http.StatusNotFound, backToWebhooks)
assert.NotContains(t, w.Body.String(), "Webhook saved.")
}
func TestErrorPage_UnknownPath(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
userID, _ := env.seedUser(t, "owner", "somepassword")
cookies := env.authCookies(t, userID, "owner")
assertErrorPage(
t, env.get("/no-such-page", nil),
http.StatusNotFound, backToSignIn,
)
// Outside every route group there is no form token, so the
// page leaves out the logout form rather than offer one that
// would be refused.
w := env.get("/no-such-page", cookies)
assertErrorPage(t, w, http.StatusNotFound, backToWebhooks)
assert.NotContains(t, w.Body.String(), `action="/pages/logout"`)
// Inside a route group the page has a token, and logout works.
wh := env.seedWebhook(t, userID)
w = env.get("/hook/"+wh.ID+"/no-such-page", cookies)
assertErrorPage(t, w, http.StatusNotFound, backToWebhooks)
assert.Contains(t, w.Body.String(), `action="/pages/logout"`)
}
func TestErrorPage_BadCSRFToken(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
form := url.Values{}
form.Set("username", "someone")
form.Set("password", "irrelevant")
form.Set("csrf_token", "not-a-token")
assertErrorPage(
t, env.post("/pages/login", form, nil),
http.StatusForbidden, backToSignIn,
)
userID, _ := env.seedUser(t, "owner", "somepassword")
cookies := env.authCookies(t, userID, "owner")
wh := env.seedWebhook(t, userID)
edit := url.Values{}
edit.Set("name", "renamed")
assertErrorPage(
t, env.post("/hook/"+wh.ID+"/edit", edit, cookies),
http.StatusForbidden, backToWebhooks,
)
}
// TestErrorPage_PanicOnAdminPage sends a panicking handler in an
// admin page route group through the real router, with error
// tracking on: the client gets the 500 error page, and the tracker
// still gets the panic, once. The same panic outside the admin page
// route groups keeps the plain 500.
func TestErrorPage_PanicOnAdminPage(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
transport := &captureTransport{}
opts := server.SentryClientOptionsForTest(
"https://public@sentry.invalid/1", "webhooker-test",
)
opts.Transport = transport
client, err := sentry.NewClient(opts)
require.NoError(t, err)
serve := func(router http.Handler, path string) *httptest.ResponseRecorder {
req := httptest.NewRequestWithContext(
sentry.SetHubOnContext(
context.Background(),
sentry.NewHub(client, sentry.NewScope()),
),
http.MethodGet, path, nil,
)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
return w
}
w := serve(
server.NewRouterWithPageProbeForTest(
env.log.Get(), env.cfg, env.mw, env.hnd,
true, panicProbeHandler,
),
server.PageProbePattern,
)
assertErrorPage(t, w, http.StatusInternalServerError, backToSignIn)
w = serve(
server.NewRouterWithProbeForTest(
env.log.Get(), env.cfg, env.mw, env.hnd,
true, panicProbeHandler,
),
server.ProbePattern,
)
assert.Equal(t, http.StatusInternalServerError, w.Code)
assert.Equal(t, "Internal Server Error\n", w.Body.String())
require.Len(t, transport.events, 2)
for _, event := range transport.events {
assert.Contains(t, marshalEvent(t, event), panicProbeMarker)
}
}
// TestErrorPage_ReceiverStaysPlain pins that the error page is for
// the web UI only: a sender posting to an entrypoint that does not
// exist still gets the plain-text answer.
func TestErrorPage_ReceiverStaysPlain(t *testing.T) {
t.Parallel()
// newTestEnv leaves the receiver rate limit at zero, which
// refuses every request before it reaches the receiver.
env := newTestEnvWithConfig(t, &config.Config{
DataDir: t.TempDir(),
Environment: config.EnvironmentDev,
ReceiverRateLimit: 10,
})
w := env.post(
"/h/0b8f3c1e-7d2a-4e6b-9f15-3a9c2d4e6f70", url.Values{}, nil,
)
assert.Equal(t, http.StatusNotFound, w.Code)
assert.Equal(t, "404 page not found\n", w.Body.String())
}
+37
View File
@@ -5,6 +5,7 @@ import (
"net/http"
"github.com/getsentry/sentry-go"
"github.com/go-chi/chi"
"sneak.berlin/go/webhooker/internal/config"
"sneak.berlin/go/webhooker/internal/handlers"
"sneak.berlin/go/webhooker/internal/middleware"
@@ -101,3 +102,39 @@ func NewRouterWithProbeForTest(
return s.router
}
// PageProbePattern is where NewRouterWithPageProbeForTest serves its
// probe: inside the /pages route group, the admin page group a
// request reaches without signing in.
const PageProbePattern = "/pages/probe"
// NewRouterWithPageProbeForTest is NewRouterWithProbeForTest with the
// probe added to the /pages route group once SetupRoutes has built
// it, so the probe runs behind that group's own middleware exactly as
// the group's real routes do.
func NewRouterWithPageProbeForTest(
log *slog.Logger,
cfg *config.Config,
mw *middleware.Middleware,
h *handlers.Handlers,
sentryEnabled bool,
probe http.HandlerFunc,
) http.Handler {
s := &Server{
log: log,
mw: mw,
h: h,
params: ServerParams{Config: cfg},
}
s.sentryEnabled.Store(sentryEnabled)
s.SetupRoutes()
for _, route := range s.router.Routes() {
pages, ok := route.SubRoutes.(chi.Router)
if ok && route.Pattern == "/pages/*" {
pages.Get("/probe", probe)
}
}
return s.router
}
+44 -13
View File
@@ -15,9 +15,10 @@ import (
// bytes) for form POST endpoints. 1 MB is generous for any form
// submission while preventing abuse from oversized payloads.
//
// Every route group below installs MaxBodySize(maxFormBodySize) as
// its FIRST middleware, ahead of both CSRF and RequireAuth. Both
// orderings are deliberate.
// The four admin page route groups below (/pages, /user/{username},
// /hooks and /hook/{sourceID}) install MaxBodySize(maxFormBodySize)
// right after their recoverer and error reporting, ahead of both CSRF
// and RequireAuth. Both orderings are deliberate.
//
// Ahead of CSRF because gorilla/csrf parses the form. The cap has to
// be installed before anything reads the body, or the parse runs
@@ -46,6 +47,14 @@ const requestTimeout = 60 * time.Second
// server's router.
func (s *Server) SetupRoutes() {
s.router = chi.NewRouter()
// An unknown path gets the error page. Registered before the
// global middleware, because chi wraps a not-found handler in the
// middleware already on its router, which would then run twice.
// The route groups below wrap it in their own middleware the same
// way; running theirs twice is harmless.
s.router.NotFound(s.h.HandleErrorPage(http.StatusNotFound))
s.setupGlobalMiddleware()
s.setupRoutes()
}
@@ -69,23 +78,33 @@ func (s *Server) setupGlobalMiddleware() {
// Panic recovery, deliberately here rather than first. It has to
// run inside every middleware that observes the response, so the
// 500 it writes is the status the access log records and the
// metrics count, and outside the sentryhttp handler below, whose
// metrics count, and outside the sentryhttp handler, whose
// Repanic option needs something further out to catch what it
// re-raises. chi's own middleware.Recoverer held the first slot
// until it was measured: on a current Go release it crashes
// inside its stack pretty-printer instead of recovering, so the
// connection dropped and the original panic was never reported.
// See https://git.eeqj.de/sneak/webhooker/issues/187.
s.router.Use(s.mw.Recoverer())
s.recoverPanics(s.router, nil)
}
// recoverPanics installs on r the recoverer, answering a panic with
// page (a plain 500 when page is nil), and inside it the Sentry error
// reporting (if SENTRY_DSN is set). Repanic is true so panics still
// bubble up to the recoverer.
//
// Each admin page route group installs its own, with the error page,
// as its first middleware. A panic there is logged, reported and
// answered inside the group and never reaches the global recoverer,
// which keeps the plain 500 for every other route.
func (s *Server) recoverPanics(r chi.Router, page http.Handler) {
r.Use(s.mw.Recoverer(page))
// Sentry error reporting (if SENTRY_DSN is set). Repanic is
// true so panics still bubble up to the Recoverer middleware
// registered immediately above.
if s.sentryEnabled.Load() {
sentryHandler := sentryhttp.New(sentryhttp.Options{
Repanic: true,
})
s.router.Use(sentryHandler.Handle)
r.Use(sentryHandler.Handle)
}
}
@@ -147,10 +166,13 @@ func (s *Server) setupRoutes() {
func (s *Server) setupPageRoutes() {
s.router.Route("/pages", func(r chi.Router) {
s.recoverPanics(
r, s.h.HandleErrorPage(http.StatusInternalServerError),
)
// MaxBodySize precedes CSRF and RequireAuth deliberately;
// see maxFormBodySize for why, and for what it costs.
r.Use(s.mw.MaxBodySize(maxFormBodySize))
r.Use(s.mw.CSRF())
r.Use(s.mw.CSRF(s.h.HandleErrorPage(http.StatusForbidden)))
r.Use(s.mw.NoCache())
// The login POST carries no pre-emptive rate limiter. Behind
@@ -169,10 +191,13 @@ func (s *Server) setupPageRoutes() {
func (s *Server) setupUserRoutes() {
s.router.Route("/user/{username}", func(r chi.Router) {
s.recoverPanics(
r, s.h.HandleErrorPage(http.StatusInternalServerError),
)
// MaxBodySize precedes CSRF and RequireAuth deliberately;
// see maxFormBodySize for why, and for what it costs.
r.Use(s.mw.MaxBodySize(maxFormBodySize))
r.Use(s.mw.CSRF())
r.Use(s.mw.CSRF(s.h.HandleErrorPage(http.StatusForbidden)))
r.Use(s.mw.NoCache())
r.Use(s.mw.RequireAuth())
r.Get("/", s.h.HandleProfile())
@@ -184,10 +209,13 @@ func (s *Server) setupUserRoutes() {
func (s *Server) setupSourceRoutes() {
s.router.Route("/hooks", func(r chi.Router) {
s.recoverPanics(
r, s.h.HandleErrorPage(http.StatusInternalServerError),
)
// MaxBodySize precedes CSRF and RequireAuth deliberately;
// see maxFormBodySize for why, and for what it costs.
r.Use(s.mw.MaxBodySize(maxFormBodySize))
r.Use(s.mw.CSRF())
r.Use(s.mw.CSRF(s.h.HandleErrorPage(http.StatusForbidden)))
r.Use(s.mw.NoCache())
r.Use(s.mw.RequireAuth())
r.Get("/", s.h.HandleSourceList())
@@ -196,10 +224,13 @@ func (s *Server) setupSourceRoutes() {
})
s.router.Route("/hook/{sourceID}", func(r chi.Router) {
s.recoverPanics(
r, s.h.HandleErrorPage(http.StatusInternalServerError),
)
// MaxBodySize precedes CSRF and RequireAuth deliberately;
// see maxFormBodySize for why, and for what it costs.
r.Use(s.mw.MaxBodySize(maxFormBodySize))
r.Use(s.mw.CSRF())
r.Use(s.mw.CSRF(s.h.HandleErrorPage(http.StatusForbidden)))
r.Use(s.mw.NoCache())
r.Use(s.mw.RequireAuth())
r.Get("/", s.h.HandleSourceDetail())
+86 -34
View File
@@ -260,6 +260,24 @@ func (e *testEnv) urlFrom(
return html.UnescapeString(match[1])
}
// requireNotice requires w to redirect to dest carrying the notice
// code, then renders that page and requires it to show text.
func (e *testEnv) requireNotice(
t *testing.T,
w *httptest.ResponseRecorder,
dest, code, text string,
cookies []*http.Cookie,
) {
t.Helper()
require.Equal(t, http.StatusSeeOther, w.Code)
require.Equal(t, dest+"?notice="+code, w.Header().Get("Location"))
page := e.get(w.Header().Get("Location"), cookies)
require.Equal(t, http.StatusOK, page.Code)
assert.Contains(t, page.Body.String(), text)
}
// authCookies forges an authenticated session for the given user.
func (e *testEnv) authCookies(
t *testing.T,
@@ -738,6 +756,31 @@ func TestPagesLogin_ReturnsToTheRequestedPage(t *testing.T) {
assert.Equal(t, asked, w.Header().Get("Location"))
}
// TestPagesLogout_SaysSignedOut signs out with the navbar's form and
// lands on the sign-in page, which says so.
func TestPagesLogout_SaysSignedOut(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
userID, _ := env.seedUser(t, "leaver", "somepassword")
token, cookies := env.csrfFrom(
t, "/hooks", env.authCookies(t, userID, "leaver"),
)
form := url.Values{}
form.Set("csrf_token", token)
w := env.post(
env.urlFrom(t, "/hooks", `action="(/pages/logout)"`, cookies),
form, cookies,
)
// The sign-in page is requested without the session cookie, which
// the logout told the browser to delete.
env.requireNotice(t, w, "/pages/login", "signed-out", "Signed out.", nil)
}
// --- /user/{username} group ---
// TestPasswordChange_OversizeBody_RejectedAndPasswordUnchanged
@@ -855,9 +898,9 @@ func TestHooks_ListAndNewWebhookForm(t *testing.T) {
require.NoError(t,
env.db.DB().Where("name = ?", "created").First(&created).Error,
)
assert.Equal(
t, "/hook/"+created.ID, w.Header().Get("Location"),
"creating a webhook should redirect to its page",
env.requireNotice(
t, w, "/hook/"+created.ID, "webhook-created", "Webhook created.",
cookies,
)
}
@@ -888,8 +931,7 @@ func TestHook_EditFormAndDelete(t *testing.T) {
env.urlFrom(t, editPage, `action="(/hook/[^/"]+/edit)"`, cookies),
form, cookies,
)
require.Equal(t, http.StatusSeeOther, w.Code)
assert.Equal(t, page, w.Header().Get("Location"))
env.requireNotice(t, w, page, "webhook-saved", "Webhook saved.", cookies)
var edited database.Webhook
@@ -903,16 +945,17 @@ func TestHook_EditFormAndDelete(t *testing.T) {
env.urlFrom(t, page, `action="(/hook/[^/"]+/delete)"`, cookies),
form, cookies,
)
require.Equal(t, http.StatusSeeOther, w.Code)
assert.Equal(t, "/hooks", w.Header().Get("Location"))
env.requireNotice(
t, w, "/hooks", "webhook-deleted", "Webhook deleted.", cookies,
)
assert.Equal(
t, http.StatusNotFound, env.get(page, cookies).Code,
"a deleted webhook's page should be gone",
)
}
// TestHook_EntrypointActions adds, deactivates and deletes an
// entrypoint with the forms on the webhook page, each submitted to
// TestHook_EntrypointActions adds, deactivates, activates and deletes
// an entrypoint with the forms on the webhook page, each submitted to
// the action and with the token the page rendered.
func TestHook_EntrypointActions(t *testing.T) {
t.Parallel()
@@ -930,16 +973,19 @@ func TestHook_EntrypointActions(t *testing.T) {
form.Set("csrf_token", token)
// submit posts the webhook page's form whose action pattern
// captures, and requires the redirect back to that page.
submit := func(pattern string) {
// captures, and requires the redirect back to that page with the
// notice code, and the page to show text.
submit := func(pattern, code, text string) {
t.Helper()
w := env.post(env.urlFrom(t, page, pattern, cookies), form, cookies)
require.Equal(t, http.StatusSeeOther, w.Code)
require.Equal(t, page, w.Header().Get("Location"))
env.requireNotice(t, w, page, code, text, cookies)
}
submit(`action="(/hook/[^/"]+/entrypoints)"`)
toggle := `action="(/hook/[^/"]+/entrypoints/[^/"]+/toggle)"`
submit(`action="(/hook/[^/"]+/entrypoints)"`,
"entrypoint-added", "Entrypoint added.")
var added database.Entrypoint
@@ -948,7 +994,7 @@ func TestHook_EntrypointActions(t *testing.T) {
)
require.True(t, added.Active)
submit(`action="(/hook/[^/"]+/entrypoints/[^/"]+/toggle)"`)
submit(toggle, "entrypoint-deactivated", "Entrypoint deactivated.")
var toggled database.Entrypoint
@@ -957,7 +1003,10 @@ func TestHook_EntrypointActions(t *testing.T) {
)
assert.False(t, toggled.Active, "the toggle should deactivate it")
submit(`action="(/hook/[^/"]+/entrypoints/[^/"]+/delete)"`)
submit(toggle, "entrypoint-activated", "Entrypoint activated.")
submit(`action="(/hook/[^/"]+/entrypoints/[^/"]+/delete)"`,
"entrypoint-deleted", "Entrypoint deleted.")
var left int64
@@ -968,8 +1017,8 @@ func TestHook_EntrypointActions(t *testing.T) {
// TestHook_TargetActions adds a target with the form on the webhook
// page, follows its Edit link to the target edit form and submits
// it, then deactivates and deletes it, every URL and token taken from
// the rendered pages.
// it, then deactivates, activates and deletes it, every URL and token
// taken from the rendered pages.
func TestHook_TargetActions(t *testing.T) {
t.Parallel()
@@ -984,27 +1033,29 @@ func TestHook_TargetActions(t *testing.T) {
// submit posts form, with the token, to the action pattern
// captures on the page at from, and requires the redirect back to
// the webhook page.
submit := func(from, pattern string, form url.Values) {
// the webhook page with the notice code, and that page to show
// text.
submit := func(from, pattern string, form url.Values, code, text string) {
t.Helper()
form.Set("csrf_token", token)
w := env.post(env.urlFrom(t, from, pattern, cookies), form, cookies)
require.Equal(t, http.StatusSeeOther, w.Code)
require.Equal(t, page, w.Header().Get("Location"))
env.requireNotice(t, w, page, code, text, cookies)
}
toggle := `action="(/hook/[^/"]+/targets/[^/"]+/toggle)"`
submit(page, `action="(/hook/[^/"]+/targets)"`, url.Values{
"name": {"added"},
"type": {string(database.TargetTypeLog)},
})
}, "target-added", "Target added.")
editPage := env.urlFrom(
t, page, `href="(/hook/[^/"]+/targets/[^/"]+/edit)"`, cookies,
)
submit(editPage, `action="(/hook/[^/"]+/targets/[^/"]+/edit)"`,
url.Values{"name": {"renamed"}})
url.Values{"name": {"renamed"}}, "target-saved", "Target saved.")
var edited database.Target
@@ -1014,8 +1065,8 @@ func TestHook_TargetActions(t *testing.T) {
assert.Equal(t, "renamed", edited.Name)
require.True(t, edited.Active)
submit(page, `action="(/hook/[^/"]+/targets/[^/"]+/toggle)"`,
url.Values{})
submit(page, toggle, url.Values{},
"target-deactivated", "Target deactivated.")
var toggled database.Target
@@ -1024,8 +1075,11 @@ func TestHook_TargetActions(t *testing.T) {
)
assert.False(t, toggled.Active, "the toggle should deactivate it")
submit(page, toggle, url.Values{},
"target-activated", "Target activated.")
submit(page, `action="(/hook/[^/"]+/targets/[^/"]+/delete)"`,
url.Values{})
url.Values{}, "target-deleted", "Target deleted.")
var left int64
@@ -1061,9 +1115,9 @@ func TestHook_ResubmitFromEventLog(t *testing.T) {
env.urlFrom(t, logsPath, `action="(/hook/[^"]+/resubmit)"`, cookies),
form, cookies,
)
require.Equal(t, http.StatusSeeOther, w.Code)
assert.Equal(
t, logsPath+"?resubmit=no-targets", w.Header().Get("Location"),
env.requireNotice(
t, w, logsPath, "resubmit-no-targets",
"this source has no active targets", cookies,
)
webhookDB, err := env.dbMgr.GetDB(wh.ID)
@@ -1299,10 +1353,8 @@ func TestDeliveryReplay_PostOnlyAndCSRFProtected(t *testing.T) {
html.UnescapeString(action[1]), form, cookies,
)
require.Equal(t, http.StatusSeeOther, w.Code)
assert.Equal(
t, logsPath+"?replay=queued",
w.Header().Get("Location"),
env.requireNotice(
t, w, logsPath, "replay-queued", "Replay queued:", cookies,
)
assert.Equal(
t, int64(2), env.countDeliveries(t, wh.ID),
@@ -115,8 +115,8 @@ func TestVersion_EnclosingRepositoryIsNotUsed(t *testing.T) {
require.Equal(t, unknown, runScript(t, inner, nil))
}
// The Docker build has no git metadata, so the version arrives as an
// environment override. It wins over anything derivable.
// An explicit VERSION, such as the Dockerfile's build arg, wins over
// anything derivable.
func TestVersion_EnvironmentOverrideWins(t *testing.T) {
t.Parallel()
@@ -128,8 +128,8 @@ func TestVersion_EnvironmentOverrideWins(t *testing.T) {
}
// An empty VERSION is treated as unset rather than stamping an empty
// string: the Dockerfile's build arg has a non-empty default, but a
// caller exporting VERSION= must not produce a binary reporting "".
// string: a caller exporting VERSION= must not produce a binary
// reporting "".
func TestVersion_EmptyOverrideFallsBackToGit(t *testing.T) {
t.Parallel()
@@ -168,8 +168,8 @@ func TestMakefile_BuildComposesVersionAndExtraFlags(t *testing.T) {
}
// A caller can define VERSION as the empty string -- `make build
// VERSION=`, or a `--build-arg VERSION=` reaching the Dockerfile's `make
// build VERSION="$VERSION"`. script/version's own guard does not cover
// VERSION=`, or the Dockerfile's `make build VERSION="$VERSION"` when no
// VERSION build arg was given. script/version's own guard does not cover
// that: the value never passes through the script. Stamping "" would
// leave the binary reporting no version and the footer on "dev", which
// is the defect this package exists for.
@@ -231,7 +231,7 @@ func TestDockerfile_BuildsThroughTheMakeTarget(t *testing.T) {
require.NotContains(t, dockerfile, "go build",
"a raw go build bypasses the Makefile's -X flag")
require.Contains(t, dockerfile, "ARG VERSION=")
require.Contains(t, dockerfile, "ARG VERSION")
require.Contains(t, dockerfile,
`make build VERSION="$VERSION" GO_LDFLAGS='-extldflags "-static"'`)
}
+3 -3
View File
@@ -2,9 +2,9 @@
# script/docker: build the Docker image tagged with the project name.
# The tag comes from script/projectname.
#
# .dockerignore excludes .git/, so the builder stage cannot derive the
# version itself. It is resolved here, where the checkout is, and passed
# in as a build arg; without it the image would stamp itself "unknown".
# The version script/version resolves here goes in as the VERSION build
# arg, which takes precedence over what the build would derive from the
# .git in its context.
set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
+5 -7
View File
@@ -7,18 +7,16 @@
#
# Order of precedence:
#
# 1. $VERSION, if set and non-empty. This is how the value reaches a
# build that cannot derive it: .dockerignore excludes .git/, so the
# builder stage has no git metadata and the Dockerfile takes the
# value as a build arg instead.
# 1. $VERSION, if set and non-empty: an explicit value, such as the
# Dockerfile's VERSION build arg.
# 2. `git describe --tags --always --dirty` against this checkout. At
# a clean tagged commit that is exactly the tag; otherwise it
# carries the short SHA, the commit distance when a tag is
# reachable, and a -dirty suffix for uncommitted changes.
# 3. "unknown", for a tree with no git metadata and no $VERSION -- a
# source tarball, or `docker build .` with no --build-arg. That
# case must not fail the build and must not name a tag the tree may
# not be at, so it names nothing.
# source tarball, or a `docker build` with no .git in its context
# and no VERSION build arg. That case must not fail the build and
# must not name a tag the tree may not be at, so it names nothing.
#
# The git step insists the enclosing repository is this checkout, not
# merely some repository above it: an unpacked tarball sitting inside an
+1
View File
@@ -7,6 +7,7 @@
<body class="bg-gray-50 min-h-screen flex flex-col">
<div class="flex-grow">
{{template "navbar" .}}
{{template "notice" .}}
{{block "content" .}}{{end}}
</div>
{{template "footer" .}}
+15
View File
@@ -0,0 +1,15 @@
{{template "base" .}}
{{define "title"}}{{.StatusText}} - Webhooker{{end}}
{{define "content"}}
<div class="max-w-4xl mx-auto px-6 py-12">
<h1 class="text-2xl font-medium text-gray-900 mb-4">{{.Status}} {{.StatusText}}</h1>
<p class="text-gray-600 mb-6">{{.Message}}</p>
{{if .User}}
<a href="/hooks" class="btn-secondary">Back to webhooks</a>
{{else}}
<a href="/pages/login" class="btn-primary">Sign in</a>
{{end}}
</div>
{{end}}
+6
View File
@@ -26,11 +26,15 @@
</svg>
{{.User.Username}}
</a>
{{/* An error page can be served before a form token is issued,
and a logout without one is refused. */}}
{{if .CSRFToken}}
<form method="POST" action="/pages/logout" class="inline">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<button type="submit" class="btn-text">Logout</button>
</form>
{{end}}
{{end}}
</div>
</div>
@@ -40,11 +44,13 @@
{{if .User}}
<a href="/hooks" class="btn-text w-full text-left">Webhooks</a>
<a href="/user/{{.User.Username}}" class="btn-text w-full text-left">Profile</a>
{{if .CSRFToken}}
<form method="POST" action="/pages/logout">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<button type="submit" class="btn-text w-full text-left">Logout</button>
</form>
{{end}}
{{end}}
</div>
</div>
</nav>
+7
View File
@@ -0,0 +1,7 @@
{{define "notice"}}
{{with .Notice}}
<div class="max-w-6xl mx-auto px-6 pt-4">
<div class="{{if .Failed}}alert-error{{else}}alert-success{{end}}">{{.Text}}</div>
</div>
{{end}}
{{end}}
-8
View File
@@ -12,14 +12,6 @@
</div>
</div>
{{if .ReplayMessage}}
<div class="{{if .ReplayQueued}}alert-success{{else}}alert-error{{end}}">{{.ReplayMessage}}</div>
{{end}}
{{if .ResubmitMessage}}
<div class="{{if .ResubmitQueued}}alert-success{{else}}alert-error{{end}}">{{.ResubmitMessage}}</div>
{{end}}
<div class="card">
<div class="divide-y divide-gray-100">
{{range .Events}}
+4 -10
View File
@@ -27,16 +27,10 @@
</div>
<span class="badge-info">Retention: {{.RetentionLabel}}</span>
</div>
<div class="flex flex-wrap gap-6 mt-4 text-sm text-gray-500">
<span>{{.EntrypointCount}} entrypoint{{if ne .EntrypointCount 1}}s{{end}}{{if .InactiveEntrypointCount}}, {{.InactiveEntrypointCount}} inactive{{end}}</span>
<span>{{.TargetCount}} target{{if ne .TargetCount 1}}s{{end}}{{if .InactiveTargetCount}}, {{.InactiveTargetCount}} inactive{{end}}</span>
{{if .EventsUnreadable}}
<span class="text-red-600">The event figures could not be read.</span>
{{else}}
<span>{{.EventCount}} event{{if ne .EventCount 1}}s{{end}} within retention</span>
<span>{{with .LastEventAt}}Last event {{.UTC.Format "2006-01-02 15:04:05 UTC"}}{{else}}No events yet{{end}}</span>
<span class="{{if .FailedLast24Hours}}font-medium text-red-600{{end}}">{{.FailedLast24Hours}} failed deliver{{if eq .FailedLast24Hours 1}}y{{else}}ies{{end}} in the last 24 hours</span>
{{end}}
<div class="flex gap-6 mt-4 text-sm text-gray-500">
<span>{{.EntrypointCount}} entrypoint{{if ne .EntrypointCount 1}}s{{end}}</span>
<span>{{.TargetCount}} target{{if ne .TargetCount 1}}s{{end}}</span>
<span>{{.EventCount}} event{{if ne .EventCount 1}}s{{end}}</span>
</div>
</a>
{{end}}