5 Commits
Author SHA1 Message Date
clawbot 6f67721188 Scope the entrypoint edit check's buttons; fit the type row at 360px
check / check (push) Waiting to run
The browser test's entrypoint edit check clicked any Cancel and Save
on the page, which now also match the add target form's hidden
buttons, so the clicks timed out. It finds them inside the edit form.

The type choice takes p-2 and flex-1 in place of w-32, so it, Next
and Cancel share one row on a 360px-wide phone with "Database" shown
in full.

Model: opus-5-5
2026-10-02 20:06:02 +00:00
sneak b5c10dabea Give the target type choice a width the stylesheet defines
The type select used w-40, which the committed static/css/tailwind.css
has no rule for, so it took the full width and pushed Next and Cancel
onto the line below. It now uses w-32, as the old type select did, so
the type choice, Next and Cancel sit on one row.

Model: opus-5-5
2026-10-02 20:06:02 +00:00
clawbot f49f1fc9db Empty the add target form on Cancel; encoding failures stay a 500
The form's reason and values now come from the targetForm component,
loaded from the section's data attributes after a refusal and emptied
by Cancel, which also resets the form. Each type's fields used to be
recreated with the refused values written into the markup, so they
came back after Cancel. The browser test checks this after a refusal.

A target configuration that cannot be encoded is again a logged 500
with the generic error page, on the add and the edit path; only
refusals of submitted values come back on the form.

The README paragraph on the browser test is re-wrapped at 80 columns
and names Cancel at the type step.

Model: opus-5-5
2026-10-02 20:06:02 +00:00
clawbot 346374c923 Targets section: one Add, then a type and Next, then its fields (closes #370)
The webhook page's targets section lists only its targets until Add is
clicked. Add shows a choice of target type with Next; Next shows only
that type's fields, with Save and Cancel. The `database` and `log`
types have no URL field, and the `slack` form gains max retries.

A refused target now shows the webhook page again with the form open on
its type, the values entered and the reason, instead of a bare text
page. Target validation returns that message rather than writing the
response; newTarget validates a whole new target for reuse by the
new-webhook page. The edit page still answers a refusal in plain text.

Model: opus-5-5
2026-10-02 20:06:02 +00:00
clawbot 719d7013ee Give each event its own page and show bodies the same everywhere (closes #369)
check / check (push) Waiting to run
Each event now has its own page at /hook/ID/events/EVENTID, behind the login, showing its details, its whole body and every delivery; a resubmitted copy links to its original's page. The recent events on the webhook page link there and expand to show their bodies, only the newest expanded on load. One renderer and one template show a body the same way in the recent events, the event log and the event's page: whole up to 32 KiB, cut there in the two lists with links to the event's page and the download; JSON pretty-printed unless that would grow it past four times plus 1 KiB; over 200 lines in a scrolling box; a body holding NUL or control characters treated as binary and never dumped raw.

Model: opus-5-5
2026-10-02 22:00:42 +02:00
23 changed files with 1056 additions and 181 deletions
+13 -10
View File
@@ -1354,15 +1354,17 @@ empty form and no reason; the Copy button beside an entrypoint URL reads
"Copied" once clicked; an entrypoint's Edit button shows its edit form in place
of its description and hides until the form closes, Cancel hides the form and
drops what was typed, as does leaving the page and going back to it, and Save
changes the description; an event expands and collapses, and so do a delivery's
attempts inside it; and at phone width the menu button opens and closes the
mobile menu. It also fails if the browser reports a console warning or error, an
uncaught exception, or anything the policy refused. `make check` and the image
build lint it but do not run it, and `make test` leaves it out (its file is
built only with the `browser` build tag). Run it with `make test-browser` after
changing `templates/` or `static/js/`: that builds `Dockerfile.browser`, which
runs the test in a digest-pinned headless browser image, so the host needs no
browser.
changes the description; of the recent events on the webhook page only the
newest starts expanded, each expands and collapses, and Open leads to the
event's own page; an event in the event log expands and collapses, and so do a
delivery's attempts inside it; and at phone width the menu button opens and
closes the mobile menu. It also fails if the browser reports a console warning
or error, an uncaught exception, or anything the policy refused. `make check`
and the image build lint it but do not run it, and `make test` leaves it out
(its file is built only with the `browser` build tag). Run it with
`make test-browser` after changing `templates/` or `static/js/`: that builds
`Dockerfile.browser`, which runs the test in a digest-pinned headless browser
image, so the host needs no browser.
The package's tarball is committed as `3p/alpinejs-csp-3.14.9.tgz`, byte for
byte as the npm registry publishes it. It is a dependency, not this repo's build
@@ -2962,7 +2964,8 @@ returns to the page that was asked for.
| `POST` | `/hook/{id}/edit` | Edit webhook submission |
| `POST` | `/hook/{id}/delete` | Delete webhook |
| `GET` | `/hook/{id}/events` | Full Event Log |
| `GET` | `/hook/{id}/events/{eventID}/body` | Download an event's full stored body. The log page renders each body only up to its cap, so this is the only route that serves a whole one; it is offered wherever a body is shown truncated |
| `GET` | `/hook/{id}/events/{eventID}` | One event's own page: its details, its whole body and every delivery of it |
| `GET` | `/hook/{id}/events/{eventID}/body` | Download an event's stored body. The pages show a body as text, cut at 32 KiB in the recent events and the event log, and leave a binary one out, so this is the only route that serves the stored bytes; it is offered wherever a body is cut or binary |
| `POST` | `/hook/{id}/deliveries/{deliveryID}/replay` | Replay a finished delivery: creates a new delivery for the same event against the target's current configuration (30 per minute per bucket, then `429`) |
| `POST` | `/hook/{id}/events/{eventID}/resubmit` | Resubmit a stored event: creates a new event copying it and fans that out to every currently active target (30 per minute per bucket, then `429`) |
| `POST` | `/hook/{id}/entrypoints` | Add entrypoint to webhook |
+2 -2
View File
@@ -34,8 +34,8 @@ type Event struct {
ContentType string `json:"contentType"`
// BodyBytes is the size of Body in bytes, recorded when the event
// is stored so the recent events list can show it without reading
// the body.
// is stored, so that the recent events list, which reads only the
// start of each body, knows the whole body's size.
BodyBytes int64 `gorm:"not null" json:"bodyBytes"`
// ResubmittedFromID names the event this one was copied from by
+4 -3
View File
@@ -24,9 +24,10 @@ import (
const eventBodyQuery = "SELECT cast(body as blob) " +
"FROM events WHERE id = ? AND webhook_id = ? AND deleted_at IS NULL"
// HandleEventBodyDownload serves one event's stored body in
// full, which the event log page cannot: it caps each rendered
// body at maxRenderedBodyBytes.
// HandleEventBodyDownload serves one event's stored body byte
// for byte, which the pages do not: they show it as escaped
// text, cut at maxRenderedBodyBytes in the lists of events, and
// leave a binary one out.
//
// The bytes are attacker-supplied — anyone who can reach the
// public receiver chooses them — and this route hands them back
+168
View File
@@ -0,0 +1,168 @@
package handlers
import (
"bytes"
"encoding/json"
"errors"
"io"
"unicode"
"unicode/utf8"
)
// maxRenderedBodyBytes is the most of one event's body that the
// recent events on a webhook's page and the event log show; a larger
// body is cut there and shown whole only on the event's own page.
// Bodies come from the unauthenticated receiver under its 1 MB cap,
// and renderTemplate buffers a whole page before writing it, so a list
// of events cannot show every body whole.
const maxRenderedBodyBytes = 32 << 10
// maxInlineBodyLines is the most lines a body is shown at its full
// height with. A body with more lines, or larger than
// maxRenderedBodyBytes, is shown in a box of fixed height that
// scrolls, so that it does not make the page huge.
const maxInlineBodyLines = 200
// maxIndentDepth is how deeply a JSON body's objects and arrays may
// nest for it to be indented at all; a deeper one is shown as received.
// Each level indents every line inside it two more spaces, so 10 KB of
// nested brackets would indent to some 50 MB; within this depth a body
// grows at most 35 times.
const maxIndentDepth = 16
// A JSON body is shown pretty-printed only when that makes it at most
// maxIndentGrowth times its size plus indentAllowance bytes, and
// otherwise as received, so that indenting does not undo
// maxRenderedBodyBytes. The allowance keeps a small nested body
// pretty-printed.
const (
maxIndentGrowth = 4
indentAllowance = 1 << 10
)
// jsonIndent is the indent of a pretty-printed JSON body.
const jsonIndent = " "
// BodyView is an event's body as the pages show it. newBodyView
// decides it and templates/event_body.html shows it, the same way in
// the recent events on a webhook's page, in the event log and on the
// event's own page.
type BodyView struct {
// EventURL is the event's own page. The stored body downloads
// from EventURL/body.
EventURL string
// Text is the body as shown, pretty-printed when it is JSON.
Text string
// Size is the stored body's size in bytes, and ShownBytes how
// many of them Text holds when Cut.
Size int64
ShownBytes int
// Cut reports that Text is only the start of the body.
Cut bool
// Binary reports a body that is not text. It is not shown.
Binary bool
// Scroll reports a body to show in a box that scrolls.
Scroll bool
}
// newBodyView decides how to show an event's body. body is the
// stored body, or its first maxRenderedBodyBytes when only those were
// read, and size is the stored body's size.
func newBodyView(eventURL string, body []byte, size int64) BodyView {
v := BodyView{EventURL: eventURL, Size: size}
if size > int64(len(body)) {
v.Cut = true
body = trimPartialRune(body)
v.ShownBytes = len(body)
}
// html/template shows invalid UTF-8 as replacement characters,
// and a browser shows a control character other than tab, line
// feed and carriage return as a box or not at all, so a body
// holding either is not text.
isControl := func(r rune) bool {
return unicode.IsControl(r) && r != '\t' && r != '\n' && r != '\r'
}
if !utf8.Valid(body) || bytes.IndexFunc(body, isControl) >= 0 {
v.Binary = true
return v
}
// A cut JSON document is no longer valid JSON.
if !v.Cut {
body = indentJSON(body)
}
// The page shows a carriage return, a line feed, or the two
// together as one line break. A final one ends the last line
// rather than starting another.
text := bytes.TrimSuffix(body, []byte("\n"))
text = bytes.TrimSuffix(text, []byte("\r"))
breaks := bytes.Count(text, []byte("\n")) + bytes.Count(text, []byte("\r")) -
bytes.Count(text, []byte("\r\n"))
lines := breaks + 1
v.Text = string(body)
v.Scroll = lines > maxInlineBodyLines || size > maxRenderedBodyBytes
return v
}
// indentJSON returns body pretty-printed when it is a JSON document,
// and unchanged when it is not, nests deeper than maxIndentDepth, or
// would grow past maxIndentGrowth times its size plus indentAllowance
// bytes.
func indentJSON(body []byte) []byte {
if !json.Valid(body) || !indentFits(body) {
return body
}
var out bytes.Buffer
err := json.Indent(&out, body, "", jsonIndent)
if err != nil || out.Len() > maxIndentGrowth*len(body)+indentAllowance {
return body
}
return out.Bytes()
}
// indentFits reports whether the objects and arrays of the JSON
// document body nest at most maxIndentDepth deep.
func indentFits(body []byte) bool {
depth := 0
dec := json.NewDecoder(bytes.NewReader(body))
// A number too large for a float64 is still valid JSON.
dec.UseNumber()
for {
tok, err := dec.Token()
if errors.Is(err, io.EOF) {
return true
}
if err != nil {
return false
}
switch tok {
case json.Delim('{'), json.Delim('['):
depth++
if depth > maxIndentDepth {
return false
}
case json.Delim('}'), json.Delim(']'):
depth--
}
}
}
+176
View File
@@ -0,0 +1,176 @@
package handlers_test
import (
"strings"
"testing"
"github.com/stretchr/testify/assert"
"sneak.berlin/go/webhooker/internal/handlers"
)
// bodyView is how the pages would show body, stored whole.
func bodyView(body string) handlers.BodyView {
return handlers.NewBodyViewForTest([]byte(body), int64(len(body)))
}
// lines is n lines of text, without a newline after the last.
func lines(n int) string {
return strings.TrimSuffix(strings.Repeat("line\n", n), "\n")
}
// TestNewBodyView_FormatsValidJSON proves a JSON body is shown
// pretty-printed, whatever its content type, with its keys in
// the order they arrived.
func TestNewBodyView_FormatsValidJSON(t *testing.T) {
t.Parallel()
v := bodyView(`{"b":1,"a":[true,null,"x"],"c":{}}`)
assert.Equal(t, []string{
`{`,
` "b": 1,`,
` "a": [`,
` true,`,
` null,`,
` "x"`,
` ],`,
` "c": {}`,
`}`,
}, strings.Split(v.Text, "\n"))
assert.False(t, v.Scroll)
}
// TestNewBodyView_FormatsNestedJSON proves a small document with a
// few levels of nesting is pretty-printed.
func TestNewBodyView_FormatsNestedJSON(t *testing.T) {
t.Parallel()
v := bodyView(`{"data":[[1,2,3],[4,5,6]]}`)
assert.Equal(t, []string{
`{`,
` "data": [`,
` [`,
` 1,`,
` 2,`,
` 3`,
` ],`,
` [`,
` 4,`,
` 5,`,
` 6`,
` ]`,
` ]`,
`}`,
}, strings.Split(v.Text, "\n"))
}
// TestNewBodyView_InvalidJSONAsReceived proves a body that is not
// a JSON document is shown exactly as it arrived.
func TestNewBodyView_InvalidJSONAsReceived(t *testing.T) {
t.Parallel()
for _, body := range []string{
`{"a":1,`,
`{"a":1} {"b":2}`,
"plain text\n indented",
} {
assert.Equal(t, body, bodyView(body).Text)
}
}
// TestNewBodyView_DeepJSONAsReceived proves a JSON body nested
// more than 16 levels deep is shown as it arrived. 10 KB of nested
// arrays would indent to some 50 MB.
func TestNewBodyView_DeepJSONAsReceived(t *testing.T) {
t.Parallel()
nested := func(depth int) string {
return strings.Repeat("[", depth) + "1" + strings.Repeat("]", depth)
}
assert.NotEqual(t, nested(16), bodyView(nested(16)).Text)
assert.Equal(t, nested(17), bodyView(nested(17)).Text)
body := strings.Repeat("[", 5000) + strings.Repeat("]", 5000)
assert.Equal(t, body, bodyView(body).Text)
}
// TestNewBodyView_GrowingJSONAsReceived proves a JSON body that
// pretty-printing would make more than four times its size plus 1 KiB
// is shown as it arrived, however shallow: each short element eight
// levels deep gets a line indented sixteen spaces.
func TestNewBodyView_GrowingJSONAsReceived(t *testing.T) {
t.Parallel()
numbers := func(n int) string {
return strings.Repeat("[", 8) +
strings.TrimSuffix(strings.Repeat("1,", n), ",") +
strings.Repeat("]", 8)
}
assert.NotEqual(t, numbers(10), bodyView(numbers(10)).Text)
assert.Equal(t, numbers(1000), bodyView(numbers(1000)).Text)
}
// TestNewBodyView_ScrollsPast200Lines proves a body is shown at
// its full height up to 200 lines and in the scrolling box past
// them, counting the lines after formatting.
func TestNewBodyView_ScrollsPast200Lines(t *testing.T) {
t.Parallel()
assert.False(t, bodyView(lines(200)).Scroll)
assert.True(t, bodyView(lines(201)).Scroll)
// A final newline ends the last line rather than starting another.
assert.False(t, bodyView(lines(200)+"\n").Scroll)
assert.True(t, bodyView(lines(201)+"\n").Scroll)
// The page shows a carriage return, a line feed, or the two
// together as one line break.
assert.True(t, bodyView(strings.Repeat("line\r", 400)).Scroll)
assert.False(t, bodyView(strings.Repeat("line\r\n", 200)).Scroll)
// One line as received, 201 once formatted: the brackets and
// 199 elements.
numbers := "[" + strings.TrimSuffix(strings.Repeat("1,", 199), ",") + "]"
assert.NotContains(t, numbers, "\n")
assert.True(t, bodyView(numbers).Scroll)
}
// TestNewBodyView_LargeBodyScrolls proves a body larger than the
// cap of the lists of events is shown in the scrolling box
// however few lines it has, on the event's own page as in the
// lists.
func TestNewBodyView_LargeBodyScrolls(t *testing.T) {
t.Parallel()
assert.False(t, bodyView(strings.Repeat("x", bodyCap)).Scroll)
assert.True(t, bodyView(strings.Repeat("x", bodyCap+1)).Scroll)
}
// TestNewBodyView_BinaryNotShown proves a body that is not text
// is never shown: one that is not valid UTF-8, or that holds a
// control character other than tab, line feed and carriage return.
func TestNewBodyView_BinaryNotShown(t *testing.T) {
t.Parallel()
for _, body := range []string{
"\xff\xfe\xfd",
"a\x00b",
// A small protobuf message: valid UTF-8, but control bytes.
"\x08\x01\x12\x03abc",
"\x1b[31mred\x1b[0m",
"a\x7fb",
} {
v := bodyView(body)
assert.True(t, v.Binary, "%q", body)
assert.Empty(t, v.Text)
}
assert.False(t, bodyView("snow "+snowman).Binary)
assert.False(t, bodyView("a\tb\r\nc\n").Binary)
}
+74
View File
@@ -0,0 +1,74 @@
package handlers
import (
"net/http"
"github.com/go-chi/chi"
"sneak.berlin/go/webhooker/internal/database"
)
// HandleEventDetail shows one event on its own page: its details,
// its whole body and every delivery of it. The page reads the
// event's body whole, which the receiver caps at 1 MB.
func (h *Handlers) HandleEventDetail() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
webhook, ok := h.ownedWebhook(w, r)
if !ok {
return
}
if !h.dbMgr.DBExists(webhook.ID) {
h.renderError(w, r, http.StatusNotFound)
return
}
webhookDB, err := h.dbMgr.GetDB(webhook.ID)
if err != nil {
h.serverError(w, r, "failed to get webhook database", err)
return
}
var rows []eventLogRow
err = webhookDB.Model(&database.Event{}).
Select(eventColumns).
Where(
"id = ? AND webhook_id = ?",
chi.URLParam(r, "eventID"), webhook.ID,
).
Limit(1).
Find(&rows).Error
if err != nil {
h.serverError(w, r, "failed to load event", err)
return
}
if len(rows) == 0 {
h.renderError(w, r, http.StatusNotFound)
return
}
targets, err := h.loadTargetMap(webhook.ID)
if err != nil {
h.serverError(w, r, "failed to load targets", err)
return
}
views, ok := h.eventLogViews(
w, r, webhookDB, webhook.ID, rows, targets,
)
if !ok {
return
}
h.renderTemplate(w, r, "event_detail.html", map[string]any{
tmplKeyWebhook: &webhook,
"Event": views[0],
})
}
}
+152
View File
@@ -0,0 +1,152 @@
package handlers_test
import (
"context"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"testing"
"time"
"github.com/go-chi/chi"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm/clause"
"sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/handlers"
"sneak.berlin/go/webhooker/internal/session"
)
// serveEventPage runs the real event page handler as the test user
// for the given webhook and event ids.
func serveEventPage(
t *testing.T,
h *handlers.Handlers,
sess *session.Session,
webhookID, eventID string,
) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequestWithContext(
context.Background(),
http.MethodGet,
"/hook/"+webhookID+"/events/"+eventID,
nil,
)
for _, c := range authenticatedCookies(
t, sess, deleteTestUserID, deleteTestUsername,
) {
req.AddCookie(c)
}
rctx := chi.NewRouteContext()
rctx.URLParams.Add(paramSourceID, webhookID)
rctx.URLParams.Add(paramEventID, eventID)
req = req.WithContext(
context.WithValue(req.Context(), chi.RouteCtxKey, rctx),
)
w := httptest.NewRecorder()
h.HandleEventDetail().ServeHTTP(w, req)
return w
}
// TestHandleEventDetail_ShowsEventWholeWithDeliveries proves the
// event's page shows its details, its whole body even past the cap
// of the lists of events, pretty-printed and in the scrolling box,
// and each delivery with its status and attempts.
func TestHandleEventDetail_ShowsEventWholeWithDeliveries(t *testing.T) {
t.Parallel()
f := newRecentEventsFixture(t)
target := seedTarget(t, f.db, f.webhook.ID, database.TargetTypeHTTP)
const sentinel = "TAIL-SENTINEL-5b2e"
body := `{"pad":"` + strings.Repeat("x", 2*bodyCap) +
`","tail":"` + sentinel + `"}`
event := f.event(t, contentTypeJSON, body, time.Now())
f.attempt(t, f.delivery(
t, event, target.ID, database.DeliveryStatusFailed,
), http.StatusBadGateway, time.Second)
w := serveEventPage(t, f.h, f.sess, f.webhook.ID, event.ID)
require.Equal(t, http.StatusOK, w.Code)
page := w.Body.String()
assert.Contains(t, page, event.ID)
assert.Contains(t, page, contentTypeJSON)
assert.Contains(t, page, strconv.Itoa(len(body))+" bytes")
assert.Contains(t, page, "{\n &#34;pad&#34;: &#34;xxx")
assert.Contains(t, page, "&#34;tail&#34;: &#34;"+sentinel+"&#34;\n}")
assert.Contains(t, page, `style="max-height: 32rem; overflow-y: auto"`)
assert.NotContains(t, page, "Showing the first")
assert.Contains(t, page, target.Name)
assert.Contains(t, page, ">failed</span>")
assert.Contains(t, page, "Status: 502")
}
// TestHandleEventDetail_ResubmitLinks proves a resubmitted copy's
// page links to its original's page, and the original's page says
// it was resubmitted.
func TestHandleEventDetail_ResubmitLinks(t *testing.T) {
t.Parallel()
f := newRecentEventsFixture(t)
original := f.event(t, contentTypeJSON, "{}", time.Now())
cp := &database.Event{
WebhookID: f.webhook.ID,
Method: http.MethodPost,
Body: "{}",
ContentType: contentTypeJSON,
ResubmittedFromID: &original.ID,
}
require.NoError(t, f.webhookDB.Omit(clause.Associations).Create(cp).Error)
w := serveEventPage(t, f.h, f.sess, f.webhook.ID, cp.ID)
require.Equal(t, http.StatusOK, w.Code)
assert.Contains(
t, w.Body.String(),
`href="/hook/`+f.webhook.ID+`/events/`+original.ID+`"`,
)
w = serveEventPage(t, f.h, f.sess, f.webhook.ID, original.ID)
require.Equal(t, http.StatusOK, w.Code)
assert.Contains(t, w.Body.String(), "as 1 new event<")
}
// TestHandleEventDetail_UnknownEventNotFound proves the page is a
// 404 for an event that does not exist and for one that belongs to
// another webhook.
func TestHandleEventDetail_UnknownEventNotFound(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)
mine := seedWebhook(t, db)
theirs := seedWebhook(t, db)
seedEventWithBody(t, dbMgr, mine.ID, "{}")
elsewhere := seedEventWithBody(t, dbMgr, theirs.ID, "{}")
for _, id := range []string{"no-such-event", elsewhere.ID} {
w := serveEventPage(t, h, sess, mine.ID, id)
assert.Equal(t, http.StatusNotFound, w.Code, id)
}
}
+24 -51
View File
@@ -5,14 +5,6 @@ import (
"unicode/utf8"
)
// maxRenderedBodyBytes caps how many bytes of a stored event
// body reach the event log page. Bodies come from the
// unauthenticated receiver under the 1 MB ingest cap and
// renderTemplate buffers a whole page before writing it, so
// an uncapped page of paginationPerPage events is tens of
// megabytes of resident memory per concurrent viewer.
const maxRenderedBodyBytes = 8192
// eventLogColumns is the event log's projection. The casts to
// blob are load-bearing: they make substr and length count
// bytes rather than characters, so the cap bounds the page in
@@ -24,27 +16,23 @@ const eventLogColumns = "id, created_at, method, content_type, " +
"substr(cast(body as blob), 1, ?) AS body, " +
"length(cast(body as blob)) AS body_bytes"
// eventColumns is eventLogColumns for the event's own page, which
// shows the whole body.
const eventColumns = "id, created_at, method, content_type, " +
"resubmitted_from_id, " +
"cast(body as blob) AS body, " +
"length(cast(body as blob)) AS body_bytes"
// EventLogView is the display-safe projection of an event for
// the event log page, alongside DeliveryView and TargetView.
// It carries a capped body plus the true stored size, so the
// page can mark a body as truncated without ever holding the
// whole thing.
// the event log page and the event's own page, alongside
// DeliveryView and TargetView.
type EventLogView struct {
ID string
CreatedAt time.Time
Method string
ContentType string
// Body holds at most maxRenderedBodyBytes bytes of the
// stored body.
Body string
// BodyBytes is the true size of the stored body.
BodyBytes int64
// BodyTruncated reports that the stored body was larger
// than the cap, so the page owes the reader a marker.
BodyTruncated bool
Body BodyView
// ResubmittedFromID names the event this one was copied
// from, empty for an event that arrived on the receiver.
@@ -65,16 +53,10 @@ func (v EventLogView) ResubmittedFrom() bool {
return v.ResubmittedFromID != ""
}
// BodyShownBytes is how many body bytes the page is actually
// rendering, which the truncation marker reports beside the
// true size.
func (v EventLogView) BodyShownBytes() int {
return len(v.Body)
}
// eventLogRow is one row of the event log projection. Its
// body column arrives already cut to the cap by SQLite, with
// the true size beside it.
// eventLogRow is one row of the event log projection, or of
// eventColumns. In the event log its body column arrives
// already cut to the cap by SQLite, with the true size beside
// it.
type eventLogRow struct {
ID string
CreatedAt time.Time
@@ -85,31 +67,22 @@ type eventLogRow struct {
BodyBytes int64
}
// view projects a loaded row for rendering.
func (r *eventLogRow) view() EventLogView {
body := r.Body
truncated := r.BodyBytes > int64(len(body))
// Only a cut body can have been left mid-sequence by
// this query. A whole body is passed through exactly as
// stored, however malformed.
if truncated {
body = trimPartialRune(body)
}
// view projects a loaded row of the webhook's events for
// rendering.
func (r *eventLogRow) view(webhookID string) EventLogView {
var from string
if r.ResubmittedFromID != nil {
from = *r.ResubmittedFromID
}
return EventLogView{
ID: r.ID,
CreatedAt: r.CreatedAt,
Method: r.Method,
ContentType: r.ContentType,
Body: string(body),
BodyBytes: r.BodyBytes,
BodyTruncated: truncated,
ID: r.ID,
CreatedAt: r.CreatedAt,
Method: r.Method,
ContentType: r.ContentType,
Body: newBodyView(
"/hook/"+webhookID+"/events/"+r.ID, r.Body, r.BodyBytes,
),
ResubmittedFromID: from,
}
}
+31 -21
View File
@@ -16,7 +16,7 @@ import (
"sneak.berlin/go/webhooker/internal/session"
)
// bodyCap is the number of body bytes the event log page is
// bodyCap is the number of body bytes the lists of events are
// allowed to render for one event.
const bodyCap = handlers.MaxRenderedBodyBytesForTest
@@ -85,7 +85,7 @@ func seedAndProject(
// TestHandleSourceLogs_BoundsOversizeBody proves the rendered
// page is bounded by the cap rather than by the stored payload:
// the body here is 64 times the cap, and the ingest path would
// the body here is 16 times the cap, and the ingest path would
// accept twice as much again.
func TestHandleSourceLogs_BoundsOversizeBody(t *testing.T) {
t.Parallel()
@@ -123,7 +123,7 @@ func TestHandleSourceLogs_BoundsOversizeBody(t *testing.T) {
// The marker states the true stored size, not the cut one.
assert.Contains(
t, page,
"showing "+strconv.Itoa(bodyCap)+
"Showing the first "+strconv.Itoa(bodyCap)+
" of "+strconv.Itoa(storedBytes)+" bytes",
)
}
@@ -152,34 +152,35 @@ func TestHandleSourceLogs_SmallBodyRendersWhole(t *testing.T) {
page := renderSourceLogsPage(t, h, sess, wh.ID)
assert.Contains(t, page, "&#34;kept&#34;")
assert.NotContains(t, page, "Body truncated for display")
assert.NotContains(t, page, "Showing the first")
}
// TestEventLogView_CutMidRune proves a multi-byte rune severed
// by the byte-wise cut is dropped rather than surfaced as a
// mojibake tail.
// mojibake tail, which would also make the text look binary.
func TestEventLogView_CutMidRune(t *testing.T) {
t.Parallel()
body := strings.Repeat(snowman, 4096)
body := strings.Repeat(snowman, bodyCap)
view := seedAndProject(t, body)
// bodyCap bytes hold bodyCap/3 whole snowmen and two bytes
// of the next one; those two are dropped.
whole := bodyCap / len(snowman)
assert.True(t, view.BodyTruncated)
assert.Equal(t, int64(len(body)), view.BodyBytes)
assert.Equal(t, strings.Repeat(snowman, whole), view.Body)
assert.True(t, utf8.ValidString(view.Body))
assert.LessOrEqual(t, len(view.Body), bodyCap)
assert.True(t, view.Body.Cut)
assert.False(t, view.Body.Binary)
assert.Equal(t, int64(len(body)), view.Body.Size)
assert.Equal(t, strings.Repeat(snowman, whole), view.Body.Text)
assert.True(t, utf8.ValidString(view.Body.Text))
assert.Equal(t, len(view.Body.Text), view.Body.ShownBytes)
assert.LessOrEqual(t, view.Body.ShownBytes, bodyCap)
}
// TestEventLogView_BinaryBodyLeftAsStored proves a binary
// payload is passed through byte for byte. Its tail is invalid
// UTF-8 however the cut falls, so repairing it would misreport
// what the sender delivered.
func TestEventLogView_BinaryBodyLeftAsStored(t *testing.T) {
// TestEventLogView_BinaryBodyNotShown proves a body that is not
// text is left out rather than shown as replacement characters,
// whether it is cut or not.
func TestEventLogView_BinaryBodyNotShown(t *testing.T) {
t.Parallel()
raw := make([]byte, bodyCap+808)
@@ -188,12 +189,21 @@ func TestEventLogView_BinaryBodyLeftAsStored(t *testing.T) {
raw[i] = 0x80 | byte(i%0x40)
}
view := seedAndProject(t, string(raw))
for name, body := range map[string][]byte{
"cut": raw,
"whole": raw[:2048],
"NUL": []byte("text\x00text"),
} {
t.Run(name, func(t *testing.T) {
t.Parallel()
assert.True(t, view.BodyTruncated)
assert.Equal(t, int64(len(raw)), view.BodyBytes)
assert.Equal(t, string(raw[:bodyCap]), view.Body)
assert.False(t, utf8.ValidString(view.Body))
view := seedAndProject(t, string(body))
assert.True(t, view.Body.Binary)
assert.Empty(t, view.Body.Text)
assert.Equal(t, int64(len(body)), view.Body.Size)
})
}
}
// TestTrimPartialRune covers the distinction the cut repair
+8 -2
View File
@@ -19,10 +19,16 @@ func (s *Handlers) SetLogForTest(log *slog.Logger) {
s.log = log
}
// MaxRenderedBodyBytesForTest exposes the event log's body cap
// to the handlers_test package.
// MaxRenderedBodyBytesForTest exposes the body cap of the lists
// of events to the handlers_test package.
const MaxRenderedBodyBytesForTest = maxRenderedBodyBytes
// NewBodyViewForTest exposes newBodyView for use in the
// handlers_test package.
func NewBodyViewForTest(body []byte, size int64) BodyView {
return newBodyView("/hook/w/events/e", body, size)
}
// MaxRenderedResponseBytesForTest exposes the event log's
// delivery response cap to the handlers_test package.
const MaxRenderedResponseBytesForTest = maxRenderedResponseBytes
+18 -11
View File
@@ -150,16 +150,23 @@ func New(
// Parse all page templates once at startup
s.templates = map[string]*template.Template{
"login.html": parsePageTemplate("login.html"),
"profile.html": parsePageTemplate("profile.html"),
"settings.html": parsePageTemplate("settings.html"),
"sources_list.html": parsePageTemplate("sources_list.html"),
"sources_new.html": parsePageTemplate("sources_new.html"),
"source_detail.html": parsePageTemplate("source_detail.html", "webhook_stats.html"),
"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"),
"login.html": parsePageTemplate("login.html"),
"profile.html": parsePageTemplate("profile.html"),
"settings.html": parsePageTemplate("settings.html"),
"sources_list.html": parsePageTemplate("sources_list.html"),
"sources_new.html": parsePageTemplate("sources_new.html"),
"source_detail.html": parsePageTemplate(
"source_detail.html", "webhook_stats.html", "event_body.html",
),
"source_edit.html": parsePageTemplate("source_edit.html"),
"source_logs.html": parsePageTemplate(
"source_logs.html", "event_body.html", "delivery_attempts.html",
),
"event_detail.html": parsePageTemplate(
"event_detail.html", "event_body.html", "delivery_attempts.html",
),
"target_edit.html": parsePageTemplate("target_edit.html"),
"error.html": parsePageTemplate("error.html"),
}
lc.Append(fx.Hook{
@@ -386,7 +393,7 @@ func (s *Handlers) pageData(
// partial body and the status before a mid-render error can be
// reported, leaving no way to serve a 500. Buffering makes a page's
// rendered size resident memory per concurrent viewer, so every page
// owes it a bound: the event log caps each stored body at
// owes it a bound: the lists of events cap each stored body at
// maxRenderedBodyBytes for exactly this reason.
func (s *Handlers) executeTemplate(
w http.ResponseWriter,
+20 -8
View File
@@ -12,11 +12,12 @@ import (
)
// recentEventColumns is the recent events list's projection. It
// leaves out the body, for the reason maxRenderedBodyBytes gives,
// and reads its size from body_bytes, recorded when the event was
// reads the body cut to maxRenderedBodyBytes, as eventLogColumns
// does, and its size from body_bytes, recorded when the event was
// stored.
const recentEventColumns = "id, created_at, method, content_type, " +
"resubmitted_from_id, body_bytes"
"resubmitted_from_id, body_bytes, " +
"substr(cast(body as blob), 1, ?) AS body"
// recentAttemptColumns is the part of a recorded attempt the list
// uses. The event log's deliveryResultColumns also reads response
@@ -50,6 +51,9 @@ type RecentEventView struct {
// unless the webhook has exactly one HTTP target.
Status string
StatusClass string
// Body is what the row shows when it is expanded.
Body BodyView
}
// recentEventRow is one row of recentEventColumns.
@@ -60,6 +64,7 @@ type recentEventRow struct {
ContentType string
ResubmittedFromID *string
BodyBytes uint64
Body []byte
}
// recentAttemptRow is one row of recentAttemptColumns. CreatedAt is
@@ -100,7 +105,7 @@ func loadRecentEvents(
var rows []recentEventRow
err := webhookDB.Model(&database.Event{}).
Select(recentEventColumns).
Select(recentEventColumns, maxRenderedBodyBytes).
Where("webhook_id = ?", webhookID).
Order("created_at DESC").
Limit(recentEventLimit).
@@ -145,7 +150,7 @@ func loadRecentEvents(
views := make([]RecentEventView, len(rows))
for i := range rows {
views[i] = rows[i].view(
byEvent[rows[i].ID], attempts, statusTargetID,
webhookID, byEvent[rows[i].ID], attempts, statusTargetID,
)
}
@@ -182,14 +187,20 @@ func loadRecentAttempts(
return byDelivery, nil
}
// view projects a loaded row for rendering. deliveries is the
// event's deliveries, oldest first, and attempts their recorded
// attempts keyed by delivery ID.
// view projects a loaded row of the webhook's events for
// rendering. deliveries is the event's deliveries, oldest first,
// and attempts their recorded attempts keyed by delivery ID.
func (r *recentEventRow) view(
webhookID string,
deliveries []database.Delivery,
attempts map[string][]recentAttemptRow,
statusTargetID string,
) RecentEventView {
//nolint:gosec // body_bytes is at most the receiver's 1 MB cap
body := newBodyView(
"/hook/"+webhookID+"/events/"+r.ID, r.Body, int64(r.BodyBytes),
)
v := RecentEventView{
Method: r.Method,
ContentType: r.ContentType,
@@ -197,6 +208,7 @@ func (r *recentEventRow) view(
ReceivedUTC: r.CreatedAt.UTC().Format(time.DateTime) + " UTC",
Size: humanize.Bytes(r.BodyBytes),
ProcessingTime: processingTime(deliveries, attempts),
Body: body,
}
if r.ResubmittedFromID != nil {
+68
View File
@@ -5,6 +5,7 @@ import (
"fmt"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"testing"
"time"
@@ -301,6 +302,73 @@ func TestHandleSourceDetail_NoStatusWithoutSingleHTTPTarget(
}
}
// TestHandleSourceDetail_RecentEventsLinkAndExpand proves each row
// links to its event's own page and expands to show its body, and
// that only the newest row starts expanded.
func TestHandleSourceDetail_RecentEventsLinkAndExpand(t *testing.T) {
t.Parallel()
f := newRecentEventsFixture(t)
now := time.Now()
older := f.event(
t, contentTypeJSON, `{"which":"older"}`, now.Add(-time.Minute),
)
newer := f.event(t, contentTypeJSON, `{"which":"newer"}`, now)
body := f.render(t)
for _, e := range []*database.Event{older, newer} {
assert.Contains(
t, body, `href="/hook/`+f.webhook.ID+`/events/`+e.ID+`"`,
)
}
assert.Equal(t, 2, strings.Count(body, `<div x-show="open" x-cloak class="mt-3">`))
assert.Equal(t, 1, strings.Count(body, " data-open>"))
open := strings.Index(body, " data-open>")
newerBody := strings.Index(body, "&#34;which&#34;: &#34;newer&#34;")
olderBody := strings.Index(body, "&#34;which&#34;: &#34;older&#34;")
assert.Less(t, open, newerBody, "the newest row is not the open one")
assert.Less(t, newerBody, olderBody)
}
// TestHandleSourceDetail_RecentEventBodyCut proves a body up to
// the cap is shown whole and pretty-printed, and a larger one only
// its first bodyCap bytes, as received, with links to the whole
// body on the event's page and to the download.
func TestHandleSourceDetail_RecentEventBodyCut(t *testing.T) {
t.Parallel()
f := newRecentEventsFixture(t)
now := time.Now()
// A JSON document of n bytes.
document := func(n int) string {
return `{"pad":"` + strings.Repeat("x", n-len(`{"pad":""}`)) + `"}`
}
whole := f.event(
t, contentTypeJSON, document(bodyCap), now.Add(-time.Minute),
)
cut := f.event(t, contentTypeJSON, document(bodyCap+1), now)
body := f.render(t)
eventURL := `href="/hook/` + f.webhook.ID + `/events/`
assert.Equal(t, 1, strings.Count(body, "{\n &#34;pad&#34;: "))
assert.Contains(t, body, "{&#34;pad&#34;:&#34;xxx")
assert.Contains(
t, body,
"Showing the first "+strconv.Itoa(bodyCap)+" of "+
strconv.Itoa(bodyCap+1)+" bytes, unformatted.",
)
assert.Contains(t, body, eventURL+cut.ID+`/body"`)
assert.NotContains(t, body, eventURL+whole.ID+`/body"`)
}
// TestHandleWebhook_RecordsBodySize proves the receiver records the
// body's size in bytes, not characters, with the event it stores.
func TestHandleWebhook_RecordsBodySize(t *testing.T) {
+29 -12
View File
@@ -1032,10 +1032,10 @@ func (h *Handlers) HandleSourceLogs() http.HandlerFunc {
// view, which renders its target as a blank name.
//
// This map is historical display only. It is built for the event
// log page and reaches nothing but DeliveryView.Target: the
// target list on the source detail page, the edit form and the
// replay path each resolve targets themselves, and a deleted row
// is refused there as before.
// log and an event's own page, and reaches nothing but
// DeliveryView.Target: the target list on the source detail page,
// the edit form and the replay path each resolve targets
// themselves, and a deleted row is refused there as before.
func (h *Handlers) loadTargetMap(
webhookID string,
) (map[string]eventLogTarget, error) {
@@ -1091,10 +1091,8 @@ func (h *Handlers) loadEventsWithDeliveries(
targetMap map[string]eventLogTarget,
page int,
) ([]EventLogView, int64, bool) {
var result []EventLogView
if !h.dbMgr.DBExists(webhook.ID) {
return result, 0, true
return nil, 0, true
}
webhookDB, err := h.dbMgr.GetDB(webhook.ID)
@@ -1110,7 +1108,26 @@ func (h *Handlers) loadEventsWithDeliveries(
webhookDB, webhook.ID, page,
)
result = make([]EventLogView, len(rows))
result, ok := h.eventLogViews(
w, r, webhookDB, webhook.ID, rows, targetMap,
)
return result, totalEvents, ok
}
// eventLogViews projects loaded events for rendering, each with
// its deliveries and how many times it has been resubmitted. Like
// loadEventsWithDeliveries, it reports false once it has answered
// the request with an error.
func (h *Handlers) eventLogViews(
w http.ResponseWriter,
r *http.Request,
webhookDB *gorm.DB,
webhookID string,
rows []eventLogRow,
targetMap map[string]eventLogTarget,
) ([]EventLogView, bool) {
result := make([]EventLogView, len(rows))
eventDeliveries := make([][]database.Delivery, len(rows))
var deliveryIDs []string
@@ -1118,7 +1135,7 @@ func (h *Handlers) loadEventsWithDeliveries(
eventIDs := make([]string, len(rows))
for i := range rows {
result[i] = rows[i].view()
result[i] = rows[i].view(webhookID)
eventIDs[i] = rows[i].ID
webhookDB.Where(
@@ -1140,7 +1157,7 @@ func (h *Handlers) loadEventsWithDeliveries(
w, r, "failed to load delivery attempts", err,
)
return nil, 0, false
return nil, false
}
resubmits, err := resubmitCounts(webhookDB, eventIDs)
@@ -1149,7 +1166,7 @@ func (h *Handlers) loadEventsWithDeliveries(
w, r, "failed to count event resubmissions", err,
)
return nil, 0, false
return nil, false
}
for i := range rows {
@@ -1159,7 +1176,7 @@ func (h *Handlers) loadEventsWithDeliveries(
result[i].ResubmitCount = resubmits[rows[i].ID]
}
return result, totalEvents, true
return result, true
}
// loadEventLogRows reads one page of the event log projection, newest
+46 -1
View File
@@ -39,6 +39,9 @@ const (
// the mobile menu button instead of the navigation links.
phoneWidth = 390
phoneHeight = 844
// olderBody is the body of the event received before the newest.
olderBody = "the older event"
)
// TestAlpineRunsUnderTheSecurityPolicy loads the webhook page and the
@@ -63,6 +66,7 @@ func TestAlpineRunsUnderTheSecurityPolicy(t *testing.T) {
Active: true,
},
).Error)
env.seedEvent(t, webhook.ID, olderBody)
event := env.seedEvent(t, webhook.ID, `{"hello":"browser"}`)
target := env.seedTarget(t, webhook.ID)
dlv := env.seedFailedDelivery(t, webhook.ID, event.ID, target.ID)
@@ -116,6 +120,7 @@ func TestAlpineRunsUnderTheSecurityPolicy(t *testing.T) {
checkRefusedTarget(ctx, t, page)
checkCopy(ctx, t, page)
checkEntrypointEdit(ctx, t, page, page+"/events")
checkRecentEvents(ctx, t, page)
checkEventLog(ctx, t, page+"/events", event.ID, target.Name)
checkMobileMenu(ctx, t, page)
@@ -559,6 +564,46 @@ func checkEntrypointEdit(
"saving the edit form does not change the description")
}
// checkRecentEvents loads a webhook page and checks that of its recent
// events only the newest starts expanded, showing its body, that
// clicking the older one's row expands it and clicking again collapses
// it, and that clicking the newest one's row collapses it. It then
// follows the newest one's Open link to the event's own page, which
// shows the body.
func checkRecentEvents(ctx context.Context, t *testing.T, url string) {
t.Helper()
// The newest event's body is pretty-printed JSON. Each row's
// toggle is the button in the element that holds its state.
newest := `//pre[contains(., '"hello": "browser"')]`
older := `//pre[text()="` + olderBody + `"]`
toggle := `/ancestor::div[@x-data][1]//button`
require.NoError(t, chromedp.Run(ctx, loadPage(url)))
assert.True(t, shown(ctx, newest), "the newest event starts collapsed")
assert.True(t, hidden(ctx, older), "an older event starts expanded")
click(ctx, t, older+toggle)
assert.True(t, shown(ctx, older), "clicking an event does not expand it")
click(ctx, t, older+toggle)
assert.True(t, hidden(ctx, older),
"clicking an event again does not collapse it")
click(ctx, t, newest+toggle)
assert.True(t, hidden(ctx, newest),
"clicking the newest event does not collapse it")
require.NoError(t, chromedp.Run(ctx, loadPage(url)))
click(ctx, t, newest+`/ancestor::div[@x-data][1]//a[text()="Open"]`)
assert.True(t, shown(ctx, `//h2[text()="Body"]`),
"Open does not lead to the event's own page")
assert.True(t, shown(ctx, newest),
"the event's own page does not show its body")
}
// checkEventLog loads the event log and checks that clicking an event's
// row expands it, that in there clicking its delivery shows the
// delivery's attempts and clicking again hides them, and that clicking
@@ -571,7 +616,7 @@ func checkEventLog(
// The event's row shows its ID, and its Resubmit form is in the part
// that expands. The delivery's row there shows the target's name.
eventRow := `//span[text()="` + eventID + `"]`
expanded := `form[action$="/resubmit"]`
expanded := `form[action$="/` + eventID + `/resubmit"]`
deliveryRow := `//span[text()="` + targetName + `"]`
attempt := `//span[text()="Attempt 1"]`
+6 -5
View File
@@ -252,11 +252,12 @@ func (s *Server) setupSourceRoutes() {
r.Post("/edit", s.h.HandleSourceEditSubmit())
r.Post("/delete", s.h.HandleSourceDelete())
r.Get("/events", s.h.HandleSourceLogs())
// The log page renders each body only up to its cap, so
// this is the only route that serves a whole one. It
// belongs to this group for its RequireAuth and
// NoCache; see HandleEventBodyDownload for the headers
// that keep the bytes it returns inert.
r.Get("/events/{eventID}", s.h.HandleEventDetail())
// The pages show a body as escaped text and leave a
// binary one out, so this is the only route that serves
// the stored bytes. It belongs to this group for its
// RequireAuth and NoCache; see HandleEventBodyDownload for
// the headers that keep the bytes it returns inert.
r.Get(
"/events/{eventID}/body",
s.h.HandleEventBodyDownload(),
+43
View File
@@ -365,6 +365,7 @@ func (e *testEnv) seedEvent(
WebhookID: webhookID,
Method: http.MethodPost,
Body: body,
BodyBytes: int64(len(body)),
ContentType: "application/octet-stream",
}
@@ -1416,6 +1417,48 @@ func TestHook_LinksBetweenPages(t *testing.T) {
}
}
// TestEventPage_OpenedFromRecentEvents follows the Open link of a
// row in the recent events on the webhook page through the
// production router to the event's own page, which shows the body
// and links back. Another user gets a 404 at the same URL, and a
// logged-out request is sent to log in.
func TestEventPage_OpenedFromRecentEvents(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
ownerID, _ := env.seedUser(t, "owner", "somepassword")
cookies := env.authCookies(t, ownerID, "owner")
wh := env.seedWebhook(t, ownerID)
evt := env.seedEvent(t, wh.ID, "OWNERS-PAYLOAD-3e9d")
page := "/hook/" + wh.ID
path := env.urlFrom(t, page, `href="([^"]+)"[^>]*>Open<`, cookies)
require.Equal(t, page+"/events/"+evt.ID, path)
w := env.get(path, cookies)
require.Equal(t, http.StatusOK, w.Code)
assert.Contains(t, w.Body.String(), "OWNERS-PAYLOAD-3e9d")
assert.Equal(
t, page,
env.urlFrom(t, path, `href="([^"]+)"[^>]*>&larr; Back to `, cookies),
)
intruderID, _ := env.seedUser(t, "intruder", "somepassword")
w = env.get(path, env.authCookies(t, intruderID, "intruder"))
assert.Equal(t, http.StatusNotFound, w.Code)
assert.NotContains(t, w.Body.String(), "OWNERS-PAYLOAD-3e9d")
anon := env.get(path, nil)
assert.Equal(t, http.StatusSeeOther, anon.Code)
assert.Equal(
t, "/pages/login?next="+url.QueryEscape(path),
anon.Header().Get("Location"),
)
}
// TestSourceLogs_TruncationLinkDownloadsTheBody walks the whole
// feature the way a user does: render the event log page through
// the production router, take the download URL out of the markup
+6 -2
View File
@@ -70,11 +70,15 @@ document.addEventListener("alpine:init", function () {
"use strict";
// Something a click shows and hides: the mobile menu, an add form,
// an entrypoint's edit form, an event in the event log, a delivery's
// attempts.
// an entrypoint's edit form, an event in the event log or in the
// recent events, a delivery's attempts. It starts hidden, or shown
// when its element has the data-open attribute.
window.Alpine.data("collapsible", function () {
return {
open: false,
init() {
this.open = this.$root.hasAttribute("data-open");
},
toggle() {
this.open = !this.open;
},
+32
View File
@@ -0,0 +1,32 @@
{{define "delivery_attempts"}}
<!-- A delivery's recorded attempts, as handlers.DeliveryView holds
them: in the event log and on an event's own page. -->
{{if .AttemptsOmitted}}
<p class="text-xs text-gray-500">{{.AttemptsOmitted}} attempt{{if ne .AttemptsOmitted 1}}s{{end}} omitted between the first and last shown.</p>
{{end}}
{{range .Results}}
<div class="rounded-md bg-white border border-gray-200 p-2">
<div class="flex flex-wrap items-center gap-3 text-xs">
<span class="text-gray-500">Attempt {{.AttemptNum}}</span>
<span class="{{if .Success}}text-green-600{{else}}text-red-600{{end}}">{{if .Success}}success{{else}}failure{{end}}</span>
<span class="text-gray-500">Status: {{if .HasStatusCode}}{{.StatusCode}}{{else}}&mdash; (no response){{end}}</span>
<span class="text-gray-500">Duration: {{.DurationMS}} ms</span>
</div>
{{if .Error}}
<p class="mt-2 text-xs text-red-700 break-all">Error: {{.Error}}</p>
{{end}}
{{if .ResponseBody}}
<pre class="mt-2 text-xs text-gray-700 overflow-x-auto whitespace-pre-wrap break-all">{{.ResponseBody}}</pre>
{{end}}
{{if .ResponseTruncated}}
{{if .ResponseSizeKnown}}
<p class="mt-1 text-xs text-gray-500">Response truncated for display: showing {{.ResponseShownBytes}} of {{.ResponseBytes}} bytes.</p>
{{else}}
<p class="mt-1 text-xs text-gray-500">Showing {{.ResponseShownBytes}} of the {{.ResponseBytes}} recorded bytes. The response reached the recording limit, so the remote may have sent more that was never stored.</p>
{{end}}
{{end}}
</div>
{{else}}
<p class="text-xs text-gray-500">No attempts recorded yet.</p>
{{end}}
{{end}}
+15
View File
@@ -0,0 +1,15 @@
{{define "event_body"}}
<!-- An event's body, as handlers.BodyView describes it: the same in
the recent events on the webhook page, the event log and the
event's own page. -->
{{if .Binary}}
<p class="text-xs text-gray-500">This body is binary ({{.Size}} bytes) and is not shown. <a href="{{.EventURL}}/body" class="btn-small">Download the body</a></p>
{{else if .Text}}
<pre class="rounded-md border border-gray-200 bg-white p-2 text-xs text-gray-700 overflow-x-auto whitespace-pre-wrap break-all"{{if .Scroll}} style="max-height: 32rem; overflow-y: auto"{{end}}>{{.Text}}</pre>
{{if .Cut}}
<p class="mt-2 text-xs text-gray-500">Showing the first {{.ShownBytes}} of {{.Size}} bytes, unformatted. <a href="{{.EventURL}}" class="btn-small">Show the whole body</a> <a href="{{.EventURL}}/body" class="btn-small">Download the body</a></p>
{{end}}
{{else}}
<p class="text-xs text-gray-500">No body.</p>
{{end}}
{{end}}
+87
View File
@@ -0,0 +1,87 @@
{{template "base" .}}
{{define "title"}}Event - {{.Webhook.Name}} - Webhooker{{end}}
{{define "content"}}
<div class="max-w-6xl mx-auto px-6 py-8">
<div class="mb-6">
<div class="flex flex-wrap gap-2">
<a href="/hook/{{.Webhook.ID}}" class="btn-small">&larr; Back to {{.Webhook.Name}}</a>
<a href="/hook/{{.Webhook.ID}}/events" class="btn-small">Full Event Log</a>
</div>
<h1 class="text-2xl font-medium text-gray-900 mt-2">Event</h1>
</div>
{{with .Event}}
<div class="card p-4">
<dl class="space-y-2 text-sm">
<div class="flex flex-wrap gap-2">
<dt class="w-32 flex-shrink-0 text-gray-500">ID</dt>
<dd class="font-mono text-gray-900 break-all">{{.ID}}</dd>
</div>
<div class="flex flex-wrap gap-2">
<dt class="w-32 flex-shrink-0 text-gray-500">Received</dt>
<dd class="text-gray-900">{{.CreatedAt.UTC.Format "2006-01-02 15:04:05"}} UTC</dd>
</div>
<div class="flex flex-wrap gap-2">
<dt class="w-32 flex-shrink-0 text-gray-500">Method</dt>
<dd><span class="badge-info">{{.Method}}</span></dd>
</div>
<div class="flex flex-wrap gap-2">
<dt class="w-32 flex-shrink-0 text-gray-500">Content type</dt>
<dd class="text-gray-900 break-all">{{.ContentType}}</dd>
</div>
<div class="flex flex-wrap gap-2">
<dt class="w-32 flex-shrink-0 text-gray-500">Body size</dt>
<dd class="text-gray-900">{{.Body.Size}} bytes</dd>
</div>
{{if .ResubmittedFrom}}
<div class="flex flex-wrap gap-2">
<dt class="w-32 flex-shrink-0 text-gray-500">Resubmitted from</dt>
<dd><a href="/hook/{{$.Webhook.ID}}/events/{{.ResubmittedFromID}}" class="btn-small font-mono">{{.ResubmittedFromID}}</a></dd>
</div>
{{end}}
{{if .ResubmitCount}}
<div class="flex flex-wrap gap-2">
<dt class="w-32 flex-shrink-0 text-gray-500">Resubmitted</dt>
<dd class="text-gray-900">as {{.ResubmitCount}} new event{{if ne .ResubmitCount 1}}s{{end}}</dd>
</div>
{{end}}
</dl>
</div>
<div class="card mt-6">
<div class="p-4 border-b border-gray-200">
<h2 class="text-lg font-medium text-gray-900">Body</h2>
</div>
<div class="p-4">
{{template "event_body" .Body}}
</div>
</div>
<div class="card mt-6">
<div class="p-4 border-b border-gray-200">
<h2 class="text-lg font-medium text-gray-900">Deliveries</h2>
</div>
<div class="divide-y divide-gray-100">
{{range .Deliveries}}
<div class="p-4">
<div class="flex flex-wrap items-center justify-between gap-3">
<span class="text-sm text-gray-700">{{.Target.DisplayName}}</span>
<span class="flex flex-wrap items-center gap-3">
<span class="text-xs {{if eq .Status "delivered"}}text-green-600{{else if eq .Status "failed"}}text-red-600{{else if eq .Status "retrying"}}text-yellow-600{{else}}text-gray-400{{end}}">{{.Status}}</span>
<span class="text-xs text-gray-400">{{.AttemptCount}} attempt{{if ne .AttemptCount 1}}s{{end}}</span>
</span>
</div>
<div class="mt-2 space-y-2">
{{template "delivery_attempts" .}}
</div>
</div>
{{else}}
<div class="p-4 text-sm text-gray-500">No deliveries.</div>
{{end}}
</div>
</div>
{{end}}
</div>
{{end}}
+31 -20
View File
@@ -274,26 +274,37 @@
<a href="/hook/{{.Webhook.ID}}/events" class="btn-small">Full Event Log</a>
</div>
<div class="divide-y divide-gray-100">
{{range .Events}}
<div class="p-4">
<div class="flex flex-wrap items-center justify-between gap-3">
<div class="flex flex-wrap items-center gap-3">
<span class="badge-info">{{.Method}}</span>
<span class="text-sm text-gray-500 break-all">{{.ContentType}}</span>
{{if .ResubmittedFromID}}
<span class="text-xs text-gray-500" title="This event is a copy of {{.ResubmittedFromID}}">resubmitted copy</span>
{{end}}
</div>
<div class="flex flex-wrap items-center gap-3 text-xs text-gray-400">
<span title="Body size">{{.Size}}</span>
{{if .ProcessingTime}}
<span title="Processing time: how long the slowest delivery took, from being queued to its last attempt">{{.ProcessingTime}}</span>
{{end}}
{{if .Status}}
<span class="font-medium {{.StatusClass}}" title="HTTP status from the HTTP target">{{.Status}}</span>
{{end}}
<span title="{{.ReceivedUTC}}">{{.Received}}</span>
</div>
<!-- Each row expands to show its body; only the newest
starts expanded. -->
{{range $i, $event := .Events}}
<div class="p-4" x-data="collapsible"{{if eq $i 0}} data-open{{end}}>
<div class="flex flex-wrap items-center gap-3">
<button type="button" class="btn-small flex-1 flex-wrap justify-between gap-3 text-left" @click="toggle">
<span class="flex flex-wrap items-center gap-3">
<span class="badge-info">{{.Method}}</span>
<span class="text-sm text-gray-500 break-all">{{.ContentType}}</span>
{{if .ResubmittedFromID}}
<span class="text-xs text-gray-500" title="This event is a copy of {{.ResubmittedFromID}}">resubmitted copy</span>
{{end}}
</span>
<span class="flex flex-wrap items-center gap-3 text-xs text-gray-400">
<span title="Body size">{{.Size}}</span>
{{if .ProcessingTime}}
<span title="Processing time: how long the slowest delivery took, from being queued to its last attempt">{{.ProcessingTime}}</span>
{{end}}
{{if .Status}}
<span class="font-medium {{.StatusClass}}" title="HTTP status from the HTTP target">{{.Status}}</span>
{{end}}
<span title="{{.ReceivedUTC}}">{{.Received}}</span>
<svg class="w-4 h-4 text-gray-400 transition-transform" :class="caretClass" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
</svg>
</span>
</button>
<a href="{{.Body.EventURL}}" class="btn-small">Open</a>
</div>
<div x-show="open" x-cloak class="mt-3">
{{template "event_body" .Body}}
</div>
</div>
{{else}}
+3 -33
View File
@@ -44,7 +44,7 @@
<div x-show="open" x-cloak class="mt-3 p-3 bg-gray-50 rounded-md">
<div class="mb-3 flex flex-wrap items-center justify-between gap-2">
<div class="text-xs text-gray-500">
{{if .ResubmittedFrom}}Resubmitted from event <span class="font-mono">{{.ResubmittedFromID}}</span>.{{end}}
{{if .ResubmittedFrom}}Resubmitted from event <a href="/hook/{{$.Webhook.ID}}/events/{{.ResubmittedFromID}}" class="btn-small font-mono">{{.ResubmittedFromID}}</a>.{{end}}
{{if .ResubmitCount}}Resubmitted as {{.ResubmitCount}} new event{{if ne .ResubmitCount 1}}s{{end}}.{{end}}
</div>
<form method="POST" action="/hook/{{$.Webhook.ID}}/events/{{.ID}}/resubmit" class="inline">
@@ -53,10 +53,7 @@
<button type="submit" class="btn-small" title="Submit this event again as a new event, to every currently active target">Resubmit</button>
</form>
</div>
<pre class="text-xs text-gray-700 overflow-x-auto whitespace-pre-wrap break-all">{{.Body}}</pre>
{{if .BodyTruncated}}
<p class="mt-2 text-xs text-gray-500">Body truncated for display: showing {{.BodyShownBytes}} of {{.BodyBytes}} bytes. The stored body is unchanged &mdash; <a href="/hook/{{$.Webhook.ID}}/events/{{.ID}}/body" class="btn-small">download the full body</a>.</p>
{{end}}
{{template "event_body" .Body}}
{{if .Deliveries}}
<div class="mt-4 border-t border-gray-200 pt-3">
@@ -87,34 +84,7 @@
</div>
<div x-show="open" x-cloak class="mt-2 space-y-2">
{{if .AttemptsOmitted}}
<p class="text-xs text-gray-500">{{.AttemptsOmitted}} attempt{{if ne .AttemptsOmitted 1}}s{{end}} omitted between the first and last shown.</p>
{{end}}
{{range .Results}}
<div class="rounded-md bg-white border border-gray-200 p-2">
<div class="flex flex-wrap items-center gap-3 text-xs">
<span class="text-gray-500">Attempt {{.AttemptNum}}</span>
<span class="{{if .Success}}text-green-600{{else}}text-red-600{{end}}">{{if .Success}}success{{else}}failure{{end}}</span>
<span class="text-gray-500">Status: {{if .HasStatusCode}}{{.StatusCode}}{{else}}&mdash; (no response){{end}}</span>
<span class="text-gray-500">Duration: {{.DurationMS}} ms</span>
</div>
{{if .Error}}
<p class="mt-2 text-xs text-red-700 break-all">Error: {{.Error}}</p>
{{end}}
{{if .ResponseBody}}
<pre class="mt-2 text-xs text-gray-700 overflow-x-auto whitespace-pre-wrap break-all">{{.ResponseBody}}</pre>
{{end}}
{{if .ResponseTruncated}}
{{if .ResponseSizeKnown}}
<p class="mt-1 text-xs text-gray-500">Response truncated for display: showing {{.ResponseShownBytes}} of {{.ResponseBytes}} bytes.</p>
{{else}}
<p class="mt-1 text-xs text-gray-500">Showing {{.ResponseShownBytes}} of the {{.ResponseBytes}} recorded bytes. The response reached the recording limit, so the remote may have sent more that was never stored.</p>
{{end}}
{{end}}
</div>
{{else}}
<p class="text-xs text-gray-500">No attempts recorded yet.</p>
{{end}}
{{template "delivery_attempts" .}}
</div>
</div>
{{end}}