diff --git a/README.md b/README.md index 1f845f4..2dc6ade 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 @@ -2889,7 +2891,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/handlers/event_body.go b/internal/handlers/event_body.go index cf2452f..8d9e7f6 100644 --- a/internal/handlers/event_body.go +++ b/internal/handlers/event_body.go @@ -22,9 +22,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..3c2b551 --- /dev/null +++ b/internal/handlers/event_body_view.go @@ -0,0 +1,147 @@ +package handlers + +import ( + "bytes" + "encoding/json" + "errors" + "io" + "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 + +// maxIndentGrowth is how many times its size a JSON body may grow +// when it is pretty-printed; past that it is shown as received. +// Indenting grows with nesting depth as well as with size: 20 KB of +// nested brackets indents to some 200 MB. +const maxIndentGrowth = 4 + +// 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 and NUL bytes as replacement + // characters, so a body holding either is not text. + if !utf8.Valid(body) || bytes.IndexByte(body, 0) >= 0 { + v.Binary = true + + return v + } + + // A cut JSON document is no longer valid JSON. + if !v.Cut { + body = indentJSON(body) + } + + lines := bytes.Count(body, []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 would grow more than +// maxIndentGrowth times. +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 pretty-printing the JSON document body +// keeps it within maxIndentGrowth times its size. It adds up an upper +// bound instead of indenting: each token starts at most one line, +// indented once per enclosing object or array, and a key gains the +// space after its colon. +func indentFits(body []byte) bool { + limit := maxIndentGrowth * len(body) + size, depth := len(body), 0 + + dec := json.NewDecoder(bytes.NewReader(body)) + + // A number too large for a float64 is still valid JSON. + dec.UseNumber() + + for size <= limit { + 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++ + case json.Delim('}'), json.Delim(']'): + depth-- + } + + size += len("\n") + depth*len(jsonIndent) + len(" ") + } + + return false +} diff --git a/internal/handlers/event_body_view_test.go b/internal/handlers/event_body_view_test.go new file mode 100644 index 0000000..68910e4 --- /dev/null +++ b/internal/handlers/event_body_view_test.go @@ -0,0 +1,110 @@ +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_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 that +// indenting would grow out of all proportion is shown as it +// arrived. 10 KB of nested arrays would indent to some 50 MB. +func TestNewBodyView_DeepJSONAsReceived(t *testing.T) { + t.Parallel() + + 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) + + // 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, since html/template would turn it into +// replacement characters. +func TestNewBodyView_BinaryNotShown(t *testing.T) { + t.Parallel() + + for _, body := range []string{"\xff\xfe\xfd", "a\x00b"} { + v := bodyView(body) + + assert.True(t, v.Binary) + assert.Empty(t, v.Text) + } + + assert.False(t, bodyView("snow "+snowman).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 057ae1f..dc51e40 100644 --- a/internal/handlers/handlers.go +++ b/internal/handlers/handlers.go @@ -149,16 +149,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{ @@ -385,7 +392,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, `
{{.AttemptsOmitted}} attempt{{if ne .AttemptsOmitted 1}}s{{end}} omitted between the first and last shown.
+{{end}} +{{range .Results}} +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}} +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"}} +{{.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}}{{.AttemptsOmitted}} attempt{{if ne .AttemptsOmitted 1}}s{{end}} omitted between the first and last shown.
- {{end}} - {{range .Results}} -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}} -No attempts recorded yet.
- {{end}} + {{template "delivery_attempts" .}}