Drop 'unsafe-inline' from the Content-Security-Policy (closes #530)
check / check (push) Waiting to run

The delete prompts on the webhook page were inline onsubmit handlers.
Each delete form now carries its question in a data-confirm attribute,
which one submit listener in static/js/app.js asks. The three style
attributes became Tailwind classes, with tailwind.css regenerated, and
the x-cloak rule moved to style.css. The policy now sends
script-src 'self' and style-src 'self'.

The browser test now answers a target's delete prompt no and then yes,
and also loads the sign-in page, the webhook list, the settings and
profile pages and the 404 error page under the policy. The README
states the policy as sent.

Model: opus-5-5
This commit is contained in:
2026-10-15 13:05:41 +00:00
parent 0d38aea12c
commit 5ea5356c1d
15 changed files with 219 additions and 94 deletions
+45 -33
View File
@@ -1251,34 +1251,37 @@ markup. The CSP build runs no expressions, so every Alpine directive in
`static/js/app.js`: `x-data="collapsible"` and `@click="toggle"`, never
`x-data="{ open: false }"` or `@click="open = !open"`.
A browser test in `internal/server` loads the webhook page, its edit pages and
the event log under the real policy and checks that: the add entrypoint form
stays hidden until Add is clicked; for every target type, the targets section's
Add shows only a choice of type with Next and Cancel, Next shows only that
type's fields (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; a refused save on the target edit page
and on the webhook edit page comes back with the reason and every value entered;
the Copy button beside an entrypoint URL reads "Copied" once clicked; an
entrypoint's Edit button shows its edit form in place of its description and
hides until the form closes, Cancel hides the form and drops what was typed, as
does leaving the page and going back to it, and Save changes the description; of
the recent events on the webhook page only the newest starts expanded, each
expands and collapses, and Open leads to the event's own page; of the events in
the event log only the newest starts expanded, and an event there expands and
collapses when its row's caret or its ID is clicked, and from the keyboard, but
not when its ID is selected with the mouse, and a delivery's attempts inside it
expand and collapse; and at phone width the menu button opens and closes the
mobile menu, and neither the webhook page nor the event log, with a delivery's
attempts open, scrolls sideways or cuts anything off at the page's or a card's
edge. It also fails if the browser reports a console warning or error, an
uncaught exception, or anything the policy refused. `make check` and the image
build lint it but do not run it, and `make test` leaves it out (its file is
built only with the `browser` build tag). Run it with `make test-browser` after
changing `templates/` or `static/js/`: that builds `Dockerfile.browser`, which
runs the test in a digest-pinned headless browser image, so the host needs no
browser.
A browser test in `internal/server` loads the sign-in page, the webhook page,
its edit pages, the event log, the webhook list, the settings page, the profile
page and the `404` error page shown for a path that does not exist, under the
real policy and checks that: the add entrypoint form stays hidden until Add is
clicked; for every target type, the targets section's Add shows only a choice of
type with Next and Cancel, Next shows only that type's fields (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; a refused save on the target edit page and on the webhook edit page
comes back with the reason and every value entered; the Copy button beside an
entrypoint URL reads "Copied" once clicked; an entrypoint's Edit button shows
its edit form in place of its description and hides until the form closes,
Cancel hides the form and drops what was typed, as does leaving the page and
going back to it, and Save changes the description; of the recent events on the
webhook page only the newest starts expanded, each expands and collapses, and
Open leads to the event's own page; of the events in the event log only the
newest starts expanded, and an event there expands and collapses when its row's
caret or its ID is clicked, and from the keyboard, but not when its ID is
selected with the mouse, and a delivery's attempts inside it expand and
collapse; at phone width the menu button opens and closes the mobile menu, and
neither the webhook page nor the event log, with a delivery's attempts open,
scrolls sideways or cuts anything off at the page's or a card's edge; and a
target's Delete asks first, naming the target, sends nothing when the answer is
no and deletes the target when it is yes. It also fails if the browser reports a
console warning or error, an uncaught exception, or anything the policy refused.
`make check` and the image build lint it but do not run it, and `make test`
leaves it out (its file is built only with the `browser` build tag). Run it with
`make test-browser` after changing `templates/` or `static/js/`: that builds
`Dockerfile.browser`, which runs the test in a digest-pinned headless browser
image, so the host needs no browser.
The package's tarball is committed as `3p/alpinejs-csp-3.14.9.tgz`, byte for
byte as the npm registry publishes it. It is a dependency, not this repo's build
@@ -1311,7 +1314,9 @@ and commit the regenerated file with the change. Tailwind takes classes only
from the files that `input.css` names in its `@source` lines; a class written in
any other file is not generated until that file is named there too. `make check`
and the image build fail when the committed file differs from what `make css`
generates. `static/css/style.css` is hand-written and is not generated.
generates. `static/css/style.css` is hand-written and is not generated. The
pages' Content-Security-Policy refuses inline styles, so a template styles an
element with a class, never a `style` attribute.
`make css` runs the Tailwind standalone CLI in Docker, at the version and sha256
pinned in the Dockerfile's stylesheet stages; it is never installed on the host.
@@ -3106,8 +3111,8 @@ webhooker/
│ ├── static.go # //go:embed directive
│ ├── css/input.css # Tailwind input, source for tailwind.css (make css)
│ ├── css/tailwind.css # Generated stylesheet the pages load
│ ├── css/style.css # Hand-written, loaded after tailwind.css: btn-small, the pointer cursor for input.css's buttons, the webhook list cards' focus outline
│ ├── js/app.js # Copy-to-clipboard, and the Alpine.js components
│ ├── css/style.css # Hand-written, loaded after tailwind.css: btn-small, the pointer cursor for input.css's buttons, the webhook list cards' focus outline, the rule hiding x-cloak elements
│ ├── js/app.js # Copy-to-clipboard, the delete prompts, and the Alpine.js components
│ └── js/alpine.min.js # Alpine.js CSP build, extracted from 3p/ by make assets, not committed
├── templates/ # Go HTML templates (base, login, sources, etc.)
├── script/ # Scripts to Rule Them All entrypoints
@@ -3161,8 +3166,9 @@ Applied to all routes in this order:
1. **RequestID** — Generate unique request IDs (chi built-in)
2. **SecurityHeaders** — Production security headers on every response (HSTS
unless `HSTS_MAX_AGE` is `0`, X-Content-Type-Options, X-Frame-Options, CSP,
Referrer-Policy, Permissions-Policy)
unless `HSTS_MAX_AGE` is `0`, X-Content-Type-Options, X-Frame-Options, CSP
`default-src 'self'; script-src 'self'; style-src 'self'`, Referrer-Policy,
Permissions-Policy)
3. **Logging** — Structured request logging (method, URL, status, latency,
remote IP, client IP, user agent, request ID)
4. **Metrics** — Prometheus HTTP metrics (if `METRICS_USERNAME` and
@@ -3247,6 +3253,12 @@ credential check, see [The login endpoint](#the-login-endpoint).
- Production security headers on all responses: HSTS (unless `HSTS_MAX_AGE` is
`0`), X-Content-Type-Options (`nosniff`), X-Frame-Options (`DENY`),
Content-Security-Policy, Referrer-Policy, and Permissions-Policy
- The Content-Security-Policy is
`default-src 'self'; script-src 'self'; style-src 'self'`: a page takes
scripts and styles only from files webhooker serves, and the browser runs no
inline script or event handler and ignores any `<style>` element or `style`
attribute in a page. The pages need none: their scripts are in `static/js/`
and their styles in `static/css/`
- Request body size limits (1 MB) on all form POST endpoints, enforced by
middleware that runs before CSRF parses the form
- **CSRF protection** via [gorilla/csrf](https://github.com/gorilla/csrf) on all
@@ -158,8 +158,7 @@ func TestHandleSourceLogs_RendersFailedAttempt(t *testing.T) {
// TestHandleSourceLogs_EscapesResponseBody proves the
// response body is treated as the untrusted remote content it
// is. The remote chooses these bytes and the page is rendered
// inside the operator's authenticated origin, where the
// application's own CSP allows inline script from 'self'.
// inside the operator's authenticated origin.
func TestHandleSourceLogs_EscapesResponseBody(t *testing.T) {
t.Parallel()
+4 -5
View File
@@ -35,11 +35,10 @@ const eventBodyQuery = "SELECT cast(body as blob) " +
// response is deliberately not renderable. Content-Disposition
// makes the browser download rather than display it, and the
// octet-stream type plus nosniff stop it being interpreted as
// HTML or script. Without those a stored payload would execute
// as the logged-in operator. The application's CSP does not
// help here: script-src allows 'unsafe-inline' from 'self', so
// a document served from this origin could run its own inline
// script.
// HTML or script. Without those a stored payload would be shown
// as a page of the operator's own origin. The application's CSP
// is a second defence behind them: it allows no inline script,
// so such a page could not run script of its own.
func (h *Handlers) HandleEventBodyDownload() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
webhook, ok := h.ownedWebhook(w, r)
+1 -1
View File
@@ -84,7 +84,7 @@ func TestHandleEventDetail_ShowsEventWholeWithDeliveries(t *testing.T) {
assert.Contains(t, page, strconv.Itoa(len(body))+" bytes")
assert.Contains(t, page, "{\n &#34;pad&#34;: &#34;xxx")
assert.Contains(t, page, "&#34;tail&#34;: &#34;"+sentinel+"&#34;\n}")
assert.Contains(t, page, `style="max-height: 32rem; overflow-y: auto"`)
assert.Contains(t, page, `break-all max-h-[32rem] overflow-y-auto"`)
assert.NotContains(t, page, "Showing the first")
assert.Contains(t, page, target.Name)
assert.Contains(t, page, ">failed</span>")
+17 -18
View File
@@ -273,7 +273,7 @@ func TestHandleSourceDetail_FitsWideAndNarrowWindows(t *testing.T) {
assert.Contains(
t, body,
`<div class="mx-auto px-6 py-8" style="max-width: 108rem"`,
`<div class="max-w-[108rem] mx-auto px-6 py-8">`,
)
assert.Contains(
t, body,
@@ -286,8 +286,8 @@ func TestHandleSourceDetail_FitsWideAndNarrowWindows(t *testing.T) {
// target and says what deleting it loses, that the webhook's gives its
// number of stored events (5 received, 2 removed by retention, so 3,
// the statistics pane's "Within retention" figure), and that an
// entrypoint with no description is named by its URL. The template
// writes the slashes after http: as \/, which the browser reads as /.
// entrypoint with no description is named by its URL. Each prompt is
// its form's data-confirm attribute, where &#10; is a line break.
func TestHandleSourceDetail_DeletePromptsNameWhatIsLost(t *testing.T) {
t.Parallel()
@@ -325,29 +325,28 @@ func TestHandleSourceDetail_DeletePromptsNameWhatIsLost(t *testing.T) {
body := renderSourceDetailPage(t, h, sess, wh.ID)
assert.Contains(t, body,
`Delete webhook &quot;delete-me&quot;?\n\n`+
`data-confirm="Delete webhook &quot;delete-me&quot;?&#10;&#10;`+
`This deletes its stored events (3) and their deliveries. `+
`Any archive files it wrote are kept.`)
`Any archive files it wrote are kept."`)
assert.Contains(t, body,
`Delete entrypoint &quot;Stripe&quot;?\n\n`+
`data-confirm="Delete entrypoint &quot;Stripe&quot;?&#10;&#10;`+
`Senders using its URL get an error from now on, `+
`and the URL cannot be restored.`)
`and the URL cannot be restored."`)
assert.Contains(t, body,
`Delete entrypoint &quot;http:\/\/example.com/h/`+
unnamed.Path+`&quot;?`)
`data-confirm="Delete entrypoint &quot;http://example.com/h/`+
unnamed.Path+`&quot;?&#10;&#10;`)
assert.Contains(t, body,
`Delete target &quot;t-log&quot;?\n\n`+
`data-confirm="Delete target &quot;t-log&quot;?&#10;&#10;`+
`Nothing more is delivered to it. `+
`Its past deliveries stay in the event log.`)
`Its past deliveries stay in the event log."`)
}
// TestHandleSourceDetail_DeletePromptKeepsQuotesInName checks that a
// webhook name with quotes, a backslash, a closing script tag and a
// newline reaches its delete prompt escaped for the script, which the
// browser reads back as the name typed: each quote and angle bracket
// as a \u escape, the slash as \/, the newline as \n and the backslash
// doubled. An unescaped newline would break the prompt's script, and
// the form would then submit without asking.
// newline reaches its delete prompt escaped for the attribute, which
// the browser reads back as the name typed: each quote and angle
// bracket as a character reference, the rest as typed. An unescaped
// double quote would end the attribute and cut the prompt short.
func TestHandleSourceDetail_DeletePromptKeepsQuotesInName(t *testing.T) {
t.Parallel()
@@ -373,6 +372,6 @@ func TestHandleSourceDetail_DeletePromptKeepsQuotesInName(t *testing.T) {
body := renderSourceDetailPage(t, h, sess, wh.ID)
assert.Contains(t, body,
"Delete webhook &quot;Bob\\u0027s \\u0022best\\u0022 \\\\ hook"+
"\\u003c\\/script\\u003e\\nline two&quot;?")
"Delete webhook &quot;Bob&#39;s &#34;best&#34; \\ hook"+
"&lt;/script&gt;\nline two&quot;?")
}
+5 -3
View File
@@ -572,7 +572,9 @@ func (s *Middleware) MetricsAuth() func(http.Handler) http.Handler {
// SecurityHeaders returns middleware that sets production security
// headers on every response: HSTS (unless HSTS_MAX_AGE is zero),
// X-Content-Type-Options, X-Frame-Options, CSP, Referrer-Policy, and
// Permissions-Policy.
// Permissions-Policy. The CSP takes scripts and styles only from
// files on this origin, so a template may hold no inline script,
// event handler or style attribute.
func (s *Middleware) SecurityHeaders() func(http.Handler) http.Handler {
maxAge := s.params.Config.HSTSMaxAge
hsts := "max-age=" + strconv.FormatInt(int64(maxAge/time.Second), 10) +
@@ -594,8 +596,8 @@ func (s *Middleware) SecurityHeaders() func(http.Handler) http.Handler {
w.Header().Set(
"Content-Security-Policy",
"default-src 'self'; "+
"script-src 'self' 'unsafe-inline'; "+
"style-src 'self' 'unsafe-inline'",
"script-src 'self'; "+
"style-src 'self'",
)
w.Header().Set(
"Referrer-Policy",
+23 -6
View File
@@ -747,10 +747,10 @@ func TestNoCache_SetsHeaders(t *testing.T) {
// --- SecurityHeaders Middleware Tests ---
// hstsHeaders serves one request through SecurityHeaders built over
// a Config with the given HSTSMaxAge, and returns every
// Strict-Transport-Security value on the response.
func hstsHeaders(t *testing.T, maxAge time.Duration) []string {
// securityHeaders serves one request through SecurityHeaders built
// over a Config with the given HSTSMaxAge, and returns the response's
// headers.
func securityHeaders(t *testing.T, maxAge time.Duration) http.Header {
t.Helper()
cfg := &config.Config{HSTSMaxAge: maxAge}
@@ -772,7 +772,7 @@ func hstsHeaders(t *testing.T, maxAge time.Duration) []string {
handler.ServeHTTP(w, req)
return w.Header().Values("Strict-Transport-Security")
return w.Header()
}
func TestSecurityHeaders_HSTS(t *testing.T) {
@@ -804,11 +804,28 @@ func TestSecurityHeaders_HSTS(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
assert.Equal(t, tt.expected, hstsHeaders(t, tt.maxAge))
assert.Equal(
t, tt.expected,
securityHeaders(t, tt.maxAge).Values(
"Strict-Transport-Security",
),
)
})
}
}
// TestSecurityHeaders_CSP pins the Content-Security-Policy: a page
// takes its scripts and styles only from files on its own origin,
// never from an inline script, event handler or style.
func TestSecurityHeaders_CSP(t *testing.T) {
t.Parallel()
assert.Equal(
t, "default-src 'self'; script-src 'self'; style-src 'self'",
securityHeaders(t, 0).Get("Content-Security-Policy"),
)
}
// --- MaxBodySize Middleware Tests ---
const testBodyLimit int64 = 64
+83 -2
View File
@@ -60,8 +60,8 @@ const (
// TestAlpineRunsUnderTheSecurityPolicy loads the webhook page and the
// event log in a headless browser, served by the real router and so
// under the real Content-Security-Policy, and checks that the pages'
// Alpine.js directives and the copy control work, and that a target's
// row shows its delivery counts.
// Alpine.js directives, the copy control and the delete prompts work,
// and that a target's row shows its delivery counts.
func TestAlpineRunsUnderTheSecurityPolicy(t *testing.T) {
t.Parallel()
@@ -74,6 +74,10 @@ func TestAlpineRunsUnderTheSecurityPolicy(t *testing.T) {
userID, _ := env.seedUser(t, "browser", "browser-password")
webhook, older, event, target := seedBrowserWebhook(t, env, userID)
// The sign-in page is loaded before the browser signs in, since a
// signed-in visit to it is sent on to another page.
checkOtherPages(ctx, t, srv.URL+"/pages/login")
require.NoError(t, chromedp.Run(
ctx, setCookies(srv.URL, env.authCookies(t, userID, "browser")),
))
@@ -99,6 +103,11 @@ func TestAlpineRunsUnderTheSecurityPolicy(t *testing.T) {
checkEventLog(ctx, t, page+"/events", event.ID, older.ID, target.Name)
checkMobileMenu(ctx, t, page)
checkPhoneWidth(ctx, t, page, page+"/events", target.Name)
checkOtherPages(ctx, t,
srv.URL+"/hooks", srv.URL+"/settings", srv.URL+"/user/browser",
// A path that does not exist, which gets the 404 error page.
srv.URL+"/no-such-page")
checkDeletePrompt(ctx, t, page, target)
assert.Empty(t, problems(), "the browser reported problems")
}
@@ -1388,3 +1397,75 @@ func checkPhoneWidth(
assert.False(t, sideways, "the event log scrolls sideways on a phone")
assert.Empty(t, cutOff, "the event log cuts these off on a phone")
}
// checkOtherPages loads the pages at urls, which no other check opens,
// so that anything the browser reports going wrong on them fails the
// test too.
func checkOtherPages(ctx context.Context, t *testing.T, urls ...string) {
t.Helper()
for _, url := range urls {
require.NoError(t, chromedp.Run(ctx, loadPage(url)))
}
}
// answerPrompt returns a script that stands in for the user at the
// browser's prompt: it makes window.confirm record the question in
// window.asked and give answer. It also records in window.sent whether
// a form is sent once its submit event is handled, from a listener on
// window, which the event reaches after the listener static/js/app.js
// adds to document.
func answerPrompt(answer bool) string {
return fmt.Sprintf(`
window.asked = "";
window.sent = false;
window.confirm = (question) => {
window.asked = question;
return %t;
};
window.addEventListener("submit", (event) => {
window.sent = !event.defaultPrevented;
});`, answer)
}
// checkDeletePrompt loads a webhook page and clicks Delete beside
// target. It checks that the browser asks first, naming the target,
// that answering no leaves the form unsent, and that answering yes
// sends it and the target is deleted.
func checkDeletePrompt(
ctx context.Context, t *testing.T, url string, target *database.Target,
) {
t.Helper()
form := `form[action$="/targets/` + target.ID + `/delete"]`
question := `Delete target "` + target.Name + `"?` + "\n\n" +
"Nothing more is delivered to it. " +
"Its past deliveries stay in the event log."
var (
asked string
sent bool
listed bool
)
require.NoError(t, chromedp.Run(
ctx, loadPage(url), chromedp.Evaluate(answerPrompt(false), nil),
))
click(ctx, t, form+" button")
require.NoError(t, chromedp.Run(
ctx,
chromedp.Evaluate(`window.asked`, &asked),
chromedp.Evaluate(`window.sent`, &sent),
))
assert.Equal(t, question, asked, "Delete does not ask about the target")
assert.False(t, sent, "answering no still sends the delete form")
require.NoError(t, chromedp.Run(
ctx, chromedp.Evaluate(answerPrompt(true), nil),
))
clickAndLoad(ctx, t, form+" button")
require.NoError(t, chromedp.Run(ctx, chromedp.Evaluate(
`document.querySelector('`+form+`') !== null`, &listed,
)))
assert.False(t, listed, "answering yes does not delete the target")
}
+11 -3
View File
@@ -1,7 +1,7 @@
/*
* The two shared styles for the controls a user clicks. Every page loads
* this file after tailwind.css. It is plain CSS: make css does not build
* it.
* The two shared styles for the controls a user clicks, and the rule that
* hides an element marked x-cloak. Every page loads this file after
* tailwind.css. It is plain CSS: make css does not build it.
*
* A button is btn-primary, btn-secondary or btn-danger, from input.css.
* A secondary or inline action, such as Copy beside an entrypoint URL or
@@ -46,3 +46,11 @@
outline-offset: 2px;
}
}
/*
* Alpine.js removes x-cloak from an element once it has started, so
* something Alpine hides is not seen on the page while it loads.
*/
[x-cloak] {
display: none !important;
}
File diff suppressed because one or more lines are too long
+12
View File
@@ -58,6 +58,18 @@
}
})();
// Asks before a form with a data-confirm attribute is sent, such as a
// delete form on the webhook page. The browser's own prompt shows the
// attribute's text, and the form is sent only if the user accepts.
document.addEventListener("submit", function (event) {
"use strict";
const message = event.target.getAttribute("data-confirm");
if (message !== null && !window.confirm(message)) {
event.preventDefault();
}
});
// Alpine.js components.
//
// The pages' Content-Security-Policy forbids eval, so the UI loads
+1 -1
View File
@@ -5,7 +5,7 @@
{{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>
<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}} max-h-[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}}
-1
View File
@@ -4,6 +4,5 @@
<title>{{block "title" .}}Webhooker{{end}}</title>
<link rel="stylesheet" href="/s/css/tailwind.css">
<link rel="stylesheet" href="/s/css/style.css">
<style>[x-cloak] { display: none !important; }</style>
{{block "head" .}}{{end}}
{{end}}
+3 -5
View File
@@ -13,13 +13,11 @@
<div class="p-4">
<!-- A value too wide to sit beside its name moves to the
next line, where a list breaks only at the spaces
between its entries. overflow-wrap: anywhere breaks
inside a value only when it alone is wider than the
line; an inline style, because the committed
tailwind.css has no class for it. -->
between its entries. wrap-anywhere breaks inside a
value only when it alone is wider than the line. -->
<div class="flex flex-wrap justify-between items-start gap-4">
<code class="text-sm font-medium text-gray-900">{{.Name}}</code>
<code class="text-sm text-gray-900" style="overflow-wrap: anywhere">{{.Value}}</code>
<code class="text-sm text-gray-900 wrap-anywhere">{{.Value}}</code>
</div>
<p class="text-sm text-gray-500 mt-1">{{.Description}}</p>
</div>
+12 -13
View File
@@ -5,12 +5,10 @@
{{define "content"}}
<!-- 108rem, half again the 72rem (max-w-6xl) of the webhook list, the
event log, the navbar and the footer, so an entrypoint URL fits on
one line. An inline style, because the committed tailwind.css has
no class this wide. wrap-anywhere goes only on names and
descriptions: a row too wide for a phone must still run past the
edge, where the browser test sees it, rather than break its
controls mid-word. -->
<div class="mx-auto px-6 py-8" style="max-width: 108rem">
one line. wrap-anywhere goes only on names and descriptions: a
row too wide for a phone must still run past the edge, where the
browser test sees it, rather than break its controls mid-word. -->
<div class="max-w-[108rem] mx-auto px-6 py-8">
<div class="mb-6">
<a href="/hooks" class="btn-small">&larr; Back to webhooks</a>
<div class="flex flex-wrap justify-between items-center gap-2 mt-2">
@@ -23,11 +21,12 @@
<div class="flex gap-2">
<a href="/hook/{{.Webhook.ID}}/events" class="btn-secondary">Full Event Log</a>
<a href="/hook/{{.Webhook.ID}}/edit" class="btn-secondary">Edit</a>
<!-- The delete prompts are the browser's own, so they
work without the page's scripts. The template
escapes each name for the script, so a quote or a
backslash in it shows as typed. -->
<form method="POST" action="/hook/{{.Webhook.ID}}/delete" onsubmit="return confirm('Delete webhook &quot;{{.Webhook.Name}}&quot;?\n\nThis deletes its stored events{{with .Stats}} ({{.WithinRetention.Events}}){{end}} and their deliveries. Any archive files it wrote are kept.')">
<!-- Before a delete form is sent, static/js/app.js
asks in the browser's own prompt, with the form's
data-confirm text; &#10; is a line break. The
template escapes each name for the attribute, so a
quote in it shows as typed. -->
<form method="POST" action="/hook/{{.Webhook.ID}}/delete" data-confirm="Delete webhook &quot;{{.Webhook.Name}}&quot;?&#10;&#10;This deletes its stored events{{with .Stats}} ({{.WithinRetention.Events}}){{end}} and their deliveries. Any archive files it wrote are kept.">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<button type="submit" class="btn-danger">Delete</button>
</form>
@@ -90,7 +89,7 @@
{{if .Active}}Deactivate{{else}}Activate{{end}}
</button>
</form>
<form method="POST" action="/hook/{{$.Webhook.ID}}/entrypoints/{{.ID}}/delete" onsubmit="return confirm('Delete entrypoint &quot;{{if .Description}}{{.Description}}{{else}}{{$.BaseURL}}/h/{{.Path}}{{end}}&quot;?\n\nSenders using its URL get an error from now on, and the URL cannot be restored.')" class="inline">
<form method="POST" action="/hook/{{$.Webhook.ID}}/entrypoints/{{.ID}}/delete" data-confirm="Delete entrypoint &quot;{{if .Description}}{{.Description}}{{else}}{{$.BaseURL}}/h/{{.Path}}{{end}}&quot;?&#10;&#10;Senders using its URL get an error from now on, and the URL cannot be restored." class="inline">
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
<button type="submit" class="btn-small text-red-600" title="Delete">Delete</button>
</form>
@@ -278,7 +277,7 @@
{{if .Active}}Deactivate{{else}}Activate{{end}}
</button>
</form>
<form method="POST" action="/hook/{{$.Webhook.ID}}/targets/{{.ID}}/delete" onsubmit="return confirm('Delete target &quot;{{.Name}}&quot;?\n\nNothing more is delivered to it. Its past deliveries stay in the event log.')" class="inline">
<form method="POST" action="/hook/{{$.Webhook.ID}}/targets/{{.ID}}/delete" data-confirm="Delete target &quot;{{.Name}}&quot;?&#10;&#10;Nothing more is delivered to it. Its past deliveries stay in the event log." class="inline">
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
<button type="submit" class="btn-small text-red-600" title="Delete">Delete</button>
</form>