Compare commits
5
Commits
f2dfa9bfac
...
6f67721188
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6f67721188 | ||
|
|
b5c10dabea | ||
|
|
f49f1fc9db | ||
|
|
346374c923 | ||
|
|
719d7013ee |
@@ -1344,22 +1344,27 @@ markup. The CSP build runs no expressions, so every Alpine directive in
|
|||||||
`x-data="{ open: false }"` or `@click="open = !open"`.
|
`x-data="{ open: false }"` or `@click="open = !open"`.
|
||||||
|
|
||||||
A browser test in `internal/server` loads the webhook page and the event log
|
A browser test in `internal/server` loads the webhook page and the event log
|
||||||
under the real policy and checks that: both add forms stay hidden until Add is
|
under the real policy and checks that: the add entrypoint form stays hidden
|
||||||
clicked; choosing Slack in the add target form leaves the HTTP fields out of
|
until Add is clicked; for every target type, the targets section's Add shows
|
||||||
what it submits, also after leaving the page and going back to it, when the
|
only a choice of type with Next and Cancel, Next shows only that type's fields
|
||||||
browser restores the choice; the Copy button beside an entrypoint URL reads
|
(no url field for `database` or `log`), Cancel at either step closes the form,
|
||||||
|
and saving adds the target; a refused target comes back with its form open, the
|
||||||
|
values entered and the reason, and after Cancel the next Add starts with an
|
||||||
|
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
|
"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
|
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
|
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
|
changes the description; of the recent events on the webhook page only the
|
||||||
attempts inside it; and at phone width the menu button opens and closes the
|
newest starts expanded, each expands and collapses, and Open leads to the
|
||||||
mobile menu. It also fails if the browser reports a console warning or error, an
|
event's own page; an event in the event log expands and collapses, and so do a
|
||||||
uncaught exception, or anything the policy refused. `make check` and the image
|
delivery's attempts inside it; and at phone width the menu button opens and
|
||||||
build lint it but do not run it, and `make test` leaves it out (its file is
|
closes the mobile menu. It also fails if the browser reports a console warning
|
||||||
built only with the `browser` build tag). Run it with `make test-browser` after
|
or error, an uncaught exception, or anything the policy refused. `make check`
|
||||||
changing `templates/` or `static/js/`: that builds `Dockerfile.browser`, which
|
and the image build lint it but do not run it, and `make test` leaves it out
|
||||||
runs the test in a digest-pinned headless browser image, so the host needs no
|
(its file is built only with the `browser` build tag). Run it with
|
||||||
browser.
|
`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
|
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
|
byte as the npm registry publishes it. It is a dependency, not this repo's build
|
||||||
@@ -2959,7 +2964,8 @@ returns to the page that was asked for.
|
|||||||
| `POST` | `/hook/{id}/edit` | Edit webhook submission |
|
| `POST` | `/hook/{id}/edit` | Edit webhook submission |
|
||||||
| `POST` | `/hook/{id}/delete` | Delete webhook |
|
| `POST` | `/hook/{id}/delete` | Delete webhook |
|
||||||
| `GET` | `/hook/{id}/events` | Full Event Log |
|
| `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}/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}/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 |
|
| `POST` | `/hook/{id}/entrypoints` | Add entrypoint to webhook |
|
||||||
|
|||||||
@@ -34,8 +34,8 @@ type Event struct {
|
|||||||
ContentType string `json:"contentType"`
|
ContentType string `json:"contentType"`
|
||||||
|
|
||||||
// BodyBytes is the size of Body in bytes, recorded when the event
|
// BodyBytes is the size of Body in bytes, recorded when the event
|
||||||
// is stored so the recent events list can show it without reading
|
// is stored, so that the recent events list, which reads only the
|
||||||
// the body.
|
// start of each body, knows the whole body's size.
|
||||||
BodyBytes int64 `gorm:"not null" json:"bodyBytes"`
|
BodyBytes int64 `gorm:"not null" json:"bodyBytes"`
|
||||||
|
|
||||||
// ResubmittedFromID names the event this one was copied from by
|
// ResubmittedFromID names the event this one was copied from by
|
||||||
|
|||||||
@@ -24,9 +24,10 @@ import (
|
|||||||
const eventBodyQuery = "SELECT cast(body as blob) " +
|
const eventBodyQuery = "SELECT cast(body as blob) " +
|
||||||
"FROM events WHERE id = ? AND webhook_id = ? AND deleted_at IS NULL"
|
"FROM events WHERE id = ? AND webhook_id = ? AND deleted_at IS NULL"
|
||||||
|
|
||||||
// HandleEventBodyDownload serves one event's stored body in
|
// HandleEventBodyDownload serves one event's stored body byte
|
||||||
// full, which the event log page cannot: it caps each rendered
|
// for byte, which the pages do not: they show it as escaped
|
||||||
// body at maxRenderedBodyBytes.
|
// 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
|
// The bytes are attacker-supplied — anyone who can reach the
|
||||||
// public receiver chooses them — and this route hands them back
|
// public receiver chooses them — and this route hands them back
|
||||||
|
|||||||
@@ -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--
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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</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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,14 +5,6 @@ import (
|
|||||||
"unicode/utf8"
|
"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
|
// eventLogColumns is the event log's projection. The casts to
|
||||||
// blob are load-bearing: they make substr and length count
|
// blob are load-bearing: they make substr and length count
|
||||||
// bytes rather than characters, so the cap bounds the page in
|
// 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, " +
|
"substr(cast(body as blob), 1, ?) AS body, " +
|
||||||
"length(cast(body as blob)) AS body_bytes"
|
"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
|
// EventLogView is the display-safe projection of an event for
|
||||||
// the event log page, alongside DeliveryView and TargetView.
|
// the event log page and the event's own page, alongside
|
||||||
// It carries a capped body plus the true stored size, so the
|
// DeliveryView and TargetView.
|
||||||
// page can mark a body as truncated without ever holding the
|
|
||||||
// whole thing.
|
|
||||||
type EventLogView struct {
|
type EventLogView struct {
|
||||||
ID string
|
ID string
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
Method string
|
Method string
|
||||||
ContentType string
|
ContentType string
|
||||||
|
|
||||||
// Body holds at most maxRenderedBodyBytes bytes of the
|
Body BodyView
|
||||||
// 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
|
|
||||||
|
|
||||||
// ResubmittedFromID names the event this one was copied
|
// ResubmittedFromID names the event this one was copied
|
||||||
// from, empty for an event that arrived on the receiver.
|
// from, empty for an event that arrived on the receiver.
|
||||||
@@ -65,16 +53,10 @@ func (v EventLogView) ResubmittedFrom() bool {
|
|||||||
return v.ResubmittedFromID != ""
|
return v.ResubmittedFromID != ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// BodyShownBytes is how many body bytes the page is actually
|
// eventLogRow is one row of the event log projection, or of
|
||||||
// rendering, which the truncation marker reports beside the
|
// eventColumns. In the event log its body column arrives
|
||||||
// true size.
|
// already cut to the cap by SQLite, with the true size beside
|
||||||
func (v EventLogView) BodyShownBytes() int {
|
// it.
|
||||||
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.
|
|
||||||
type eventLogRow struct {
|
type eventLogRow struct {
|
||||||
ID string
|
ID string
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
@@ -85,18 +67,9 @@ type eventLogRow struct {
|
|||||||
BodyBytes int64
|
BodyBytes int64
|
||||||
}
|
}
|
||||||
|
|
||||||
// view projects a loaded row for rendering.
|
// view projects a loaded row of the webhook's events for
|
||||||
func (r *eventLogRow) view() EventLogView {
|
// rendering.
|
||||||
body := r.Body
|
func (r *eventLogRow) view(webhookID string) EventLogView {
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
var from string
|
var from string
|
||||||
if r.ResubmittedFromID != nil {
|
if r.ResubmittedFromID != nil {
|
||||||
from = *r.ResubmittedFromID
|
from = *r.ResubmittedFromID
|
||||||
@@ -107,9 +80,9 @@ func (r *eventLogRow) view() EventLogView {
|
|||||||
CreatedAt: r.CreatedAt,
|
CreatedAt: r.CreatedAt,
|
||||||
Method: r.Method,
|
Method: r.Method,
|
||||||
ContentType: r.ContentType,
|
ContentType: r.ContentType,
|
||||||
Body: string(body),
|
Body: newBodyView(
|
||||||
BodyBytes: r.BodyBytes,
|
"/hook/"+webhookID+"/events/"+r.ID, r.Body, r.BodyBytes,
|
||||||
BodyTruncated: truncated,
|
),
|
||||||
ResubmittedFromID: from,
|
ResubmittedFromID: from,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import (
|
|||||||
"sneak.berlin/go/webhooker/internal/session"
|
"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.
|
// allowed to render for one event.
|
||||||
const bodyCap = handlers.MaxRenderedBodyBytesForTest
|
const bodyCap = handlers.MaxRenderedBodyBytesForTest
|
||||||
|
|
||||||
@@ -85,7 +85,7 @@ func seedAndProject(
|
|||||||
|
|
||||||
// TestHandleSourceLogs_BoundsOversizeBody proves the rendered
|
// TestHandleSourceLogs_BoundsOversizeBody proves the rendered
|
||||||
// page is bounded by the cap rather than by the stored payload:
|
// 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.
|
// accept twice as much again.
|
||||||
func TestHandleSourceLogs_BoundsOversizeBody(t *testing.T) {
|
func TestHandleSourceLogs_BoundsOversizeBody(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
@@ -123,7 +123,7 @@ func TestHandleSourceLogs_BoundsOversizeBody(t *testing.T) {
|
|||||||
// The marker states the true stored size, not the cut one.
|
// The marker states the true stored size, not the cut one.
|
||||||
assert.Contains(
|
assert.Contains(
|
||||||
t, page,
|
t, page,
|
||||||
"showing "+strconv.Itoa(bodyCap)+
|
"Showing the first "+strconv.Itoa(bodyCap)+
|
||||||
" of "+strconv.Itoa(storedBytes)+" bytes",
|
" of "+strconv.Itoa(storedBytes)+" bytes",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -152,34 +152,35 @@ func TestHandleSourceLogs_SmallBodyRendersWhole(t *testing.T) {
|
|||||||
page := renderSourceLogsPage(t, h, sess, wh.ID)
|
page := renderSourceLogsPage(t, h, sess, wh.ID)
|
||||||
|
|
||||||
assert.Contains(t, page, ""kept"")
|
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
|
// TestEventLogView_CutMidRune proves a multi-byte rune severed
|
||||||
// by the byte-wise cut is dropped rather than surfaced as a
|
// 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) {
|
func TestEventLogView_CutMidRune(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
body := strings.Repeat(snowman, 4096)
|
body := strings.Repeat(snowman, bodyCap)
|
||||||
view := seedAndProject(t, body)
|
view := seedAndProject(t, body)
|
||||||
|
|
||||||
// bodyCap bytes hold bodyCap/3 whole snowmen and two bytes
|
// bodyCap bytes hold bodyCap/3 whole snowmen and two bytes
|
||||||
// of the next one; those two are dropped.
|
// of the next one; those two are dropped.
|
||||||
whole := bodyCap / len(snowman)
|
whole := bodyCap / len(snowman)
|
||||||
|
|
||||||
assert.True(t, view.BodyTruncated)
|
assert.True(t, view.Body.Cut)
|
||||||
assert.Equal(t, int64(len(body)), view.BodyBytes)
|
assert.False(t, view.Body.Binary)
|
||||||
assert.Equal(t, strings.Repeat(snowman, whole), view.Body)
|
assert.Equal(t, int64(len(body)), view.Body.Size)
|
||||||
assert.True(t, utf8.ValidString(view.Body))
|
assert.Equal(t, strings.Repeat(snowman, whole), view.Body.Text)
|
||||||
assert.LessOrEqual(t, len(view.Body), bodyCap)
|
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
|
// TestEventLogView_BinaryBodyNotShown proves a body that is not
|
||||||
// payload is passed through byte for byte. Its tail is invalid
|
// text is left out rather than shown as replacement characters,
|
||||||
// UTF-8 however the cut falls, so repairing it would misreport
|
// whether it is cut or not.
|
||||||
// what the sender delivered.
|
func TestEventLogView_BinaryBodyNotShown(t *testing.T) {
|
||||||
func TestEventLogView_BinaryBodyLeftAsStored(t *testing.T) {
|
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
raw := make([]byte, bodyCap+808)
|
raw := make([]byte, bodyCap+808)
|
||||||
@@ -188,12 +189,21 @@ func TestEventLogView_BinaryBodyLeftAsStored(t *testing.T) {
|
|||||||
raw[i] = 0x80 | byte(i%0x40)
|
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)
|
view := seedAndProject(t, string(body))
|
||||||
assert.Equal(t, int64(len(raw)), view.BodyBytes)
|
|
||||||
assert.Equal(t, string(raw[:bodyCap]), view.Body)
|
assert.True(t, view.Body.Binary)
|
||||||
assert.False(t, utf8.ValidString(view.Body))
|
assert.Empty(t, view.Body.Text)
|
||||||
|
assert.Equal(t, int64(len(body)), view.Body.Size)
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestTrimPartialRune covers the distinction the cut repair
|
// TestTrimPartialRune covers the distinction the cut repair
|
||||||
|
|||||||
@@ -19,10 +19,16 @@ func (s *Handlers) SetLogForTest(log *slog.Logger) {
|
|||||||
s.log = log
|
s.log = log
|
||||||
}
|
}
|
||||||
|
|
||||||
// MaxRenderedBodyBytesForTest exposes the event log's body cap
|
// MaxRenderedBodyBytesForTest exposes the body cap of the lists
|
||||||
// to the handlers_test package.
|
// of events to the handlers_test package.
|
||||||
const MaxRenderedBodyBytesForTest = maxRenderedBodyBytes
|
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
|
// MaxRenderedResponseBytesForTest exposes the event log's
|
||||||
// delivery response cap to the handlers_test package.
|
// delivery response cap to the handlers_test package.
|
||||||
const MaxRenderedResponseBytesForTest = maxRenderedResponseBytes
|
const MaxRenderedResponseBytesForTest = maxRenderedResponseBytes
|
||||||
@@ -137,22 +143,20 @@ func (s *Handlers) RenderTemplateForTest(
|
|||||||
// BuildSlackTargetConfigForTest exposes
|
// BuildSlackTargetConfigForTest exposes
|
||||||
// buildSlackTargetConfig for use in the handlers_test package.
|
// buildSlackTargetConfig for use in the handlers_test package.
|
||||||
func (s *Handlers) BuildSlackTargetConfigForTest(
|
func (s *Handlers) BuildSlackTargetConfigForTest(
|
||||||
w http.ResponseWriter,
|
ctx context.Context,
|
||||||
r *http.Request,
|
|
||||||
targetURL string,
|
targetURL string,
|
||||||
) (string, error) {
|
) (string, string, error) {
|
||||||
return s.buildSlackTargetConfig(w, r, targetURL)
|
return s.buildSlackTargetConfig(ctx, targetURL)
|
||||||
}
|
}
|
||||||
|
|
||||||
// BuildHTTPTargetConfigForTest exposes buildHTTPTargetConfig
|
// BuildHTTPTargetConfigForTest exposes buildHTTPTargetConfig
|
||||||
// for use in the handlers_test package, taking the form fields
|
// for use in the handlers_test package, taking the form fields
|
||||||
// an HTTP target's configuration is built from.
|
// an HTTP target's configuration is built from.
|
||||||
func (s *Handlers) BuildHTTPTargetConfigForTest(
|
func (s *Handlers) BuildHTTPTargetConfigForTest(
|
||||||
w http.ResponseWriter,
|
ctx context.Context,
|
||||||
r *http.Request,
|
|
||||||
targetURL, headers, timeout string,
|
targetURL, headers, timeout string,
|
||||||
) (string, error) {
|
) (string, string, error) {
|
||||||
return s.buildHTTPTargetConfig(w, r, targetFormInput{
|
return s.buildHTTPTargetConfig(ctx, targetFormInput{
|
||||||
URL: targetURL,
|
URL: targetURL,
|
||||||
Headers: headers,
|
Headers: headers,
|
||||||
Timeout: timeout,
|
Timeout: timeout,
|
||||||
@@ -162,9 +166,8 @@ func (s *Handlers) BuildHTTPTargetConfigForTest(
|
|||||||
// BuildDatabaseTargetConfigForTest exposes
|
// BuildDatabaseTargetConfigForTest exposes
|
||||||
// buildDatabaseTargetConfig for use in the handlers_test
|
// buildDatabaseTargetConfig for use in the handlers_test
|
||||||
// package.
|
// package.
|
||||||
func (s *Handlers) BuildDatabaseTargetConfigForTest(
|
func BuildDatabaseTargetConfigForTest(
|
||||||
w http.ResponseWriter,
|
|
||||||
expiry string,
|
expiry string,
|
||||||
) (string, error) {
|
) (string, string, error) {
|
||||||
return s.buildDatabaseTargetConfig(w, newRequestForTest(), expiry)
|
return buildDatabaseTargetConfig(expiry)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -155,9 +155,16 @@ func New(
|
|||||||
"settings.html": parsePageTemplate("settings.html"),
|
"settings.html": parsePageTemplate("settings.html"),
|
||||||
"sources_list.html": parsePageTemplate("sources_list.html"),
|
"sources_list.html": parsePageTemplate("sources_list.html"),
|
||||||
"sources_new.html": parsePageTemplate("sources_new.html"),
|
"sources_new.html": parsePageTemplate("sources_new.html"),
|
||||||
"source_detail.html": parsePageTemplate("source_detail.html", "webhook_stats.html"),
|
"source_detail.html": parsePageTemplate(
|
||||||
|
"source_detail.html", "webhook_stats.html", "event_body.html",
|
||||||
|
),
|
||||||
"source_edit.html": parsePageTemplate("source_edit.html"),
|
"source_edit.html": parsePageTemplate("source_edit.html"),
|
||||||
"source_logs.html": parsePageTemplate("source_logs.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"),
|
"target_edit.html": parsePageTemplate("target_edit.html"),
|
||||||
"error.html": parsePageTemplate("error.html"),
|
"error.html": parsePageTemplate("error.html"),
|
||||||
}
|
}
|
||||||
@@ -386,7 +393,7 @@ func (s *Handlers) pageData(
|
|||||||
// partial body and the status before a mid-render error can be
|
// 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
|
// reported, leaving no way to serve a 500. Buffering makes a page's
|
||||||
// rendered size resident memory per concurrent viewer, so every page
|
// 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.
|
// maxRenderedBodyBytes for exactly this reason.
|
||||||
func (s *Handlers) executeTemplate(
|
func (s *Handlers) executeTemplate(
|
||||||
w http.ResponseWriter,
|
w http.ResponseWriter,
|
||||||
|
|||||||
@@ -314,16 +314,12 @@ func TestBuildSlackTargetConfig_AcceptsPublicURL(t *testing.T) {
|
|||||||
|
|
||||||
t.Cleanup(app.RequireStop)
|
t.Cleanup(app.RequireStop)
|
||||||
|
|
||||||
req := httptest.NewRequestWithContext(
|
cfg, errMsg, err := h.BuildSlackTargetConfigForTest(
|
||||||
context.Background(), http.MethodPost, "/", nil)
|
t.Context(), "http://93.184.216.34/services/T00/B00/xxx",
|
||||||
w := httptest.NewRecorder()
|
|
||||||
|
|
||||||
cfg, err := h.BuildSlackTargetConfigForTest(
|
|
||||||
w, req, "http://93.184.216.34/services/T00/B00/xxx",
|
|
||||||
)
|
)
|
||||||
|
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, http.StatusOK, w.Code)
|
assert.Empty(t, errMsg)
|
||||||
assert.Contains(t, cfg, "webhookUrl")
|
assert.Contains(t, cfg, "webhookUrl")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -337,17 +333,13 @@ func TestBuildSlackTargetConfig_RejectsReservedURL(t *testing.T) {
|
|||||||
|
|
||||||
t.Cleanup(app.RequireStop)
|
t.Cleanup(app.RequireStop)
|
||||||
|
|
||||||
req := httptest.NewRequestWithContext(
|
cfg, errMsg, err := h.BuildSlackTargetConfigForTest(
|
||||||
context.Background(), http.MethodPost, "/", nil)
|
t.Context(), "http://169.254.169.254/latest/meta-data/",
|
||||||
w := httptest.NewRecorder()
|
|
||||||
|
|
||||||
cfg, err := h.BuildSlackTargetConfigForTest(
|
|
||||||
w, req, "http://169.254.169.254/latest/meta-data/",
|
|
||||||
)
|
)
|
||||||
|
|
||||||
require.Error(t, err)
|
require.NoError(t, err)
|
||||||
|
assert.Contains(t, errMsg, "Invalid target URL")
|
||||||
assert.Empty(t, cfg)
|
assert.Empty(t, cfg)
|
||||||
assert.Equal(t, http.StatusBadRequest, w.Code)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRenderTemplate(t *testing.T) {
|
func TestRenderTemplate(t *testing.T) {
|
||||||
@@ -444,29 +436,22 @@ func TestRenderTemplateMidRenderErrorSendsNoPartialBody(t *testing.T) {
|
|||||||
func TestBuildDatabaseTargetConfig_Valid(t *testing.T) {
|
func TestBuildDatabaseTargetConfig_Valid(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
var h *handlers.Handlers
|
|
||||||
|
|
||||||
app := newTestApp(t, &h)
|
|
||||||
app.RequireStart()
|
|
||||||
|
|
||||||
t.Cleanup(app.RequireStop)
|
|
||||||
|
|
||||||
// Empty expiry: the keep-forever default, empty config.
|
// Empty expiry: the keep-forever default, empty config.
|
||||||
w := httptest.NewRecorder()
|
cfg, errMsg, err := handlers.BuildDatabaseTargetConfigForTest("")
|
||||||
cfg, err := h.BuildDatabaseTargetConfigForTest(w, "")
|
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
assert.Empty(t, errMsg)
|
||||||
assert.Empty(t, cfg)
|
assert.Empty(t, cfg)
|
||||||
|
|
||||||
// Explicit never is stored as config.
|
// Explicit never is stored as config.
|
||||||
w = httptest.NewRecorder()
|
cfg, errMsg, err = handlers.BuildDatabaseTargetConfigForTest("never")
|
||||||
cfg, err = h.BuildDatabaseTargetConfigForTest(w, "never")
|
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
assert.Empty(t, errMsg)
|
||||||
assert.JSONEq(t, `{"expiry":"never"}`, cfg)
|
assert.JSONEq(t, `{"expiry":"never"}`, cfg)
|
||||||
|
|
||||||
// A positive duration is stored as config.
|
// A positive duration is stored as config.
|
||||||
w = httptest.NewRecorder()
|
cfg, errMsg, err = handlers.BuildDatabaseTargetConfigForTest("720h")
|
||||||
cfg, err = h.BuildDatabaseTargetConfigForTest(w, "720h")
|
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
assert.Empty(t, errMsg)
|
||||||
assert.JSONEq(t, `{"expiry":"720h"}`, cfg)
|
assert.JSONEq(t, `{"expiry":"720h"}`, cfg)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -475,22 +460,14 @@ func TestBuildDatabaseTargetConfig_RejectsBadExpiry(
|
|||||||
) {
|
) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
var h *handlers.Handlers
|
|
||||||
|
|
||||||
app := newTestApp(t, &h)
|
|
||||||
app.RequireStart()
|
|
||||||
|
|
||||||
t.Cleanup(app.RequireStop)
|
|
||||||
|
|
||||||
for _, bad := range []string{"nonsense", "7d", "-5h"} {
|
for _, bad := range []string{"nonsense", "7d", "-5h"} {
|
||||||
w := httptest.NewRecorder()
|
cfg, errMsg, err := handlers.BuildDatabaseTargetConfigForTest(bad)
|
||||||
cfg, err := h.BuildDatabaseTargetConfigForTest(w, bad)
|
|
||||||
|
|
||||||
require.Error(t, err, "expiry %q", bad)
|
require.NoError(t, err)
|
||||||
assert.Empty(t, cfg)
|
assert.Contains(
|
||||||
assert.Equal(
|
t, errMsg, "Invalid archive expiry",
|
||||||
t, http.StatusBadRequest, w.Code,
|
"expiry %q should be refused", bad,
|
||||||
"expiry %q should be rejected with 400", bad,
|
|
||||||
)
|
)
|
||||||
|
assert.Empty(t, cfg)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,11 +12,12 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// recentEventColumns is the recent events list's projection. It
|
// recentEventColumns is the recent events list's projection. It
|
||||||
// leaves out the body, for the reason maxRenderedBodyBytes gives,
|
// reads the body cut to maxRenderedBodyBytes, as eventLogColumns
|
||||||
// and reads its size from body_bytes, recorded when the event was
|
// does, and its size from body_bytes, recorded when the event was
|
||||||
// stored.
|
// stored.
|
||||||
const recentEventColumns = "id, created_at, method, content_type, " +
|
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
|
// recentAttemptColumns is the part of a recorded attempt the list
|
||||||
// uses. The event log's deliveryResultColumns also reads response
|
// uses. The event log's deliveryResultColumns also reads response
|
||||||
@@ -50,6 +51,9 @@ type RecentEventView struct {
|
|||||||
// unless the webhook has exactly one HTTP target.
|
// unless the webhook has exactly one HTTP target.
|
||||||
Status string
|
Status string
|
||||||
StatusClass string
|
StatusClass string
|
||||||
|
|
||||||
|
// Body is what the row shows when it is expanded.
|
||||||
|
Body BodyView
|
||||||
}
|
}
|
||||||
|
|
||||||
// recentEventRow is one row of recentEventColumns.
|
// recentEventRow is one row of recentEventColumns.
|
||||||
@@ -60,6 +64,7 @@ type recentEventRow struct {
|
|||||||
ContentType string
|
ContentType string
|
||||||
ResubmittedFromID *string
|
ResubmittedFromID *string
|
||||||
BodyBytes uint64
|
BodyBytes uint64
|
||||||
|
Body []byte
|
||||||
}
|
}
|
||||||
|
|
||||||
// recentAttemptRow is one row of recentAttemptColumns. CreatedAt is
|
// recentAttemptRow is one row of recentAttemptColumns. CreatedAt is
|
||||||
@@ -100,7 +105,7 @@ func loadRecentEvents(
|
|||||||
var rows []recentEventRow
|
var rows []recentEventRow
|
||||||
|
|
||||||
err := webhookDB.Model(&database.Event{}).
|
err := webhookDB.Model(&database.Event{}).
|
||||||
Select(recentEventColumns).
|
Select(recentEventColumns, maxRenderedBodyBytes).
|
||||||
Where("webhook_id = ?", webhookID).
|
Where("webhook_id = ?", webhookID).
|
||||||
Order("created_at DESC").
|
Order("created_at DESC").
|
||||||
Limit(recentEventLimit).
|
Limit(recentEventLimit).
|
||||||
@@ -145,7 +150,7 @@ func loadRecentEvents(
|
|||||||
views := make([]RecentEventView, len(rows))
|
views := make([]RecentEventView, len(rows))
|
||||||
for i := range rows {
|
for i := range rows {
|
||||||
views[i] = rows[i].view(
|
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
|
return byDelivery, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// view projects a loaded row for rendering. deliveries is the
|
// view projects a loaded row of the webhook's events for
|
||||||
// event's deliveries, oldest first, and attempts their recorded
|
// rendering. deliveries is the event's deliveries, oldest first,
|
||||||
// attempts keyed by delivery ID.
|
// and attempts their recorded attempts keyed by delivery ID.
|
||||||
func (r *recentEventRow) view(
|
func (r *recentEventRow) view(
|
||||||
|
webhookID string,
|
||||||
deliveries []database.Delivery,
|
deliveries []database.Delivery,
|
||||||
attempts map[string][]recentAttemptRow,
|
attempts map[string][]recentAttemptRow,
|
||||||
statusTargetID string,
|
statusTargetID string,
|
||||||
) RecentEventView {
|
) 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{
|
v := RecentEventView{
|
||||||
Method: r.Method,
|
Method: r.Method,
|
||||||
ContentType: r.ContentType,
|
ContentType: r.ContentType,
|
||||||
@@ -197,6 +208,7 @@ func (r *recentEventRow) view(
|
|||||||
ReceivedUTC: r.CreatedAt.UTC().Format(time.DateTime) + " UTC",
|
ReceivedUTC: r.CreatedAt.UTC().Format(time.DateTime) + " UTC",
|
||||||
Size: humanize.Bytes(r.BodyBytes),
|
Size: humanize.Bytes(r.BodyBytes),
|
||||||
ProcessingTime: processingTime(deliveries, attempts),
|
ProcessingTime: processingTime(deliveries, attempts),
|
||||||
|
Body: body,
|
||||||
}
|
}
|
||||||
|
|
||||||
if r.ResubmittedFromID != nil {
|
if r.ResubmittedFromID != nil {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"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, ""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
|
// TestHandleWebhook_RecordsBodySize proves the receiver records the
|
||||||
// body's size in bytes, not characters, with the event it stores.
|
// body's size in bytes, not characters, with the event it stores.
|
||||||
func TestHandleWebhook_RecordsBodySize(t *testing.T) {
|
func TestHandleWebhook_RecordsBodySize(t *testing.T) {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -38,9 +39,6 @@ type WebhookListItem struct {
|
|||||||
EventsUnreadable bool
|
EventsUnreadable bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// errMissingURL signals that a required URL was not provided.
|
|
||||||
var errMissingURL = errors.New("missing URL")
|
|
||||||
|
|
||||||
// parseRetentionDays interprets a retention_days form value. It
|
// parseRetentionDays interprets a retention_days form value. It
|
||||||
// returns the number of days, or, for a value it refuses, the message
|
// returns the number of days, or, for a value it refuses, the message
|
||||||
// the create and edit forms show; the message is empty when the value
|
// the create and edit forms show; the message is empty when the value
|
||||||
@@ -460,15 +458,20 @@ func (h *Handlers) HandleSourceDetail() http.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
h.renderSourceDetail(w, r, webhook)
|
h.renderSourceDetail(w, r, webhook, targetFormInput{}, "")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// renderSourceDetail loads and renders a source detail page.
|
// renderSourceDetail loads and renders a source detail page. With a
|
||||||
|
// targetErr, it is the page shown again for a refused add target
|
||||||
|
// form: it answers 400, and the form opens on targetForm's type with
|
||||||
|
// its values and the message.
|
||||||
func (h *Handlers) renderSourceDetail(
|
func (h *Handlers) renderSourceDetail(
|
||||||
w http.ResponseWriter,
|
w http.ResponseWriter,
|
||||||
r *http.Request,
|
r *http.Request,
|
||||||
webhook database.Webhook,
|
webhook database.Webhook,
|
||||||
|
targetForm targetFormInput,
|
||||||
|
targetErr string,
|
||||||
) {
|
) {
|
||||||
var entrypoints []database.Entrypoint
|
var entrypoints []database.Entrypoint
|
||||||
|
|
||||||
@@ -525,9 +528,16 @@ func (h *Handlers) renderSourceDetail(
|
|||||||
"Events": events,
|
"Events": events,
|
||||||
"BaseURL": baseURL,
|
"BaseURL": baseURL,
|
||||||
"Stats": h.loadWebhookStats(webhook.ID, entrypoints, targets),
|
"Stats": h.loadWebhookStats(webhook.ID, entrypoints, targets),
|
||||||
|
"TargetForm": targetForm,
|
||||||
|
"TargetError": targetErr,
|
||||||
}
|
}
|
||||||
|
|
||||||
h.renderTemplate(w, r, "source_detail.html", data)
|
status := http.StatusOK
|
||||||
|
if targetErr != "" {
|
||||||
|
status = http.StatusBadRequest
|
||||||
|
}
|
||||||
|
|
||||||
|
h.renderTemplateStatus(w, r, "source_detail.html", data, status)
|
||||||
}
|
}
|
||||||
|
|
||||||
// HandleSourceEdit shows the form to edit a webhook.
|
// HandleSourceEdit shows the form to edit a webhook.
|
||||||
@@ -1022,10 +1032,10 @@ func (h *Handlers) HandleSourceLogs() http.HandlerFunc {
|
|||||||
// view, which renders its target as a blank name.
|
// view, which renders its target as a blank name.
|
||||||
//
|
//
|
||||||
// This map is historical display only. It is built for the event
|
// This map is historical display only. It is built for the event
|
||||||
// log page and reaches nothing but DeliveryView.Target: the
|
// log and an event's own page, and reaches nothing but
|
||||||
// target list on the source detail page, the edit form and the
|
// DeliveryView.Target: the target list on the source detail page,
|
||||||
// replay path each resolve targets themselves, and a deleted row
|
// the edit form and the replay path each resolve targets
|
||||||
// is refused there as before.
|
// themselves, and a deleted row is refused there as before.
|
||||||
func (h *Handlers) loadTargetMap(
|
func (h *Handlers) loadTargetMap(
|
||||||
webhookID string,
|
webhookID string,
|
||||||
) (map[string]eventLogTarget, error) {
|
) (map[string]eventLogTarget, error) {
|
||||||
@@ -1081,10 +1091,8 @@ func (h *Handlers) loadEventsWithDeliveries(
|
|||||||
targetMap map[string]eventLogTarget,
|
targetMap map[string]eventLogTarget,
|
||||||
page int,
|
page int,
|
||||||
) ([]EventLogView, int64, bool) {
|
) ([]EventLogView, int64, bool) {
|
||||||
var result []EventLogView
|
|
||||||
|
|
||||||
if !h.dbMgr.DBExists(webhook.ID) {
|
if !h.dbMgr.DBExists(webhook.ID) {
|
||||||
return result, 0, true
|
return nil, 0, true
|
||||||
}
|
}
|
||||||
|
|
||||||
webhookDB, err := h.dbMgr.GetDB(webhook.ID)
|
webhookDB, err := h.dbMgr.GetDB(webhook.ID)
|
||||||
@@ -1100,7 +1108,26 @@ func (h *Handlers) loadEventsWithDeliveries(
|
|||||||
webhookDB, webhook.ID, page,
|
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))
|
eventDeliveries := make([][]database.Delivery, len(rows))
|
||||||
|
|
||||||
var deliveryIDs []string
|
var deliveryIDs []string
|
||||||
@@ -1108,7 +1135,7 @@ func (h *Handlers) loadEventsWithDeliveries(
|
|||||||
eventIDs := make([]string, len(rows))
|
eventIDs := make([]string, len(rows))
|
||||||
|
|
||||||
for i := range rows {
|
for i := range rows {
|
||||||
result[i] = rows[i].view()
|
result[i] = rows[i].view(webhookID)
|
||||||
eventIDs[i] = rows[i].ID
|
eventIDs[i] = rows[i].ID
|
||||||
|
|
||||||
webhookDB.Where(
|
webhookDB.Where(
|
||||||
@@ -1130,7 +1157,7 @@ func (h *Handlers) loadEventsWithDeliveries(
|
|||||||
w, r, "failed to load delivery attempts", err,
|
w, r, "failed to load delivery attempts", err,
|
||||||
)
|
)
|
||||||
|
|
||||||
return nil, 0, false
|
return nil, false
|
||||||
}
|
}
|
||||||
|
|
||||||
resubmits, err := resubmitCounts(webhookDB, eventIDs)
|
resubmits, err := resubmitCounts(webhookDB, eventIDs)
|
||||||
@@ -1139,7 +1166,7 @@ func (h *Handlers) loadEventsWithDeliveries(
|
|||||||
w, r, "failed to count event resubmissions", err,
|
w, r, "failed to count event resubmissions", err,
|
||||||
)
|
)
|
||||||
|
|
||||||
return nil, 0, false
|
return nil, false
|
||||||
}
|
}
|
||||||
|
|
||||||
for i := range rows {
|
for i := range rows {
|
||||||
@@ -1149,7 +1176,7 @@ func (h *Handlers) loadEventsWithDeliveries(
|
|||||||
result[i].ResubmitCount = resubmits[rows[i].ID]
|
result[i].ResubmitCount = resubmits[rows[i].ID]
|
||||||
}
|
}
|
||||||
|
|
||||||
return result, totalEvents, true
|
return result, true
|
||||||
}
|
}
|
||||||
|
|
||||||
// loadEventLogRows reads one page of the event log projection, newest
|
// loadEventLogRows reads one page of the event log projection, newest
|
||||||
@@ -1501,64 +1528,27 @@ func (h *Handlers) HandleTargetCreate() http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// processTargetCreate validates and creates a new target.
|
// processTargetCreate validates and creates a new target. A refused
|
||||||
|
// submission shows the webhook page again, with the add target form
|
||||||
|
// open on the chosen type, the values entered, and the reason.
|
||||||
func (h *Handlers) processTargetCreate(
|
func (h *Handlers) processTargetCreate(
|
||||||
w http.ResponseWriter,
|
w http.ResponseWriter,
|
||||||
r *http.Request,
|
r *http.Request,
|
||||||
webhook database.Webhook,
|
webhook database.Webhook,
|
||||||
) {
|
) {
|
||||||
// The body size cap is enforced by the MaxBodySize middleware,
|
in := targetFormInputFrom(r)
|
||||||
// which runs before CSRF parses the form.
|
|
||||||
//
|
|
||||||
// Every field here is read with PostFormValue, not FormValue.
|
|
||||||
// FormValue falls back to the query string, which would let
|
|
||||||
// `POST /hook/{id}/targets?url=https://hooks.slack.com/...`
|
|
||||||
// configure a target from a value the request line carries — and
|
|
||||||
// the request line, unlike the body, is what logs, proxies,
|
|
||||||
// Referer headers and error trackers record.
|
|
||||||
name := r.PostFormValue("name")
|
|
||||||
targetType := database.TargetType(r.PostFormValue("type"))
|
|
||||||
|
|
||||||
if name == "" {
|
target, errMsg, err := h.newTarget(r.Context(), webhook.ID, in)
|
||||||
http.Error(
|
|
||||||
w, "Name is required", http.StatusBadRequest,
|
|
||||||
)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if !isValidTargetType(targetType) {
|
|
||||||
http.Error(
|
|
||||||
w, "Invalid target type",
|
|
||||||
http.StatusBadRequest,
|
|
||||||
)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
configJSON, err := h.buildTargetConfig(
|
|
||||||
w, r, targetType, targetFormInputFrom(r),
|
|
||||||
)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
h.serverError(w, r, "failed to encode target config", err)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// A new target has no stored retry count, so an absent field
|
if errMsg != "" {
|
||||||
// takes the fire-and-forget default. A field the operator filled
|
h.renderSourceDetail(w, r, webhook, in, errMsg)
|
||||||
// in with something invalid is rejected rather than becoming
|
|
||||||
// that default.
|
|
||||||
maxRetries, ok := targetMaxRetries(w, r, 0)
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
target := &database.Target{
|
return
|
||||||
WebhookID: webhook.ID,
|
|
||||||
Name: name,
|
|
||||||
Type: targetType,
|
|
||||||
Active: true,
|
|
||||||
Config: configJSON,
|
|
||||||
MaxRetries: maxRetries,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
err = h.db.DB().Create(target).Error
|
err = h.db.DB().Create(target).Error
|
||||||
@@ -1574,6 +1564,49 @@ func (h *Handlers) processTargetCreate(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// newTarget validates a new target for a webhook and returns the row
|
||||||
|
// to create, or, when it refuses the target, the message the form
|
||||||
|
// shows. An error is the server's fault, not a refusal: the accepted
|
||||||
|
// configuration could not be encoded. Every form that creates a
|
||||||
|
// target goes through here, so they all accept and refuse the same
|
||||||
|
// things.
|
||||||
|
func (h *Handlers) newTarget(
|
||||||
|
ctx context.Context,
|
||||||
|
webhookID string,
|
||||||
|
in targetFormInput,
|
||||||
|
) (*database.Target, string, error) {
|
||||||
|
if in.Name == "" {
|
||||||
|
return nil, "Name is required", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if !isValidTargetType(in.Type) {
|
||||||
|
return nil, "Invalid target type", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
configJSON, errMsg, err := h.buildTargetConfig(ctx, in.Type, in)
|
||||||
|
if err != nil || errMsg != "" {
|
||||||
|
return nil, errMsg, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// A new target has no stored retry count, so an absent field
|
||||||
|
// takes the fire-and-forget default. A field the operator filled
|
||||||
|
// in with something invalid is refused rather than becoming
|
||||||
|
// that default.
|
||||||
|
maxRetries, err := parseMaxRetries(in.MaxRetries, 0)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "Invalid max retries: " + retriesErrorMessage(err), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return &database.Target{
|
||||||
|
WebhookID: webhookID,
|
||||||
|
Name: in.Name,
|
||||||
|
Type: in.Type,
|
||||||
|
Active: true,
|
||||||
|
Config: configJSON,
|
||||||
|
MaxRetries: maxRetries,
|
||||||
|
}, "", nil
|
||||||
|
}
|
||||||
|
|
||||||
// isValidTargetType checks whether the target type is supported.
|
// isValidTargetType checks whether the target type is supported.
|
||||||
func isValidTargetType(tt database.TargetType) bool {
|
func isValidTargetType(tt database.TargetType) bool {
|
||||||
switch tt {
|
switch tt {
|
||||||
@@ -1605,11 +1638,16 @@ func pageOrFirst(s string) int {
|
|||||||
return v
|
return v
|
||||||
}
|
}
|
||||||
|
|
||||||
// targetFormInput carries the raw form values describing a target's
|
// targetFormInput carries the raw values of a target form. Both the
|
||||||
// configuration. Both the create and the edit path fill one and hand
|
// create and the edit path fill one and hand it to buildTargetConfig,
|
||||||
// it to buildTargetConfig, so neither can come to validate a
|
// so neither can come to validate a destination differently from the
|
||||||
// destination differently from the other.
|
// other. A refused add target form is shown again from it.
|
||||||
type targetFormInput struct {
|
type targetFormInput struct {
|
||||||
|
// Name is the target's name.
|
||||||
|
Name string
|
||||||
|
// Type is the type chosen on the add target form. The edit form
|
||||||
|
// has none: a target's stored type decides.
|
||||||
|
Type database.TargetType
|
||||||
// URL is the destination for an HTTP target and the webhook URL
|
// URL is the destination for an HTTP target and the webhook URL
|
||||||
// for a Slack target.
|
// for a Slack target.
|
||||||
URL string
|
URL string
|
||||||
@@ -1618,13 +1656,15 @@ type targetFormInput struct {
|
|||||||
Headers string
|
Headers string
|
||||||
// Timeout is an HTTP target's per-request timeout in seconds.
|
// Timeout is an HTTP target's per-request timeout in seconds.
|
||||||
Timeout string
|
Timeout string
|
||||||
|
// MaxRetries is an HTTP or Slack target's max_retries.
|
||||||
|
MaxRetries string
|
||||||
// Expiry is a database (archive) target's row expiry.
|
// Expiry is a database (archive) target's row expiry.
|
||||||
Expiry string
|
Expiry string
|
||||||
}
|
}
|
||||||
|
|
||||||
// targetFormInputFrom reads the configuration fields from a request
|
// targetFormInputFrom reads a target form from a request body. The
|
||||||
// body. The body size cap is enforced by the MaxBodySize middleware,
|
// body size cap is enforced by the MaxBodySize middleware, which runs
|
||||||
// which runs before CSRF parses the form.
|
// before CSRF parses the form.
|
||||||
//
|
//
|
||||||
// Every field is read with PostFormValue, not FormValue. FormValue
|
// Every field is read with PostFormValue, not FormValue. FormValue
|
||||||
// falls back to the query string, which would let
|
// falls back to the query string, which would let
|
||||||
@@ -1636,38 +1676,38 @@ type targetFormInput struct {
|
|||||||
// tokens.
|
// tokens.
|
||||||
func targetFormInputFrom(r *http.Request) targetFormInput {
|
func targetFormInputFrom(r *http.Request) targetFormInput {
|
||||||
return targetFormInput{
|
return targetFormInput{
|
||||||
|
Name: r.PostFormValue("name"),
|
||||||
|
Type: database.TargetType(r.PostFormValue("type")),
|
||||||
URL: r.PostFormValue("url"),
|
URL: r.PostFormValue("url"),
|
||||||
Headers: r.PostFormValue("headers"),
|
Headers: r.PostFormValue("headers"),
|
||||||
Timeout: r.PostFormValue("timeout"),
|
Timeout: r.PostFormValue("timeout"),
|
||||||
|
MaxRetries: r.PostFormValue("max_retries"),
|
||||||
Expiry: r.PostFormValue("expiry"),
|
Expiry: r.PostFormValue("expiry"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildTargetConfig builds the JSON config string for a target from
|
// buildTargetConfig builds the JSON config string for a target from
|
||||||
// the submitted form values, writing its own 4xx response on
|
// the submitted form values, or returns the message the form shows
|
||||||
// rejection. Which fields of in apply depends on the target type.
|
// for a value it refuses. An error is the server's fault, not a
|
||||||
|
// refusal: the accepted configuration could not be encoded. Which
|
||||||
|
// fields of in apply depends on the target type; a type without a URL
|
||||||
|
// ignores any URL submitted.
|
||||||
func (h *Handlers) buildTargetConfig(
|
func (h *Handlers) buildTargetConfig(
|
||||||
w http.ResponseWriter,
|
ctx context.Context,
|
||||||
r *http.Request,
|
|
||||||
targetType database.TargetType,
|
targetType database.TargetType,
|
||||||
in targetFormInput,
|
in targetFormInput,
|
||||||
) (string, error) {
|
) (string, string, error) {
|
||||||
switch targetType {
|
switch targetType {
|
||||||
case database.TargetTypeHTTP:
|
case database.TargetTypeHTTP:
|
||||||
return h.buildHTTPTargetConfig(w, r, in)
|
return h.buildHTTPTargetConfig(ctx, in)
|
||||||
case database.TargetTypeSlack:
|
case database.TargetTypeSlack:
|
||||||
return h.buildSlackTargetConfig(w, r, in.URL)
|
return h.buildSlackTargetConfig(ctx, in.URL)
|
||||||
case database.TargetTypeDatabase:
|
case database.TargetTypeDatabase:
|
||||||
return h.buildDatabaseTargetConfig(w, r, in.Expiry)
|
return buildDatabaseTargetConfig(in.Expiry)
|
||||||
case database.TargetTypeLog:
|
case database.TargetTypeLog:
|
||||||
return "", nil
|
return "", "", nil
|
||||||
default:
|
default:
|
||||||
http.Error(
|
return "", "Invalid target type", nil
|
||||||
w, "Invalid target type",
|
|
||||||
http.StatusBadRequest,
|
|
||||||
)
|
|
||||||
|
|
||||||
return "", errMissingURL
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1675,92 +1715,73 @@ func (h *Handlers) buildTargetConfig(
|
|||||||
// SSRF-validated destination plus the optional headers and timeout
|
// SSRF-validated destination plus the optional headers and timeout
|
||||||
// the delivery path honours.
|
// the delivery path honours.
|
||||||
func (h *Handlers) buildHTTPTargetConfig(
|
func (h *Handlers) buildHTTPTargetConfig(
|
||||||
w http.ResponseWriter,
|
ctx context.Context,
|
||||||
r *http.Request,
|
|
||||||
in targetFormInput,
|
in targetFormInput,
|
||||||
) (string, error) {
|
) (string, string, error) {
|
||||||
err := h.validateTargetURL(
|
errMsg := h.validateTargetURL(
|
||||||
w, r, in.URL, "URL is required for HTTP targets",
|
ctx, in.URL, "URL is required for HTTP targets",
|
||||||
)
|
)
|
||||||
if err != nil {
|
if errMsg != "" {
|
||||||
return "", err
|
return "", errMsg, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
headers, err := delivery.ParseTargetHeaders(in.Headers)
|
headers, err := delivery.ParseTargetHeaders(in.Headers)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(
|
return "", fmt.Sprintf("Invalid headers: %v", err), nil
|
||||||
w,
|
|
||||||
"Invalid headers: "+err.Error(),
|
|
||||||
http.StatusBadRequest,
|
|
||||||
)
|
|
||||||
|
|
||||||
return "", err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
timeout, err := delivery.ParseTargetTimeout(in.Timeout)
|
timeout, err := delivery.ParseTargetTimeout(in.Timeout)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(
|
return "", fmt.Sprintf("Invalid timeout: %v", err), nil
|
||||||
w,
|
|
||||||
"Invalid timeout: "+err.Error(),
|
|
||||||
http.StatusBadRequest,
|
|
||||||
)
|
|
||||||
|
|
||||||
return "", err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return h.marshalTargetConfig(w, r, delivery.HTTPTargetConfig{
|
configJSON, err := marshalTargetConfig(delivery.HTTPTargetConfig{
|
||||||
URL: in.URL,
|
URL: in.URL,
|
||||||
Headers: headers,
|
Headers: headers,
|
||||||
Timeout: timeout,
|
Timeout: timeout,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
return configJSON, "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildSlackTargetConfig builds config JSON for a Slack target,
|
// buildSlackTargetConfig builds config JSON for a Slack target,
|
||||||
// whose whole configuration is one SSRF-validated webhook URL.
|
// whose whole configuration is one SSRF-validated webhook URL.
|
||||||
func (h *Handlers) buildSlackTargetConfig(
|
func (h *Handlers) buildSlackTargetConfig(
|
||||||
w http.ResponseWriter,
|
ctx context.Context,
|
||||||
r *http.Request,
|
|
||||||
targetURL string,
|
targetURL string,
|
||||||
) (string, error) {
|
) (string, string, error) {
|
||||||
err := h.validateTargetURL(
|
errMsg := h.validateTargetURL(
|
||||||
w, r, targetURL,
|
ctx, targetURL,
|
||||||
"Webhook URL is required for Slack targets",
|
"Webhook URL is required for Slack targets",
|
||||||
)
|
)
|
||||||
if err != nil {
|
if errMsg != "" {
|
||||||
return "", err
|
return "", errMsg, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
return h.marshalTargetConfig(w, r, delivery.SlackTargetConfig{
|
configJSON, err := marshalTargetConfig(delivery.SlackTargetConfig{
|
||||||
WebhookURL: targetURL,
|
WebhookURL: targetURL,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
return configJSON, "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
// validateTargetURL rejects an empty or SSRF-blocked destination,
|
// validateTargetURL refuses an empty or SSRF-blocked destination,
|
||||||
// writing the 400 itself. missingMsg is the error shown when no URL
|
// returning the message the form shows, or "" when the destination
|
||||||
// is given.
|
// is accepted. missingMsg is the message for no URL at all.
|
||||||
//
|
//
|
||||||
// It is the single point at which a user-supplied destination enters
|
// It is the single point at which a user-supplied destination enters
|
||||||
// the SSRF guard, on create and on edit alike. An edit path that
|
// the SSRF guard, on create and on edit alike. An edit path that
|
||||||
// reached storage without passing through here would reopen the hole
|
// reached storage without passing through here would reopen the hole
|
||||||
// the guard closes.
|
// the guard closes.
|
||||||
func (h *Handlers) validateTargetURL(
|
func (h *Handlers) validateTargetURL(
|
||||||
w http.ResponseWriter,
|
ctx context.Context,
|
||||||
r *http.Request,
|
|
||||||
targetURL, missingMsg string,
|
targetURL, missingMsg string,
|
||||||
) error {
|
) string {
|
||||||
if targetURL == "" {
|
if targetURL == "" {
|
||||||
http.Error(
|
return missingMsg
|
||||||
w,
|
|
||||||
missingMsg,
|
|
||||||
http.StatusBadRequest,
|
|
||||||
)
|
|
||||||
|
|
||||||
return errMissingURL
|
|
||||||
}
|
}
|
||||||
|
|
||||||
err := h.ssrf.ValidateTargetURL(
|
err := h.ssrf.ValidateTargetURL(ctx, targetURL)
|
||||||
r.Context(), targetURL,
|
|
||||||
)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// The submitted URL can be a credential (a Slack
|
// The submitted URL can be a credential (a Slack
|
||||||
// incoming webhook URL is a bearer token), so the log
|
// incoming webhook URL is a bearer token), so the log
|
||||||
@@ -1786,25 +1807,16 @@ func (h *Handlers) validateTargetURL(
|
|||||||
"egress to your own network\" in the README)."
|
"egress to your own network\" in the README)."
|
||||||
}
|
}
|
||||||
|
|
||||||
http.Error(w, msg, http.StatusBadRequest)
|
return msg
|
||||||
|
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// marshalTargetConfig serialises a target configuration for storage,
|
// marshalTargetConfig serialises a target configuration for storage.
|
||||||
// writing a 500 itself if it cannot.
|
func marshalTargetConfig(cfg any) (string, error) {
|
||||||
func (h *Handlers) marshalTargetConfig(
|
|
||||||
w http.ResponseWriter,
|
|
||||||
r *http.Request,
|
|
||||||
cfg any,
|
|
||||||
) (string, error) {
|
|
||||||
configBytes, err := json.Marshal(cfg)
|
configBytes, err := json.Marshal(cfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.serverError(w, r, "failed to encode target config", err)
|
|
||||||
|
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1812,35 +1824,24 @@ func (h *Handlers) marshalTargetConfig(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// buildDatabaseTargetConfig builds config JSON for a database
|
// buildDatabaseTargetConfig builds config JSON for a database
|
||||||
// (archive) target. The optional expiry (a form value read by
|
// (archive) target. The optional expiry is validated here, at
|
||||||
// the caller, which bounds the request body) is validated here,
|
// creation time, so an unparseable value is refused instead of
|
||||||
// at creation time, so an unparseable value is rejected with a
|
// failing every subsequent delivery. An empty expiry yields an
|
||||||
// 400 instead of failing every subsequent delivery. An empty
|
// empty config (the keep-forever default).
|
||||||
// expiry yields an empty config (the keep-forever default).
|
func buildDatabaseTargetConfig(expiry string) (string, string, error) {
|
||||||
func (h *Handlers) buildDatabaseTargetConfig(
|
|
||||||
w http.ResponseWriter,
|
|
||||||
r *http.Request,
|
|
||||||
expiry string,
|
|
||||||
) (string, error) {
|
|
||||||
expiry = strings.TrimSpace(expiry)
|
expiry = strings.TrimSpace(expiry)
|
||||||
if expiry == "" {
|
if expiry == "" {
|
||||||
return "", nil
|
return "", "", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
err := delivery.ValidateArchiveExpiry(expiry)
|
err := delivery.ValidateArchiveExpiry(expiry)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(
|
return "", fmt.Sprintf("Invalid archive expiry: %v", err), nil
|
||||||
w,
|
|
||||||
"Invalid archive expiry: "+err.Error(),
|
|
||||||
http.StatusBadRequest,
|
|
||||||
)
|
|
||||||
|
|
||||||
return "", err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return h.marshalTargetConfig(
|
configJSON, err := marshalTargetConfig(map[string]any{"expiry": expiry})
|
||||||
w, r, map[string]any{"expiry": expiry},
|
|
||||||
)
|
return configJSON, "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
// HandleEntrypointDelete handles deleting an entrypoint.
|
// HandleEntrypointDelete handles deleting an entrypoint.
|
||||||
|
|||||||
@@ -0,0 +1,154 @@
|
|||||||
|
package handlers_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"html"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"sneak.berlin/go/webhooker/internal/database"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestHandleTargetCreate_EveryType adds a target of each type. Each
|
||||||
|
// submission carries a url: only the http and slack types store one.
|
||||||
|
func TestHandleTargetCreate_EveryType(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := setupSourceTest(t)
|
||||||
|
|
||||||
|
// fields is the rest of each submission, as a query string.
|
||||||
|
cases := []struct {
|
||||||
|
targetType database.TargetType
|
||||||
|
fields string
|
||||||
|
wantConfig string
|
||||||
|
wantRetries int
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
database.TargetTypeHTTP, "timeout=12&max_retries=3",
|
||||||
|
`{"url":"` + editOriginalURL + `","timeout":12}`, 3,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
database.TargetTypeSlack, "max_retries=4",
|
||||||
|
`{"webhookUrl":"` + editOriginalURL + `"}`, 4,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
database.TargetTypeDatabase, "expiry=720h",
|
||||||
|
`{"expiry":"720h"}`, 0,
|
||||||
|
},
|
||||||
|
{database.TargetTypeLog, "", "", 0},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(string(tc.targetType), func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
webhook := seedWebhookWithRetention(t, env.db, 30)
|
||||||
|
|
||||||
|
form, err := url.ParseQuery(tc.fields)
|
||||||
|
require.NoError(t, err)
|
||||||
|
form.Set("name", "every-type")
|
||||||
|
form.Set("type", string(tc.targetType))
|
||||||
|
form.Set("url", editOriginalURL)
|
||||||
|
|
||||||
|
w := serveTarget(
|
||||||
|
env, http.MethodPost,
|
||||||
|
"/hook/"+webhook.ID+"/targets", form,
|
||||||
|
)
|
||||||
|
require.Equal(t, http.StatusSeeOther, w.Code, w.Body.String())
|
||||||
|
|
||||||
|
targets := targetsForWebhook(t, env.db, webhook.ID)
|
||||||
|
require.Len(t, targets, 1)
|
||||||
|
assert.Equal(t, tc.targetType, targets[0].Type)
|
||||||
|
assert.Equal(t, tc.wantRetries, targets[0].MaxRetries)
|
||||||
|
|
||||||
|
if tc.wantConfig == "" {
|
||||||
|
assert.Empty(t, targets[0].Config)
|
||||||
|
} else {
|
||||||
|
assert.JSONEq(t, tc.wantConfig, targets[0].Config)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHandleTargetCreate_RefusedFormComesBack refuses a target of each
|
||||||
|
// type and checks that the webhook page comes back with the add target
|
||||||
|
// form open on that type, the values entered, and the reason.
|
||||||
|
func TestHandleTargetCreate_RefusedFormComesBack(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := setupSourceTest(t)
|
||||||
|
|
||||||
|
// fields is what the operator typed, as a query string.
|
||||||
|
cases := []struct {
|
||||||
|
targetType database.TargetType
|
||||||
|
fields string
|
||||||
|
reason string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
database.TargetTypeHTTP,
|
||||||
|
"name=private&url=" + editBlockedURL +
|
||||||
|
"&timeout=12&max_retries=3",
|
||||||
|
"Invalid target URL",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
database.TargetTypeSlack, "name=no-url&max_retries=4",
|
||||||
|
"Webhook URL is required for Slack targets",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
database.TargetTypeDatabase, "name=archive&expiry=7d",
|
||||||
|
"Invalid archive expiry",
|
||||||
|
},
|
||||||
|
{database.TargetTypeLog, "name=", "Name is required"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(string(tc.targetType), func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
webhook := seedWebhookWithRetention(t, env.db, 30)
|
||||||
|
|
||||||
|
typed, err := url.ParseQuery(tc.fields)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
form := url.Values{}
|
||||||
|
form.Set("type", string(tc.targetType))
|
||||||
|
|
||||||
|
for field := range typed {
|
||||||
|
form.Set(field, typed.Get(field))
|
||||||
|
}
|
||||||
|
|
||||||
|
w := serveTarget(
|
||||||
|
env, http.MethodPost,
|
||||||
|
"/hook/"+webhook.ID+"/targets", form,
|
||||||
|
)
|
||||||
|
assert.Equal(t, http.StatusBadRequest, w.Code)
|
||||||
|
|
||||||
|
page := w.Body.String()
|
||||||
|
assert.Contains(
|
||||||
|
t, page, `data-type="`+string(tc.targetType)+`"`,
|
||||||
|
)
|
||||||
|
assert.Contains(t, page, html.EscapeString(tc.reason))
|
||||||
|
|
||||||
|
// Each value comes back in a data attribute of the targets
|
||||||
|
// section named after its field (max_retries as
|
||||||
|
// data-max-retries), except url, which comes back in
|
||||||
|
// data-destination; templates/source_detail.html says why.
|
||||||
|
for field := range typed {
|
||||||
|
attr := "data-" + strings.ReplaceAll(field, "_", "-")
|
||||||
|
if field == "url" {
|
||||||
|
attr = "data-destination"
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Contains(
|
||||||
|
t, page, attr+`="`+
|
||||||
|
html.EscapeString(typed.Get(field))+`"`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Empty(t, targetsForWebhook(t, env.db, webhook.ID))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -120,18 +120,23 @@ func (h *Handlers) applyTargetEdit(
|
|||||||
) {
|
) {
|
||||||
name := r.PostFormValue("name")
|
name := r.PostFormValue("name")
|
||||||
if name == "" {
|
if name == "" {
|
||||||
http.Error(
|
http.Error(w, "Name is required", http.StatusBadRequest)
|
||||||
w, "Name is required", http.StatusBadRequest,
|
|
||||||
)
|
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
configJSON, err := h.buildTargetConfig(
|
configJSON, errMsg, err := h.buildTargetConfig(
|
||||||
w, r, target.Type, targetFormInputFrom(r),
|
r.Context(), target.Type, targetFormInputFrom(r),
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// buildTargetConfig has already written the response.
|
h.serverError(w, r, "failed to encode target config", err)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if errMsg != "" {
|
||||||
|
http.Error(w, errMsg, http.StatusBadRequest)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package handlers_test
|
package handlers_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"html"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -43,12 +44,16 @@ func TestTargetRefusal_PrivateDestinationSaysHowToAllowIt(
|
|||||||
form.Set("type", string(targetType))
|
form.Set("type", string(targetType))
|
||||||
form.Set("url", editBlockedURL)
|
form.Set("url", editBlockedURL)
|
||||||
|
|
||||||
|
// A refused add shows the webhook page again, where
|
||||||
|
// the hint is HTML-escaped; a refused edit answers in
|
||||||
|
// plain text.
|
||||||
added := serveTarget(
|
added := serveTarget(
|
||||||
env, http.MethodPost, targetsPath, form,
|
env, http.MethodPost, targetsPath, form,
|
||||||
)
|
)
|
||||||
assert.Equal(t, http.StatusBadRequest, added.Code)
|
assert.Equal(t, http.StatusBadRequest, added.Code)
|
||||||
assert.Contains(
|
assert.Contains(
|
||||||
t, added.Body.String(), privateRefusalHint,
|
t, added.Body.String(),
|
||||||
|
html.EscapeString(privateRefusalHint),
|
||||||
)
|
)
|
||||||
|
|
||||||
form.Set("url", editOriginalURL)
|
form.Set("url", editOriginalURL)
|
||||||
|
|||||||
@@ -90,13 +90,14 @@ func retriesErrorMessage(err error) string {
|
|||||||
", or 0 for fire-and-forget"
|
", or 0 for fire-and-forget"
|
||||||
}
|
}
|
||||||
|
|
||||||
// targetMaxRetries reads and validates max_retries from a target form
|
// targetMaxRetries reads and validates max_retries from a target edit
|
||||||
// submission, answering the request with a 400 and reporting false
|
// submission, answering the request with a 400 and reporting false
|
||||||
// when the value is set but invalid.
|
// when the value is set but invalid.
|
||||||
//
|
//
|
||||||
// Both the create and the edit path go through here, so the two
|
// It and the create path (newTarget) both use parseMaxRetries and
|
||||||
// cannot come to disagree about what a valid retry count is. The
|
// retriesErrorMessage, so the two cannot come to disagree about what a
|
||||||
// wording matches the timeout control on the same submission.
|
// valid retry count is. The wording matches the timeout control on
|
||||||
|
// the same submission.
|
||||||
func targetMaxRetries(
|
func targetMaxRetries(
|
||||||
w http.ResponseWriter,
|
w http.ResponseWriter,
|
||||||
r *http.Request,
|
r *http.Request,
|
||||||
|
|||||||
@@ -39,6 +39,9 @@ const (
|
|||||||
// the mobile menu button instead of the navigation links.
|
// the mobile menu button instead of the navigation links.
|
||||||
phoneWidth = 390
|
phoneWidth = 390
|
||||||
phoneHeight = 844
|
phoneHeight = 844
|
||||||
|
|
||||||
|
// olderBody is the body of the event received before the newest.
|
||||||
|
olderBody = "the older event"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestAlpineRunsUnderTheSecurityPolicy loads the webhook page and the
|
// TestAlpineRunsUnderTheSecurityPolicy loads the webhook page and the
|
||||||
@@ -63,6 +66,7 @@ func TestAlpineRunsUnderTheSecurityPolicy(t *testing.T) {
|
|||||||
Active: true,
|
Active: true,
|
||||||
},
|
},
|
||||||
).Error)
|
).Error)
|
||||||
|
env.seedEvent(t, webhook.ID, olderBody)
|
||||||
event := env.seedEvent(t, webhook.ID, `{"hello":"browser"}`)
|
event := env.seedEvent(t, webhook.ID, `{"hello":"browser"}`)
|
||||||
target := env.seedTarget(t, webhook.ID)
|
target := env.seedTarget(t, webhook.ID)
|
||||||
dlv := env.seedFailedDelivery(t, webhook.ID, event.ID, target.ID)
|
dlv := env.seedFailedDelivery(t, webhook.ID, event.ID, target.ID)
|
||||||
@@ -83,10 +87,40 @@ func TestAlpineRunsUnderTheSecurityPolicy(t *testing.T) {
|
|||||||
|
|
||||||
page := srv.URL + "/hook/" + webhook.ID
|
page := srv.URL + "/hook/" + webhook.ID
|
||||||
|
|
||||||
checkAddForms(ctx, t, page)
|
checkAddEntrypoint(ctx, t, page)
|
||||||
checkTargetType(ctx, t, page+"/events")
|
|
||||||
|
// Each target type, with the fields its add target form submits, in
|
||||||
|
// page order. Only http and slack have a url field.
|
||||||
|
targetTypes := []struct {
|
||||||
|
name string
|
||||||
|
fields string
|
||||||
|
values map[string]string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
"http", "csrf_token name type url headers timeout max_retries",
|
||||||
|
map[string]string{"url": publicTargetURL},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slack", "csrf_token name type url max_retries",
|
||||||
|
map[string]string{"url": publicTargetURL},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"database", "csrf_token name type expiry",
|
||||||
|
map[string]string{"expiry": "720h"},
|
||||||
|
},
|
||||||
|
{"log", "csrf_token name type", nil},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range targetTypes {
|
||||||
|
checkAddTarget(
|
||||||
|
ctx, t, page, tt.name, strings.Fields(tt.fields), tt.values,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
checkRefusedTarget(ctx, t, page)
|
||||||
checkCopy(ctx, t, page)
|
checkCopy(ctx, t, page)
|
||||||
checkEntrypointEdit(ctx, t, page, page+"/events")
|
checkEntrypointEdit(ctx, t, page, page+"/events")
|
||||||
|
checkRecentEvents(ctx, t, page)
|
||||||
checkEventLog(ctx, t, page+"/events", event.ID, target.Name)
|
checkEventLog(ctx, t, page+"/events", event.ID, target.Name)
|
||||||
checkMobileMenu(ctx, t, page)
|
checkMobileMenu(ctx, t, page)
|
||||||
|
|
||||||
@@ -228,115 +262,194 @@ func click(ctx context.Context, t *testing.T, xpath string) {
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
// checkAddForms loads a webhook page and checks that each section's add
|
// checkAddEntrypoint loads a webhook page and checks that the add
|
||||||
// form stays hidden until the Add button beside its heading is clicked.
|
// entrypoint form stays hidden until the Add button beside its heading
|
||||||
// The click looks for a button element there, so it also checks that
|
// is clicked. The click looks for a button element there, so it also
|
||||||
// Add is one.
|
// checks that Add is one.
|
||||||
func checkAddForms(ctx context.Context, t *testing.T, url string) {
|
func checkAddEntrypoint(ctx context.Context, t *testing.T, url string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
form := `form[action$="/entrypoints"]`
|
||||||
|
|
||||||
|
require.NoError(t, chromedp.Run(ctx, loadPage(url)))
|
||||||
|
|
||||||
|
assert.True(t, hidden(ctx, form),
|
||||||
|
"the add entrypoint form shows before Add is clicked")
|
||||||
|
|
||||||
|
click(ctx, t, `//h2[text()="Entrypoints"]/following-sibling::button`)
|
||||||
|
|
||||||
|
assert.True(t, shown(ctx, form),
|
||||||
|
"the add entrypoint form stays hidden when Add is clicked")
|
||||||
|
}
|
||||||
|
|
||||||
|
// publicTargetURL is a destination the server accepts for an http or
|
||||||
|
// slack target. It is a literal public address, so accepting it needs
|
||||||
|
// no DNS.
|
||||||
|
const publicTargetURL = "https://93.184.216.34/hook"
|
||||||
|
|
||||||
|
// The parts of the targets section's add target form the checks below
|
||||||
|
// find and click. Add is the button beside the Targets heading; each
|
||||||
|
// Cancel is found from the button beside it, since both are on the
|
||||||
|
// page at once.
|
||||||
|
const (
|
||||||
|
addTarget = `//h2[text()="Targets"]/following-sibling::button`
|
||||||
|
typeSelect = `//select[@aria-label="Target type"]`
|
||||||
|
nextButton = `//button[text()="Next"]`
|
||||||
|
cancelChoice = nextButton + `/following-sibling::button[text()="Cancel"]`
|
||||||
|
saveButton = `//form[contains(@action, "/targets")]//button[text()="Save"]`
|
||||||
|
cancelFields = saveButton + `/following-sibling::button[text()="Cancel"]`
|
||||||
|
targetName = `form[action$="/targets"] input[name="name"]`
|
||||||
|
submittedKeys = `[...new FormData(
|
||||||
|
document.querySelector('form[action$="/targets"]')).keys()]`
|
||||||
|
)
|
||||||
|
|
||||||
|
// checkAddTarget loads a webhook page and walks the add target form for
|
||||||
|
// one target type. The form shows nothing until Add is clicked; Add
|
||||||
|
// shows only the type choice; Cancel there closes it; Next shows the
|
||||||
|
// type's own fields in place of the choice, and the form then submits
|
||||||
|
// exactly fields, so a field another type uses, such as url, is absent;
|
||||||
|
// Cancel closes it again. It then adds a target of the type, filling in
|
||||||
|
// values, and checks that the section lists it with that type.
|
||||||
|
func checkAddTarget(
|
||||||
|
ctx context.Context,
|
||||||
|
t *testing.T,
|
||||||
|
url, targetType string,
|
||||||
|
fields []string,
|
||||||
|
values map[string]string,
|
||||||
|
) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
require.NoError(t, chromedp.Run(ctx, loadPage(url)))
|
require.NoError(t, chromedp.Run(ctx, loadPage(url)))
|
||||||
|
|
||||||
sections := []struct{ heading, form string }{
|
assert.Truef(t, hidden(ctx, typeSelect),
|
||||||
{"Entrypoints", `form[action$="/entrypoints"]`},
|
"%s: the type choice shows before Add is clicked", targetType)
|
||||||
{"Targets", `form[action$="/targets"]`},
|
assert.Truef(t, hidden(ctx, targetName),
|
||||||
|
"%s: the fields show before Add is clicked", targetType)
|
||||||
|
|
||||||
|
click(ctx, t, addTarget)
|
||||||
|
assert.Truef(t, shown(ctx, typeSelect),
|
||||||
|
"%s: Add does not show the type choice", targetType)
|
||||||
|
assert.Truef(t, hidden(ctx, targetName),
|
||||||
|
"%s: Add shows the fields before Next", targetType)
|
||||||
|
|
||||||
|
click(ctx, t, cancelChoice)
|
||||||
|
assert.Truef(t, hidden(ctx, typeSelect),
|
||||||
|
"%s: Cancel does not close the type choice", targetType)
|
||||||
|
|
||||||
|
chooseTargetType(ctx, t, targetType)
|
||||||
|
|
||||||
|
var submitted []string
|
||||||
|
|
||||||
|
require.NoError(t, chromedp.Run(
|
||||||
|
ctx, chromedp.Evaluate(submittedKeys, &submitted),
|
||||||
|
))
|
||||||
|
assert.Equalf(t, fields, submitted,
|
||||||
|
"%s: the form does not submit exactly the type's fields", targetType)
|
||||||
|
|
||||||
|
click(ctx, t, cancelFields)
|
||||||
|
assert.Truef(t, hidden(ctx, targetName),
|
||||||
|
"%s: Cancel does not close the fields", targetType)
|
||||||
|
assert.Truef(t, shown(ctx, addTarget),
|
||||||
|
"%s: Add does not come back after Cancel", targetType)
|
||||||
|
|
||||||
|
name := "added-" + targetType
|
||||||
|
|
||||||
|
chooseTargetType(ctx, t, targetType)
|
||||||
|
require.NoError(t, chromedp.Run(
|
||||||
|
ctx, chromedp.SetValue(targetName, name, chromedp.ByQuery),
|
||||||
|
))
|
||||||
|
|
||||||
|
for field, value := range values {
|
||||||
|
require.NoError(t, chromedp.Run(ctx, chromedp.SetValue(
|
||||||
|
`form[action$="/targets"] [name="`+field+`"]`, value,
|
||||||
|
chromedp.ByQuery,
|
||||||
|
)))
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, s := range sections {
|
click(ctx, t, saveButton)
|
||||||
assert.Truef(
|
assert.Truef(t, shown(ctx, `//span[text()="`+name+
|
||||||
t, hidden(ctx, s.form),
|
`"]/following-sibling::div/span[text()="`+targetType+`"]`),
|
||||||
"%s: the add form shows before Add is clicked", s.heading,
|
"%s: the added target is not listed with its type", targetType)
|
||||||
)
|
|
||||||
|
|
||||||
click(ctx, t, `//h2[text()="`+s.heading+
|
|
||||||
`"]/following-sibling::button`)
|
|
||||||
|
|
||||||
assert.Truef(
|
|
||||||
t, shown(ctx, s.form),
|
|
||||||
"%s: the add form stays hidden when Add is clicked", s.heading,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// checkTargetType chooses Slack in the open add target form and checks
|
// chooseTargetType clicks Add, picks targetType and clicks Next, and
|
||||||
// what the form would then submit: one url field, the Slack one, and
|
// checks that the type's fields then show in place of the type choice.
|
||||||
// not the HTTP url, headers or timeout, which are hidden and disabled.
|
func chooseTargetType(ctx context.Context, t *testing.T, targetType string) {
|
||||||
//
|
t.Helper()
|
||||||
// It then opens the page at elsewhere and goes back. The browser loads
|
|
||||||
// the webhook page again and restores the form as it was left, Slack
|
click(ctx, t, addTarget)
|
||||||
// chosen, without a change event; the form must again show and submit
|
require.NoError(t, chromedp.Run(
|
||||||
// Slack's fields, not the HTTP ones.
|
ctx, chromedp.SetValue(typeSelect, targetType, chromedp.BySearch),
|
||||||
func checkTargetType(ctx context.Context, t *testing.T, elsewhere string) {
|
))
|
||||||
|
click(ctx, t, nextButton)
|
||||||
|
|
||||||
|
assert.Truef(t, shown(ctx, targetName),
|
||||||
|
"%s: Next does not show the fields", targetType)
|
||||||
|
assert.Truef(t, hidden(ctx, typeSelect),
|
||||||
|
"%s: Next leaves the type choice showing", targetType)
|
||||||
|
assert.Truef(t, hidden(ctx, addTarget),
|
||||||
|
"%s: Add still shows while the form is open", targetType)
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkRefusedTarget submits an http target the server refuses, a
|
||||||
|
// loopback destination, and checks that the page comes back with the
|
||||||
|
// form open on the http fields, the values entered and the reason, and
|
||||||
|
// that after Cancel the next Add starts with an empty form and no
|
||||||
|
// reason.
|
||||||
|
func checkRefusedTarget(ctx context.Context, t *testing.T, url string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
const (
|
const (
|
||||||
chooseSlack = `(() => {
|
refusedURL = "http://127.0.0.1/hook"
|
||||||
const type = document.querySelector('select[name="type"]');
|
urlField = `form[action$="/targets"] input[name="url"]`
|
||||||
type.value = "slack";
|
reason = `//div[@class="alert-error"]`
|
||||||
type.dispatchEvent(new Event("change"));
|
|
||||||
})()`
|
|
||||||
chosen = `document.querySelector('select[name="type"]').value`
|
|
||||||
howLoaded = `performance.getEntriesByType("navigation")[0].type`
|
|
||||||
submitted = `[...new FormData(
|
|
||||||
document.querySelector('form[action$="/targets"]')).keys()]`
|
|
||||||
slackURL = `input[placeholder^="https://hooks.slack.com/"]`
|
|
||||||
httpURL = `input[placeholder="https://example.com/webhook"]`
|
|
||||||
)
|
)
|
||||||
|
|
||||||
slackFields := strings.Fields("csrf_token name type max_retries url")
|
require.NoError(t, chromedp.Run(ctx, loadPage(url)))
|
||||||
|
|
||||||
var fields []string
|
chooseTargetType(ctx, t, "http")
|
||||||
|
require.NoError(t, chromedp.Run(
|
||||||
|
ctx,
|
||||||
|
chromedp.SetValue(targetName, "refused", chromedp.ByQuery),
|
||||||
|
chromedp.SetValue(urlField, refusedURL, chromedp.ByQuery),
|
||||||
|
))
|
||||||
|
|
||||||
|
click(ctx, t, saveButton)
|
||||||
|
|
||||||
|
assert.True(t, shown(ctx, reason),
|
||||||
|
"a refused target does not show the reason")
|
||||||
|
|
||||||
|
var name, typed string
|
||||||
|
|
||||||
require.NoError(t, chromedp.Run(
|
require.NoError(t, chromedp.Run(
|
||||||
ctx,
|
ctx,
|
||||||
chromedp.Evaluate(chooseSlack, nil),
|
chromedp.Value(targetName, &name, chromedp.ByQuery),
|
||||||
chromedp.Evaluate(submitted, &fields),
|
chromedp.Value(urlField, &typed, chromedp.ByQuery),
|
||||||
))
|
))
|
||||||
|
|
||||||
assert.Equal(
|
assert.Equal(t, "refused", name,
|
||||||
t, slackFields, fields,
|
"a refused target does not keep the name entered")
|
||||||
"with Slack chosen, the HTTP fields must not be submitted",
|
assert.Equal(t, refusedURL, typed,
|
||||||
)
|
"a refused target does not keep the url entered")
|
||||||
|
assert.True(t, shown(ctx, targetName),
|
||||||
|
"a refused target does not come back with the form open")
|
||||||
|
assert.True(t, hidden(ctx, typeSelect),
|
||||||
|
"a refused target comes back on the type choice")
|
||||||
|
|
||||||
var loaded, restored string
|
click(ctx, t, cancelFields)
|
||||||
|
chooseTargetType(ctx, t, "http")
|
||||||
|
|
||||||
|
assert.True(t, hidden(ctx, reason),
|
||||||
|
"after Cancel, the next Add still shows the reason")
|
||||||
|
|
||||||
// Going back waits for the load event, after which the browser has
|
|
||||||
// restored the form.
|
|
||||||
require.NoError(t, chromedp.Run(
|
require.NoError(t, chromedp.Run(
|
||||||
ctx,
|
ctx,
|
||||||
loadPage(elsewhere),
|
chromedp.Value(targetName, &name, chromedp.ByQuery),
|
||||||
chromedp.NavigateBack(),
|
chromedp.Value(urlField, &typed, chromedp.ByQuery),
|
||||||
chromedp.WaitNotPresent("[x-cloak]", chromedp.ByQuery),
|
|
||||||
chromedp.Evaluate(howLoaded, &loaded),
|
|
||||||
chromedp.Evaluate(chosen, &restored),
|
|
||||||
))
|
))
|
||||||
|
|
||||||
// A page the browser kept in memory and showed again as it was
|
assert.Empty(t, name, "after Cancel, the next Add keeps the name entered")
|
||||||
// would prove nothing here.
|
assert.Empty(t, typed, "after Cancel, the next Add keeps the url entered")
|
||||||
require.Equal(
|
|
||||||
t, "back_forward", loaded,
|
|
||||||
"going back, the browser did not load the page again",
|
|
||||||
)
|
|
||||||
require.Equal(
|
|
||||||
t, "slack", restored,
|
|
||||||
"going back, the browser did not restore the chosen type",
|
|
||||||
)
|
|
||||||
|
|
||||||
click(ctx, t, `//h2[text()="Targets"]/following-sibling::button`)
|
|
||||||
|
|
||||||
assert.True(t, shown(ctx, slackURL),
|
|
||||||
"going back with Slack chosen, the Slack fields are not shown")
|
|
||||||
assert.True(t, hidden(ctx, httpURL),
|
|
||||||
"going back with Slack chosen, the HTTP fields are shown")
|
|
||||||
|
|
||||||
require.NoError(t, chromedp.Run(
|
|
||||||
ctx, chromedp.Evaluate(submitted, &fields),
|
|
||||||
))
|
|
||||||
|
|
||||||
assert.Equal(
|
|
||||||
t, slackFields, fields,
|
|
||||||
"going back with Slack chosen, the HTTP fields must not be submitted",
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// checkCopy loads a webhook page and checks that the Copy control beside
|
// checkCopy loads a webhook page and checks that the Copy control beside
|
||||||
@@ -376,9 +489,13 @@ func checkEntrypointEdit(
|
|||||||
) {
|
) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
|
// Cancel and Save are found inside the edit form, since the add
|
||||||
|
// target form has buttons of the same names.
|
||||||
const (
|
const (
|
||||||
editForm = `form[action$="/edit"]`
|
editForm = `form[action$="/edit"]`
|
||||||
input = editForm + ` input[name="description"]`
|
input = editForm + ` input[name="description"]`
|
||||||
|
cancelEdit = `//form[contains(@action, "/edit")]/button[text()="Cancel"]`
|
||||||
|
saveEdit = `//form[contains(@action, "/edit")]/button[text()="Save"]`
|
||||||
description = `//span[text()="Entrypoint"]`
|
description = `//span[text()="Entrypoint"]`
|
||||||
edit = `//button[text()="Edit"]`
|
edit = `//button[text()="Edit"]`
|
||||||
)
|
)
|
||||||
@@ -399,7 +516,7 @@ func checkEntrypointEdit(
|
|||||||
require.NoError(t, chromedp.Run(
|
require.NoError(t, chromedp.Run(
|
||||||
ctx, chromedp.SendKeys(input, "draft", chromedp.ByQuery),
|
ctx, chromedp.SendKeys(input, "draft", chromedp.ByQuery),
|
||||||
))
|
))
|
||||||
click(ctx, t, `//button[text()="Cancel"]`)
|
click(ctx, t, cancelEdit)
|
||||||
assert.True(t, hidden(ctx, editForm),
|
assert.True(t, hidden(ctx, editForm),
|
||||||
"clicking Cancel does not hide the edit form")
|
"clicking Cancel does not hide the edit form")
|
||||||
assert.True(t, shown(ctx, description),
|
assert.True(t, shown(ctx, description),
|
||||||
@@ -441,12 +558,52 @@ func checkEntrypointEdit(
|
|||||||
require.NoError(t, chromedp.Run(
|
require.NoError(t, chromedp.Run(
|
||||||
ctx, chromedp.SendKeys(input, "Billing sender", chromedp.ByQuery),
|
ctx, chromedp.SendKeys(input, "Billing sender", chromedp.ByQuery),
|
||||||
))
|
))
|
||||||
click(ctx, t, `//button[text()="Save"]`)
|
click(ctx, t, saveEdit)
|
||||||
|
|
||||||
assert.True(t, shown(ctx, `//span[text()="Billing sender"]`),
|
assert.True(t, shown(ctx, `//span[text()="Billing sender"]`),
|
||||||
"saving the edit form does not change the description")
|
"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
|
// checkEventLog loads the event log and checks that clicking an event's
|
||||||
// row expands it, that in there clicking its delivery shows the
|
// row expands it, that in there clicking its delivery shows the
|
||||||
// delivery's attempts and clicking again hides them, and that clicking
|
// delivery's attempts and clicking again hides them, and that clicking
|
||||||
@@ -459,7 +616,7 @@ func checkEventLog(
|
|||||||
// The event's row shows its ID, and its Resubmit form is in the part
|
// 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.
|
// that expands. The delivery's row there shows the target's name.
|
||||||
eventRow := `//span[text()="` + eventID + `"]`
|
eventRow := `//span[text()="` + eventID + `"]`
|
||||||
expanded := `form[action$="/resubmit"]`
|
expanded := `form[action$="/` + eventID + `/resubmit"]`
|
||||||
deliveryRow := `//span[text()="` + targetName + `"]`
|
deliveryRow := `//span[text()="` + targetName + `"]`
|
||||||
attempt := `//span[text()="Attempt 1"]`
|
attempt := `//span[text()="Attempt 1"]`
|
||||||
|
|
||||||
|
|||||||
@@ -252,11 +252,12 @@ func (s *Server) setupSourceRoutes() {
|
|||||||
r.Post("/edit", s.h.HandleSourceEditSubmit())
|
r.Post("/edit", s.h.HandleSourceEditSubmit())
|
||||||
r.Post("/delete", s.h.HandleSourceDelete())
|
r.Post("/delete", s.h.HandleSourceDelete())
|
||||||
r.Get("/events", s.h.HandleSourceLogs())
|
r.Get("/events", s.h.HandleSourceLogs())
|
||||||
// The log page renders each body only up to its cap, so
|
r.Get("/events/{eventID}", s.h.HandleEventDetail())
|
||||||
// this is the only route that serves a whole one. It
|
// The pages show a body as escaped text and leave a
|
||||||
// belongs to this group for its RequireAuth and
|
// binary one out, so this is the only route that serves
|
||||||
// NoCache; see HandleEventBodyDownload for the headers
|
// the stored bytes. It belongs to this group for its
|
||||||
// that keep the bytes it returns inert.
|
// RequireAuth and NoCache; see HandleEventBodyDownload for
|
||||||
|
// the headers that keep the bytes it returns inert.
|
||||||
r.Get(
|
r.Get(
|
||||||
"/events/{eventID}/body",
|
"/events/{eventID}/body",
|
||||||
s.h.HandleEventBodyDownload(),
|
s.h.HandleEventBodyDownload(),
|
||||||
|
|||||||
@@ -365,6 +365,7 @@ func (e *testEnv) seedEvent(
|
|||||||
WebhookID: webhookID,
|
WebhookID: webhookID,
|
||||||
Method: http.MethodPost,
|
Method: http.MethodPost,
|
||||||
Body: body,
|
Body: body,
|
||||||
|
BodyBytes: int64(len(body)),
|
||||||
ContentType: "application/octet-stream",
|
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="([^"]+)"[^>]*>← 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
|
// TestSourceLogs_TruncationLinkDownloadsTheBody walks the whole
|
||||||
// feature the way a user does: render the event log page through
|
// feature the way a user does: render the event log page through
|
||||||
// the production router, take the download URL out of the markup
|
// the production router, take the download URL out of the markup
|
||||||
|
|||||||
+61
-22
@@ -70,11 +70,15 @@ document.addEventListener("alpine:init", function () {
|
|||||||
"use strict";
|
"use strict";
|
||||||
|
|
||||||
// Something a click shows and hides: the mobile menu, an add form,
|
// 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
|
// an entrypoint's edit form, an event in the event log or in the
|
||||||
// attempts.
|
// recent events, a delivery's attempts. It starts hidden, or shown
|
||||||
|
// when its element has the data-open attribute.
|
||||||
window.Alpine.data("collapsible", function () {
|
window.Alpine.data("collapsible", function () {
|
||||||
return {
|
return {
|
||||||
open: false,
|
open: false,
|
||||||
|
init() {
|
||||||
|
this.open = this.$root.hasAttribute("data-open");
|
||||||
|
},
|
||||||
toggle() {
|
toggle() {
|
||||||
this.open = !this.open;
|
this.open = !this.open;
|
||||||
},
|
},
|
||||||
@@ -88,24 +92,65 @@ document.addEventListener("alpine:init", function () {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
// The add target form. Only the chosen type's fields show, and the
|
// The targets section's add target form, in three steps: closed,
|
||||||
// others are disabled so that the form does not submit them.
|
// choosing a type, then filling in that type's fields. targetType
|
||||||
|
// is empty until Next takes it from the type select.
|
||||||
//
|
//
|
||||||
// The type is read from the type select when Alpine starts, when the
|
// The reason and the fields' values come from the properties below
|
||||||
// select changes, and on pageshow. Going back to the page, the
|
// rather than from the markup, because each type's fields are made
|
||||||
// browser restores the type chosen before without a change event,
|
// afresh from the markup whenever that type is chosen. A refused
|
||||||
// in some browsers only after Alpine has started, but always before
|
// submission comes back with its type, reason and values in the
|
||||||
// pageshow.
|
// section's data attributes, and starts on that type's fields with
|
||||||
|
// them. Cancel empties these properties and resets the form, which
|
||||||
|
// holds whatever was typed, so the next Add starts with an empty
|
||||||
|
// form and no reason.
|
||||||
window.Alpine.data("targetForm", function () {
|
window.Alpine.data("targetForm", function () {
|
||||||
return {
|
return {
|
||||||
|
choosing: false,
|
||||||
targetType: "",
|
targetType: "",
|
||||||
|
reason: "",
|
||||||
|
name: "",
|
||||||
|
url: "",
|
||||||
|
headers: "",
|
||||||
|
timeout: "",
|
||||||
|
maxRetries: "",
|
||||||
|
expiry: "",
|
||||||
init() {
|
init() {
|
||||||
this.readType();
|
const refused = this.$root.dataset;
|
||||||
|
|
||||||
|
this.targetType = refused.type;
|
||||||
|
this.reason = refused.reason;
|
||||||
|
this.name = refused.name;
|
||||||
|
this.url = refused.destination;
|
||||||
|
this.headers = refused.headers;
|
||||||
|
this.timeout = refused.timeout;
|
||||||
|
this.maxRetries = refused.maxRetries;
|
||||||
|
this.expiry = refused.expiry;
|
||||||
},
|
},
|
||||||
readType() {
|
add() {
|
||||||
this.targetType = this.$root.querySelector(
|
this.choosing = true;
|
||||||
'select[name="type"]'
|
},
|
||||||
).value;
|
next() {
|
||||||
|
this.targetType = this.$refs.type.value;
|
||||||
|
this.choosing = false;
|
||||||
|
},
|
||||||
|
cancel() {
|
||||||
|
this.choosing = false;
|
||||||
|
this.targetType = "";
|
||||||
|
this.reason = "";
|
||||||
|
this.name = "";
|
||||||
|
this.url = "";
|
||||||
|
this.headers = "";
|
||||||
|
this.timeout = "";
|
||||||
|
this.maxRetries = "";
|
||||||
|
this.expiry = "";
|
||||||
|
this.$refs.form.reset();
|
||||||
|
},
|
||||||
|
get filling() {
|
||||||
|
return this.targetType !== "";
|
||||||
|
},
|
||||||
|
get closed() {
|
||||||
|
return !this.choosing && !this.filling;
|
||||||
},
|
},
|
||||||
get isHttp() {
|
get isHttp() {
|
||||||
return this.targetType === "http";
|
return this.targetType === "http";
|
||||||
@@ -116,14 +161,8 @@ document.addEventListener("alpine:init", function () {
|
|||||||
get isDatabase() {
|
get isDatabase() {
|
||||||
return this.targetType === "database";
|
return this.targetType === "database";
|
||||||
},
|
},
|
||||||
get notHttp() {
|
get isLog() {
|
||||||
return !this.isHttp;
|
return this.targetType === "log";
|
||||||
},
|
|
||||||
get notSlack() {
|
|
||||||
return !this.isSlack;
|
|
||||||
},
|
|
||||||
get notDatabase() {
|
|
||||||
return !this.isDatabase;
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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}}— (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}}
|
||||||
@@ -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}}
|
||||||
@@ -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">← 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}}
|
||||||
@@ -104,11 +104,23 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Targets -->
|
<!-- Targets. The data attributes carry a refused add target
|
||||||
<div class="card" x-data="collapsible">
|
submission's type, reason and values back to the form. The
|
||||||
|
URL is data-destination, not data-url: html/template treats
|
||||||
|
an attribute named like a URL as a link and would rewrite
|
||||||
|
a refused ftp: or javascript: value. -->
|
||||||
|
<div class="card" x-data="targetForm"
|
||||||
|
data-type="{{.TargetForm.Type}}"
|
||||||
|
data-reason="{{.TargetError}}"
|
||||||
|
data-name="{{.TargetForm.Name}}"
|
||||||
|
data-destination="{{.TargetForm.URL}}"
|
||||||
|
data-headers="{{.TargetForm.Headers}}"
|
||||||
|
data-timeout="{{.TargetForm.Timeout}}"
|
||||||
|
data-max-retries="{{.TargetForm.MaxRetries}}"
|
||||||
|
data-expiry="{{.TargetForm.Expiry}}">
|
||||||
<div class="p-4 border-b border-gray-200 flex justify-between items-center">
|
<div class="p-4 border-b border-gray-200 flex justify-between items-center">
|
||||||
<h2 class="text-lg font-medium text-gray-900">Targets</h2>
|
<h2 class="text-lg font-medium text-gray-900">Targets</h2>
|
||||||
<button type="button" @click="toggle" class="btn-small">
|
<button type="button" @click="add" x-show="closed" class="btn-small">
|
||||||
<svg class="w-3 h-3 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-3 h-3 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/>
|
||||||
</svg>
|
</svg>
|
||||||
@@ -116,48 +128,85 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Add target form -->
|
<!-- Add target form. Add shows the type choice; Next replaces
|
||||||
<div x-show="open" x-cloak class="p-4 bg-gray-50 border-b border-gray-200">
|
it with the chosen type's fields. Each type's fields,
|
||||||
<form method="POST" action="/hook/{{.Webhook.ID}}/targets" x-data="targetForm" @pageshow.window="readType" class="space-y-3">
|
and the hidden type field submitted with them, exist
|
||||||
|
only while that type is chosen. A refused submission
|
||||||
|
comes back open on its type, with the values entered;
|
||||||
|
Cancel empties the form. The type choice's p-2, narrower
|
||||||
|
than an input's own padding, keeps it, Next and Cancel on
|
||||||
|
one row on a 360px-wide phone. -->
|
||||||
|
<form method="POST" action="/hook/{{.Webhook.ID}}/targets" x-ref="form">
|
||||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||||
<div class="flex gap-2">
|
<div x-show="choosing" x-cloak class="p-4 bg-gray-50 border-b border-gray-200 flex flex-wrap gap-2">
|
||||||
<input type="text" name="name" placeholder="Target name" required class="input text-sm flex-1">
|
<select x-ref="type" aria-label="Target type" class="input text-sm p-2 flex-1">
|
||||||
<select name="type" @change="readType" class="input text-sm w-32">
|
|
||||||
<option value="http">HTTP</option>
|
<option value="http">HTTP</option>
|
||||||
<option value="slack">Slack</option>
|
<option value="slack">Slack</option>
|
||||||
<option value="database">Database</option>
|
<option value="database">Database</option>
|
||||||
<option value="log">Log</option>
|
<option value="log">Log</option>
|
||||||
</select>
|
</select>
|
||||||
|
<button type="button" @click="next" class="btn-primary text-sm">Next</button>
|
||||||
|
<button type="button" @click="cancel" class="btn-secondary text-sm">Cancel</button>
|
||||||
</div>
|
</div>
|
||||||
<div x-show="isHttp">
|
<div x-show="filling" x-cloak class="p-4 bg-gray-50 border-b border-gray-200 space-y-3">
|
||||||
<input type="url" name="url" placeholder="https://example.com/webhook" :disabled="notHttp" class="input text-sm">
|
<div x-show="reason" x-text="reason" class="alert-error"></div>
|
||||||
</div>
|
<input type="text" name="name" :value="name" placeholder="Target name" required class="input text-sm">
|
||||||
<div x-show="isHttp">
|
<template x-if="isHttp">
|
||||||
<textarea name="headers" rows="3" placeholder="Authorization: Bearer ..." :disabled="notHttp" class="input text-sm"></textarea>
|
<div class="space-y-3">
|
||||||
|
<input type="hidden" name="type" value="http">
|
||||||
|
<input type="url" name="url" :value="url" placeholder="https://example.com/webhook" class="input text-sm">
|
||||||
|
<div>
|
||||||
|
<textarea name="headers" rows="3" :value="headers" placeholder="Authorization: Bearer ..." class="input text-sm"></textarea>
|
||||||
<p class="text-xs text-gray-500 mt-1">Optional request headers, one <code>Name: value</code> per line, sent with every delivery.</p>
|
<p class="text-xs text-gray-500 mt-1">Optional request headers, one <code>Name: value</code> per line, sent with every delivery.</p>
|
||||||
</div>
|
</div>
|
||||||
<div x-show="isHttp" class="flex gap-2 items-center">
|
<div class="flex gap-2 items-center">
|
||||||
<label class="text-sm text-gray-700">Timeout (seconds, blank = default):</label>
|
<label class="text-sm text-gray-700">Timeout (seconds, blank = default):</label>
|
||||||
<input type="number" name="timeout" min="0" max="300" :disabled="notHttp" class="input text-sm w-24">
|
<input type="number" name="timeout" :value="timeout" min="0" max="300" class="input text-sm w-24">
|
||||||
</div>
|
</div>
|
||||||
<div x-show="isHttp">
|
<div>
|
||||||
<div class="flex gap-2 items-center">
|
<div class="flex gap-2 items-center">
|
||||||
<label class="text-sm text-gray-700">Max retries:</label>
|
<label class="text-sm text-gray-700">Max retries:</label>
|
||||||
<input type="number" name="max_retries" value="0" min="0" max="20" class="input text-sm w-24">
|
<input type="number" name="max_retries" :value="maxRetries" placeholder="0" min="0" max="20" class="input text-sm w-24">
|
||||||
</div>
|
</div>
|
||||||
<p class="text-xs text-gray-500 mt-1">This is the total number of delivery attempts, not retries on top of the first: a value of 3 makes three attempts in all. 0 means a single attempt with no retries and no circuit breaker.</p>
|
<p class="text-xs text-gray-500 mt-1">This is the total number of delivery attempts, not retries on top of the first: a value of 3 makes three attempts in all. 0 means a single attempt with no retries and no circuit breaker.</p>
|
||||||
</div>
|
</div>
|
||||||
<div x-show="isSlack">
|
</div>
|
||||||
<input type="url" name="url" placeholder="https://hooks.slack.com/services/..." :disabled="notSlack" class="input text-sm">
|
</template>
|
||||||
|
<template x-if="isSlack">
|
||||||
|
<div class="space-y-3">
|
||||||
|
<input type="hidden" name="type" value="slack">
|
||||||
|
<div>
|
||||||
|
<input type="url" name="url" :value="url" placeholder="https://hooks.slack.com/services/..." class="input text-sm">
|
||||||
<p class="text-xs text-gray-500 mt-1">Slack or Mattermost incoming webhook URL. Payloads are pretty-printed in code blocks.</p>
|
<p class="text-xs text-gray-500 mt-1">Slack or Mattermost incoming webhook URL. Payloads are pretty-printed in code blocks.</p>
|
||||||
</div>
|
</div>
|
||||||
<div x-show="isDatabase">
|
<div>
|
||||||
<input type="text" name="expiry" placeholder="never" :disabled="notDatabase" class="input text-sm">
|
<div class="flex gap-2 items-center">
|
||||||
|
<label class="text-sm text-gray-700">Max retries:</label>
|
||||||
|
<input type="number" name="max_retries" :value="maxRetries" placeholder="0" min="0" max="20" class="input text-sm w-24">
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-gray-500 mt-1">This is the total number of delivery attempts, not retries on top of the first: a value of 3 makes three attempts in all. 0 means a single attempt with no retries and no circuit breaker.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template x-if="isDatabase">
|
||||||
|
<div>
|
||||||
|
<input type="hidden" name="type" value="database">
|
||||||
|
<input type="text" name="expiry" :value="expiry" placeholder="never" class="input text-sm">
|
||||||
<p class="text-xs text-gray-500 mt-1">Archive expiry: "never" (default) keeps rows forever, or a duration like "720h" prunes older rows.</p>
|
<p class="text-xs text-gray-500 mt-1">Archive expiry: "never" (default) keeps rows forever, or a duration like "720h" prunes older rows.</p>
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" class="btn-primary text-sm">Add Target</button>
|
</template>
|
||||||
</form>
|
<template x-if="isLog">
|
||||||
|
<div>
|
||||||
|
<input type="hidden" name="type" value="log">
|
||||||
|
<p class="text-xs text-gray-500">A log target writes each event to the application log. It has no settings beyond its name.</p>
|
||||||
</div>
|
</div>
|
||||||
|
</template>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button type="submit" class="btn-primary text-sm">Save</button>
|
||||||
|
<button type="button" @click="cancel" class="btn-secondary text-sm">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
<div class="divide-y divide-gray-100">
|
<div class="divide-y divide-gray-100">
|
||||||
{{range .Targets}}
|
{{range .Targets}}
|
||||||
@@ -225,17 +274,20 @@
|
|||||||
<a href="/hook/{{.Webhook.ID}}/events" class="btn-small">Full Event Log</a>
|
<a href="/hook/{{.Webhook.ID}}/events" class="btn-small">Full Event Log</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="divide-y divide-gray-100">
|
<div class="divide-y divide-gray-100">
|
||||||
{{range .Events}}
|
<!-- Each row expands to show its body; only the newest
|
||||||
<div class="p-4">
|
starts expanded. -->
|
||||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
{{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">
|
<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="badge-info">{{.Method}}</span>
|
||||||
<span class="text-sm text-gray-500 break-all">{{.ContentType}}</span>
|
<span class="text-sm text-gray-500 break-all">{{.ContentType}}</span>
|
||||||
{{if .ResubmittedFromID}}
|
{{if .ResubmittedFromID}}
|
||||||
<span class="text-xs text-gray-500" title="This event is a copy of {{.ResubmittedFromID}}">resubmitted copy</span>
|
<span class="text-xs text-gray-500" title="This event is a copy of {{.ResubmittedFromID}}">resubmitted copy</span>
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</span>
|
||||||
<div class="flex flex-wrap items-center gap-3 text-xs text-gray-400">
|
<span class="flex flex-wrap items-center gap-3 text-xs text-gray-400">
|
||||||
<span title="Body size">{{.Size}}</span>
|
<span title="Body size">{{.Size}}</span>
|
||||||
{{if .ProcessingTime}}
|
{{if .ProcessingTime}}
|
||||||
<span title="Processing time: how long the slowest delivery took, from being queued to its last attempt">{{.ProcessingTime}}</span>
|
<span title="Processing time: how long the slowest delivery took, from being queued to its last attempt">{{.ProcessingTime}}</span>
|
||||||
@@ -244,7 +296,15 @@
|
|||||||
<span class="font-medium {{.StatusClass}}" title="HTTP status from the HTTP target">{{.Status}}</span>
|
<span class="font-medium {{.StatusClass}}" title="HTTP status from the HTTP target">{{.Status}}</span>
|
||||||
{{end}}
|
{{end}}
|
||||||
<span title="{{.ReceivedUTC}}">{{.Received}}</span>
|
<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>
|
||||||
|
<div x-show="open" x-cloak class="mt-3">
|
||||||
|
{{template "event_body" .Body}}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{{else}}
|
{{else}}
|
||||||
|
|||||||
@@ -44,7 +44,7 @@
|
|||||||
<div x-show="open" x-cloak class="mt-3 p-3 bg-gray-50 rounded-md">
|
<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="mb-3 flex flex-wrap items-center justify-between gap-2">
|
||||||
<div class="text-xs text-gray-500">
|
<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}}
|
{{if .ResubmitCount}}Resubmitted as {{.ResubmitCount}} new event{{if ne .ResubmitCount 1}}s{{end}}.{{end}}
|
||||||
</div>
|
</div>
|
||||||
<form method="POST" action="/hook/{{$.Webhook.ID}}/events/{{.ID}}/resubmit" class="inline">
|
<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>
|
<button type="submit" class="btn-small" title="Submit this event again as a new event, to every currently active target">Resubmit</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
<pre class="text-xs text-gray-700 overflow-x-auto whitespace-pre-wrap break-all">{{.Body}}</pre>
|
{{template "event_body" .Body}}
|
||||||
{{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 — <a href="/hook/{{$.Webhook.ID}}/events/{{.ID}}/body" class="btn-small">download the full body</a>.</p>
|
|
||||||
{{end}}
|
|
||||||
|
|
||||||
{{if .Deliveries}}
|
{{if .Deliveries}}
|
||||||
<div class="mt-4 border-t border-gray-200 pt-3">
|
<div class="mt-4 border-t border-gray-200 pt-3">
|
||||||
@@ -87,34 +84,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div x-show="open" x-cloak class="mt-2 space-y-2">
|
<div x-show="open" x-cloak class="mt-2 space-y-2">
|
||||||
{{if .AttemptsOmitted}}
|
{{template "delivery_attempts" .}}
|
||||||
<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}}— (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}}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
Reference in New Issue
Block a user