diff --git a/README.md b/README.md index 2d18612..385ff7d 100644 --- a/README.md +++ b/README.md @@ -1327,7 +1327,9 @@ under the real policy and checks that: both add forms stay hidden until Add is clicked; choosing Slack in the add target form leaves the HTTP fields out of what it submits, also after leaving the page and going back to it, when the browser restores the choice; the Copy button beside an entrypoint URL reads -"Copied" once clicked; an event expands and collapses, and so do a delivery's +"Copied" once clicked; 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 @@ -2911,7 +2913,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 | diff --git a/internal/database/model_event.go b/internal/database/model_event.go index 8980a53..f5d3fd6 100644 --- a/internal/database/model_event.go +++ b/internal/database/model_event.go @@ -32,8 +32,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 diff --git a/internal/handlers/event_body.go b/internal/handlers/event_body.go index e0b242c..603d145 100644 --- a/internal/handlers/event_body.go +++ b/internal/handlers/event_body.go @@ -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 diff --git a/internal/handlers/event_body_view.go b/internal/handlers/event_body_view.go new file mode 100644 index 0000000..8b89b61 --- /dev/null +++ b/internal/handlers/event_body_view.go @@ -0,0 +1,150 @@ +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 pretty-printed; 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 + +// 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) + } + + // A final newline ends the last line rather than starting another. + lines := bytes.Count(bytes.TrimSuffix(body, []byte("\n")), []byte("\n")) + 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 or nests deeper than maxIndentDepth. +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 { + 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-- + } + } +} diff --git a/internal/handlers/event_body_view_test.go b/internal/handlers/event_body_view_test.go new file mode 100644 index 0000000..5d3229c --- /dev/null +++ b/internal/handlers/event_body_view_test.go @@ -0,0 +1,155 @@ +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 document with a few +// levels of nesting is pretty-printed: only deep nesting is shown +// as received. +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_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) + + // 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) +} diff --git a/internal/handlers/event_detail.go b/internal/handlers/event_detail.go new file mode 100644 index 0000000..9ee452c --- /dev/null +++ b/internal/handlers/event_detail.go @@ -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], + }) + } +} diff --git a/internal/handlers/event_detail_test.go b/internal/handlers/event_detail_test.go new file mode 100644 index 0000000..ff6d68f --- /dev/null +++ b/internal/handlers/event_detail_test.go @@ -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 "pad": "xxx") + assert.Contains(t, page, ""tail": ""+sentinel+""\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") + 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) + } +} diff --git a/internal/handlers/event_log_view.go b/internal/handlers/event_log_view.go index 5cfc0d1..3079493 100644 --- a/internal/handlers/event_log_view.go +++ b/internal/handlers/event_log_view.go @@ -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, } } diff --git a/internal/handlers/event_log_view_test.go b/internal/handlers/event_log_view_test.go index e1d5a41..4c5b0e4 100644 --- a/internal/handlers/event_log_view_test.go +++ b/internal/handlers/event_log_view_test.go @@ -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, ""kept"") - 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 diff --git a/internal/handlers/export_test.go b/internal/handlers/export_test.go index a86e5bd..c1f0bf6 100644 --- a/internal/handlers/export_test.go +++ b/internal/handlers/export_test.go @@ -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 diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go index 7c2b363..9decb63 100644 --- a/internal/handlers/handlers.go +++ b/internal/handlers/handlers.go @@ -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, diff --git a/internal/handlers/recent_events.go b/internal/handlers/recent_events.go index aee4e03..d272419 100644 --- a/internal/handlers/recent_events.go +++ b/internal/handlers/recent_events.go @@ -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 { diff --git a/internal/handlers/recent_events_test.go b/internal/handlers/recent_events_test.go index 34fa45b..99ef59d 100644 --- a/internal/handlers/recent_events_test.go +++ b/internal/handlers/recent_events_test.go @@ -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, `
`)) + assert.Equal(t, 1, strings.Count(body, " data-open>")) + + open := strings.Index(body, " data-open>") + newerBody := strings.Index(body, ""which": "newer"") + olderBody := strings.Index(body, ""which": "older"") + + 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 "pad": ")) + assert.Contains(t, body, "{"pad":"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) { diff --git a/internal/handlers/source_management.go b/internal/handlers/source_management.go index 377eac8..cd56bc3 100644 --- a/internal/handlers/source_management.go +++ b/internal/handlers/source_management.go @@ -1022,10 +1022,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) { @@ -1081,10 +1081,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) @@ -1100,7 +1098,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 @@ -1108,7 +1125,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( @@ -1130,7 +1147,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) @@ -1139,7 +1156,7 @@ func (h *Handlers) loadEventsWithDeliveries( w, r, "failed to count event resubmissions", err, ) - return nil, 0, false + return nil, false } for i := range rows { @@ -1149,7 +1166,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 diff --git a/internal/server/alpine_browser_test.go b/internal/server/alpine_browser_test.go index 7637cf8..615d23a 100644 --- a/internal/server/alpine_browser_test.go +++ b/internal/server/alpine_browser_test.go @@ -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) @@ -86,6 +90,7 @@ func TestAlpineRunsUnderTheSecurityPolicy(t *testing.T) { checkAddForms(ctx, t, page) checkTargetType(ctx, t, page+"/events") checkCopy(ctx, t, page) + checkRecentEvents(ctx, t, page) checkEventLog(ctx, t, page+"/events", event.ID, target.Name) checkMobileMenu(ctx, t, page) @@ -363,6 +368,46 @@ func checkCopy(ctx context.Context, t *testing.T, url string) { `clicking Copy does not show "Copied"`) } +// 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 @@ -375,7 +420,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"]` diff --git a/internal/server/routes.go b/internal/server/routes.go index f376ce1..03bc541 100644 --- a/internal/server/routes.go +++ b/internal/server/routes.go @@ -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(), diff --git a/internal/server/routes_test.go b/internal/server/routes_test.go index 4ad98bc..6bb49fa 100644 --- a/internal/server/routes_test.go +++ b/internal/server/routes_test.go @@ -364,6 +364,7 @@ func (e *testEnv) seedEvent( WebhookID: webhookID, Method: http.MethodPost, Body: body, + BodyBytes: int64(len(body)), ContentType: "application/octet-stream", } @@ -1264,6 +1265,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="([^"]+)"[^>]*>← 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 diff --git a/static/js/app.js b/static/js/app.js index 4f76da9..577b61d 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -70,10 +70,15 @@ document.addEventListener("alpine:init", function () { "use strict"; // Something a click shows and hides: the mobile menu, an add form, - // an event in the event log, a delivery's attempts. + // 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; }, diff --git a/templates/delivery_attempts.html b/templates/delivery_attempts.html new file mode 100644 index 0000000..96d77a2 --- /dev/null +++ b/templates/delivery_attempts.html @@ -0,0 +1,32 @@ +{{define "delivery_attempts"}} + +{{if .AttemptsOmitted}} +

{{.AttemptsOmitted}} attempt{{if ne .AttemptsOmitted 1}}s{{end}} omitted between the first and last shown.

+{{end}} +{{range .Results}} +
+
+ Attempt {{.AttemptNum}} + {{if .Success}}success{{else}}failure{{end}} + Status: {{if .HasStatusCode}}{{.StatusCode}}{{else}}— (no response){{end}} + Duration: {{.DurationMS}} ms +
+ {{if .Error}} +

Error: {{.Error}}

+ {{end}} + {{if .ResponseBody}} +
{{.ResponseBody}}
+ {{end}} + {{if .ResponseTruncated}} + {{if .ResponseSizeKnown}} +

Response truncated for display: showing {{.ResponseShownBytes}} of {{.ResponseBytes}} bytes.

+ {{else}} +

Showing {{.ResponseShownBytes}} of the {{.ResponseBytes}} recorded bytes. The response reached the recording limit, so the remote may have sent more that was never stored.

+ {{end}} + {{end}} +
+{{else}} +

No attempts recorded yet.

+{{end}} +{{end}} diff --git a/templates/event_body.html b/templates/event_body.html new file mode 100644 index 0000000..b860528 --- /dev/null +++ b/templates/event_body.html @@ -0,0 +1,15 @@ +{{define "event_body"}} + +{{if .Binary}} +

This body is binary ({{.Size}} bytes) and is not shown. Download the body

+{{else if .Text}} +
{{.Text}}
+{{if .Cut}} +

Showing the first {{.ShownBytes}} of {{.Size}} bytes, unformatted. Show the whole body Download the body

+{{end}} +{{else}} +

No body.

+{{end}} +{{end}} diff --git a/templates/event_detail.html b/templates/event_detail.html new file mode 100644 index 0000000..aec0280 --- /dev/null +++ b/templates/event_detail.html @@ -0,0 +1,87 @@ +{{template "base" .}} + +{{define "title"}}Event - {{.Webhook.Name}} - Webhooker{{end}} + +{{define "content"}} +
+ + + {{with .Event}} +
+
+
+
ID
+
{{.ID}}
+
+
+
Received
+
{{.CreatedAt.UTC.Format "2006-01-02 15:04:05"}} UTC
+
+
+
Method
+
{{.Method}}
+
+
+
Content type
+
{{.ContentType}}
+
+
+
Body size
+
{{.Body.Size}} bytes
+
+ {{if .ResubmittedFrom}} +
+
Resubmitted from
+
{{.ResubmittedFromID}}
+
+ {{end}} + {{if .ResubmitCount}} +
+
Resubmitted
+
as {{.ResubmitCount}} new event{{if ne .ResubmitCount 1}}s{{end}}
+
+ {{end}} +
+
+ +
+
+

Body

+
+
+ {{template "event_body" .Body}} +
+
+ +
+
+

Deliveries

+
+
+ {{range .Deliveries}} +
+
+ {{.Target.DisplayName}} + + {{.Status}} + {{.AttemptCount}} attempt{{if ne .AttemptCount 1}}s{{end}} + +
+
+ {{template "delivery_attempts" .}} +
+
+ {{else}} +
No deliveries.
+ {{end}} +
+
+ {{end}} +
+{{end}} diff --git a/templates/source_detail.html b/templates/source_detail.html index b37cda6..3031ad3 100644 --- a/templates/source_detail.html +++ b/templates/source_detail.html @@ -194,26 +194,37 @@ Full Event Log
- {{range .Events}} -
-
-
- {{.Method}} - {{.ContentType}} - {{if .ResubmittedFromID}} - resubmitted copy - {{end}} -
-
- {{.Size}} - {{if .ProcessingTime}} - {{.ProcessingTime}} - {{end}} - {{if .Status}} - {{.Status}} - {{end}} - {{.Received}} -
+ + {{range $i, $event := .Events}} +
+
+ + Open +
+
+ {{template "event_body" .Body}}
{{else}} diff --git a/templates/source_logs.html b/templates/source_logs.html index d17c304..d900661 100644 --- a/templates/source_logs.html +++ b/templates/source_logs.html @@ -44,7 +44,7 @@
- {{if .ResubmittedFrom}}Resubmitted from event {{.ResubmittedFromID}}.{{end}} + {{if .ResubmittedFrom}}Resubmitted from event {{.ResubmittedFromID}}.{{end}} {{if .ResubmitCount}}Resubmitted as {{.ResubmitCount}} new event{{if ne .ResubmitCount 1}}s{{end}}.{{end}}
@@ -53,10 +53,7 @@
-
{{.Body}}
- {{if .BodyTruncated}} -

Body truncated for display: showing {{.BodyShownBytes}} of {{.BodyBytes}} bytes. The stored body is unchanged — download the full body.

- {{end}} + {{template "event_body" .Body}} {{if .Deliveries}}
@@ -87,34 +84,7 @@
- {{if .AttemptsOmitted}} -

{{.AttemptsOmitted}} attempt{{if ne .AttemptsOmitted 1}}s{{end}} omitted between the first and last shown.

- {{end}} - {{range .Results}} -
-
- Attempt {{.AttemptNum}} - {{if .Success}}success{{else}}failure{{end}} - Status: {{if .HasStatusCode}}{{.StatusCode}}{{else}}— (no response){{end}} - Duration: {{.DurationMS}} ms -
- {{if .Error}} -

Error: {{.Error}}

- {{end}} - {{if .ResponseBody}} -
{{.ResponseBody}}
- {{end}} - {{if .ResponseTruncated}} - {{if .ResponseSizeKnown}} -

Response truncated for display: showing {{.ResponseShownBytes}} of {{.ResponseBytes}} bytes.

- {{else}} -

Showing {{.ResponseShownBytes}} of the {{.ResponseBytes}} recorded bytes. The response reached the recording limit, so the remote may have sent more that was never stored.

- {{end}} - {{end}} -
- {{else}} -

No attempts recorded yet.

- {{end}} + {{template "delivery_attempts" .}}
{{end}}