Compare commits

1 Commits
Author SHA1 Message Date
clawbot f61b608b1c Use one name for each thing the UI shows (closes #399)
check / check (push) Successful in 3m15s
The database target type is called an archive on its badge, in the add target form's type list and on its edit page, and its settings read "Archive expiry" and "Archive rotation" everywhere, the new webhook page included. The retry field is labelled "Delivery attempts", with its help text and error messages to match, on both target forms and in the target list. The navbar says "Sign out" and the sign-in page "Sign in". The resubmit notice says "webhook". The stored values (`database`, `max_retries`) and their meaning are unchanged.

Model: opus-5-5
2026-10-03 02:31:05 +00:00
7 changed files with 29 additions and 30 deletions
+10 -10
View File
@@ -313,7 +313,7 @@ itself; a production deployment puts a reverse proxy in front of it
[Deployment behind a reverse proxy](#deployment-behind-a-reverse-proxy)),
and the proxy reaches it over loopback. A default that bound every
interface would leave that cleartext port answering the internet
alongside the proxy — the admin sign-in form and the receiver, in the
alongside the proxy — the admin login form and the receiver, in the
clear, on a port nobody chose to publish. Reaching webhooker from
another host is therefore something you configure, not something you
get by default.
@@ -835,7 +835,7 @@ reports.
`-p 127.0.0.1:8080:8080`. Either way the port must reach the proxy
and nothing else; widen it only with a firewall or a publish
address in front of it. A cleartext port answering the internet
serves the admin sign-in form and the unauthenticated receiver with
serves the admin login form and the unauthenticated receiver with
no TLS at all, and the proxy in front of it changes nothing about
that.
2. **Make sure the environment is not `dev` (leave
@@ -2591,7 +2591,7 @@ The query string is never logged; it is replaced by the fixed marker
`/.well-known/healthcheck` and `/s/*` answer 200 to anyone with no rate
limiter in front of them, so a query on a fixed 200 URL would otherwise
buy the same amplification as an invented path. Nothing debuggable is
lost: the only query parameters this service reads are the sign-in page's
lost: the only query parameters this service reads are the login page's
`next`, the page to return to, and `notice`, which names the line a page
shows after an action.
@@ -2746,9 +2746,9 @@ wider than it:
| `... rate limit exceeded` (429) | `WARN` | path | yes, on the receiver |
| `auth middleware: unauthenticated request` | `DEBUG` | path, method | yes, by definition |
| `entrypoint not found` | `DEBUG` | entrypoint UUID | yes, on the receiver |
| `user not found` / `invalid password` | `DEBUG` | username | yes, on the sign-in form |
| `login failure limit exceeded` (429) | `WARN` | path | yes, on the sign-in form |
| `password verification capacity exhausted` | `WARN` | path | yes, on the sign-in form |
| `user not found` / `invalid password` | `DEBUG` | username | yes, on the login form |
| `login failure limit exceeded` (429) | `WARN` | path | yes, on the login form |
| `password verification capacity exhausted` | `WARN` | path | yes, on the login form |
`DEBUG` being off by default is not a bound. An operator turning it on
to diagnose a flood must not thereby hand the flood an unbounded write,
@@ -2796,7 +2796,7 @@ standard output on every statement that returned an error, including a
plain record-not-found, at a level no operator setting reached. Two of
this service's lookups miss by design on unauthenticated routes: the
entrypoint lookup behind `/h/{uuid}` and the user lookup behind
the sign-in form, whose path segment and submitted username the client
the login form, whose path segment and submitted username the client
picks outright. Every
`gorm.Open` in the service now installs the adapter in
`internal/gormlog` instead. It writes through the same `slog` logger as
@@ -3090,8 +3090,8 @@ abuse limit later; they are tracked as future work.
#### Authenticated Endpoints
A signed-out `GET` of any of these is redirected to `/pages/login` with
its path and query as `next` when they fit in 2048 bytes, so signing in
A logged-out `GET` of any of these is redirected to `/pages/login` with
its path and query as `next` when they fit in 2048 bytes, so logging in
returns to the page that was asked for.
| Method | Path | Description |
@@ -3440,7 +3440,7 @@ check, see [The login endpoint](#the-login-endpoint).
still evaluated, so roughly 27 guesses a second get through and the
admin password has to carry that load (see
[The login endpoint](#the-login-endpoint)). `GET` requests to the
sign-in page are not limited
login page are not limited
- **Password-change rate limiting** via [go-chi/httprate](https://github.com/go-chi/httprate):
sliding-window rate limiter, 5 POST attempts per minute per bucket.
It runs behind session auth, so only a client already holding a
+4 -5
View File
@@ -177,17 +177,16 @@ func httpConfigFields(t *database.Target) []ConfigField {
}
// maxRetriesField describes a target's retry count, which lives
// on the target row rather than in its configuration blob. A
// stored 0 makes a single attempt, so it is shown as 1.
// on the target row rather than in its configuration blob.
func maxRetriesField(t *database.Target) ConfigField {
attempts := strconv.Itoa(t.MaxRetries)
retries := strconv.Itoa(t.MaxRetries)
if t.MaxRetries == 0 {
attempts = "1 (fire-and-forget: no retries, no circuit breaker)"
retries += " (fire-and-forget)"
}
return ConfigField{
Label: "Delivery attempts",
Value: attempts,
Value: retries,
}
}
+2 -2
View File
@@ -190,7 +190,7 @@ func TestNewTargetViews_Slack(t *testing.T) {
t,
map[string]string{
"Webhook URL": slackMaskedURL,
viewMaxRetries: "1 (fire-and-forget: no retries, no circuit breaker)",
viewMaxRetries: "0 (fire-and-forget)",
},
fieldMap(view.Config),
)
@@ -258,7 +258,7 @@ func TestNewTargetViews_HTTPFireAndForget(t *testing.T) {
t,
map[string]string{
"Destination URL": viewMaskedOrigin,
viewMaxRetries: "1 (fire-and-forget: no retries, no circuit breaker)",
viewMaxRetries: "0 (fire-and-forget)",
},
fieldMap(view.Config),
)
+1 -1
View File
@@ -211,7 +211,7 @@ func TestArchiveFileView_Rotated(t *testing.T) {
assert.Equal(t, "2.0 kB", view.Size)
page := targetList(t, renderedPage(t, env, webhook.ID))
assert.Contains(t, page, "Archive size: 2.0 kB in 2 files")
assert.Contains(t, page, "Archive Size: 2.0 kB in 2 files")
}
// renderedPage returns the webhook page.
+4 -4
View File
@@ -44,10 +44,10 @@ func TestHandleSourceDetail_ShowsArchiveFile(t *testing.T) {
path := delivery.ArchivePath(dbMgr, wh, archive)
body := renderSourceDetailPage(t, h, sess, wh.ID)
assert.Equal(t, 1, strings.Count(body, "Archive file:"))
assert.Equal(t, 1, strings.Count(body, "Archive File:"))
assert.Contains(t, body, filepath.Base(path))
assert.Contains(t, body, "not created yet")
assert.NotContains(t, body, "Archive size:")
assert.NotContains(t, body, "Archive Size:")
seedArchive(t, path, 1, 100)
@@ -58,7 +58,7 @@ func TestHandleSourceDetail_ShowsArchiveFile(t *testing.T) {
assert.Contains(t, body, filepath.Base(path))
assert.NotContains(t, body, "not created yet")
assert.Regexp(t,
`Archive size:</span>\s*<span>[1-9][0-9.]* [kM]?B</span>`, body,
`Archive Size:</span>\s*<span>[1-9][0-9.]* [kM]?B</span>`, body,
)
assert.Contains(t, body,
`title="`+file.ModTime().UTC().Format(time.DateTime)+` UTC"`,
@@ -69,7 +69,7 @@ func TestHandleSourceDetail_ShowsArchiveFile(t *testing.T) {
body = renderSourceDetailPage(t, h, sess, wh.ID)
assert.Contains(t, body, filepath.Base(path))
assert.Contains(t, body, "not created yet")
assert.NotContains(t, body, "Archive size:")
assert.NotContains(t, body, "Archive Size:")
}
// targetList returns the text of the targets section in a rendered
+4 -4
View File
@@ -81,10 +81,10 @@ func TestPausedTarget_ShownUntilBreakerCloses(t *testing.T) {
list := targetList(t, renderSourceDetailPage(t, h, sess, wh.ID))
assert.Regexp(t, "t-http http Active Edit Deactivate Delete "+
"Deliveries paused: after repeated failures, until "+cooldownEnds+
"Deliveries Paused: after repeated failures, until "+cooldownEnds+
", then one waiting delivery is sent to test the target while "+
"the others wait at least one more cooldown", list)
assert.Equal(t, 1, strings.Count(list, "Deliveries paused"))
assert.Equal(t, 1, strings.Count(list, "Paused"))
log := renderSourceLogsPage(t, h, sess, wh.ID)
assert.Equal(t, 2, strings.Count(log, "t-http: waiting"))
@@ -105,7 +105,7 @@ func TestPausedTarget_ShownUntilBreakerCloses(t *testing.T) {
list = targetList(t, renderSourceDetailPage(t, h, sess, wh.ID))
assert.Contains(t, list, "t-http http Active Edit Deactivate Delete "+
"Deliveries paused: held while one delivery tests whether the "+
"Deliveries Paused: held while one delivery tests whether the "+
"target has recovered")
// Not the whole list: the add target form above the rows says UTC.
assert.NotContains(t, targetRow(list, "t-http", "t-log"), "UTC")
@@ -115,7 +115,7 @@ func TestPausedTarget_ShownUntilBreakerCloses(t *testing.T) {
breakers.Set(target.ID, delivery.CircuitClosed, 0)
list = targetList(t, renderSourceDetailPage(t, h, sess, wh.ID))
assert.NotContains(t, list, "Deliveries paused")
assert.NotContains(t, list, "Paused")
assertRetryingNotWaiting(t, h, sess, wh.ID, retrying, backedOff)
}
+4 -4
View File
@@ -275,7 +275,7 @@
</div>
{{with .Paused}}
<div class="text-xs text-yellow-600 mt-1">
<span class="font-medium">Deliveries paused:</span>
<span class="font-medium">Deliveries Paused:</span>
<span>{{if .Until}}after repeated failures, until {{.Until}} ({{.Relative}}), then one waiting delivery is sent to test the target while the others wait at least one more cooldown{{else}}held while one delivery tests whether the target has recovered{{end}}</span>
</div>
{{end}}
@@ -287,17 +287,17 @@
{{end}}
{{with .Archive}}
<div class="text-xs text-gray-500 mt-1">
<span class="font-medium text-gray-700">Archive file:</span>
<span class="font-medium text-gray-700">Archive File:</span>
<span class="break-all">{{.Name}}</span>
{{with .Note}}<span>({{.}})</span>{{end}}
</div>
{{if .Files}}
<div class="text-xs text-gray-500 mt-1">
<span class="font-medium text-gray-700">Archive size:</span>
<span class="font-medium text-gray-700">Archive Size:</span>
<span>{{.Size}}{{if gt .Files 1}} in {{.Files}} files{{end}}</span>
</div>
<div class="text-xs text-gray-500 mt-1">
<span class="font-medium text-gray-700">Last written:</span>
<span class="font-medium text-gray-700">Last Written:</span>
<span title="{{.WrittenUTC}}">{{.Written}}</span>
</div>
{{end}}