Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a4337e949b |
@@ -145,11 +145,6 @@ TTY detection, and security headers are always applied.
|
|||||||
| `TRUSTED_PROXIES` | CIDRs whose forwarded headers are trusted. A set value replaces the default. If any client can reach webhooker, or the proxy in front of it, from an RFC 1918 source address, set it to the proxy's address alone. See [Trusted proxies](#trusted-proxies) | `10.0.0.0/8,172.16.0.0/12,192.168.0.0/16` (RFC 1918) |
|
| `TRUSTED_PROXIES` | CIDRs whose forwarded headers are trusted. A set value replaces the default. If any client can reach webhooker, or the proxy in front of it, from an RFC 1918 source address, set it to the proxy's address alone. See [Trusted proxies](#trusted-proxies) | `10.0.0.0/8,172.16.0.0/12,192.168.0.0/16` (RFC 1918) |
|
||||||
| `ALLOWED_EGRESS_CIDRS` | CIDRs that delivery targets may reach despite the SSRF blocklist. Read [Allowing egress to your own network](#allowing-egress-to-your-own-network) before setting it | `""` (none) |
|
| `ALLOWED_EGRESS_CIDRS` | CIDRs that delivery targets may reach despite the SSRF blocklist. Read [Allowing egress to your own network](#allowing-egress-to-your-own-network) before setting it | `""` (none) |
|
||||||
|
|
||||||
The Settings page of the web UI (`/settings`, behind the login) lists
|
|
||||||
every one of these with the value the running server loaded. It is
|
|
||||||
read-only, and it shows `METRICS_PASSWORD` and `SENTRY_DSN` only as
|
|
||||||
set or not set, never their values.
|
|
||||||
|
|
||||||
#### Allowing egress to your own network
|
#### Allowing egress to your own network
|
||||||
|
|
||||||
By default every delivery target must resolve to a public address. The
|
By default every delivery target must resolve to a public address. The
|
||||||
@@ -1773,7 +1768,7 @@ retries) is individually logged for full observability.
|
|||||||
#### EventTotals and TargetTotals
|
#### EventTotals and TargetTotals
|
||||||
|
|
||||||
Running counts in each event database, read by the statistics pane at the
|
Running counts in each event database, read by the statistics pane at the
|
||||||
top of the webhook page. `EventTotals` is one row:
|
top of the webhook page and by the webhook list. `EventTotals` is one row:
|
||||||
|
|
||||||
| Field | Type | Description |
|
| Field | Type | Description |
|
||||||
| ---------------- | --------- | ----------- |
|
| ---------------- | --------- | ----------- |
|
||||||
@@ -1805,6 +1800,14 @@ target. Its failure percentage for a window is the deliveries that became
|
|||||||
`failed` in it out of all that became `delivered` or `failed` in it, and
|
`failed` in it out of all that became `delivered` or `failed` in it, and
|
||||||
a dash when none did.
|
a dash when none did.
|
||||||
|
|
||||||
|
The webhook list at `/hooks` shows three of the pane's figures for each
|
||||||
|
webhook: its events within retention and its last event, both from
|
||||||
|
`EventTotals`, and its deliveries that failed in the last 24 hours,
|
||||||
|
counted with the pane's query. It opens each webhook's event database once
|
||||||
|
(the handle stays open) and runs those two reads there, so its cost grows
|
||||||
|
with the number of webhooks and, for each, with the deliveries that
|
||||||
|
finished in the last 24 hours, never with the events stored.
|
||||||
|
|
||||||
#### Event-tier indexes
|
#### Event-tier indexes
|
||||||
|
|
||||||
These indexes on the per-webhook event databases are declared in the model
|
These indexes on the per-webhook event databases are declared in the model
|
||||||
@@ -1812,7 +1815,7 @@ tags, so `AutoMigrate` creates them on a fresh database:
|
|||||||
|
|
||||||
| Table | Columns | Serves |
|
| Table | Columns | Serves |
|
||||||
| ------------------ | --------------------------- | ------ |
|
| ------------------ | --------------------------- | ------ |
|
||||||
| `deliveries` | `status`, `deleted_at`, `finished_at`, `target_id` | Startup recovery, the retry and pending sweeps every 60 seconds and the queue-depth sampler every 30 seconds, which select deliveries by status, and the webhook page's statistics, which count each target's deliveries by status and when they finished |
|
| `deliveries` | `status`, `deleted_at`, `finished_at`, `target_id` | Startup recovery, the retry and pending sweeps every 60 seconds and the queue-depth sampler every 30 seconds, which select deliveries by status, and the webhook page's statistics and the webhook list, which count each target's deliveries by status and when they finished |
|
||||||
| `deliveries` | `event_id`, `deleted_at` | The event log, which loads each event's deliveries, and retention, which counts and deletes the deliveries of expired events |
|
| `deliveries` | `event_id`, `deleted_at` | The event log, which loads each event's deliveries, and retention, which counts and deletes the deliveries of expired events |
|
||||||
| `delivery_results` | `delivery_id`, `deleted_at` | The event log, which loads the attempts of a page's deliveries, and retention, which deletes the attempts of expired events |
|
| `delivery_results` | `delivery_id`, `deleted_at` | The event log, which loads the attempts of a page's deliveries, and retention, which deletes the attempts of expired events |
|
||||||
| `events` | `deleted_at`, `created_at` | The webhook page's statistics, which count recent events |
|
| `events` | `deleted_at`, `created_at` | The webhook page's statistics, which count recent events |
|
||||||
@@ -2785,7 +2788,6 @@ returns to the page that was asked for.
|
|||||||
| ------ | ------------------------ | ----------- |
|
| ------ | ------------------------ | ----------- |
|
||||||
| `GET` | `/user/{username}` | User profile page |
|
| `GET` | `/user/{username}` | User profile page |
|
||||||
| `POST` | `/user/{username}/password` | Change the user's password (5 per minute per bucket, then `429`; `503` if no verification slot frees up within 5s, or immediately if 16 requests are already queued for one) |
|
| `POST` | `/user/{username}/password` | Change the user's password (5 per minute per bucket, then `429`; `503` if no verification slot frees up within 5s, or immediately if 16 requests are already queued for one) |
|
||||||
| `GET` | `/settings` | Read-only list of the configuration the server is running with; `METRICS_PASSWORD` and `SENTRY_DSN` show only as set or not set |
|
|
||||||
| `GET` | `/hooks` | List user's webhooks |
|
| `GET` | `/hooks` | List user's webhooks |
|
||||||
| `GET` | `/hooks/new` | Create webhook form |
|
| `GET` | `/hooks/new` | Create webhook form |
|
||||||
| `POST` | `/hooks/new` | Create webhook submission |
|
| `POST` | `/hooks/new` | Create webhook submission |
|
||||||
@@ -2899,7 +2901,6 @@ webhooker/
|
|||||||
│ │ ├── healthcheck.go # Health check handler
|
│ │ ├── healthcheck.go # Health check handler
|
||||||
│ │ ├── index.go # Index page handler
|
│ │ ├── index.go # Index page handler
|
||||||
│ │ ├── profile.go # User profile handler
|
│ │ ├── profile.go # User profile handler
|
||||||
│ │ ├── settings.go # Read-only Settings page handler
|
|
||||||
│ │ ├── source_management.go # Webhook CRUD handlers
|
│ │ ├── source_management.go # Webhook CRUD handlers
|
||||||
│ │ └── webhook.go # Webhook receiver handler
|
│ │ └── webhook.go # Webhook receiver handler
|
||||||
│ ├── healthcheck/
|
│ ├── healthcheck/
|
||||||
@@ -3009,14 +3010,14 @@ local record instead of nothing. What that placement gives up is
|
|||||||
recovery of a panic in the six entries above it, none of which does
|
recovery of a panic in the six entries above it, none of which does
|
||||||
more than set a header or start a timer.
|
more than set a header or start a timer.
|
||||||
|
|
||||||
Each admin page route group (`/pages`, `/user/*`, `/settings`,
|
Each admin page route group (`/pages`, `/user/*`, `/hooks`,
|
||||||
`/hooks`, `/hook/*`) starts with its own **Recoverer** and, if
|
`/hook/*`) starts with its own **Recoverer** and, if `SENTRY_DSN` is
|
||||||
`SENTRY_DSN` is set, its own **Sentry** error reporting. That Recoverer answers a panic
|
set, its own **Sentry** error reporting. That Recoverer answers a panic
|
||||||
with the `500` error page in the normal layout; the global one keeps
|
with the `500` error page in the normal layout; the global one keeps
|
||||||
the plain-text `500` for every other route.
|
the plain-text `500` for every other route.
|
||||||
|
|
||||||
Additionally, form endpoints (`/pages`, `/user/*`, `/settings`,
|
Additionally, form endpoints (`/pages`, `/user/*`, `/hooks`,
|
||||||
`/hooks`, `/hook/*`) apply a **MaxBodySize** middleware that limits
|
`/hook/*`) apply a **MaxBodySize** middleware that limits
|
||||||
POST/PUT/PATCH request bodies to 1 MB. It is registered ahead of the
|
POST/PUT/PATCH request bodies to 1 MB. It is registered ahead of the
|
||||||
CSRF middleware in every one of those route groups, because
|
CSRF middleware in every one of those route groups, because
|
||||||
gorilla/csrf parses the form; if the cap were installed after it, form
|
gorilla/csrf parses the form; if the cap were installed after it, form
|
||||||
@@ -3035,7 +3036,7 @@ declared length. A chunked request, or
|
|||||||
one that lies about its length, is hard-capped by
|
one that lies about its length, is hard-capped by
|
||||||
`http.MaxBytesReader` and fails downstream at form-parse time.
|
`http.MaxBytesReader` and fails downstream at form-parse time.
|
||||||
|
|
||||||
Those same five route groups then apply **CSRF** and **NoCache**
|
Those same four route groups then apply **CSRF** and **NoCache**
|
||||||
(`Cache-Control: no-store`, `Pragma: no-cache`), and every group except
|
(`Cache-Control: no-store`, `Pragma: no-cache`), and every group except
|
||||||
`/pages` applies **RequireAuth**. The rate limiters are per-route
|
`/pages` applies **RequireAuth**. The rate limiters are per-route
|
||||||
rather than global: **PasswordChangeRateLimit** on
|
rather than global: **PasswordChangeRateLimit** on
|
||||||
@@ -3081,12 +3082,12 @@ check, see [The login endpoint](#the-login-endpoint).
|
|||||||
by middleware that runs before CSRF parses the form
|
by middleware that runs before CSRF parses the form
|
||||||
- **CSRF protection** via [gorilla/csrf](https://github.com/gorilla/csrf)
|
- **CSRF protection** via [gorilla/csrf](https://github.com/gorilla/csrf)
|
||||||
on all state-changing forms (cookie-based double-submit tokens with
|
on all state-changing forms (cookie-based double-submit tokens with
|
||||||
HMAC authentication). Applied to `/pages`, `/hooks`, `/hook`,
|
HMAC authentication). Applied to `/pages`, `/hooks`, `/hook`, and
|
||||||
`/settings`, and `/user` routes. Excluded from `/h` (inbound webhook
|
`/user` routes. Excluded from `/h` (inbound webhook POSTs) and
|
||||||
POSTs) and `/api` (stateless API). The middleware detects TLS
|
`/api` (stateless API). The middleware detects TLS per-request through
|
||||||
per-request through `internal/reqtls.IsTLS` — the same predicate the
|
`internal/reqtls.IsTLS` — the same predicate the session cookie uses —
|
||||||
session cookie uses — to set appropriate cookie security flags and
|
to set appropriate cookie security flags and Origin/Referer validation
|
||||||
Origin/Referer validation mode
|
mode
|
||||||
- **The entrypoint URL is the receiver's only credential.** Nothing
|
- **The entrypoint URL is the receiver's only credential.** Nothing
|
||||||
about an inbound request is verified; possession of the UUID
|
about an inbound request is verified; possession of the UUID
|
||||||
authorises submission, and no shared secret or signature check will
|
authorises submission, and no shared secret or signature check will
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import (
|
|||||||
|
|
||||||
"github.com/prometheus/client_golang/prometheus"
|
"github.com/prometheus/client_golang/prometheus"
|
||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
"sneak.berlin/go/webhooker/internal/config"
|
|
||||||
"sneak.berlin/go/webhooker/internal/database"
|
"sneak.berlin/go/webhooker/internal/database"
|
||||||
"sneak.berlin/go/webhooker/internal/delivery"
|
"sneak.berlin/go/webhooker/internal/delivery"
|
||||||
"sneak.berlin/go/webhooker/internal/globals"
|
"sneak.berlin/go/webhooker/internal/globals"
|
||||||
@@ -58,7 +57,6 @@ type HandlersParams struct {
|
|||||||
|
|
||||||
Logger *logger.Logger
|
Logger *logger.Logger
|
||||||
Globals *globals.Globals
|
Globals *globals.Globals
|
||||||
Config *config.Config
|
|
||||||
Database *database.Database
|
Database *database.Database
|
||||||
WebhookDBMgr *database.WebhookDBManager
|
WebhookDBMgr *database.WebhookDBManager
|
||||||
Healthcheck *healthcheck.Healthcheck
|
Healthcheck *healthcheck.Healthcheck
|
||||||
@@ -141,7 +139,6 @@ func New(
|
|||||||
s.templates = map[string]*template.Template{
|
s.templates = map[string]*template.Template{
|
||||||
"login.html": parsePageTemplate("login.html"),
|
"login.html": parsePageTemplate("login.html"),
|
||||||
"profile.html": parsePageTemplate("profile.html"),
|
"profile.html": parsePageTemplate("profile.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"),
|
||||||
|
|||||||
@@ -84,25 +84,16 @@ func newTestApp(
|
|||||||
) *fxtest.App {
|
) *fxtest.App {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
return newTestAppWithConfig(
|
|
||||||
t, &config.Config{DataDir: t.TempDir()}, targets...,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// newTestAppWithConfig is newTestApp over a caller-supplied Config.
|
|
||||||
func newTestAppWithConfig(
|
|
||||||
t *testing.T,
|
|
||||||
cfg *config.Config,
|
|
||||||
targets ...any,
|
|
||||||
) *fxtest.App {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
return fxtest.New(
|
return fxtest.New(
|
||||||
t,
|
t,
|
||||||
fx.Provide(
|
fx.Provide(
|
||||||
globals.New,
|
globals.New,
|
||||||
logger.New,
|
logger.New,
|
||||||
func() *config.Config { return cfg },
|
func() *config.Config {
|
||||||
|
return &config.Config{
|
||||||
|
DataDir: t.TempDir(),
|
||||||
|
}
|
||||||
|
},
|
||||||
database.New,
|
database.New,
|
||||||
database.NewWebhookDBManager,
|
database.NewWebhookDBManager,
|
||||||
healthcheck.New,
|
healthcheck.New,
|
||||||
|
|||||||
@@ -1,130 +0,0 @@
|
|||||||
package handlers
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
"net/netip"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"sneak.berlin/go/webhooker/internal/config"
|
|
||||||
)
|
|
||||||
|
|
||||||
// notSet is what the Settings page shows for a value that is empty.
|
|
||||||
const notSet = "not set"
|
|
||||||
|
|
||||||
// settingRow is one line of the Settings page: an environment
|
|
||||||
// variable, what it controls, and the value the server loaded for it.
|
|
||||||
type settingRow struct {
|
|
||||||
Name string
|
|
||||||
Description string
|
|
||||||
Value string
|
|
||||||
}
|
|
||||||
|
|
||||||
// HandleSettings returns a handler for the read-only Settings page,
|
|
||||||
// which lists the configuration the server started with.
|
|
||||||
func (h *Handlers) HandleSettings() http.HandlerFunc {
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
h.renderTemplate(w, r, "settings.html", map[string]any{
|
|
||||||
"Settings": settingRows(h.params.Config),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// settingRows lists every field of cfg under the environment variable
|
|
||||||
// it is read from, in the order of the README's configuration table.
|
|
||||||
// METRICS_PASSWORD and SENTRY_DSN are credentials, so their values
|
|
||||||
// never reach the page: only whether they are set.
|
|
||||||
func settingRows(cfg *config.Config) []settingRow {
|
|
||||||
metricsUsername := cfg.MetricsUsername
|
|
||||||
if metricsUsername == "" {
|
|
||||||
metricsUsername = notSet
|
|
||||||
}
|
|
||||||
|
|
||||||
return []settingRow{
|
|
||||||
{"WEBHOOKER_ENVIRONMENT", "dev or prod", cfg.Environment},
|
|
||||||
{"PORT", "HTTP listen port", strconv.Itoa(cfg.Port)},
|
|
||||||
{
|
|
||||||
"BIND_ADDRESS",
|
|
||||||
"IP address the HTTP listener binds",
|
|
||||||
cfg.BindAddress,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"DATA_DIR",
|
|
||||||
"Directory for all SQLite databases",
|
|
||||||
cfg.DataDir,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"DEBUG",
|
|
||||||
"Enable debug logging",
|
|
||||||
strconv.FormatBool(cfg.Debug),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"MAINTENANCE_MODE",
|
|
||||||
"Report maintenanceMode: true in the healthcheck JSON. " +
|
|
||||||
"It does not change how any request is served",
|
|
||||||
strconv.FormatBool(cfg.MaintenanceMode),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"METRICS_USERNAME",
|
|
||||||
"Basic auth username for /metrics",
|
|
||||||
metricsUsername,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"METRICS_PASSWORD",
|
|
||||||
"Basic auth password for /metrics",
|
|
||||||
setOrNotSet(cfg.MetricsPassword),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"SENTRY_DSN",
|
|
||||||
"Error reporting DSN. Unset leaves error reporting off",
|
|
||||||
setOrNotSet(cfg.SentryDSN),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"RETENTION_SWEEP_INTERVAL",
|
|
||||||
"How often the retention reaper and archive sweeper run",
|
|
||||||
cfg.RetentionSweepInterval.String(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"SESSION_IDLE_TIMEOUT",
|
|
||||||
"Idle session timeout. Zero or negative disables idle " +
|
|
||||||
"expiry",
|
|
||||||
cfg.SessionIdleTimeout.String(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"RECEIVER_RATE_LIMIT",
|
|
||||||
"Receiver requests per minute per IP per entrypoint " +
|
|
||||||
"(10x that per IP across the route)",
|
|
||||||
strconv.Itoa(cfg.ReceiverRateLimit),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"TRUSTED_PROXIES",
|
|
||||||
"CIDRs whose forwarded headers are trusted",
|
|
||||||
cidrList(cfg.TrustedProxies),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ALLOWED_EGRESS_CIDRS",
|
|
||||||
"CIDRs that delivery targets may reach despite the " +
|
|
||||||
"SSRF blocklist",
|
|
||||||
cidrList(cfg.AllowedEgressCIDRs),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// setOrNotSet is how the Settings page shows a credential: whether it
|
|
||||||
// has a value, never the value itself.
|
|
||||||
func setOrNotSet(value string) string {
|
|
||||||
if value == "" {
|
|
||||||
return notSet
|
|
||||||
}
|
|
||||||
|
|
||||||
return "set"
|
|
||||||
}
|
|
||||||
|
|
||||||
// cidrList renders a CIDR list setting for the Settings page.
|
|
||||||
func cidrList(prefixes []netip.Prefix) string {
|
|
||||||
if len(prefixes) == 0 {
|
|
||||||
return "none"
|
|
||||||
}
|
|
||||||
|
|
||||||
return strings.Join(config.PrefixStrings(prefixes), ", ")
|
|
||||||
}
|
|
||||||
@@ -1,151 +0,0 @@
|
|||||||
package handlers_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"html"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"net/netip"
|
|
||||||
"regexp"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
"sneak.berlin/go/webhooker/internal/config"
|
|
||||||
"sneak.berlin/go/webhooker/internal/handlers"
|
|
||||||
"sneak.berlin/go/webhooker/internal/session"
|
|
||||||
)
|
|
||||||
|
|
||||||
// settingsShown renders the Settings page over cfg as a logged-in user
|
|
||||||
// and returns the value it shows for each variable name, plus the
|
|
||||||
// whole page.
|
|
||||||
func settingsShown(
|
|
||||||
t *testing.T, cfg *config.Config,
|
|
||||||
) (map[string]string, string) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
var h *handlers.Handlers
|
|
||||||
|
|
||||||
var sess *session.Session
|
|
||||||
|
|
||||||
app := newTestAppWithConfig(t, cfg, &h, &sess)
|
|
||||||
app.RequireStart()
|
|
||||||
|
|
||||||
t.Cleanup(app.RequireStop)
|
|
||||||
|
|
||||||
req := httptest.NewRequestWithContext(
|
|
||||||
context.Background(), http.MethodGet, "/settings", nil,
|
|
||||||
)
|
|
||||||
for _, c := range authenticatedCookies(t, sess, "id", "admin") {
|
|
||||||
req.AddCookie(c)
|
|
||||||
}
|
|
||||||
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
h.HandleSettings().ServeHTTP(w, req)
|
|
||||||
require.Equal(t, http.StatusOK, w.Code)
|
|
||||||
|
|
||||||
body := w.Body.String()
|
|
||||||
|
|
||||||
row := regexp.MustCompile(
|
|
||||||
`<code[^>]*>([A-Z_]+)</code>\s*<code[^>]*>([^<]*)</code>`,
|
|
||||||
)
|
|
||||||
|
|
||||||
shown := map[string]string{}
|
|
||||||
for _, match := range row.FindAllStringSubmatch(body, -1) {
|
|
||||||
shown[match[1]] = html.UnescapeString(match[2])
|
|
||||||
}
|
|
||||||
|
|
||||||
return shown, body
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSettingsPageShowsLoadedConfiguration(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
// DEBUG and MAINTENANCE_MODE get opposite values, and each of
|
|
||||||
// METRICS_USERNAME, METRICS_PASSWORD and SENTRY_DSN is the only one
|
|
||||||
// of the three set in one of the content tests, so each row is
|
|
||||||
// checked against its own field.
|
|
||||||
cfg := &config.Config{
|
|
||||||
DataDir: t.TempDir(),
|
|
||||||
Debug: true,
|
|
||||||
MaintenanceMode: false,
|
|
||||||
Environment: config.EnvironmentDev,
|
|
||||||
MetricsUsername: "scraper",
|
|
||||||
MetricsPassword: "",
|
|
||||||
Port: 9123,
|
|
||||||
SentryDSN: "",
|
|
||||||
BindAddress: "192.0.2.10",
|
|
||||||
RetentionSweepInterval: 17 * time.Minute,
|
|
||||||
SessionIdleTimeout: 3 * time.Hour,
|
|
||||||
ReceiverRateLimit: 77,
|
|
||||||
TrustedProxies: []netip.Prefix{
|
|
||||||
netip.MustParsePrefix("10.1.0.0/16"),
|
|
||||||
},
|
|
||||||
AllowedEgressCIDRs: []netip.Prefix{
|
|
||||||
netip.MustParsePrefix("192.168.5.0/24"),
|
|
||||||
netip.MustParsePrefix("fd00::/8"),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
shown, body := settingsShown(t, cfg)
|
|
||||||
|
|
||||||
assert.Equal(t, map[string]string{
|
|
||||||
"WEBHOOKER_ENVIRONMENT": "dev",
|
|
||||||
"PORT": "9123",
|
|
||||||
"BIND_ADDRESS": "192.0.2.10",
|
|
||||||
"DATA_DIR": cfg.DataDir,
|
|
||||||
"DEBUG": "true",
|
|
||||||
"MAINTENANCE_MODE": "false",
|
|
||||||
"METRICS_USERNAME": "scraper",
|
|
||||||
"METRICS_PASSWORD": "not set",
|
|
||||||
"SENTRY_DSN": "not set",
|
|
||||||
"RETENTION_SWEEP_INTERVAL": "17m0s",
|
|
||||||
"SESSION_IDLE_TIMEOUT": "3h0m0s",
|
|
||||||
"RECEIVER_RATE_LIMIT": "77",
|
|
||||||
"TRUSTED_PROXIES": "10.1.0.0/16",
|
|
||||||
"ALLOWED_EGRESS_CIDRS": "192.168.5.0/24, fd00::/8",
|
|
||||||
}, shown)
|
|
||||||
|
|
||||||
assert.Contains(
|
|
||||||
t, body, `href="/settings"`,
|
|
||||||
"the navigation bar links to the page",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSettingsPageShowsUnsetValues(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
const metricsPassword = "metrics-password-1f9a"
|
|
||||||
|
|
||||||
shown, body := settingsShown(t, &config.Config{
|
|
||||||
DataDir: t.TempDir(),
|
|
||||||
MetricsPassword: metricsPassword,
|
|
||||||
})
|
|
||||||
|
|
||||||
assert.Equal(t, "not set", shown["METRICS_USERNAME"])
|
|
||||||
assert.Equal(t, "set", shown["METRICS_PASSWORD"])
|
|
||||||
assert.Equal(t, "not set", shown["SENTRY_DSN"])
|
|
||||||
assert.NotContains(t, body, metricsPassword)
|
|
||||||
assert.Equal(t, "none", shown["TRUSTED_PROXIES"])
|
|
||||||
assert.Equal(t, "none", shown["ALLOWED_EGRESS_CIDRS"])
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSettingsPageShowsSentryDSNOnlyAsSet(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
const (
|
|
||||||
sentryKey = "dsnkey7c2e"
|
|
||||||
sentryDSN = "https://" + sentryKey + "@errors.example.com/42"
|
|
||||||
)
|
|
||||||
|
|
||||||
shown, body := settingsShown(t, &config.Config{
|
|
||||||
DataDir: t.TempDir(),
|
|
||||||
SentryDSN: sentryDSN,
|
|
||||||
})
|
|
||||||
|
|
||||||
assert.Equal(t, "not set", shown["METRICS_USERNAME"])
|
|
||||||
assert.Equal(t, "not set", shown["METRICS_PASSWORD"])
|
|
||||||
assert.Equal(t, "set", shown["SENTRY_DSN"])
|
|
||||||
assert.NotContains(t, body, sentryKey)
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,355 @@
|
|||||||
|
package handlers_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/clause"
|
||||||
|
"sneak.berlin/go/webhooker/internal/database"
|
||||||
|
"sneak.berlin/go/webhooker/internal/handlers"
|
||||||
|
"sneak.berlin/go/webhooker/internal/logger"
|
||||||
|
"sneak.berlin/go/webhooker/internal/session"
|
||||||
|
)
|
||||||
|
|
||||||
|
// failedHighlight is how the list marks a number of failed deliveries
|
||||||
|
// that is not zero.
|
||||||
|
const failedHighlight = `class="font-medium text-red-600"`
|
||||||
|
|
||||||
|
// listWebhook adds a webhook with the given name, owned by the test
|
||||||
|
// user.
|
||||||
|
func listWebhook(
|
||||||
|
t *testing.T, db *database.Database, name string,
|
||||||
|
) *database.Webhook {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
wh := &database.Webhook{UserID: deleteTestUserID, Name: name}
|
||||||
|
require.NoError(t, db.DB().Omit(clause.Associations).Create(wh).Error)
|
||||||
|
|
||||||
|
return wh
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderWebhookList runs the real webhook list handler as the test user
|
||||||
|
// and returns the rendered page.
|
||||||
|
func renderWebhookList(
|
||||||
|
t *testing.T, h *handlers.Handlers, sess *session.Session,
|
||||||
|
) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
cookies := authenticatedCookies(
|
||||||
|
t, sess, deleteTestUserID, deleteTestUsername,
|
||||||
|
)
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h.HandleSourceList().ServeHTTP(
|
||||||
|
w, getRequest(t, "/hooks", cookies, nil),
|
||||||
|
)
|
||||||
|
require.Equal(t, http.StatusOK, w.Code)
|
||||||
|
|
||||||
|
return w.Body.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// listCard returns one webhook's entry in a rendered webhook list, its
|
||||||
|
// markup as rendered and its text with the markup taken out and each
|
||||||
|
// run of space made one space.
|
||||||
|
func listCard(t *testing.T, page, webhookID string) (string, string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
_, card, found := strings.Cut(page, `href="/hook/`+webhookID+`"`)
|
||||||
|
require.True(t, found, "the list has no entry for %s", webhookID)
|
||||||
|
|
||||||
|
card, _, _ = strings.Cut(card, "</a>")
|
||||||
|
text := regexp.MustCompile(`<[^>]*>`).ReplaceAllString(card, " ")
|
||||||
|
|
||||||
|
return card, strings.Join(strings.Fields(text), " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
// receiveEvents posts the given number of events to an entrypoint
|
||||||
|
// through the real receiver, and returns the webhook's event database
|
||||||
|
// and its events, oldest first.
|
||||||
|
func receiveEvents(
|
||||||
|
t *testing.T,
|
||||||
|
h *handlers.Handlers,
|
||||||
|
dbMgr *database.WebhookDBManager,
|
||||||
|
webhookID, path string,
|
||||||
|
count int,
|
||||||
|
) (*gorm.DB, []database.Event) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
router := receiverRouter(h)
|
||||||
|
|
||||||
|
for range count {
|
||||||
|
require.Equal(t, http.StatusOK, postReceiver(t, router, path))
|
||||||
|
}
|
||||||
|
|
||||||
|
webhookDB, err := dbMgr.GetDB(webhookID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
events := listEvents(t, webhookDB)
|
||||||
|
require.Len(t, events, count)
|
||||||
|
|
||||||
|
return webhookDB, events
|
||||||
|
}
|
||||||
|
|
||||||
|
// seedFailingWebhook adds a webhook with two entrypoints, one inactive,
|
||||||
|
// and four targets, one inactive. Three events each reach the three
|
||||||
|
// active targets. Two deliveries failed in the last 24 hours, one 30
|
||||||
|
// hours ago, and one was delivered. It returns the webhook and its
|
||||||
|
// newest event.
|
||||||
|
func seedFailingWebhook(
|
||||||
|
t *testing.T,
|
||||||
|
h *handlers.Handlers,
|
||||||
|
db *database.Database,
|
||||||
|
dbMgr *database.WebhookDBManager,
|
||||||
|
) (*database.Webhook, database.Event) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
wh := listWebhook(t, db, "failing")
|
||||||
|
path := statsEntrypoint(t, db, wh.ID, true)
|
||||||
|
|
||||||
|
statsEntrypoint(t, db, wh.ID, false)
|
||||||
|
|
||||||
|
first := seedTarget(t, db, wh.ID, database.TargetTypeLog)
|
||||||
|
second := seedTarget(t, db, wh.ID, database.TargetTypeLog)
|
||||||
|
|
||||||
|
seedTarget(t, db, wh.ID, database.TargetTypeLog)
|
||||||
|
|
||||||
|
inactive := seedTarget(t, db, wh.ID, database.TargetTypeLog)
|
||||||
|
require.NoError(t, db.DB().Model(inactive).
|
||||||
|
Update("active", false).Error)
|
||||||
|
|
||||||
|
webhookDB, events := receiveEvents(t, h, dbMgr, wh.ID, path, 3)
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
statsFinish(t, webhookDB,
|
||||||
|
statsDelivery(t, webhookDB, events[0].ID, first.ID),
|
||||||
|
database.DeliveryStatusFailed, now.Add(-30*time.Hour))
|
||||||
|
statsFinish(t, webhookDB,
|
||||||
|
statsDelivery(t, webhookDB, events[1].ID, first.ID),
|
||||||
|
database.DeliveryStatusFailed, now.Add(-time.Hour))
|
||||||
|
statsFinish(t, webhookDB,
|
||||||
|
statsDelivery(t, webhookDB, events[2].ID, first.ID),
|
||||||
|
database.DeliveryStatusFailed, now.Add(-time.Minute))
|
||||||
|
statsFinish(t, webhookDB,
|
||||||
|
statsDelivery(t, webhookDB, events[2].ID, second.ID),
|
||||||
|
database.DeliveryStatusDelivered, now.Add(-time.Minute))
|
||||||
|
|
||||||
|
return wh, events[2]
|
||||||
|
}
|
||||||
|
|
||||||
|
// seedHealthyWebhook adds a webhook with one entrypoint and one target,
|
||||||
|
// both active, and two events, both delivered. It returns the webhook
|
||||||
|
// and its newest event.
|
||||||
|
func seedHealthyWebhook(
|
||||||
|
t *testing.T,
|
||||||
|
h *handlers.Handlers,
|
||||||
|
db *database.Database,
|
||||||
|
dbMgr *database.WebhookDBManager,
|
||||||
|
) (*database.Webhook, database.Event) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
wh := listWebhook(t, db, "healthy")
|
||||||
|
path := statsEntrypoint(t, db, wh.ID, true)
|
||||||
|
target := seedTarget(t, db, wh.ID, database.TargetTypeLog)
|
||||||
|
|
||||||
|
webhookDB, events := receiveEvents(t, h, dbMgr, wh.ID, path, 2)
|
||||||
|
|
||||||
|
for _, ev := range events {
|
||||||
|
statsFinish(t, webhookDB,
|
||||||
|
statsDelivery(t, webhookDB, ev.ID, target.ID),
|
||||||
|
database.DeliveryStatusDelivered, time.Now())
|
||||||
|
}
|
||||||
|
|
||||||
|
return wh, events[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
// lastEventText is how the list shows the arrival of an event.
|
||||||
|
func lastEventText(ev database.Event) string {
|
||||||
|
return ev.CreatedAt.UTC().Format("2006-01-02 15:04:05 UTC")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSourceList_ShowsActivityOfEachWebhook checks the figures the list
|
||||||
|
// shows for a webhook with recent failures, a healthy one, a new one
|
||||||
|
// that has received no event, and one without an event database.
|
||||||
|
func TestSourceList_ShowsActivityOfEachWebhook(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)
|
||||||
|
|
||||||
|
failing, failingNewest := seedFailingWebhook(t, h, db, dbMgr)
|
||||||
|
healthy, healthyNewest := seedHealthyWebhook(t, h, db, dbMgr)
|
||||||
|
|
||||||
|
// Creating a webhook creates its event database.
|
||||||
|
fresh := listWebhook(t, db, "fresh")
|
||||||
|
require.NoError(t, dbMgr.CreateDB(fresh.ID))
|
||||||
|
|
||||||
|
quiet := listWebhook(t, db, "quiet")
|
||||||
|
|
||||||
|
page := renderWebhookList(t, h, sess)
|
||||||
|
|
||||||
|
card, text := listCard(t, page, failing.ID)
|
||||||
|
assert.Contains(t, text, "2 entrypoints, 1 inactive "+
|
||||||
|
"4 targets, 1 inactive "+
|
||||||
|
"3 events within retention "+
|
||||||
|
"Last event "+lastEventText(failingNewest)+" "+
|
||||||
|
"2 failed deliveries in the last 24 hours")
|
||||||
|
assert.Contains(t, card,
|
||||||
|
failedHighlight+">2 failed deliveries in the last 24 hours<")
|
||||||
|
|
||||||
|
card, text = listCard(t, page, healthy.ID)
|
||||||
|
assert.Contains(t, text, "1 entrypoint "+
|
||||||
|
"1 target "+
|
||||||
|
"2 events within retention "+
|
||||||
|
"Last event "+lastEventText(healthyNewest)+" "+
|
||||||
|
"0 failed deliveries in the last 24 hours")
|
||||||
|
assert.NotContains(t, text, "inactive")
|
||||||
|
assert.NotContains(t, card, failedHighlight)
|
||||||
|
|
||||||
|
card, text = listCard(t, page, fresh.ID)
|
||||||
|
assert.Contains(t, text, "0 entrypoints "+
|
||||||
|
"0 targets "+
|
||||||
|
"0 events within retention "+
|
||||||
|
"No events yet "+
|
||||||
|
"0 failed deliveries in the last 24 hours")
|
||||||
|
assert.NotContains(t, card, failedHighlight)
|
||||||
|
|
||||||
|
card, text = listCard(t, page, quiet.ID)
|
||||||
|
assert.Contains(t, text, "0 entrypoints "+
|
||||||
|
"0 targets "+
|
||||||
|
"0 events within retention "+
|
||||||
|
"No events yet "+
|
||||||
|
"0 failed deliveries in the last 24 hours")
|
||||||
|
assert.NotContains(t, card, failedHighlight)
|
||||||
|
assert.False(t, dbMgr.DBExists(quiet.ID),
|
||||||
|
"showing the list must not create an event database")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSourceList_CountsOnlyEventsWithinRetention checks that once
|
||||||
|
// retention has removed one of a webhook's three events, the list
|
||||||
|
// counts the two still stored.
|
||||||
|
func TestSourceList_CountsOnlyEventsWithinRetention(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
var (
|
||||||
|
h *handlers.Handlers
|
||||||
|
sess *session.Session
|
||||||
|
db *database.Database
|
||||||
|
dbMgr *database.WebhookDBManager
|
||||||
|
log *logger.Logger
|
||||||
|
)
|
||||||
|
|
||||||
|
app := newTestApp(t, &h, &sess, &db, &dbMgr, &log)
|
||||||
|
app.RequireStart()
|
||||||
|
|
||||||
|
t.Cleanup(app.RequireStop)
|
||||||
|
|
||||||
|
wh := &database.Webhook{
|
||||||
|
UserID: deleteTestUserID, Name: "pruned", RetentionDays: 14,
|
||||||
|
}
|
||||||
|
require.NoError(t, db.DB().Omit(clause.Associations).Create(wh).Error)
|
||||||
|
|
||||||
|
path := statsEntrypoint(t, db, wh.ID, true)
|
||||||
|
webhookDB, events := receiveEvents(t, h, dbMgr, wh.ID, path, 3)
|
||||||
|
|
||||||
|
statsAge(t, webhookDB, events[0].ID, time.Now().Add(-15*24*time.Hour))
|
||||||
|
statsPrune(t, db, dbMgr, log, webhookDB)
|
||||||
|
require.Len(t, listEvents(t, webhookDB), 2)
|
||||||
|
|
||||||
|
_, text := listCard(t, renderWebhookList(t, h, sess), wh.ID)
|
||||||
|
assert.Contains(t, text,
|
||||||
|
"1 entrypoint 0 targets 2 events within retention")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSourceList_LastEventSurvivesPruningEveryEvent checks that once
|
||||||
|
// retention has removed every event of a webhook, the list still shows
|
||||||
|
// when the last one arrived rather than "No events yet".
|
||||||
|
func TestSourceList_LastEventSurvivesPruningEveryEvent(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
var (
|
||||||
|
h *handlers.Handlers
|
||||||
|
sess *session.Session
|
||||||
|
db *database.Database
|
||||||
|
dbMgr *database.WebhookDBManager
|
||||||
|
log *logger.Logger
|
||||||
|
)
|
||||||
|
|
||||||
|
app := newTestApp(t, &h, &sess, &db, &dbMgr, &log)
|
||||||
|
app.RequireStart()
|
||||||
|
|
||||||
|
t.Cleanup(app.RequireStop)
|
||||||
|
|
||||||
|
wh := &database.Webhook{
|
||||||
|
UserID: deleteTestUserID, Name: "emptied", RetentionDays: 1,
|
||||||
|
}
|
||||||
|
require.NoError(t, db.DB().Omit(clause.Associations).Create(wh).Error)
|
||||||
|
|
||||||
|
path := statsEntrypoint(t, db, wh.ID, true)
|
||||||
|
webhookDB, events := receiveEvents(t, h, dbMgr, wh.ID, path, 1)
|
||||||
|
|
||||||
|
statsAge(t, webhookDB, events[0].ID, time.Now().Add(-50*time.Hour))
|
||||||
|
statsPrune(t, db, dbMgr, log, webhookDB)
|
||||||
|
require.Empty(t, listEvents(t, webhookDB))
|
||||||
|
|
||||||
|
_, text := listCard(t, renderWebhookList(t, h, sess), wh.ID)
|
||||||
|
assert.Contains(t, text,
|
||||||
|
"0 events within retention "+
|
||||||
|
"Last event "+lastEventText(events[0]))
|
||||||
|
assert.NotContains(t, text, "No events yet")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSourceList_UnreadableEventDatabase checks that a webhook whose
|
||||||
|
// event database cannot be read says so in its entry instead of
|
||||||
|
// showing zeros, and that the rest of the list is still shown.
|
||||||
|
func TestSourceList_UnreadableEventDatabase(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)
|
||||||
|
|
||||||
|
broken := listWebhook(t, db, "broken")
|
||||||
|
statsEntrypoint(t, db, broken.ID, true)
|
||||||
|
|
||||||
|
brokenDB, err := dbMgr.GetDB(broken.ID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t,
|
||||||
|
brokenDB.Migrator().DropTable(&database.EventTotals{}))
|
||||||
|
|
||||||
|
quiet := listWebhook(t, db, "quiet")
|
||||||
|
|
||||||
|
page := renderWebhookList(t, h, sess)
|
||||||
|
|
||||||
|
_, text := listCard(t, page, broken.ID)
|
||||||
|
assert.Contains(t, text,
|
||||||
|
"1 entrypoint 0 targets The event figures could not be read.")
|
||||||
|
assert.NotContains(t, text, "events")
|
||||||
|
assert.NotContains(t, text, "failed")
|
||||||
|
|
||||||
|
_, text = listCard(t, page, quiet.ID)
|
||||||
|
assert.Contains(t, text, "No events yet")
|
||||||
|
}
|
||||||
@@ -3,10 +3,12 @@ package handlers
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"slices"
|
"slices"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/go-chi/chi"
|
"github.com/go-chi/chi"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
@@ -20,9 +22,20 @@ import (
|
|||||||
type WebhookListItem struct {
|
type WebhookListItem struct {
|
||||||
database.Webhook
|
database.Webhook
|
||||||
|
|
||||||
EntrypointCount int64
|
EntrypointCount int
|
||||||
TargetCount int64
|
InactiveEntrypointCount int
|
||||||
EventCount int64
|
TargetCount int
|
||||||
|
InactiveTargetCount int
|
||||||
|
|
||||||
|
// EventCount is how many events the webhook holds, LastEventAt
|
||||||
|
// when the newest arrived (nil before the first), and
|
||||||
|
// FailedLast24Hours how many of its deliveries failed in the last
|
||||||
|
// 24 hours. When the webhook's event database could not be read,
|
||||||
|
// EventsUnreadable is set and these three are not known.
|
||||||
|
EventCount int64
|
||||||
|
LastEventAt *time.Time
|
||||||
|
FailedLast24Hours int64
|
||||||
|
EventsUnreadable bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// errMissingURL signals that a required URL was not provided.
|
// errMissingURL signals that a required URL was not provided.
|
||||||
@@ -154,7 +167,12 @@ func (h *Handlers) HandleSourceList() http.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
items := h.buildWebhookListItems(webhooks)
|
items, err := h.buildWebhookListItems(webhooks)
|
||||||
|
if err != nil {
|
||||||
|
h.serverError(w, r, "failed to list webhooks", err)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
data := map[string]any{
|
data := map[string]any{
|
||||||
"Webhooks": items,
|
"Webhooks": items,
|
||||||
@@ -164,36 +182,115 @@ func (h *Handlers) HandleSourceList() http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildWebhookListItems builds list items with counts.
|
// buildWebhookListItems builds the list's entry for each webhook. It
|
||||||
|
// fails when the main database cannot be read. A webhook whose event
|
||||||
|
// database cannot be read is marked on its own entry, and the error is
|
||||||
|
// logged.
|
||||||
func (h *Handlers) buildWebhookListItems(
|
func (h *Handlers) buildWebhookListItems(
|
||||||
webhooks []database.Webhook,
|
webhooks []database.Webhook,
|
||||||
) []WebhookListItem {
|
) ([]WebhookListItem, error) {
|
||||||
items := make([]WebhookListItem, len(webhooks))
|
items := make([]WebhookListItem, len(webhooks))
|
||||||
|
since := time.Now().Add(-longWindow)
|
||||||
|
|
||||||
for i := range webhooks {
|
for i := range webhooks {
|
||||||
items[i].Webhook = webhooks[i]
|
item := &items[i]
|
||||||
|
item.Webhook = webhooks[i]
|
||||||
|
|
||||||
h.db.DB().Model(&database.Entrypoint{}).Where(
|
var err error
|
||||||
"webhook_id = ?", webhooks[i].ID,
|
|
||||||
).Count(&items[i].EntrypointCount)
|
|
||||||
|
|
||||||
h.db.DB().Model(&database.Target{}).Where(
|
item.EntrypointCount, item.InactiveEntrypointCount, err =
|
||||||
"webhook_id = ?", webhooks[i].ID,
|
h.countWithInactive(&database.Entrypoint{}, item.ID)
|
||||||
).Count(&items[i].TargetCount)
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
if h.dbMgr.DBExists(webhooks[i].ID) {
|
item.TargetCount, item.InactiveTargetCount, err =
|
||||||
webhookDB, err := h.dbMgr.GetDB(
|
h.countWithInactive(&database.Target{}, item.ID)
|
||||||
webhooks[i].ID,
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Opening an event database that does not exist would create
|
||||||
|
// it, and it would hold nothing to count.
|
||||||
|
if !h.dbMgr.DBExists(item.ID) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
err = h.readListEventFigures(item, since)
|
||||||
|
if err != nil {
|
||||||
|
h.log.Error(
|
||||||
|
"failed to read webhook list figures",
|
||||||
|
"webhook_id", item.ID,
|
||||||
|
"error", err,
|
||||||
)
|
)
|
||||||
if err == nil {
|
|
||||||
webhookDB.Model(
|
item.EventsUnreadable = true
|
||||||
&database.Event{},
|
|
||||||
).Count(&items[i].EventCount)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return items
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// countWithInactive returns how many entrypoints or targets, as model
|
||||||
|
// says, a webhook has, and how many of them are inactive.
|
||||||
|
func (h *Handlers) countWithInactive(
|
||||||
|
model any, webhookID string,
|
||||||
|
) (int, int, error) {
|
||||||
|
var active []bool
|
||||||
|
|
||||||
|
err := h.db.DB().Model(model).
|
||||||
|
Where("webhook_id = ?", webhookID).
|
||||||
|
Pluck("active", &active).Error
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, fmt.Errorf(
|
||||||
|
"reading active flags of webhook %s: %w", webhookID, err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
inactive := 0
|
||||||
|
|
||||||
|
for _, a := range active {
|
||||||
|
if !a {
|
||||||
|
inactive++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return len(active), inactive, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// readListEventFigures fills in the figures the list shows from the
|
||||||
|
// webhook's event database, with the statistics pane's own queries:
|
||||||
|
// the event count and last arrival from the event totals row, and the
|
||||||
|
// deliveries that failed since the given time from the deliveries'
|
||||||
|
// status index.
|
||||||
|
func (h *Handlers) readListEventFigures(
|
||||||
|
item *WebhookListItem, since time.Time,
|
||||||
|
) error {
|
||||||
|
webhookDB, err := h.dbMgr.GetDB(item.ID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
var totals database.EventTotals
|
||||||
|
|
||||||
|
err = webhookDB.Take(&totals).Error
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("reading event totals: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
item.EventCount = totals.Events - totals.EventsRemoved
|
||||||
|
item.LastEventAt = totals.LastEventAt
|
||||||
|
|
||||||
|
byTarget, err := finishedByTarget(webhookDB, since)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, f := range byTarget {
|
||||||
|
item.FailedLast24Hours += f.Failed
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// HandleSourceCreate shows the form to create a new webhook.
|
// HandleSourceCreate shows the form to create a new webhook.
|
||||||
|
|||||||
@@ -14,11 +14,10 @@ import (
|
|||||||
// bytes) for form POST endpoints. 1 MB is generous for any form
|
// bytes) for form POST endpoints. 1 MB is generous for any form
|
||||||
// submission while preventing abuse from oversized payloads.
|
// submission while preventing abuse from oversized payloads.
|
||||||
//
|
//
|
||||||
// The five admin page route groups below (/pages, /user/{username},
|
// The four admin page route groups below (/pages, /user/{username},
|
||||||
// /settings, /hooks and /hook/{sourceID}) install
|
// /hooks and /hook/{sourceID}) install MaxBodySize(maxFormBodySize)
|
||||||
// MaxBodySize(maxFormBodySize) right after their recoverer and error
|
// right after their recoverer and error reporting, ahead of both CSRF
|
||||||
// reporting, ahead of both CSRF and RequireAuth. Both orderings are
|
// and RequireAuth. Both orderings are deliberate.
|
||||||
// deliberate.
|
|
||||||
//
|
//
|
||||||
// Ahead of CSRF because gorilla/csrf parses the form. The cap has to
|
// Ahead of CSRF because gorilla/csrf parses the form. The cap has to
|
||||||
// be installed before anything reads the body, or the parse runs
|
// be installed before anything reads the body, or the parse runs
|
||||||
@@ -155,7 +154,6 @@ func (s *Server) setupRoutes() {
|
|||||||
|
|
||||||
s.setupPageRoutes()
|
s.setupPageRoutes()
|
||||||
s.setupUserRoutes()
|
s.setupUserRoutes()
|
||||||
s.setupSettingsRoutes()
|
|
||||||
s.setupSourceRoutes()
|
s.setupSourceRoutes()
|
||||||
s.setupWebhookRoutes()
|
s.setupWebhookRoutes()
|
||||||
}
|
}
|
||||||
@@ -203,24 +201,6 @@ func (s *Server) setupUserRoutes() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// setupSettingsRoutes serves the Settings page. It is GET only:
|
|
||||||
// configuration comes from the environment and nothing here changes
|
|
||||||
// it.
|
|
||||||
func (s *Server) setupSettingsRoutes() {
|
|
||||||
s.router.Route("/settings", func(r chi.Router) {
|
|
||||||
s.recoverPanics(
|
|
||||||
r, s.h.HandleErrorPage(http.StatusInternalServerError),
|
|
||||||
)
|
|
||||||
// MaxBodySize precedes CSRF and RequireAuth deliberately;
|
|
||||||
// see maxFormBodySize for why, and for what it costs.
|
|
||||||
r.Use(s.mw.MaxBodySize(maxFormBodySize))
|
|
||||||
r.Use(s.mw.CSRF(s.h.HandleErrorPage(http.StatusForbidden)))
|
|
||||||
r.Use(s.mw.NoCache())
|
|
||||||
r.Use(s.mw.RequireAuth())
|
|
||||||
r.Get("/", s.h.HandleSettings())
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) setupSourceRoutes() {
|
func (s *Server) setupSourceRoutes() {
|
||||||
s.router.Route("/hooks", func(r chi.Router) {
|
s.router.Route("/hooks", func(r chi.Router) {
|
||||||
s.recoverPanics(
|
s.recoverPanics(
|
||||||
|
|||||||
@@ -1,24 +0,0 @@
|
|||||||
package server_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestSettingsPageIsBehindLogin(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
env := newTestEnv(t)
|
|
||||||
|
|
||||||
w := env.get("/settings", nil)
|
|
||||||
assert.Equal(t, http.StatusSeeOther, w.Code)
|
|
||||||
assert.Equal(
|
|
||||||
t, "/pages/login?next=%2Fsettings", w.Header().Get("Location"),
|
|
||||||
)
|
|
||||||
|
|
||||||
w = env.get("/settings", env.authCookies(t, "id", "admin"))
|
|
||||||
assert.Equal(t, http.StatusOK, w.Code)
|
|
||||||
assert.Contains(t, w.Body.String(), "WEBHOOKER_ENVIRONMENT")
|
|
||||||
}
|
|
||||||
@@ -19,7 +19,6 @@
|
|||||||
<div class="hidden md:flex items-center gap-4">
|
<div class="hidden md:flex items-center gap-4">
|
||||||
{{if .User}}
|
{{if .User}}
|
||||||
<a href="/hooks" class="btn-text">Webhooks</a>
|
<a href="/hooks" class="btn-text">Webhooks</a>
|
||||||
<a href="/settings" class="btn-text">Settings</a>
|
|
||||||
<a href="/user/{{.User.Username}}" class="btn-text">
|
<a href="/user/{{.User.Username}}" class="btn-text">
|
||||||
<svg class="w-5 h-5 mr-1" fill="currentColor" viewBox="0 0 16 16">
|
<svg class="w-5 h-5 mr-1" fill="currentColor" viewBox="0 0 16 16">
|
||||||
<path d="M11 6a3 3 0 1 1-6 0 3 3 0 0 1 6 0z"/>
|
<path d="M11 6a3 3 0 1 1-6 0 3 3 0 0 1 6 0z"/>
|
||||||
@@ -44,7 +43,6 @@
|
|||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
{{if .User}}
|
{{if .User}}
|
||||||
<a href="/hooks" class="btn-text w-full text-left">Webhooks</a>
|
<a href="/hooks" class="btn-text w-full text-left">Webhooks</a>
|
||||||
<a href="/settings" class="btn-text w-full text-left">Settings</a>
|
|
||||||
<a href="/user/{{.User.Username}}" class="btn-text w-full text-left">Profile</a>
|
<a href="/user/{{.User.Username}}" class="btn-text w-full text-left">Profile</a>
|
||||||
{{if .CSRFToken}}
|
{{if .CSRFToken}}
|
||||||
<form method="POST" action="/pages/logout">
|
<form method="POST" action="/pages/logout">
|
||||||
|
|||||||
@@ -1,24 +0,0 @@
|
|||||||
{{template "base" .}}
|
|
||||||
|
|
||||||
{{define "title"}}Settings - Webhooker{{end}}
|
|
||||||
|
|
||||||
{{define "content"}}
|
|
||||||
<div class="max-w-6xl mx-auto px-6 py-8">
|
|
||||||
<h1 class="text-2xl font-medium text-gray-900">Settings</h1>
|
|
||||||
<p class="text-sm text-gray-500 mt-1 mb-6">The configuration this server started with. It is set in the server's environment and cannot be changed here.</p>
|
|
||||||
|
|
||||||
<div class="card">
|
|
||||||
<div class="divide-y divide-gray-100">
|
|
||||||
{{range .Settings}}
|
|
||||||
<div class="p-4">
|
|
||||||
<div class="flex justify-between items-start gap-4">
|
|
||||||
<code class="text-sm font-medium text-gray-900">{{.Name}}</code>
|
|
||||||
<code class="text-sm text-gray-900 break-all">{{.Value}}</code>
|
|
||||||
</div>
|
|
||||||
<p class="text-sm text-gray-500 mt-1">{{.Description}}</p>
|
|
||||||
</div>
|
|
||||||
{{end}}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{{end}}
|
|
||||||
@@ -27,10 +27,16 @@
|
|||||||
</div>
|
</div>
|
||||||
<span class="badge-info">Retention: {{.RetentionLabel}}</span>
|
<span class="badge-info">Retention: {{.RetentionLabel}}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex gap-6 mt-4 text-sm text-gray-500">
|
<div class="flex flex-wrap gap-6 mt-4 text-sm text-gray-500">
|
||||||
<span>{{.EntrypointCount}} entrypoint{{if ne .EntrypointCount 1}}s{{end}}</span>
|
<span>{{.EntrypointCount}} entrypoint{{if ne .EntrypointCount 1}}s{{end}}{{if .InactiveEntrypointCount}}, {{.InactiveEntrypointCount}} inactive{{end}}</span>
|
||||||
<span>{{.TargetCount}} target{{if ne .TargetCount 1}}s{{end}}</span>
|
<span>{{.TargetCount}} target{{if ne .TargetCount 1}}s{{end}}{{if .InactiveTargetCount}}, {{.InactiveTargetCount}} inactive{{end}}</span>
|
||||||
<span>{{.EventCount}} event{{if ne .EventCount 1}}s{{end}}</span>
|
{{if .EventsUnreadable}}
|
||||||
|
<span class="text-red-600">The event figures could not be read.</span>
|
||||||
|
{{else}}
|
||||||
|
<span>{{.EventCount}} event{{if ne .EventCount 1}}s{{end}} within retention</span>
|
||||||
|
<span>{{with .LastEventAt}}Last event {{.UTC.Format "2006-01-02 15:04:05 UTC"}}{{else}}No events yet{{end}}</span>
|
||||||
|
<span class="{{if .FailedLast24Hours}}font-medium text-red-600{{end}}">{{.FailedLast24Hours}} failed deliver{{if eq .FailedLast24Hours 1}}y{{else}}ies{{end}} in the last 24 hours</span>
|
||||||
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
Reference in New Issue
Block a user