From ab63b5f7779ceed9e60ba8126a092daa5e5786b7 Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Tue, 29 Sep 2026 11:10:27 +0200 Subject: [PATCH 001/126] Restrict /s/* to GET and HEAD (closes #169) The static file server was attached with Mount, which registers every method, so POST, PUT and DELETE on an asset were answered 200 with the file. It is now registered for GET and HEAD only, inside a /s group whose method-not-allowed handler answers 405 with Allow: GET, HEAD. A method chi does not route at all, such as PROPFIND, still gets 405 from the top-level router, without Allow. The inverted test and the README route table say the same. Model: opus-5-5 --- README.md | 2 +- internal/server/routes.go | 20 +++++++++++-- internal/server/routes_test.go | 55 ++++++++++++++++++++++++---------- 3 files changed, 57 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 5729606..8a3ad14 100644 --- a/README.md +++ b/README.md @@ -2721,7 +2721,7 @@ abuse limit later; they are tracked as future work. | ------ | --------------------------- | ----------- | | `GET` | `/` | Root redirect, 303 (authenticated → `/sources`, unauthenticated → `/pages/login`) | | `GET` | `/.well-known/healthcheck` | Health check (JSON: `status`, `now`, `uptimeSeconds`, `uptimeHuman`, `version`, `appname`, `maintenanceMode`) | -| any | `/s/*` | Static file serving (embedded CSS, JS). Mounted for every method, not just `GET`/`HEAD`: chi's `Mount` registers all methods and `http.FileServer` special-cases only `HEAD` (by omitting the body), so a `POST` or `DELETE` to an asset is answered `200` with the file. Pinned by `TestStaticServesEveryMethod` | +| `GET`, `HEAD` | `/s/*` | Static file serving (embedded CSS, JS). `GET` and `HEAD` only — `POST`, `PUT`, `PATCH`, `DELETE`, `OPTIONS`, `TRACE` and `CONNECT` are answered `405 Method Not Allowed` with `Allow: GET, HEAD`. Any other method (such as `PROPFIND`) is refused by chi before it reaches this route, and gets `405` without an `Allow` header. Pinned by `TestStaticServesOnlyGetAndHead` | | `POST` | `/webhook/{uuid}` | Webhook receiver endpoint. `POST` only — every other method is answered `405 Method Not Allowed` with `Allow: POST`. Rate limited (see [Rate Limiting](#rate-limiting)) | #### Authentication Endpoints diff --git a/internal/server/routes.go b/internal/server/routes.go index 05872f8..7f3699a 100644 --- a/internal/server/routes.go +++ b/internal/server/routes.go @@ -92,11 +92,25 @@ func (s *Server) setupGlobalMiddleware() { func (s *Server) setupRoutes() { s.router.Get("/", s.h.HandleIndex()) - s.router.Mount( - "/s", - http.StripPrefix("/s", http.FileServer(http.FS(static.Static))), + // Static assets answer GET and HEAD only. chi's default 405 + // carries no Allow header, so this group supplies its own. + staticFiles := http.StripPrefix( + "/s", http.FileServer(http.FS(static.Static)), ) + s.router.Route("/s", func(r chi.Router) { + r.MethodNotAllowed(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Allow", "GET, HEAD") + http.Error( + w, + "Method Not Allowed", + http.StatusMethodNotAllowed, + ) + }) + r.Method(http.MethodGet, "/*", staticFiles) + r.Method(http.MethodHead, "/*", staticFiles) + }) + s.router.Route("/api/v1", func(_ chi.Router) { // API routes will be added here. }) diff --git a/internal/server/routes_test.go b/internal/server/routes_test.go index 97937ee..429a7f7 100644 --- a/internal/server/routes_test.go +++ b/internal/server/routes_test.go @@ -396,13 +396,15 @@ func (e *testEnv) storedHash(t *testing.T, username string) string { // --- /s static group --- -// TestStaticServesEveryMethod pins what the static mount actually -// answers. chi's Mount registers the handler for all methods and -// http.FileServer only special-cases HEAD (by suppressing the body), -// so a POST or a DELETE to an asset is served the file rather than -// refused. The README documents this; the test is what keeps the two -// from drifting. -func TestStaticServesEveryMethod(t *testing.T) { +// TestStaticServesOnlyGetAndHead pins the methods the static group +// answers: GET and HEAD are served the asset, and the other methods +// chi routes (POST, PUT, DELETE and the rest) are refused with 405 +// and an Allow header naming those two. A method chi does not route, +// such as PROPFIND, is refused with 405 by the top-level router +// before it reaches the static group, so it gets no Allow header. +// The README documents this; the test is what keeps the two from +// drifting. +func TestStaticServesOnlyGetAndHead(t *testing.T) { t.Parallel() env := newTestEnv(t) @@ -417,6 +419,7 @@ func TestStaticServesEveryMethod(t *testing.T) { http.MethodPost, http.MethodPut, http.MethodDelete, + "PROPFIND", } { t.Run(method, func(t *testing.T) { t.Parallel() @@ -428,18 +431,38 @@ func TestStaticServesEveryMethod(t *testing.T) { w := httptest.NewRecorder() env.router.ServeHTTP(w, req) - assert.Equal(t, http.StatusOK, w.Code, - "static mount answers every method") - - if method == http.MethodHead { + switch method { + case http.MethodGet: + assert.Equal(t, http.StatusOK, w.Code) + assert.Equal(t, body, w.Body.Bytes(), + "the asset itself is returned") + case http.MethodHead: + assert.Equal(t, http.StatusOK, w.Code) assert.Empty(t, w.Body.Bytes(), "HEAD must not carry a body") - - return + case "PROPFIND": + assert.Equal( + t, http.StatusMethodNotAllowed, w.Code, + ) + assert.Empty(t, w.Header().Get("Allow"), + "chi refuses a method it does not route "+ + "before the static group runs") + assert.NotContains( + t, w.Body.String(), string(body), + "a refused method must not get the asset", + ) + default: + assert.Equal( + t, http.StatusMethodNotAllowed, w.Code, + ) + assert.Equal( + t, "GET, HEAD", w.Header().Get("Allow"), + ) + assert.NotContains( + t, w.Body.String(), string(body), + "a refused method must not get the asset", + ) } - - assert.Equal(t, body, w.Body.Bytes(), - "the asset itself is returned") }) } } -- 2.54.0 From 8ad2a86e4b370f2f84729dd45539230544c9404f Mon Sep 17 00:00:00 2001 From: Jeffrey Paul <1+sneak@noreply.example.org> Date: Tue, 29 Sep 2026 12:01:33 +0200 Subject: [PATCH 002/126] Sneak/testdeploy (#356) Reviewed-on: https://git.eeqj.de/sneak/webhooker/pulls/356 --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 8a3ad14..0536a4e 100644 --- a/README.md +++ b/README.md @@ -3280,3 +3280,5 @@ MIT ## Author [@sneak](https://sneak.berlin) + + -- 2.54.0 From 1428154bbd95855c07685e843db3604c1eb91590 Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Tue, 29 Sep 2026 12:58:44 +0200 Subject: [PATCH 003/126] Let a browser with cookies from an earlier database log in (closes #359) A new database brings a new session key. A browser still holding the old session cookie got a 500 on a correct login: Session.Get returned the cookie's decode error and the login handler answered it with a 500. Get now treats a cookie that does not decode as absent, and logging in replaces it. gorilla/csrf already did the same for the CSRF cookie. A start that creates webhooker.db now logs "created a new, empty database" at WARN with its path, shortly before the first-boot banner, so an unexpectedly empty DATA_DIR is noticed. The codec tests now decode through the store, since Get no longer reports the codec's reason. Model: opus-5-5 --- README.md | 6 ++ internal/database/bootstrap_banner_test.go | 36 +++++++++++ internal/database/database.go | 14 ++++- internal/server/routes_test.go | 72 +++++++++++++++++++++- internal/session/codec_test.go | 15 ++--- internal/session/session.go | 14 ++++- 6 files changed, 145 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 0536a4e..25be1bf 100644 --- a/README.md +++ b/README.md @@ -538,6 +538,12 @@ its Argon2id hash. There is no second account and no forgot-password flow, so the banner and the reset command below are the only two ways in. +A start that finds no `webhooker.db` in `DATA_DIR` also logs +`created a new, empty database` at `WARN`, with the file's path, +shortly before the banner. On a deployment that has run before, that +line means `DATA_DIR` was empty, most often because its volume is not +mounted. + #### Recovering a lost admin password `webhooker resetpw` sets an existing account's password from the diff --git a/internal/database/bootstrap_banner_test.go b/internal/database/bootstrap_banner_test.go index f56b467..7f7860d 100644 --- a/internal/database/bootstrap_banner_test.go +++ b/internal/database/bootstrap_banner_test.go @@ -3,6 +3,8 @@ package database_test import ( "bytes" "context" + "log/slog" + "path/filepath" "strings" "testing" @@ -83,3 +85,37 @@ func TestFirstBoot_PrintsTheAdminPasswordAsABanner(t *testing.T) { t, ok, "the printed password must open the seeded account", ) } + +// TestNewDatabase_IsLoggedWithItsPath is the log half of +// https://git.eeqj.de/sneak/webhooker/issues/359. A DATA_DIR that is +// unexpectedly empty boots exactly like a first start, so the start +// that creates the database must say so, and where. Opening that +// database again must not. +func TestNewDatabase_IsLoggedWithItsPath(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + + open := func() string { + var out bytes.Buffer + + db, err := database.Open(dir, slog.New(slog.NewTextHandler(&out, nil))) + require.NoError(t, err) + require.NoError(t, db.Close()) + + return out.String() + } + + const created = `level=WARN msg="created a new, empty database"` + + first := open() + second := open() + + assert.Contains( + t, first, + created+" path="+filepath.Join(dir, database.MainDBFileName), + ) + assert.NotContains( + t, second, created, "an existing database is not new", + ) +} diff --git a/internal/database/database.go b/internal/database/database.go index ba28bae..da8bd37 100644 --- a/internal/database/database.go +++ b/internal/database/database.go @@ -8,6 +8,7 @@ import ( "errors" "fmt" "io" + "io/fs" "log/slog" "os" "path/filepath" @@ -199,6 +200,12 @@ func (d *Database) connectTo(dataDir string) error { // Construct the main application database path inside DATA_DIR. dbPath := filepath.Join(dataDir, MainDBFileName) + // Checked before opening, which creates the file. A DATA_DIR that + // is unexpectedly empty -- its volume not mounted, say -- looks + // exactly like a first start, so a new database is a warning. + _, statErr := os.Stat(dbPath) + created := errors.Is(statErr, fs.ErrNotExist) + // Opened through OpenSQLite so this handle carries the same WAL // journaling, busy timeout, immediate-transaction locking, and pool // bounds as every other database file. See sqlite_open.go. @@ -229,7 +236,12 @@ func (d *Database) connectTo(dataDir string) error { } d.db = db - d.log.Info("connected to database", "path", dbPath) + + if created { + d.log.Warn("created a new, empty database", "path", dbPath) + } else { + d.log.Info("connected to database", "path", dbPath) + } // Run migrations return d.migrate() diff --git a/internal/server/routes_test.go b/internal/server/routes_test.go index 429a7f7..a06272f 100644 --- a/internal/server/routes_test.go +++ b/internal/server/routes_test.go @@ -7,6 +7,7 @@ import ( "net/http/httptest" "net/url" "regexp" + "slices" "strconv" "strings" "testing" @@ -220,9 +221,21 @@ func (e *testEnv) csrfFrom( // out of the markup has to be unescaped before it is submitted. token := html.UnescapeString(match[1]) - combined := make([]*http.Cookie, 0, len(cookies)) - combined = append(combined, cookies...) - combined = append(combined, w.Result().Cookies()...) + // A cookie the page sets replaces the one of the same name, as in + // a browser. Sent both, the server would read the first, older one. + set := w.Result().Cookies() + combined := make([]*http.Cookie, 0, len(cookies)+len(set)) + + for _, c := range cookies { + replaced := slices.ContainsFunc(set, func(n *http.Cookie) bool { + return n.Name == c.Name + }) + if !replaced { + combined = append(combined, c) + } + } + + combined = append(combined, set...) return token, combined } @@ -614,6 +627,59 @@ func TestPagesLogin_CorrectPasswordSurvivesASpentBudget( ) } +// TestPagesLogin_CookiesFromAnEarlierDatabase is +// https://git.eeqj.de/sneak/webhooker/issues/359. A new database +// brings a new session key, and the operator's browser still holds +// the session and CSRF cookies signed with the old one. Logging in +// must work as from a fresh browser and leave cookies the new key +// accepts. +func TestPagesLogin_CookiesFromAnEarlierDatabase(t *testing.T) { + t.Parallel() + + const ( + username = "operator" + password = "correct-horse-battery-staple" + ) + + earlier := newTestEnv(t) + earlierID, _ := earlier.seedUser(t, username, password) + _, stale := earlier.csrfFrom(t, "/pages/login", nil) + stale = append(stale, earlier.authCookies(t, earlierID, username)...) + + env := newTestEnv(t) + env.seedUser(t, username, password) + + token, cookies := env.csrfFrom(t, "/pages/login", stale) + + form := url.Values{} + form.Set("csrf_token", token) + form.Set("username", username) + form.Set("password", password) + + w := env.post("/pages/login", form, cookies) + require.Equal( + t, http.StatusSeeOther, w.Code, + "a session cookie from another key must not fail the login", + ) + + // The response deletes the old session cookie and then sets the + // new one; a browser keeps the last. + var fresh *http.Cookie + + for _, c := range w.Result().Cookies() { + if c.Name == session.SessionName { + fresh = c + } + } + + require.NotNil(t, fresh, "login must set a session cookie") + assert.Equal( + t, "/sources", + env.get("/", []*http.Cookie{fresh}).Header().Get("Location"), + "the new session cookie must authenticate", + ) +} + // --- /user/{username} group --- // TestPasswordChange_OversizeBody_RejectedAndPasswordUnchanged diff --git a/internal/session/codec_test.go b/internal/session/codec_test.go index f181b25..08e5a0a 100644 --- a/internal/session/codec_test.go +++ b/internal/session/codec_test.go @@ -19,8 +19,8 @@ import ( ) // The tests below exercise the securecookie codecs underneath the -// store and nothing else: Session.Get only decodes, so no server-side -// expiry check takes part in the result. They exist because +// store and nothing else: they decode through the store itself, so no +// server-side expiry check takes part in the result. They exist because // NewCookieStore gives its codecs a 30-day max age that assigning // store.Options does not override, which would let the codec accept a // cookie weeks past the cap the cookie attribute advertises. @@ -75,10 +75,11 @@ func restamp( return base64.URLEncoding.EncodeToString(payload) } -// decodeCookie feeds value back through the store's decode path. +// decodeCookie feeds value back through the store's decode path. It +// asks the store rather than Session.Get, which treats a cookie that +// does not decode as absent and so hides the codec's reason. func decodeCookie( t *testing.T, - s *session.Session, value string, ) (*sessions.Session, error) { t.Helper() @@ -94,7 +95,7 @@ func decodeCookie( SameSite: http.SameSiteLaxMode, }) - sess, err := s.Get(req) + sess, err := session.NewStore(testKey()).Get(req, session.SessionName) require.NotNil(t, sess) return sess, err @@ -105,7 +106,7 @@ func TestCodec_AcceptsCookieInsideAbsoluteCap(t *testing.T) { s := testSession(t) - sess, err := decodeCookie(t, s, restamp( + sess, err := decodeCookie(t, restamp( t, issuedCookie(t, s), time.Now().Add(-(testAbsoluteMaxAge-time.Hour)), @@ -126,7 +127,7 @@ func TestCodec_RejectsCookiePastAbsoluteCap(t *testing.T) { s := testSession(t) - sess, err := decodeCookie(t, s, restamp( + sess, err := decodeCookie(t, restamp( t, issuedCookie(t, s), time.Now().Add(-(testAbsoluteMaxAge+time.Hour)), diff --git a/internal/session/session.go b/internal/session/session.go index 97b6664..8e97734 100644 --- a/internal/session/session.go +++ b/internal/session/session.go @@ -224,10 +224,22 @@ func New( } // Get retrieves a session for the request. +// +// A session cookie that does not decode -- one signed with an earlier +// session key, say, because the database was made anew -- is treated +// as absent: the caller gets a new, empty session and no error, and +// the next save replaces the cookie. func (s *Session) Get( r *http.Request, ) (*sessions.Session, error) { - return s.store.Get(r, SessionName) + sess, err := s.store.Get(r, SessionName) + if sess == nil { + return nil, err + } + + // For a cookie that does not decode, gorilla/sessions returns a + // new, empty session alongside the error that is dropped here. + return sess, nil } // GetKey returns the raw 32-byte authentication key used for -- 2.54.0 From b79e4649a13e62432e4156095fbe7c4f325140a6 Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Tue, 29 Sep 2026 13:05:16 +0200 Subject: [PATCH 004/126] Container sets its data directory's owner and mode itself (#353) Closes https://git.eeqj.de/sneak/webhooker/issues/340. The image no longer sets `USER`. Its new `ENTRYPOINT`, `deploy/docker-entrypoint.sh`, starts as root, creates `DATA_DIR` if missing, gives the directory and anything in it owned by another user to `webhooker` (UID 1000), sets the directory to `0750`, and runs the command as `webhooker` through `su-exec`. An empty root-owned bind mount, or data left by another UID, now works as mounted; the app never runs as root and is still PID 1. `CMD` is still `/app/webhooker`, so the `resetpw` commands are unchanged. Started with `--user`, the script only runs the command. It is in `/usr/local/bin`, not `/app`, which belongs to `webhooker`. README: the UID 1000 ownership block, the upaas pre-deploy commands and the restore ownership step are gone; the upaas volume bullet names only the path. - Judgement call: `su-exec` over `setpriv`: Alpine's small tool for this, needing only musl; busybox's `setpriv` cannot change user, and util-linux's adds `libcap-ng`. - Deviation: `su-exec` is pinned by version (`0.2-r3`), not by hash; `ca-certificates` beside it is unpinned. - Judgement call: each start reads every entry's owner but changes only entries owned by someone else. - `docker exec` and the health check now run as root, since the image sets no `USER`. - No automated test covers the script: the suite runs inside `docker build`, which cannot start a container. - A missing host directory under upaas is https://git.eeqj.de/sneak/upaas/issues/235. Model: opus-5-5 Reviewed-on: https://git.eeqj.de/sneak/webhooker/pulls/353 Co-authored-by: clawbot <35+clawbot@noreply.example.org> --- Dockerfile | 11 +++- README.md | 122 +++++++++++++----------------------- deploy/docker-entrypoint.sh | 22 +++++++ 3 files changed, 76 insertions(+), 79 deletions(-) create mode 100755 deploy/docker-entrypoint.sh diff --git a/Dockerfile b/Dockerfile index bb47620..907ae40 100644 --- a/Dockerfile +++ b/Dockerfile @@ -88,7 +88,9 @@ RUN CGO_ENABLED=1 make build VERSION="$VERSION" GO_LDFLAGS='-extldflags "-static # alpine:3.21, 2026-03-17 FROM alpine:3.21@sha256:c3f8e73fdb79deaebaa2037150150191b9dcbfba68b4a46d70103204c53f4709 -RUN apk --no-cache add ca-certificates +# su-exec 0.2-r3 (Alpine 3.21), 2026-09-29: the entrypoint runs the app +# as webhooker with it. +RUN apk --no-cache add ca-certificates su-exec=0.2-r3 # Create non-root user RUN addgroup -g 1000 -S webhooker && \ @@ -99,13 +101,17 @@ WORKDIR /app # Copy binary from builder COPY --from=builder /build/bin/webhooker /app/webhooker +# Not under /app, which belongs to webhooker: this script runs as root. +COPY deploy/docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh + # Create data directory for all SQLite databases (main app DB + # per-webhook event DBs). DATA_DIR defaults to /var/lib/webhooker. RUN mkdir -p /var/lib/webhooker RUN chown -R webhooker:webhooker /app /var/lib/webhooker -USER webhooker +# No USER: the entrypoint starts as root to make the data directory +# webhooker's, then runs the app as webhooker. EXPOSE 8080 @@ -124,4 +130,5 @@ ENV BIND_ADDRESS=0.0.0.0 HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ CMD wget --no-verbose --tries=1 --spider http://localhost:8080/.well-known/healthcheck || exit 1 +ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"] CMD ["/app/webhooker"] diff --git a/README.md b/README.md index 25be1bf..c1bb19b 100644 --- a/README.md +++ b/README.md @@ -558,8 +558,9 @@ printf '%s' "$NEW_PASSWORD" | \ DATA_DIR=/var/lib/webhooker webhooker resetpw admin ``` -In a container it is the same binary, which the image sets as `CMD` -rather than `ENTRYPOINT`, so the whole command has to be given: +In a container it is the same binary. The image's `CMD` is +`/app/webhooker`, and a command given to `docker run` replaces all of +it, so the whole command has to be given: ```bash docker run --rm -v webhooker-data:/var/lib/webhooker \ @@ -696,38 +697,22 @@ those three values rather than trusting the figure. Measured at 65s on Docker 29.7.2.) A container `unhealthy` with `connection refused` in its health log, or a published port that resets connections, is this. -The container runs as a non-root user (`webhooker`, UID 1000), exposes -port 8080, and includes a health check against -`/.well-known/healthcheck`. The `/var/lib/webhooker` volume holds all -SQLite databases: the main application database (`webhooker.db`), the -per-webhook event databases (`events-{uuid}.db`), and any archive -databases written by `database` targets (`archive-{uuid}.db`). Mount -this as a persistent volume to preserve data across container -restarts. +The app runs as a non-root user (`webhooker`, UID 1000), exposes port +8080, and includes a health check against `/.well-known/healthcheck`. +The `/var/lib/webhooker` volume holds all SQLite databases: the main +application database (`webhooker.db`), the per-webhook event databases +(`events-{uuid}.db`), and any archive databases written by `database` +targets (`archive-{uuid}.db`). Mount this as a persistent volume to +preserve data across container restarts. -**The bind-mounted directory must be owned by UID 1000, or the -container does not start.** Docker creates a `-v` source path that -does not exist yet as `root:root`, and the process runs as UID 1000, -so it cannot take its `DATA_DIR` lock: - -``` -webhooker: locking data directory /var/lib/webhooker: open -/var/lib/webhooker/webhooker.lock: permission denied -``` - -It exits non-zero at that point, before opening any database. Create -the directory ahead of the first `docker run`: - -```bash -mkdir -p /path/to/data -chown 1000:1000 /path/to/data -chmod 750 /path/to/data -``` - -The same `chown` is what a restore needs — see step 4 of -[Restore](#restore). A **named volume** does not have this problem: -Docker copies the image's ownership onto a volume it initializes, and -the image creates `/var/lib/webhooker` owned by `webhooker`. +**The container sets its data directory's owner and mode itself +before the app starts**, so a host directory can be mounted as it is, +whoever owns it. The image's `ENTRYPOINT`, +`deploy/docker-entrypoint.sh`, starts as root, creates `DATA_DIR` if +it is missing, gives the directory and anything in it that belongs to +another user to `webhooker`, sets the directory to `0750`, and only +then runs the app as `webhooker`. Started with `--user`, it changes +nothing and runs the app as that user. **The file modes are not yours to set, and do not depend on the directory.** `webhooker.db` holds target configuration in plaintext — @@ -735,13 +720,10 @@ bearer tokens, API keys, Slack webhook URLs — along with the session encryption key, so webhooker creates every SQLite file it owns `0600`: each database and both of its `-wal` and `-shm` sidecars, across all three tiers. Files an earlier build left `0644` are tightened when -they are opened. A `DATA_DIR` webhooker creates itself is `0750`, but -a bind mount supplies its own directory and Docker's default for one -it creates is `0755`; the `0600` files hold there regardless. The -`chmod 750` above is defence in depth — it stops other local users -listing the directory and learning your webhook UUIDs from the -`events-{uuid}.db` filenames — not the barrier protecting the -credentials. +they are opened. The directory's `0750` is defence in depth — it stops +other local users listing the directory and learning your webhook +UUIDs from the `events-{uuid}.db` filenames — not the barrier +protecting the credentials. ### Running under upaas @@ -757,17 +739,6 @@ repository's `Dockerfile` and runs it. The app needs: app name, port `8080`. Leave `PORT` unset: the image's health check probes `8080`. - **Volume:** one host directory mounted at `/var/lib/webhooker`. - upaas bind-mounts the host path it is given and does not create it, - and the container does not start unless UID 1000 owns it (see - [Running with Docker](#running-with-docker)). Create it before the - first deploy: - - ```bash - mkdir -p /path/to/data - chown 1000:1000 /path/to/data - chmod 750 /path/to/data - ``` - - **Environment variables:** - `WEBHOOKER_ENVIRONMENT=prod` - `TRUSTED_PROXIES`: your reverse proxy's address on that Docker @@ -1026,12 +997,12 @@ done `.backup` reads through the WAL and writes a single consistent file with no sidecars of its own, so the destination is complete as it stands. Two caveats. First, the runtime image is `alpine:3.21` with only -`ca-certificates` added — the `sqlite3` CLI is **not** in it, so run -this on the host against the volume path, or from a throwaway container -that mounts the volume. Second, each file is captured at its own -instant, so a webhook created or an event delivered between two files -being copied lands in one and not the other. If you need the whole set -coherent as of a single moment, stop the service. +`ca-certificates` and `su-exec` added — the `sqlite3` CLI is **not** in +it, so run this on the host against the volume path, or from a +throwaway container that mounts the volume. Second, each file is +captured at its own instant, so a webhook created or an event delivered +between two files being copied lands in one and not the other. If you +need the whole set coherent as of a single moment, stop the service. Note that `sqlite3 .dump` is **not** one of these procedures: it is an export, it holds a read transaction open for as long as it runs, and @@ -1085,21 +1056,11 @@ with any `-wal`/`-shm` beside it, or wait until there are none. archive not opened since a crash. A copy salvaged from a crashed instance has them for everything, and needs all of them. -4. **Fix ownership.** The container runs as the non-root `webhooker` - user, UID 1000 / GID 1000. Restored files must be owned by (or - writable by) that UID, and so must the directory itself — SQLite - creates the `-wal` and `-shm` sidecars beside the database, so a - writable file inside a directory it cannot write is not enough: - - ```bash - chown -R 1000:1000 /path/to/data - ``` - - Restoring as `root` on the host and forgetting this step is the - usual way a restore fails. - -5. Start the service. `AutoMigrate` runs against each restored database - as it is opened. +4. Start the service. The container gives the directory and the + restored files to the `webhooker` user before the app starts, + whoever restored them (see + [Running with Docker](#running-with-docker)). `AutoMigrate` runs + against each restored database as it is opened. ### Upgrades @@ -3077,7 +3038,11 @@ check, see [The login endpoint](#the-login-endpoint). - Prometheus metrics behind basic auth - Static assets embedded in binary (no filesystem access needed at runtime) -- Container runs as non-root user (UID 1000) +- The app runs as the non-root `webhooker` user (UID 1000) in the + container. The image sets no `USER`, so these run as root: the + `ENTRYPOINT` script, which sets the data directory's owner and mode + before the app starts; the image's health check; and `docker exec`, + unless given `--user` - GORM soft deletes on every entity that carries `BaseModel`, which is all of them but `Setting` (data preserved for audit) @@ -3204,10 +3169,13 @@ version is fixed independently of the compiler's: `GO_LDFLAGS`, so neither can drop the `-X` that stamps the version. The version arrives as the `VERSION` build arg, since the context has no `.git` (see [Version stamping](#version-stamping)). -3. **Runtime stage** (`alpine:3.21`) — copies the static binary, - creates the `/var/lib/webhooker` directory for all SQLite databases, - runs as the non-root `webhooker` user (UID 1000), exposes port 8080, - and includes a health check against `/.well-known/healthcheck`. +3. **Runtime stage** (`alpine:3.21`) — copies the static binary and + `deploy/docker-entrypoint.sh`, creates the `/var/lib/webhooker` + directory for all SQLite databases, exposes port 8080, and includes + a health check against `/.well-known/healthcheck`. It sets no + `USER`: the `ENTRYPOINT` script starts as root, sets the data + directory's owner and mode, and runs the app as the non-root + `webhooker` user (UID 1000) through `su-exec`. The lint stage invokes `golangci-lint` directly rather than `make lint`: it is already the pinned linter image, and `make lint` builds diff --git a/deploy/docker-entrypoint.sh b/deploy/docker-entrypoint.sh new file mode 100755 index 0000000..b081336 --- /dev/null +++ b/deploy/docker-entrypoint.sh @@ -0,0 +1,22 @@ +#!/bin/sh +# deploy/docker-entrypoint.sh: the image's ENTRYPOINT. A bind-mounted +# data directory keeps its owner from the host, often root, and the app +# could not write to it. Started as root, this creates DATA_DIR if +# needed, gives it and everything in it to webhooker, sets its mode, and +# runs the command as webhooker, so the app never runs as root. Started +# as another user, it only runs the command. +set -eu + +main() { + if [ "$(id -u)" != 0 ]; then + exec "$@" + fi + + dir="${DATA_DIR:-/var/lib/webhooker}" + mkdir -p "$dir" + find "$dir" ! -user webhooker -exec chown -h webhooker:webhooker {} + + chmod 750 "$dir" + exec su-exec webhooker "$@" +} + +main "$@" -- 2.54.0 From 507980a347eb68ca021947f98d5280c6818fa1ee Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Thu, 1 Oct 2026 21:38:48 +0200 Subject: [PATCH 005/126] Bound username length at creation (closes #184) Usernames are limited to 1024 bytes, so no account can exist that is unable to log in. The username rides in the session cookie, which browsers and securecookie refuse past about 4 KB, leaving room for roughly 2000 bytes of username; the limit is about half that. User.BeforeSave returns ErrUsernameTooLong when a whole User is created or saved. A byte-counting check constraint on users.username catches every other write, including a column update. The limit appears in the constant and in the struct tag; a test fails if they disagree. Model: opus-5-5 --- README.md | 18 ++++---- internal/database/model_user.go | 49 ++++++++++++++++++++- internal/database/model_user_test.go | 65 ++++++++++++++++++++++++++++ internal/handlers/auth_test.go | 30 +++++++++++++ internal/handlers/logbound_test.go | 8 ++-- 5 files changed, 154 insertions(+), 16 deletions(-) create mode 100644 internal/database/model_user_test.go diff --git a/README.md b/README.md index c1bb19b..f6a682d 100644 --- a/README.md +++ b/README.md @@ -1439,7 +1439,7 @@ A registered user of the webhooker service. | Field | Type | Description | | ---------- | -------- | ----------- | | `id` | UUID | Primary key | -| `username` | string | Unique login name | +| `username` | string | Unique login name, at most 1024 bytes so that it fits in the session cookie | | `password` | string | Argon2id hash (never exposed via API) | **Relations:** Has many Webhooks. Has many APIKeys. @@ -2379,14 +2379,14 @@ Removing either cap fails 14 subtests. `internal/middleware/logbound_test.go` and `internal/handlers/logbound_test.go` drive 8 KB of client-chosen text -at each of these — 1 KB at `invalid password`, whose accounts are -shared with the successful-login line, where a username past 4 KB -overflows the session cookie and answers 500 before that line is -written — through both handlers, and through seven fills: plain text -as the baseline, and then the quotation mark, backslash, tab, newline, -C0 control and astral non-printable, six characters the wider of the -two handlers spends more on than the client spent sending them. Every -case holds each line to the 2,560-byte ceiling. That per-line ceiling +at each of these — just under 1 KB at `invalid password`, whose +accounts are shared with the successful-login line and so must stay +within the 1024-byte username limit — through both handlers, and +through seven fills: plain text as the baseline, and then the +quotation mark, backslash, tab, newline, C0 control and astral +non-printable, six characters the wider of the two handlers spends +more on than the client spent sending them. Every case holds each +line to the 2,560-byte ceiling. That per-line ceiling is what the figure above states, and every row establishes it. Three of the sites go further and bound the whole flood's output — the diff --git a/internal/database/model_user.go b/internal/database/model_user.go index ec2ca1e..2c95040 100644 --- a/internal/database/model_user.go +++ b/internal/database/model_user.go @@ -1,13 +1,58 @@ package database +import ( + "errors" + "fmt" + + "gorm.io/gorm" +) + +// MaxUsernameBytes is the longest username, in bytes, that a user may +// have. The same number appears in the check constraint on +// User.Username, because a struct tag cannot reference a constant. +// +// A login stores the username in the session cookie, and both +// securecookie and browsers refuse a cookie value past about 4096 +// bytes. That value is the session base64-encoded twice, so it holds +// 4096 × 3/4 × 3/4 = 2304 bytes of session, and the signature, +// timestamp and the session's other values take about 270 of those: a +// username longer than about 2030 bytes can never log in. The limit is +// about half that, so the session can carry more values later without +// locking out an account whose username is already at the limit. +const MaxUsernameBytes = 1024 + +// ErrUsernameTooLong is returned when a user is saved with a username +// longer than MaxUsernameBytes. +var ErrUsernameTooLong = errors.New("username is too long") + // User represents a user of the webhooker service +// +//nolint:lll // a struct tag cannot wrap type User struct { BaseModel - Username string `gorm:"uniqueIndex;not null" json:"username"` - Password string `gorm:"not null" json:"-"` // Argon2 hashed + Username string `gorm:"uniqueIndex;not null;check:length(CAST(username AS BLOB)) <= 1024" json:"username"` + Password string `gorm:"not null" json:"-"` // Argon2 hashed // Relations Webhooks []Webhook `json:"webhooks,omitempty"` APIKeys []APIKey `json:"apiKeys,omitempty"` } + +// BeforeSave rejects a username longer than MaxUsernameBytes when a whole +// User is created or saved, so those calls get ErrUsernameTooLong rather +// than the database's constraint error. A column update such as +// Update("username", ...) is caught only by the check constraint, as is +// any path that writes the table without this model. +func (u *User) BeforeSave(_ *gorm.DB) error { + if len(u.Username) > MaxUsernameBytes { + return fmt.Errorf( + "%w: %d bytes, limit is %d", + ErrUsernameTooLong, + len(u.Username), + MaxUsernameBytes, + ) + } + + return nil +} diff --git a/internal/database/model_user_test.go b/internal/database/model_user_test.go new file mode 100644 index 0000000..9d82b8b --- /dev/null +++ b/internal/database/model_user_test.go @@ -0,0 +1,65 @@ +package database_test + +import ( + "strings" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "sneak.berlin/go/webhooker/internal/database" +) + +// usernameAtLimit is exactly MaxUsernameBytes long, built from a +// two-byte character. A check that counted characters rather than bytes +// would see half the length and let the one-byte-longer name through. +func usernameAtLimit() string { + return strings.Repeat("é", database.MaxUsernameBytes/2) +} + +func TestUserCreate_RejectsOverlongUsername(t *testing.T) { + t.Parallel() + + db := startedTestDB(t) + + err := db.Create(&database.User{ + Username: usernameAtLimit() + "x", + Password: "hash", + }).Error + + require.ErrorIs(t, err, database.ErrUsernameTooLong) +} + +func TestUserCreate_AcceptsUsernameAtLimit(t *testing.T) { + t.Parallel() + + db := startedTestDB(t) + + require.NoError(t, db.Create(&database.User{ + Username: usernameAtLimit(), + Password: "hash", + }).Error) +} + +// TestUsersTable_EnforcesUsernameLimitWithoutTheModel inserts with raw +// SQL, as a path that bypassed User.BeforeSave would, so only the +// table's check constraint stands between it and an over-long +// username. Accepting the name at the limit and refusing the next byte +// also pins the constraint's number to MaxUsernameBytes. +func TestUsersTable_EnforcesUsernameLimitWithoutTheModel(t *testing.T) { + t.Parallel() + + db := startedTestDB(t) + + insert := "INSERT INTO users (id, username, password) VALUES (?, ?, ?)" + + require.NoError(t, db.Exec( + insert, uuid.New().String(), usernameAtLimit(), "hash", + ).Error) + + err := db.Exec( + insert, uuid.New().String(), usernameAtLimit()+"x", "hash", + ).Error + require.Error(t, err) + assert.Contains(t, err.Error(), "CHECK constraint failed") +} diff --git a/internal/handlers/auth_test.go b/internal/handlers/auth_test.go index 98c64c8..1fdbcb1 100644 --- a/internal/handlers/auth_test.go +++ b/internal/handlers/auth_test.go @@ -453,3 +453,33 @@ func TestLogin_SuccessCreatesSession(t *testing.T) { "the issued cookie must carry an authenticated session", ) } + +// TestLogin_UsernameAtLimitCanLogIn shows that a username of exactly +// database.MaxUsernameBytes still fits in the session cookie. Past +// what the cookie can carry, a correct login answers 500. +func TestLogin_UsernameAtLimitCanLogIn(t *testing.T) { + t.Parallel() + + var ( + h *handlers.Handlers + db *database.Database + ) + + app := newTestApp(t, &h, &db) + app.RequireStart() + + t.Cleanup(app.RequireStop) + + username := strings.Repeat("a", database.MaxUsernameBytes) + + hash, err := database.HashPassword(operatorPassword) + require.NoError(t, err) + require.NoError(t, db.DB().Create(&database.User{ + Username: username, + Password: hash, + }).Error) + + w := submitLogin(h, sharedProxyPeer, username, operatorPassword) + + assert.Equal(t, http.StatusSeeOther, w.Code) +} diff --git a/internal/handlers/logbound_test.go b/internal/handlers/logbound_test.go index abb78f4..bddb149 100644 --- a/internal/handlers/logbound_test.go +++ b/internal/handlers/logbound_test.go @@ -339,11 +339,9 @@ const storedUserPassword = "correct-horse-battery-staple" // storedFillBytes is the raw length of the client-chosen value in // those accounts' usernames. It is well past the 512-byte field // budget, so the line is still truncated, but short enough that the -// session cookie a successful login writes stays inside -// securecookie's 4 KB limit: the cookie is written BEFORE the -// "user logged in" line, so an 8 KB username answers 500 and never -// reaches it. -const storedFillBytes = 1024 +// whole username, markers and fill name included, stays within +// database.MaxUsernameBytes. +const storedFillBytes = 960 // storedFill builds a username fill of storedFillBytes raw bytes out // of repetitions of ch, with both markers at its far end. -- 2.54.0 From 9d29baaa2d82b89850e07654ea018bfbbbeed5de Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Thu, 1 Oct 2026 22:44:41 +0200 Subject: [PATCH 006/126] Commit the Alpine.js tarball in 3p/ and extract it at build time (closes #345) The build no longer downloads Alpine.js. Its npm package tarball is committed as 3p/alpinejs-3.14.9.tgz, byte for byte the file script/fetch-assets downloaded, with the sha256 that script pinned. script/assets (make assets) extracts package/dist/cdn.min.js to the ignored static/js/alpine.min.js; script/test runs it, so make test, make check, the pre-commit hook and the Dockerfile get the file with no network access, and make build, run and dev run it too. Removed: script/fetch-assets, its Dockerfile step, static/vendor.sha256 and static/vendor_test.go. The README describes the new flow. Model: opus-5-5 --- .dockerignore | 6 +- .gitignore | 7 +- 3p/alpinejs-3.14.9.tgz | Bin 0 -> 140817 bytes Dockerfile | 15 +--- Makefile | 6 +- README.md | 82 ++++++++++---------- internal/server/static_assets_test.go | 6 +- script/assets | 16 ++++ script/bootstrap | 9 +-- script/check | 3 +- script/fetch-assets | 104 -------------------------- script/test | 1 + static/vendor.sha256 | 1 - static/vendor_test.go | 92 ----------------------- 14 files changed, 75 insertions(+), 273 deletions(-) create mode 100644 3p/alpinejs-3.14.9.tgz create mode 100755 script/assets delete mode 100755 script/fetch-assets delete mode 100644 static/vendor.sha256 delete mode 100644 static/vendor_test.go diff --git a/.dockerignore b/.dockerignore index 5565f6c..550060e 100644 --- a/.dockerignore +++ b/.dockerignore @@ -3,10 +3,8 @@ # stage of the Dockerfile. .git/ bin/ -# Third-party browser assets are fetched and hash-verified inside the build by -# script/fetch-assets. Excluding any host copy keeps a developer's working tree -# from supplying the bytes that get shipped. The script and its -# static/vendor.sha256 manifest stay in the context. +# Extracted from 3p/ by `make assets` inside the build; a host copy is not +# needed. The tarball in 3p/ must stay in the context. static/js/alpine.min.js *.md LICENSE diff --git a/.gitignore b/.gitignore index 50cd133..6da4ee4 100644 --- a/.gitignore +++ b/.gitignore @@ -46,7 +46,6 @@ temp/ # CI cache barrier, written into the build context by the check workflow .ci-fingerprint -# Third-party browser assets, fetched and hash-verified by -# script/fetch-assets against static/vendor.sha256. Not committed: -# REPO_POLICIES.md forbids minified bundles in version control. -/static/js/alpine.min.js \ No newline at end of file +# Alpine.js, extracted by `make assets` from its tarball in 3p/, which is +# what is committed. +/static/js/alpine.min.js diff --git a/3p/alpinejs-3.14.9.tgz b/3p/alpinejs-3.14.9.tgz new file mode 100644 index 0000000000000000000000000000000000000000..4d31381f7cc0ed22a308350bc1713d64e268dc8a GIT binary patch literal 140817 zcmV)XK&`(YiwFP!00002|LnbMdmFitFq+T$6)=yVaWkf~chj<>;eUUhs=}pkX_A_8W|KXh4`UGx6o5hjDAaYHj&IVb+-d6Z z&Mckg<7Ve^mbU5e$L2Sm>%ZOI-QRuvwTP9&e|uj)6Tf-(&F^;i_I~%f-LJ)OcK7yw zxBpE1X7@M0!hegVP3zz6?tX?z?(Xgi^Y6dNfB9@))vdS}&A6INu@sZKnu#aF;SLr& z*)kQ7I(P*lmnU0W_($>t$mYpF$U+=`OW+b&^!r=C`SRaC?|<{y{?D6v+K#V3*BP*8 z|L^a9U%UTz|7}0G|DWxBx&MFN{qJn)p4rm91GA@PVRo}DzHHk6z574Ue$D;AzYp8R z-~YS&U-tk1-2UfRx~VZPszx^LY4)zF+JTtlWp-R6uylGyxvL4HM z`=Y2y=an&r0?Rk#*^_}tWHG@0WLhrfV%diyCn?jJ9EclvCk{oqC<-jS-x5NILbhU@ zj<4l+`1SJfJyc#kzK7aJ;&L1RT3(8gc=BX8<Q z7V=Zxw0Sudr!Pe|=mNzqCCuK7n4zY4R5jar7X`iiFTTz)f6t)7*7pFT}lw%m`0|{fC7R6OM zzUgn>n*lr$vd{z2e~CkJzueLj`im4*S=@=+v~0!WJcD`Hi&9kOScK}|XFw?ZZh*~(a_ zikSGWQvjD=xQKtk`@fW*+V}bR=2v?E?|t)i`2Gi(z?b{~SG@oAqVvmHmp$a=Z`$a~ zQWt!AG5o9S|5{F(Us3-5Z~Nbb^8auC?aTiERrkMQUoCI{f(rOvHw*|r5M(`-Z|mw) zE}LJZJ^-pS0)~5!zk!b*=5_Vy&f{+E2hP6OQgP^vFqz8sxUSQ?Hi>Qh?*D(=-TSit|EKmpmjaqC-#gfPfUFQm$ZTc?`in;JU*P_4+NzeH@c>w> z{}1H<&vw85!vFue`+q9iGgx=`5{Ht9T;kMVV4;24?EiZE|Mqh#|JU#TZ@v!o|GWFY z`?CN4$>jgHzIi|`({Y=B%-cJA0Ng6Azmnn0k7=<;Tlsz3%6j07^lwmvswJncJYTmI zhb|Ck)VFfXscc(Wi^Hg79@En zwNyYG*}kpiB>yDRM$GZAf%?~$NTzVyF^&0&1xwowM4F*a?o1YP+*UOfoR&E&=n{Sm zL|*3Y`&t4nWYgBw9sMv$F@VE{5tEQq_x8;GhjQ;wB2$5{qNvqhV>@+D~6 zGC7g;EBFlxw1Joui{|>c%+A_s{t~{`c{yd@anf(D8d-nzKUDLuoDF`a$~vSw22C&1 zHa%<8@eN|~3yvrlUl>_cw#SRMQg!7Y zln{mwTWUBAd<*Kx9K|&;8rk`@ZELtHZXkR7M@{G^pTArHieJz-(APNyUAk?p+76ZA zRn2O#QdV~=+vj8@K=JW++Z=QD$6!4rw&+M#A=0VhnUvkHZ5*~9-)xcnHP&`&X=1C zx?e9Lhl8&dPPE?h2awD@!%yDaBYF4<5YFNZ;(J{;`QHcX7y>+0ZxBBZxSvPrAj>PfzZ%<#JeE9RpyR*|buj%8O0|)DL z?o;)-;~v3}T?dx>7(2Aox5$-+A0mgWU#<>X#j<(F8bSAVzJlQHek}Cw7hUMwF(lA- z&I(hJAEAUV_sqvK_qN>P3@sg&ZV%A8L6bdd2^2ro36ecp2EMdf&)nW(9BX)IszKWe z(j5yN@jE->^!59bcdw7XABnPR#e8v9FLqQ>7riGRB;LTOCMnL zOEW9{m--tID80JVd~8U*mTYSnbi-}q&xc)A{wKOM@K3yr=}+X$^q++qqx}qL+F)zB zwIx5{sg}t}x+wGk_C=WUzghlcIf-A<{&Vk}z1`sbxBvC;zQ})n-S=Pj4Q@p-O!gCF zx-It-ml1I;@z48*L;@<}dhQponL-Qqj6 zPLwsBmc+ur3v*FRQ6b*}j0=@ds#;WFdkB|S1J~3--$vZr?}`}rrtkd*%TLYo-7g(; zJ6(lrKgA)JTtN|NvGojwc{wf?nQW3L<8&sACw*}w@UIuZCZbuW7-2I4(A?(m7*}7c zglr5fh2fl*^F{k-_=woVYJIm>Ez_HWEe{?pPy`;WY^E_4sY|K5ZCEPDN-Veam!)#)N1t9$eJ8p>U?Ox4bSTe=uT$hN>1Qi`e}Ps zWwIDL1|zZnr4fH5oF1OsjR98fGF970I^(jJlrxS5YbRs{^i+r57 z#htj)(j3vW@O;!3?wS-EiDvEU>mVdOVF)7?3Y|MCm?7P+Xf*CzGJB1E4D;r+Y~@te3E^vV;*xeI(9diUUj97xpS#JDfAbYvL_)lzL#0u_3M{UaU53s+oiq zX!+7XcOTPYU`E&P{|t(L^eD0yx(QYm!#171Mpgn4dv@~u$&2^Sex%!li;+a`bKf1rXl#fpOrX%qkS;sJfc*^2=Qp2mdRaK87#l z2=GS~a5CKkqN>4o6omM26w7;P+22)z+35OK1Ki+FM#T$3Ii0*fAxsw5>>NZ_&wv_# z_YTA)EgBhYV%Y2EN;|u*7DZOJ&*j@%0yi$R)5#ywqPZh>mJ_fnhBXSP>~P3Os*{5A zMqM+|LIc$FY8hySm=y>KNjInuQ+gf8j4Xy=Q)))7tMGK7hc~b&fZA$~&)EKOQq?Ev_&T8i*IIzehMIpChvAScovO+l-R_>S zuQ4Uk)U#D%R;9zX`o6lAHP-A8YdJ5{u}pThlOJyTJJW$kW)vi0e4W;Px=+%2*j9g< z&vh%W)rA&(X6pL?UBk3ui;b0+<>6y)<|b$a{fV2AGw#FM=z1M#G-7hnbUq$=V6 zcM=S{p^R;I1Avp18WB|G<`)QjcOahZ0ysQ25c*^S|CH5}@GMyc9_w=WRv|*fcO=4T zj?n28aI>=`jvMsO`{Vmp-{b31v?wLGZrgb?+S$2oXT=c8hqu>hdpjLg^>k;pDB64u zvNm7^$Fdnhwg1O*@7vEtir?8bgK06%K^hNO2)}@LZ&Yz@po^H=;e3-)K!{3H%)NlD|o|YLa zFc@PywA91$&l;=yW*6J0X~8t>NrURm9KBjaHPPBuE7J4bWsOMgtFPh` zxJT|2+VKle?y%=U2X1-18`p?Wznl+O zK(Kt8sy(#mQhxPbBjN9IccSF8SE`g(sYNpe|DK!&zx+G@rGbSQK!m1J)yPCkvq@3Z`)n5E?+ zExrxM_T)+5VJ@~-J^=!b;SmTQ5aGnyuwx9mh^;l0$M(4^I&u$Kc%|RX)f0WJsyz`u z{RCf%YMd7K1;5C**ViD?|C9JQjNo*|AW)v!4WB-LapV8rq5nmDrq6HwyT<!Zetz&m<45z?AnlIR7?~9gh5R#otjo9de1HQpIgnS zx-Sez^2on0=u$S{N9Ip^mZPRLFPkAxb>3m+?>E4wKFO8IqD(QEKj&RaO zRm)mLt^vhMEb#(e6ovK=fDkURQ@Mn(D0pw{>SLbCx=E_5AN4F~v_o08HTW~*SgdR# zQP-4qO1SV?8P%FtEh8_iu71=Eh0FC!C7KilqG&z2;c%GNu&e>Um0fS6&-Vg7Gco1C{>+L^D=e3 z%M}H^Ta=|XXrMvdUgw1riTiz6$a30V_q}Grc{NY^3G<{$xp2XwGN3<{ziIkH#*2t6 zx#zM_vFtOLpd8J|j@ZIIXA=9 zJzg0Lmp!lgGN#UD94QHv_=d8mGh%A(kYR`q(RNL%;^54uDTq@*^@jDLd_i9u=3qr1 zljxrIpItwToVEP<3upH*{_kp$7g@72&dOig|7&;eoBh!L>+3J^AAe2$Po1OM$_`w= z>oEwb<)iz$0|C{~2!2f&i*|RV9zrV_VY}4-S2t^Hzas~de!DFByhaFiDe}pH_$FwG&ptn*> zW>KSdz`sSx@w7m8RhJZL)BXW!%j^`68gVGL_u!+|JL;~Ju_x(7X#Z7R*$>cg$s3z0 zKUP;q4)(;Z;gp3RnAK1Qdph?(ECS(0zuvaxF-CZU>y_qnuD1XE{@Z8wm z11UNE{x(33JH7_&$|N-&h`o-fs}b{=o3#OhD!z!8ef5?W%n&Ht*dn!dK) z^hRMSm_c;Wjo?&cy9A5t1W_pt#qNQ~#qSjchz&dt`O~L%==kY8zexIe#1RV1?O=ri z47=FsXhn1>qQs4?cIWP~7VviaXghGd5y)5Xz_NP)?$ufBh&Ns$@gFN-R{jBpf<6p69)xjgWS~fJ8y?77eC>c^D<<1e9jnY}e zOwVxzHuVlT4X7&T4juS`&h@1Ank+8@QtSwd!f0^#LAX*(pnOb zrMB0jt}WI4*V&ubLo%n%C)$SBp-?j*PMs^>G}@s;osP62_i57A zNE0bXAUm$jal^$Zt4L(^eWjy7*xQP^O{0Zs<1lf|F*y9-)wd^~{uW$JwiR(<-RvFf zSVd)bAu(5kF;_$PG=yH?xenBcH8xT!cX#S?Gnw#HWW>QT+o1?qXfPayaUkL3Jy-S! zBE3~xk5Augo*5d%kgE`8M20Us%yoQ5b7IvwCW**BtyDEO7k&(p8`_PHq-+?@j%jv|PO_X5x7=F7Q znUUCslNyN<__}-QNK1S>b{xrl^wU@e0ik5ggZHoD)T`XkAz@d8kR%;@3@vRjPs<^zk~yvV-Kn^qTCxqDFZMqLZ^ z!5sI|QO7y+XbcVaO=hs8pqt5Lc{8rc5`&AZ=$Ji**lRztVTOV$!0XltpFFN=yfw@y z+Vn=gqvC`=%54XgBn$mRW%P8gGmnxU0*4r~>JL@~z8M`n8(teA4R8v2y2{pcqN?m=71+J?X*+V+X+< zuqAH5Yx^+Lv}2PUxY4emIVZQgc~KQ$WEv4Xp`CKQk{I29D^_OWLXGaO)!}ivKr#N! z&}A@AHRLVsHG z@H3zs{c)_(t90%X7k2ZIW-&q2%-`~^unCZAs+{K+!#1s_lCn5W%c_?C#bfs>*wyEy zW$;5#+5oR-E{YyM7o5r*tM-~ND-a}jZx>`Cs!R^yfA66akUZ}7Bbw_2uh}DP_u>d{#@n$-P;`yI96XxiRJ#0`N-f zMBH*c^;oW==bKeSXm$~y-vg(X4nE4}7+{_oI>=q&IKt<2qsV zc#b~5QX|QD=KP``;FMLHVzoXY*s)(3#f_jHdR@BHAUf%uNN^m*rxI4{m`qsFnGTLO zI#^|$f1Nh|?0`EmOJ_{E?_CV;bBUbmTO5LPThovee@$W#S2tU_tjty%ALifyQf)BF za73ErJ@c?`-xaC&bh8F<_~Z^=UA8B$D{}+Eeh0I6e*Cz%4m%;x*<1sMd%8{YQlDqt z7mg!u2*yaQ`K=xd&JMwoq2O5uyaqlQWVU(?U04c7;P4bco(Eh08SQ)s8Hb=z^y_Nb z7&@qtV2r6KA=5owQH~+KlXu3CG03LqFmY})K&&-D-<^F3v8$wbuUTjMa>+Dg;JH+T z;cyuA>i|LV0RX!==(fa)^$?T|CwT$lSG|OI8RIbeOx*0r3t}i?ENjq-_v{G1ANX>= zGxV>+oF=a14^BL!Wo9mHD_fe@51iOghu2lpIz(!B0C?S&Eng=qX}>sd4zCp*Feu@h zjZ;@esee2I1RfV^+$Hxy)ey^pZF%>;PUnv3#QkK#l~%Xqck-^`V1j8er)#t2HAAmj z4JSp~0@JOCM9Ic(0CaATD~(dJY=pbg8NM6F%GiA$9*VQO*;Q2x7bR>-(1J10+j{U$zrrCNz#f&8I~RYd_&Z16vK?Y7vA6y$T=gOa&yi#;O=rLknP8P_>R zbvAa>Q`xd^UYZ&S4P14{pMMt05%7I*KJlWdY=gD7-=PJ~#bC^YS@`KE$ArV91i(KZ zlWxYe4vw6}qc2Bn2m4qY8qoUCfFZfk8>l#}Wwsd0BuU3(%{O}wF+F}ua)@r$`0()qgkxR>9FR9BL|L2x{GK?% ztl^mCT7Oy&RUYimF*@)`HU#57e5$J-H6edgK}0ZYT>f{NC?*XC?GknLEkrUrMR_b# zjvZBX^&^s-3X&3WgbF1bNNie@4I}Bt1sUeu)sGnuXgHSNTe@4yV22s#kkI+f)sN20 zdC7NLg&)lYNT^e~5F$5y&$w%#R48t{D9S_EFe8wL!)D0))J73-5-~vifzZow_$^*& zPA~K+w#-Y8NrMgCr!F_n2N7r(6v^^B0p&U$IRWzzE9jVv88sH^+GUhHL{C3EZbkS* zr%43&p0I>^DP^riNY3as+c-|g7LEpb5F8+4!wuPOv=Qsi{`7@)mgnqx7EOs^R>b9U z^iYJxpKa0`_WI@$9jA4D2SEg<@y5MFrAl?&ce9}AJ2C$E?jdLBUbX;nyUrEvfm0>*Hz7$@IAqy2W*p?v#Bsp z!$*tSy}ruaRQ=1n#Mz}@uEAtbCkx}yp8o;LRswVR66Q3}n&In2|7a2rq zhqZTeEDVQ316Chdft8f%%pE-fT+cRBgm~O~9^2g3cQ4Qa5c;qIQS8#<=*U_4UL|n2 z3&bqGm$0^=8e=-CguVO8k?;gYk^uYa^x9=KQYX{n!@ z>g&-Frb=p>bSg)p_qw7Fx=2j(kFp&0EOp)c-O?$b4J@j%sR}u~P3tnbR9;snwlYp& zBp%;W_e0gqa%=1HJ%f29p4dhs(R(7E@-Cl>C%qraA4&+o+3UeZG9=bWG&8IgzP%+o zdiTwPYj=b9mI)Vc4=}R6jwg4D8Mb$Z>xtQ#j??iqT*WIo;kNBvqhR8q)m95Dq2ORv z*e_UfZXR8TZz7CgM2Ed=D5Jr6xnMj1{e*F-W)k}4hp_t(86WlG?PnWl$Yh(*NO1PE zmNmvIqGTDoz0H>ck6kezl2YE9ALdNyfXS)`lkz(BQOBPnWQL!~@|An}3h&1B>am># zU>r$(ST`oLmqnkMQJBqS0~hcU%oTJWiK#4Qowf`!bu}|fli6f$eAc6QGZjWT+K5fl zgj%9YfgT7H|1}_OvxbrDbHjl{3y%!J$V=uQgVdl)@(iiwazy{^<0=A^R;M#ESP@IL z(>zOavvZ)oA;xV;95MVkLBusQ!@KBf;8tq2C{HI~vhZeNrlZF@P~G@y6Lb^OhI55K(^{Bi{+pQL{&MvRsZSU_8dm+GUzyGj}R zqx>Tl<#=BitvYSFRQ|m8;X@;fNpB!}NNv3f9s%MKBcmnw1ya3H<)NY6$M+q0T#Ds^ zu06@i9DLvOHLMB10d{a{H2?=|;>+O72BHTXd(VwAqhni~6Pt74V)-&^FQj!j0M)+B zHIInQ34aBw=lVj}Z|iE7H{gNRR0TIdSo;1`Fbb8pz+JSyb1#RxMx7(hfvF4DAA@TT zV(xfDL)`p$IQU1Nq1{iQ(#<1jTiw`pcT@wn=Uu+dcM5I6=PqO!*zSQaHbN=$3#I!7a$ z5U{O$;hv(7G=*ji&e)eP(h|~;g5`i6r}*LW@x2AL{NZwlMJQ|h;gT}wEE;GtnpVs!G`rWmz5#G1Tm(Y`^Vf3gs&Ehh=i5WED~=H4 zs0(E8<;fq8|MdO)UVk8VJzIpX^U#1g!Q5D}@Zj8>Cs?1p=b+ZzL3bQ7W3czebCh?Y zy)i>17F_PYi%%Vd>T}QBkH;&b;dMdSJVG94Y1YTcV}mg*-2t*+GzbHsziE&=3S|7i zFn7@)cMl72w=%dL=L=%{$B%4xu;srNx&i^})FJ_D?K2iL3_}_NW-9uMtl}8|Z|QbF z3^mwR?;}8(1de3NyHXVy1@i|Rl6Z&bsxlm#x_V#RTULwqbyd>iiOogzjww4bOQK0f z7pwMIf7n!Yn_S(!t@EnR+dKDpz~)b+$4_lWdo~36OYdyJ{T>{15jVB&(2?#z6h8r0 zE{H{_-3O22xTOkx*ZIsiezCjK^+EF{jc=2+j!DcrmACs&-U)5)NEKa2HmsYMQ?~Gd zV_$U0Zdit!pc~OWnj3j{5WVwhrf6w~CP&7b0iUMeJjVv$%1D&^V+X`3LF{`D!kr^9 z^#jMqjt0u-$65lgZQpQjzhK~N2*P;X>W_)rfl)6wm6&s-fu6*qrrjFEFfxqqqQ}FV zIFVph#yCKYCOLRNGb%<3C31m*4q{^W_WZWCncD&@`xKN_%D>)Fk3mJJuf8@RXUby(<|0N3 z>&{w65M@=(yTb(0!><0CGpZ5Z8-33%$gQB7AAAONcSlqT)}_9>0^K&rzKTMPGu)*% zTM0UBsx$}Y_n9mvzWK<3qkpcy9#T3Iczt?B-Y^H2hWkDcacCPO;pkuVcwP-8cGeuN zIs@ZJc%Mh_t-l5hkp9~{oft}alwWw%Dg1jfO;erUnds=9m(#!GXhNi)ydW;ZG-$?< zDd3*%5^9rp3NzXOD3ftbFP!pV#A-UjTC^|sCF=Hhpbeq%wl7;yzUa;AT)$oX1&jI; zv31mv*6x`SG2biR+HjAGc`l_&T(;yas2xe&_?BXZ5A@y#MscS;Q+@k79Qc@VBu{Ew zWMb$z378wr4BdHkI#^#dzlc&SI^WR4f0oW8>k+&l9i*)bm=os@X%@lirf$7=awB*;ga{1TC_pUT&nVkQ1^w;6V)BX=XTwV4(>iirJf(H^U z+z78wH=}kTCN)gr=x|=f2r<%hXOx_$+kZda{y)3h|Ng`Fhtb8;{*KdfR%Q7lCpzE_ ze;A(sb@;=@#na^I@cgeoTwMJ2aerruT0tAr303`S_VDEDLIk;1bUfMcy{3Zm_*8Cv zW9~qCVi9y?|32TlP$>roTi#Qdq2-AEh_x_y7}-DRm8M-r^^w>cf=02|?=u59W;{d! zZ)*jqaN@?BbGD_DF@XqEi<^SC5Q?Q>t77vW-MT&MrdP{}{u7ir`tLl`f2HO4x~lar zxHnCmGH>54(}U-B*x9Bp50uloMHzp3qD zmN#=W331H_buo)LmdQ5F3&-Y37DHX}eAgK2Mv560@UssCfD2mAFdlI+yj3iE(H1s*#xwa13gQf=ozFPE(W8 zWMiD&rp1kiFDP0U^lqi|t1Q;JzPed-F%C&BOJ=((3zG1tvFx+KtO*bUzC0ZvXJJls z%c)A3?a)eZxf>7%Zk*FGiycNvR1b?6FmflrSn0Us;_%7O+S;n((9A3pV2U;(KWWHk z`4vc6Hq{8}#jMLMx23}Y}6R^FKJ!oRjYtbfs(L4`q~tCZnE)>2I&TCd!X1hiX#p7YvIU44 zJjUAV6k-~UArT;$27}gRURPIzoHfSDz>eCAu1Gzo6L-}D!Xtn?YFmN(zPLmg+@;7% z1?5R2E`Lvsc;8-%Hk}^n;hSU6hKlRaLXGea+pHQ+0o^h-ilJp=Zzd{!1ps+pOM0@x ze(3i@<8fU%lTBM!cdC9re5N-BxDIu}S;B~(hdTt&o0I916F*P}vS*9-wQO3ML4xb5 z#KZbf=n+0r_LFd)!P!M_^lH-%_=&>KI72XhTgdv3(v?^BaZw~`QRtsd(jWd<(16^-dUh|= z@#ltdg|546Sc3@$$od6qd*WCx#E4ExGS?`RSTqa=^_##{dY$N?`AA*2yUwU}q=MS!~*gy9owD>0f!rMkwu}78HUns5xE8vas?P*Au^CprSn- z`HML6Nrg)g3^*0pM*pTz?=%W}%*n=n?Du0CYvu9bh*bqg=@Mp4F!Z3V|&bR8l~;ON6VANu|9xG;W8 zKrNwm$_LOPiXlfhh^qE7Z|1UW@Hx46;5+m?mmAiBKL&Obak(7Y=lOdt2ua{ zT=XuC`S`gJs94boHKbo|U9bs28+7^?>%rep3o9vcnm8EZM)uga^t-5Tw zcCR1I3d}jt64`a8(6i-HpzlNNgJbC5Y;gB_7;onXryD+Jd~6|8(# z_GbeTuNOFsi?nI96)Lw5eL)`*YoE)my&z`Ig4=#sS9b-3`x0ny>ZK%lo?-0+1Ffv- zm1WFz40KoZ6*069U|WcZ8;NkExkp1S#Ovew&~JXcV>ku0!m!`>m6?Do&`}pe&pbw- zR~1}C?6s^;Bh~%SfvMO7;7hS41SGc6CiDJ^&eJSY9Uqt3JH*|e%B(V@@OHA(1H=tR zcSx$|=~4V$?le`QLnE!1uBnZkI4ZE&uS42r;>fGi>xq$5^wUqG*K-c5j!7r(O7Laq zucH`7=h%#XucH`$aW|ruNmUmxu5S2C9MP!UA*px@t4Huo zN7emBgLw2yMu9Oz;~~shsTr%!OnYV~@r|>Feskx>!mjCx%cD{Kg<88+LwWSzvA{Ou z13|p39!7MF>Iry*i}Yp9cd@Hg4Gf}OhuQV_V$AuUIyAXB2Gg%A~$N1Yxymce*xXDg$d%NdQ z-;J^;y-KfMKoYAkYL+n2hv?^`98K8>Jub476;->FU^(H2-0eVgW}{s9uto17^zRav zi?q%txvQxl67s^2U#4hi&zJbt`Lxwy`P|^hw^9hoZ#M{H=KV5Tc91_fBR0yN)-z z&Y*%#`rebo(_8VEit_TKfR|}K#iwc0?LRl!u-&Fdq?P#WeA>2_5rjJH_2n2%2&79B zQPLQkxPfkb0ha{M-MG(PV08a?NO>Qz%3$adAI=pq4bDSi znm4Vi-y6)}&i$h-=1S7X5EFT^tby~wvM@oEn7~2Nwf@==qQglwZ8~Mz0UrRnW-PZl zuqS}yAamAMbvo59G-{eX3G&F^DE=DI>An~RV*O#Z;2gURbZjCl5!`LOWgZM2TdfB! z%k06RWtpu4T*&mJd;n0azX3L|-h;70(AQ#vfIkcy1Uill{yvLP&p8t1g~QE9#5NDu zh7Y20=e{OGe%X}m*<6nE3GgkCtf`&xdFuPQ#j$= z&VKqyban-o|3_|p;UV_gxpl>%D5awV`%eFZ#iZ0@*=IJiMAM~#P>X4|;aE`>T7>PaROa-l-s?_h1=?gz5Y|E-12jbtq z>5Cn)x9fofNNtQB83KKaNlEN zT-0cpbGo`OU9C3|d*6hk)t_neZUpAvz6qenw4p2Ha=f_8$JATMFLForiS+_Jfh)zUA)ky(W( zEQJaJXI7yD=KFk>ms1@QW28{!r`le_O0WpBL=q!siwr`PjKn3I`4 zYK?Tex2mr_AO14F+;0}`iuLxml7G(iezRyl?}EGB(l5sR8m8$VMNrH2oRrHCX*>;d zJPdu1-!CIclkIH!9kX=ihO|5q_kxIN1wC#IPtOllH>`r12&;u9ZY7);t0K#cM_d>v^H(*Cc$j36 zsQu3&fS)wo2w*$23jtU_Q zLZUwFee0iizGfoU7~n?3v~;i`I%)v)x>-9UpNBV-Wo9B2h@)`$c}ZwQonjR6Nw?T9 zK<_W@pwn%GzydYCHG%OWz57-g5IhmwY)RJZijm!6hLZWE_5{&E7;!K3yKYC|HPEpo zC?3)-eu3UNCPg^jVfP{@AsBuuWM=oJNw;n^31Sq)g~TCkkPDpl6TBi4dQd;#G&-i(r+wtYIfX_dVRHVD4`4#qLDI$~NjCVq>le znJXF54w()Ko5qld*Qs!YVKY_mPn{zTdE|7ipao*Um28D#B@EYx|Lh~brvjI12EbJY z#h4n0*NvP({q9q1NN$1&yGfl;YVLi7x=A?bcpVz~>N@Nq@)eva-k#OE!}O9hPJD|p ztIm+6!arXsT*BWcrE<8N%NkxD@7+}}`cTKM4E{4PA)>BbD_T36V<3Je`~+ZL>er_a zN8vMoqgcHy!x_d$#%&1ITqB(TvV$HFtzICSj&VnNN#gs_i5?k6H;Q8$CxabuCiP<8 z$_y@NP~2wE6{o9ynk7VY91KqjzVM(cb&r2aT=lY=1=j-WCD3!K#MrE7emc>Jhdx=l z%}z=$T&`**IjUBhn(|O`shem~Q5WltZT~{K$VP{bLXY_=o^k!x}ZI{?|#;HbdFyW3q zyN{rbgJj9BHg*Jgr_?6vy<){f=bY|wb{$@B)cfvZj9E9u7!2Ag1G@lEi5eW}`cU<- zu);LHQ`dlISd)lXU8oVGM1UBd!t;-C~ z@fqg2Bb}DJx0~1L>%_o{<(FpQmLX81g@GrEEk#|F8-jMG0^jJ)bPSv1e0TfbFP`>i z&5rYO#LvNiW-2sH=YW(Z%bn(VudU{=*DzZI{87kB%hX*1Fnwv;YR2`x8!XBGML&{r z^IXqyAU1+=VO?sDh!`>-1#ygpkJxQKP3Nz_DWg3Ukwo*22P9V&PTagV~avys> zd(1_Yyp}%geJ`i!_%2F;h^(Y4vJ(iFBX3Wfv;&W@1TF!S1EX)_!UjkZtI!kDla8{$UOv}p^KMbTzpksr^!g7JB__=C)?B9$2k0-_HPnl4 z<9qPZ=R_9IIvseU{Xzm>?S=iUA94KkIT$>BX|qm)t%BTcL3ZB zN}#pxL_DLiym^;qd4+K-e61Y|WN*D)+|;Ow+|*}IV)iW$(3ENvuIg71bkEmqgibY~ zRkij>BIK)lTNunzxostSB=nm2=w|zlq&9M(slNc}-&a*5Q9o8o12n|_vMWJN-n;-{ zSJkHwuk`92<_!d4n3|L_8zI#8@F?b+8)OuE#1V;hgXffhkrt7$?JEbN48y&g)jm4c zdkhCCCl_II%m0|t(ciTUA5%;mIrOp!tzXwik9+jRO`XT9j1oqT`t;yt6l z#XL(}NpS(Xyp6p*#5N9pdHR9@f63Wa6G!B2%4QMW=__5i=}G`8G6v>Sb=^C~|C%`0 ziT{-Z-tqG^7~3j;IuE=ut&WdO!HEyd__Dca@#s)^W+}b{n-8{oIPvub!^6&D zo$Id`cpwKuVc!o|?(&Y~$FU4b;pF{GozCYn(_*so3zO=G+&M7>nsSkh;{iL_!kYidv_8<;!ws811p~NSC@rFv!HheqPGjc@K8b66U;ALR=x0~=?2ao(NQ^^ zI1=F$DPlF3fLz(5#UyTuCCNFHtuh$t;mZ~B3^e0fN)x{sbN8Abi?*6n<3+;@=I8=I zzcpj8e^zB%SB0srxODbYn!wUo-atUmjK!RumDR_z$YImksMNAH?ek`q&drZj&M29$ zDk!STe4H(cHlN!#)icnR=pSV@tFndt81`DtEkak9KF9 z1ynPSSjWzS5>JII+)nA^33PfztY_XBxSv(;-@r|~ZAglE<7G2`! zF7Afym>3YYS|lz3V=-ZIsFn_%;ayuQYqQa>xZ%v1?nH%k@l<*ScCXj(x)8hgTK|@N z^hhOmq??#+XUq#1!8DXd!f$dhfqTfnTl8+O9-?Dat*7jxi)x$}_MN)Ox7XJoiCr%i z15^uuR*ayAI^DqO^TzT2P6G_;;^ZFAz7aUQ$ofBJi`Jy-=b3CNvIW-2%aYf?pICz<9#a&&Ta-c8 zMFjZAw7EWDi&XkWHSnJjqT_4*O2pzEL4k@-4LNa=Y;i3f1SN~J#0|$m{0B$_yebr!AMsltl*jP`8~nq zZpvIy<)$-b62usEZiiTs@`^r19Eo!g-O_&5&Ye z*dKR-j^x6<;Uj^yj%bi`cp)d=c(st32}oCOaSFofZEz~=&|unl_pW@~);=<|#7La$ zzb{ceOnsf1-8 zWIe{j3*FiyEwVv?oMm2a*{)+6Oh1fu!mBct@&VD>ObI+2-`pC%;)HTZ1?rU{6n6#_ z<>SGcNtaY{3gYStW6^qoEG%756TtR2mL~A0Aqhv!yssACD3uwV+czg>0j7zXXV@d3 z-KKNEd>@%V51h1&Jj0Ly8E2 zecYWWV#&24eR@Zrj$L~4SMSWD@HQzb9T;M#50NZc|PZ~&Bw>_Z1H}&@q?{ZspYYE^E)sP_j>~C+>E6i zhd+j;eqk9nL+?2nNhc^&`pnBu+d!V9$MT_c{4nMt(z)o+SiXh5I z&*t8<;ojH7e^;V-%rburntGO5-lPp17~9UMaB`qiSHUWfz1$gi5-bW6lA$9X*}$Z; z@BAiP#a-s@-AOskOXZ&zCIuhFvyct8n9uV|ZQ!b)yN28vf@4ojg#cYZqQ72JC{xHY z9SvM-Ww-SiD0kjhVBkEk>0!LtIvI+o$W1x#_5ht5G^u6L!3fuNmZXj8iyjpad z*Jj}7dULP_GuMMQ$x{ZtZ8jC`?1x&r=#& zNR~H7ExNeP+iQ@7&C(yMTJHx+u4&y^0{B}_MBVsj<8U)@FDHc(CD@@~T2Q8^O7ci| z83vBoEdA1M@$`#wF+(O$`iqy2$cBIw@ih)3>U&kpx$wyvfhAB+$6vmm`iD3tYC=M-{Zd`z0Q#$cylY;08fuKB|Z1B?2qVs(>^ zYYCbXR4#Cjq9RA>QPb8sH2$DxL#c}dg-zm6>>h|*{2r?-zTrURPoJ{*fa&}kzMkh7 z7m=s1KE1sez+*~Vzdd>cFg~V*YrOzjox?g8TxH%su)?%p$_rpTE{$4w;3>!lu7Y}i z%IJaCB@fsatscI#5(d`GegxlU!6IXNDlP}8lph#v{6MSW2U_7iP?R*lGjgDH>x1h& z%VaqK)%XCE+5@es9%vo)04JLb8kx=t*)#*Cl^vkUe4rKS1FegtWIQmdlUHxw|M!PC z&;Lu9oDYg_Cbg7*NBe3qVuBWGaQyCFR|Bx;9DbCQ~fSLytsXB(S*7`D9;kSGT_ ztihYy&916qWfQEynh=_J6R69@Ha-X?Jycv+2J6aT ziQ2wPZ{MTpj5l;;ogSLODyu2-u}uEo0k}aP@YB`U3V(+&@e@q|8vj)A)mP|Ch=Q8e z>Fb^Fo z!=P#FNGi$e-1vB$H*J04smI~-Pd|yoRsb1iiNKsknw`i@LdMj0Coo23`sIJrJ-QQ4 zydfH)Ty1R0EcAg$J`BW|%*a3-?Anouk&cJGG-PspH@}u;+N!br@Z1Er4{EF%ARiM0 zI}p9?$9sO5{Jx$3Ja5xY5CHJOIPSPjcKhDUp3=REw7b`5n*b9ZYMNaKUL1+bs(gHJ z$An*(mtrI??HPTYHp=YM?5zPful?EVGAq*6#TXXMGs6ZVGT#qb<$gEIQkz!@u`Tv@ z$hsf~Qb-8>D$0<6?b=^tdIOiFnH;_9!h~(~nmjRYJ}5hVfAa45{mF;#PW~GV^zp<@ z)FlL_WHnfSS*n93ua4hB^$#nX!f@df2|+M}0p$@jDgBhO7}{-84nOERCV^6DAB5lc zlSw&HHlo0;m^;{YQL!CbUTcDy#d2uhx9n6R=+A)g@Bq^K2?NM<%2Wl9RMD_V|IUIw zD_BX)tV4YZ?KxX8d!mnyZ4(s@eHDw29ip3XC9H0vM^}~bMt58_qBX*ZZf@_cVs^<` z$wi(ax9h0y*5T2)6)gT)2vMi0mmH%DF^smt`LLFhPS2R(XgoUEHac(&FU&jFeFSY7 z-`DB*#tXwE_$F21(gJY8*|A19|16UAkhN^YunnNbWm~BH;}*7dfN^?@yJh>k z>t_Ow-YVX9aF(n{8zM8?kKIi!Y^usVu<(8y;fGj!x$#Xx*ZlyWBI=FC^y(b`Pd|Bk zvr{kbhg*ZyCWgW=7hq4R`sP=es%Zh*290SjU;twr3UVj?$RHryE-0QeZv>SJHq6F*L-VT{ap2dQTMNY>;0-T_L%0K46vi zh9ihevuqzjEl{)f;MW%>YI8pNTo9F84VO8+J^d5otnjtq>_{+kja+JdPlKg;MDmNV z0n6?cbPZILl`oiz_QI{B=K|OXGuxUVK6DH8D53-zD5;g}1_?U>31vSLuL6o)&=js{ zUwnIL=*qOHcvzzm7l}ZljTn!AdCaYwW+U5R zjw{Es`2;)rUIyo6y(sOu_pIZ{d*)m@c;c^q1*%HXn@QDPeJQ~Wvf_?1cMCUY)whgW zM)ytUc^IE&LptQu(=?Sr9yy4V?!M%s&l_u`SuaXA4p#G8UG>1fekbpm6`GIB4m^|!O=e0fWY2g64q_zqEJp1_E8Ax#^7zJ9!p7D*8|a;N(joAqq}xAlBzml z$V9EKapV#E8rXqY2?H_0U9W#pj?9>Oj}H#15~sQ*LWePYX*%+0*fZk12Ru70)turH zV%S*tV^NwvL2o;jpL+qlXYfH3rEFyprk{SbaUyJXh-*T8ETh&-{wzYN1>cxxn2o#G zI>x9NU&32f&*uluif86MfiH<#j%EIlAI1**sgF5gq#n94$u%ALcLp4W2?Ha}3h2SY@2AsjPqX`O_&f2m0dQf<#yJ?bK*)J(kOL zu(ERjJ*d<2tFM%fIEfz0B&CRq?VX$y>iSvHl2Jr_8nit)usA&(I8jBELISt%k#|F) z900$JBG+-!F`e2^D;l7#@1`eX(j0ZV#*&Yt9WPPA=A9xV0LN@UyeLRU^r!N+#z*)g zcQJqZ2{w?&{09e!0|Qe2=+IrG?hz?lZhuh0W>NYwoj}FC=j^>TOey^~nCDS8)4zZOmvp0tY zS&r%RqVdlQf8qG5P({)VU^ADcCHN7TeTxPR&K=*tk_Gn!Yj&MkN{aq5~1;>qF;Fk zbEC}|6gjwo+oKa9!8H8Z2i!}J-%@ZgcidBaR?f9@oi@(3vdQqP+Y+z2){)(MfVJX& z5bWC{E5{pg`vI51)U)?G<0m&*)Ls=**>^PDDjqFN_l-p|TD$IRYg$efRCb*zqg~Y- zx7^WJO}rcSIyJq;)>fx2bp@5M;#DDsw`pA_muHx&SgNr2Rfz@=y~p>=xN6xGlQb{D zrdJ&`d1J;fyc`IRHCuLuT#6y%=MMZOr5E|t#?F_gt(eazvvM=+gep2>JiV=H>mHnCZd8ZvSCLP2zG#%b&(%3?PKO9h87>wp1Nvz%>Ou2fqujq* zH#_))t(8xLNF->7b%R+PM8byZo%mTX#Lm%y;l7@^N2-G~%6?!O|9ux*9+&Im>L$8- z#byxs;pQ+k=$~<#$+FGcJH~Pj)V%zy9Xv~5&RxB#*-;`m*7KRfe(KHt|u#E zB2UV;R*&%eFSPSBXh$4%kPz^f_}GlTb`@zU`#g90zuuD;ZAOxZE&X+xoq|`Godg{1 zc>V5rPk(!>lYO|O8CR*EdCACVFm1_t9I8n;_`?ydz#tjRSllrjIxOTyNJPQnAC%_E z-l2@2?~*TSb(2j11?WDmr+v?{rMjZHqKe5nhzR__K2_DSNs#?}-@p0I-M=9(_Su>0 z4VbM}9EYb6vHCl8D*9@?Fm3?tTf+K)SZq3})enF$Fqnx`P%(-?j>Ng$Y3I8aeZKR~ z_b$+5neRn6M|xGtwQ)T(8mlzKeMH5q{K$1_m8F-zb1-=X0yVwHv%kXxAzZ z2x@~zCc3+6VVx>m_5``R9dyY19QQaJ){+VT9^c={yX6{DpXCTT<$cB;RWT_PTDJie zqD5!tskz!fM~RO8TKg==v6gCtC%%%wpd)6DZV*dDAy++Yn71{+SuY6P^#{=8#M zgU-PWx<)Zr*ZoDOQP)Oh-d?4^kk$0R*7DohYR2q6yM}L#JR@JCws4Vp9q4ugpmZ45 zdafV;{PwjQ8@EQ3OUn2tk6Nbch&H(f2um3vdzDe?GGe##qAH;Oyei|SB^wd1qD!Bd z53E-{XJ;PU;)WF6L=^7YJFQa3s*jH-Dv&ZH_T=+{l3PieDDaU9f)tZU*(a?rB6)ld z4VM_dG|kIKTvhG0Xs;#ogK-139f^)$*@$X_6&i?>OJSJ^3exB0t8{(|?`)AaFo}lV z*2@6|f*oIraaxK(rWphw%u5&*H9%t=%8o*+W`d*JY#*AQUZ@ zWa1X>*y$B(-#xMq@^Uky(HnE=Q>V_Jo7nkPI``V0Ngt9LaHRCiaT-)T?04pJRuy); z)zvqs+wgxuniy z&q{I7ef$p`zm3?$$?D9V_a;N36Wh0B9IUt<27C+ly9Jc7-Z+zVl{ReZ<48aAy83j- zocNVts{^zD5NkxTW*Da>#C*MyVwPt1xEl#$lc+#1FFPfW!LnN#+t3^a4ds)VZ%jy< z#83I8`QrWQpHD1lZa|wV;=!l9X(@leCuNv|XpToxaB7EPp840XE4zb9lg$p1pd(%v z^t>C-XRhV}CpeD761NL-AEMrc+|I}$MKvOW6mGSPh3=DTt|#tByC5$Lt(rz)MPIX_@%T02&o)Txg28{AoyR(sFa?ss(xj&gdjl2J zUKf~nb*q79c{7K=z79}b2tXyp)im`m7`y%jdDr_J_H-L`b8Gv6>{w1X>yi6&f1sl- zjr1S;kTDDLHXwU%qve}t64<3^ARwD_0W!W;&q&Kh$Wj3Xbv`t;gNZXWA1%}j3 zHm3o|=jyfhBni4x{E;m-AR=EL1ygBt@4_Mt0qIpK+cV6a_xARiYv+TYdd` zmGhKC)1taci_`3toK<1E3mE%xnVpv78WRAWW=>jN&LQSa(T~}Qc8rc99Tp09-)6B_ z-P_9|~0crsvGiB|;o50r8pa?(40;NZI& zy#@JHgTXcC;h!#fbikl}j^lide>TO!7)H{Z*+)HbdQJX!wSc$elNKds=Z_!Xo2ttT zlP(04j8clZ0&>luh^?o#O;07)5ZAJnBXQlf^JcVTkT}fbN0kh+*?D{qV_RNoR(EZl zCzIo9=hODA%47ku1zHC4E$NvrjQ=&cxhY$$ACT$iGRQ2e?4lfpjBt<>+@w&Y2tSeI zkU1LDVry>L81M${aNwNmGU%r|4Q?>J%g6V2gkZh);RD7qzpjc*)|dX635LlB;KO%z zF`^lH*pax0=N|*yLmxu1G&`Ak$lE+Ew2`=<8`tE8<@MCJzb)iKzRJgS)uzpjcbKqa z5DCNQ4i21?vBo?+ub|@hD4LdabKXpcJmEcgIk|B}Ea){T2Vp?8Y=S9Yu0yD}(1?#{jRNChio>OpOS(w(1Y)!T< zdNrFlZyT&QHUkRk%7!>ux0z*(CRKf#*4fENNVU#_)Q2Bg3{?328lMx6CA}ICp=w>Zj$)Y6cerR(qy}l=}lq|NFs_Y(Lom^1XR2=d#RtP9zTn4H8iU zhN=v505`I3pUVkkRrY%AN7U!h&eHiW?hEa^&+;qag1RFh>dMq1ZUqG>9uv_dPxEdv zi)^zXT8wKZXbo`O9D5!pn@dfBVszl=^>jDv+lAheA@8Cly88I&o}V*Ax7Qd=#hpsn zsjog$C*7T@4M{gY-&QQ^UWlbFbJN)5GJ3QxNS!Wgwg3>lyBec;fy&RL-*dez|JVz} zRU!HqvuTd$)vIiL4OG8>TgduOr%eo3W;qbi_N%6)NrgL-sTf+$#dP_CR8;Ur7wnQ! z@*51w*r<_>;4#7Y(%z-};Imqj2Rs+@Y3bx}ryRDfR2m6LeHi9tLD@(ByL^-T+f-)f zO(Tm*4-~=bU5f)clf^`P`i-0~P6mdKhw{p9+*h>iZ4~?Ba6<21zhf%oNscVM5X!D}ksY)*uVpS42xLF6Bsq;lHur~{J zp&KVL&AN~;FJka(Br6ijYnh2;*Q(8(anIg!;Mwy>OSZb@K6wGnl^vUctBQCn&ey~! zC#%|C-w296(hDT3+OxZ|O+OKXvIYd^<_dveF>tT@8$p?;O@rok8jcP-tUfqfqkAax zc2P%ws1F+fxy{S0y0tKZ#ia`C@Ji?J3NX6L8bB&HD(6{%Fn|krrRswQMK?X?Krq-Y zhasMdJv3YCn{F*=3pFF}lj@lS>rJ+PmHn0bx+%~BqN%Z?KU8pjA{h07$# zp#t@iL?N6{LMKNVuwz|gf``oK7|@Cb(E~?Rd3);Tj>+rR-{*?Oj6D0y)%vxcBF%r8 zbxk3?O~I-de1&5ZT(nJ|x!Ys)5%lwspkB?|q!iwr%B(5y6m>&!>Wag>c~O-mCW>W| zu-CONhSg-!$o4OJ)?SO>i#<{gs?tBoe0tqN@dki^D{9WJD@gykN&g+>wtB^gPy6ND zfJNbyn3i<~@uROa2?*#O27<|(7ezk4;l;mvD^PDl<&b`|s*legFYzQFe<$zWU#IOK z)23{nOIf}J9!+M6E3(VXTW{u{^Bhi2_I^BES00((~qlC zlWlZoduKWjyf6CeWGCn8_TMl1 zCQCDDa?!&`E@ar&n^lVjLijh;OlnONLa?3Y(Ut!Bdz(F$Zsgr7^R8wLnog(T z8Qf?xI@gRjMD@|!H7H6Iy91$~TFP61cRNg2JqIufRDnq-@XmxP>UIPG>g934XYY!` zlsnF=2k_^*95Y^sL*X?KIP(J=zsc64WDAJ{YruA#GpKX7%Q!>Zk1(qHVkGv0`>SKV zU2OMe^R=!2x#z1BJ)#A#ONcC$>#AmZ%BkvoWj-fz2u6|?na*r>FZ$g*DTnF1rj@hr z-2HRj8@F}gr0Ylv{AVW7xUe^#W%zk9mvzqxCrQ#bnVrP|hrO8{s`o5z)HtD-Dhkow zuD^+arH%indsM;sq>=-4H&83`EjPqg*YBd z+U{Ocq|G&a*%S8ia z+idt{ChKMlBIpsYc#f*fL%q%~bR+K^rcOC#fk$HEw^3QWqFb16VAF>#xWIkG$i{T1 zYiQ0PeomMlKgaSZZuPX0=|N!pwBEW24S52IcDMYtCZUtTqu-t>Sp7`5T8#gnp}|tZ zz&O(V(B|n(wr;l1YQphAr!KliF;{BVcy-sC*-h~Xn(-0nqltpC`E}so`5DKV^w*6v z4&QzMF8%eI%2wNKt+QrhOPjD*@y4*tHSRHf@#KlEQqWLk3QP}w4awqY)MW(Mu;JM* zA2P=M$RPK<{7JDPx-R-S)+VrE3ezQ8+y|O!*uC6ZYL#`}LT;W3w|j3W8JZ1GR;O8B zF;eVSt82Tv>$G83H((>$b8hs&q6e!)wzn_c0WGdpFm>8W(PWUdLB8v}hd%pP)hA$s zX*T?QO>^xOq!#Jl?@+ng8)Nig$m-F{%lSg92Z*3RCZxqds3rjACRoZIIeP@+{dMXV zJtH7Z$KVok3LVlfXv>fKf!U8qzaM$I#wIn%i=yXwou2E^uHYIv40)`OBCDwLuz z@mF67SIXm6-)yOf?q-Xk&F6(<4rmbJS{-)dT0Cy)n*7`q>i5G(jfaoOM7)gld~9wUv3L+M)p-!FD{7u<=+Qgaoh-N7P;g8u z_NRH4wjEazESPe9G}*bM>_K=?n@>X&-X=Rg40jljYj?_?c}$WNZ3w<7uM~E8mNNfv zNB>ka4!W1AFi^8 z6!*89YlD#;tku?`=u<$!$?kzlIR`IKzGr#ob8QL3X=4J zRD^-qx4NoRLjY7y<(h<;3G^FOI|nhR3!mgQD3|KunHC>F^#7c5HW4D$PI~JfF%j*G zv&rz6-7i9Z9v*lS*mv-N@lRWJb$Gj^|Fch)HRlrF{2k6YA%qQkU;eUv=wvC-4WUl5(eW74iks6k<^$h#N-z;1Az2*Sop~hY(B=dlbm7=YJe(OP{FZkg7C6l?RHK$3>Oi1ma~ZWuDSepG%*Wm4z~pvErS1EhqUW z4X;lxyv?on%Ul_gr&)Gd=B@E&pbjt3AK&X9pl+|fydWVbw`YeBo1301f!v4%yzyQ2 zHWe^y`g+8kYP7M57uWG`>}T@1;_-SNJ=Z2pmi>o$H-3tFHhyY9mos&sTUZfCD3Afh zACD|>Q23r+C8NYQA588A`7nmryCV*A5BC_oZfBvs1`7Yz;uQUL|M>pZ_pyBF9sV!Q zBTsv$8E?Z_WC#P{Gp`rOIdqJOxjDL;q-oO{Lc{$fZ?BWy2$Ii~ZDo!$Xg2C;nH6&~ z+P7XrD&0#|J2wzjolo;JEq<1s4#kz8cnW9%hDGu0KIWM#l>|h?Mf2RNlK5>LxA&>k z4Vpmxy$xUfk(gCPvdhkH4|#VUjr;WFVO4($ggQMn3Giy^(i4 z-!u?nd3`7E{!rEb-yf=ui7|U&^cFK{F%nR>{2cqdSo3)(KS9Ey*1tEpG^P8St%AGH z+5XllHBa`WHC~@b3K=NF96{uAoy1Y?8*1tp3hQ0oU+F1F=w6grb)01~dr_oKBiTCn zUo5NbeLrjOifBXn;5yH8hr<29qj1$((6zH81i|C{IOOET{BbGNS53qT_0YMRV1P)O zBbA(J8FLqeHM7ow`>w2X&9>4meK@2Wd3VC-jMaM}M{7gvr5VPTTER796uubOXv6cx z?(6kOb8}rSitOwrpTCn+&{M#Ud6~8;=RuitnlG`8qk;Aw)oGGQi-9u=4>~`-JbGZqdu$wBv8f>0!|JD> zge|jKL4@fuiV6Jap+JYzg=wOSj($>I6P_k-Pjz6hs%{eAsLx|mW)Q2-i?0B)jx^v! z=k#p3;17kMovyU{EOc8aFZZ>OO+9lm!d#O6FAk6fXgq6~7p;zyW@dpS+VzKx;fd~KvP*JL6-p@$lcHP_W` z@CJdxpJOt%`Fo?AZ&FuQR{b&0qLW&>_x{%oM^fwNJZCC$h;B{v_`?C2iA`S}vwhy{Iz#W@xmoS(#$yiO^?+$B#E@6E>o5?K4BiJ{scnBhBH9Vsc zmL%Pgns7w$-OzuyY-3W@!8I_c>IX=o)>{kfG-;r5g_j1Y398z1^T|r%U7aXByh$9$ zkezMTMA}mrTer123iP(y*}=G#B<&f2i9efp-q1@W!@xAvqQ-!jV21(8YhY6<;MveQ zW85bTc_3x-Q;*X+)dW{H$QZ<;P1A>>s%{o@{yuZ)y{M{rh>C%Wy8%bVnwPOMji)4d z2_HkB1v686e0yaX(hu)09ybXZy0X7mq$Lx%&$lFUaTWbYV_jjM^D{8UX<3P)P=-fN zN$OnA167TuH_G`Pp#syL6g-$6A3>53IR^p8wYKV+uJF}Y0{;@yrbBCqpJ3uYC78E%LO<|f+-=KUsWmaeCI(O^?wKyqtScz>tU=`Jh4VJ zkKu5rFtKzDCY15H0!dZ+8(A~Jd79wH!hrH37r!TLDK7Uwkrba4zlEa6GV+Gy-0fat)nm&mYqzZKFnY z9t2yu7S|Dxd76)DT2@3{Sp3zSKL6}ipMF;VY<*&C|7p`@cNqLpau34f8Djf4G`|lH*xmIqPYb!BkZo}{ z*l(y{<2R}**-e>E<+>2{-7DiPvA)USwlKJvS;8H&zC>Nl*{(0+z-V7LEik=gKe)zJ z#`42BzfdJk)p~lh3mDu=4J22X z8N#{_T+KFZJ4b|S!e=XH-6vNxaz1P<$Z4Z#nvlZ4Km_OWl2QXQcYyOV%~9QTDuB#y zoH*xpx0hKB_WtJ0O@zay-A$EZo_;nM-1>~sB^pi`XD9fQsx!l}Hy9D$Eah`Ur4;g+ zV%6R}fw;vy>i@182k-Si01lLSiY^|;$OBHBR+$;Zb zN17+K+!AA)V8QPmDcylU1vp$aPY#j6`W#m~CdeEB#hS`$C%gZ(ChQ&b#GyA<>nIs}za1{xcMn$|7>TWh zp>S-|Xqp>O``ZrFqCQNke|wldVvI~A#fD+>099=H^^X}etaNv$shqH2*{$l-WF!9Q zZm7@h(_2+)v+M4bJ{F@_c(WtNXP?NCfAv(9-7Fe)ds59^*9yWj6j~ z`pJJIvQjqwmtrb?q;1Sb+XicF+{jFMff^f_sDCqT)9qpc-9+$srB)%LHalw+(&%#BYAEU z#Bv-6cr#07dS1psUkqvT}cY<_Nq_4QR++ zQh=f`Ykv4(B_{rFCDYl5!v8wuUZf=fbGv^(5c$^@CyvYv+Ij?FyIX6SZj1zcwY$CW z^8Zf=2kfi|NC`-9#q0UskeD;8uAk(mSuUc5k$lGH7kCDE zri@Coo(ohq^5gynBsrUj8TZY&t6Uh9h-CD_Erm_#Wio zwpM89hA6mB&NiOxk-3T}blX4v{`Xh^Q}ewy%E}7o_&kGo{-kYmI-M^+|2$|)@9f|I z>a+V_2LJf%i!VF-`(J+9`8@bXXaD~G{rkZ`I)B9g%wZ+|qtp2WAKB@20`vEO$-gLK z$-x3nhrCL*_IZZ?f1mURyr`1h{jNFge}o7pi#K^B*vcg0YHj6Js}(_iJG^$> zyv&(X-SolA@1E{@B3m9G5BcF)i06`4FZkI3P$N2f62Fb zTJe%9)J=VZ-&SH^(R?ao zER%ypS@nLbBFSPIvDJ$4w1TqfrLBL6^}kGnP5Q+dZJgv)m)Nm>o*ZC$mb{E|*3~RK z(09~>a=DD$#{}Z|z7-Y6L^;o&sC2B?&1pE0A~`4`##W4buhY>e${1HY%C?e(OgmdG zufaAkg^+a{fb~~ATX8H>MQk;&A9*1n!7KVq3F{%}@Ku-Sdd>W!d;T zDj1i&JSpO}y9yg9c+Pm96f4FhOuHT-cn!V5b?|cqTjW>#{}1qgyA*)*XNw;yy$ft_ zE7e;iBCe9e`bzX@02H-qWxqj5^RGH1a#%+hOWHAu5ig{ z`clnvJ*u_8_99v6IbD0zuLc{7^{avXgr>LDMd@^LTG41V7iqO>O6BHku8LXmd;n7% zRf%4A%cT=xMluHHiYbvL2U)}z*Aj~6uFC;ixpR!OKgLkkc0u<{rSB`&_2#jsPa#&D z-eMN9h*sOY!Y%j$q(oVfzd9*}ya1Dzit#a=Id=5{VC7<7wF}A zI|q=hGx0_Y3poO3^{Jrghb^WZ%?>)P);XME@EQ&=8~~NA(8Amsj|KGd%6z`kyIf#1=Duv~r!c z&(EvqtzcBh=|QEsFss<&tw`P)bVH#VUQti_11`;RoYSgEXFSut%oSH8oo?~98M5Nu zJqDjB`o%yaoEh+v`nrPC81r4vM3N+-SuUY|LtOL-M6mYEFX5_c=fz08qk{XbR*9Im zXrI(-Nj(2cycM996c7v6>&V8DUkwh81c%h9WNabz^-!9@ahicGO*Y7ISE`oum)Nx) zGFpvg;tUB->B(e6rgTV&+AWGt>GsN1hnCA6!G%i>2V9O&@Fk-@Ap04o9K{}S3Gb{6 zWD2X0&(&RMYRjD3GOxCjt-7V}mz%OyYnUVnfOz$$5sTN}_o8{k_%vcHo=0qz*#oiQ zCEk$>~pqf9xiDgqNM#rhi5c;pnh$&-8ugk~_waJLy-v-EKdOz4 zfLp@GX7L}j;dY)J)Hiho$W{Rhi1FBzb#%eo{=^^BkmmsABDH?Z<+alr;6t zWKk6`Ua-JlUFO;mxDBzTBgreaT)q)w(B$G)f)uvmuz$R!oizZXT@^qbhP~H$tV^HL z7Xj!?o*cM!=1}Kyxu9xRyj-uG=S&gTs*3WKUr@t8R8EPpp=@N<5-O}xDsrrU@ z+|k7mC>#{|fK7EbdqsS_y~tMW?M1OV?tZW;0KqW`9=*I@3=$B_XH}L?vp<1s zAM%35a4V>it=3jlx+jIwo3d6*MkOD@HKUh^^x1dh0u+0wxu}I|jHo{qfx+(<5Qz3HSgRBgm< zlC0|{CUCX!R5OUfBzFgw-kHJjJ|Y?NOl4TD&tnpK1n@o+{i@#?3=-Le&SD+*b%#OV!^=QO zSx6%Spc25usj|TAK{x1;SM3rdGy4n=4!A8cnJkID4La1`Gy-;K$qBi+pv=7Z^uDk8&2#`Rr_PVB~@Bv|W0ht0SpjaXCni zfY|0aEPuPs-v}TJD!V}8GhmI{YJk(jgTLKw=WJ2Q>xJ2Cn6w(A&0_|=i!u(w;H(d+ z7;$yhu!Yb^J_G?h%_Gt4EzU(*rl%qfUlufDfl3KZvkQ^8!xdkMXfShO^y)p&+RV zEv6Ok5DoD{c+#;m=k$Ley6|?tOH09FtzL+v;?g;t)X38=SfzZjw={+ zOX`%8o>!y*Nv!e(N9RhvndIHg4ZNjM{e(nKGr+0Mcx^-1`l9fF+h>hh;rO@0c3GVSoh+-lh>d zjs-6gwdUlt;^2S`l~F%DI-;PuAr4wN;K!gH49M`D1&e?(v<>thoE^cctJB9>o|O}b z_f9|`+8V|y);+GDT(v`~L>^dA?Dzm~;$R)T1_ zET&-4vqIgNJ3UFk3%hcj=K7YPCHi}gho2T&X0?@6I4h}iR#L7$Pn|=+3#!r3do@k| z1nP;_SJ%IxE#!;7Rim7ztWDoJO%9}prW{G~uC2Kjy@V4{H_cOyv}3I+EFQM2iO8Xv z1s0g9oU6tLp!#$=y~b5iuWRKTy)V_#3qoVu2`@ye)wHKsZJ)t?-7Q>-OU+uZpq`e= z0ajijKcIVAVlPV$Bs+^mR4^hVoF)yWQ#I9IQSC)?0EMB7iVn8RDhaQ4L%Y2nMFNOr zy}T}~d(Ig*#B=YmN(AwH!aEs89=-Bd;6=_C%A1Xdk*&u9TUFXZa*xQ)NW>hL(Y%%)S+?Sr;n>!u4i5)$D}uEcN-ojUfUV0rS5(H=?G z{YLAH@`I$)%VYa8eEj6;gCC#23mNYKW#!EMR66{y%dtKd%++Su5;NgaV~N8BS4ymX zk;!VFPK_Bvsn<=DqReO!%)R8bE z(79BSgoxS2JjQ9hMqM3|A12RSCKVqcP+L@iuua{1cwD0JcqE9WWky18Q;Z-Paw&0S z#a2A0^3*#}*L4N*G_D@>$kEK9JU2}aDlOsTNb)AXIHKP`z04TzFX%3bzvs#XB- zO>!Td`-;eoE4WpRmEjvod;{A7xw4vok18dO(NND}AeQux@}%Vm@+3w2=b=vPEk5(xYh>7?i;eqHs{T+F2sj^-@R zLF1?gt!&GAZnRbfFLBwt772cn*a z4uo@t%19WlT3y6ctWw3KCf_JISIOBbnMuPJ(mn$bC|@hBKNE$It(HKdlhpwN8LJ~&MC;RIk>6mqeO4?(DC zI;=AN1%5Wg^MK+-3{7-VIH`D8iKzgW;?SA)@4ek@Z%Gw2Jpmhx-boeHGk0&< zs+NVG^%8V%<6ZlZI}7VOhm|A1NSHSY+71iJ2`C5V4;1e$mi%OX0s>*wSr+HBq7)ZJY z6bRS78t|gYyX-MCi(C72*g;fFmF;bI0_Kd)of(l#qDFmb?*4r$0p&F&W`lC#T0D}T zp7<1Wl1H-DdJ=`I=p-$JbSgzU0?!hW2UakKE+!z>3ufu5Xa|p!ktLPX)_lQvA%T=b z_mrVPKb2t$5_Lk;8meo~CCm&+$n_+t(e8dQbTZaa7tX}3C(4?q#*}l=a;Bb+8nhM_*RE?o#knU@Q=BW*BVXy!E_p(z?uDfk>%(lXlW?N zN}DDHyDMz|j%ccY&8c@)`srD({t z?9cJex@fRu*~T|l4Y%1ZDBRxKH|_im!aI^KM^z7c5MLvSiK?b7IZ*crNFflk zTj%fQd?AHB8YpP-DVzXE*XZN-VLChmm3gw3%J|ln@TZsAUe@N_6Bu5)oC|cHdXENV zy7!t$e&Wx|1r~i`hYIq1n6?T>BI7e4S8}Uncg%E@;TX?dVli^+PQK0W-SghJS{d#gcb;C;cQEXs zL}?Mw;ZxKwXiq|0saq6Q_oCkq=Na74SvoCFL*5VjSG)8u;9!Y9D`v%okb`hA;C*{} z>mQh(9#j_KhTx*cKlmDc!pngp??!P1O1!7gVYDq!ixZYx^OlFzsuskP=y+Vr^AVW7 zXW%b7u8Bx!XGn^3!B!!TinGDd`qHCtP!z6ljY5WYPqbR5WfpNAsAPx zW)NS{kyBYqp9PxIM75cNqR4+ycivWp`+8eB_B(dP!a?%DH@G<$o)E)VELN=d0A=>- zgbYH);zk*a(l+(}Aj&Cjl=o6wU|vDBy5@J%rMO^aYi&&snlo7i>_d~o<{V{muqE0< z2nhm4IM%|#VZKc*8PpQF*}>+f-EJ35TQ%5M%4rbaT9%?pz(8)R)2os&jH~4`40#si zzG>kf9yJNP7O<#4Z@>}~cRIy&Zfb|XHPW_FU%l0lB zM;Am{Jbh76{6?q%__uY9~|k^iU` z`TM-Kihs;&Dv@TrUDc$v1D$3f2*4fGo4Lzhhr7EW3{0MUlY^3;iw5j`xG+}hB#7`p z=|Q2{YKe^?^LliDM(v$5KJUG66TW?xD(x6ph+32flR-LX55bRx8@djj;vf)iFcy2W6*ztom{gxm)`ef7pK3 zYR;|7g__Sp)@pf4#k|5a2}XFH4K?OoF8ee*;lQ=7=0!a-)&se*mwxVULmzTw^PoXf zPx%Q;4o<$VtjHg$L{o_M?rtc4uwGn=E&Y$4rNgYcUK@;Zn8F#u)1<6@oi4zK^;f#g zG)a4%c%RRcVebfdLiL9K^9#1O-+@=NgkFH)=Q`vawzn@n;}exiBBzjtLmF_#FB9D` z0LP_yIWFXR$e-$J<^&`Z^8GJVT~EZ?yRW{WSK}lc&QG%8?uqy_6Ef)=!{7?P-@3QkOKYgke zc06A3oA&QQjZi%0rgQNz4t=z}n6KFJrt+|nOzC~6G)-Kz&%zbsMZ=hQJN-X1CQro2 z4vKr2eCV_I=A-2nJzhMd{34Vh6^4XxNpx!@}C zfMZws;rJ?vp$UX%B}vT0UbzXePZuwlfcsR(;g{4p10^&rpAanU`MAj-G2 z8Q5kp@S2fiAh0&Qo6ZWQ52`ON)@#h&YDG;d)eQ%bR^Kg`wrPCtnIzooKuUIF#~{@C>A=Y*Wj|(kuqzn*J@~HQuvjM)b=uE`6gO@afuV;_M~s$2G=Qh)3tC3Igd*VE96Y%Ns_ zv{;%2so@B2kHB<{_;n*){#A&1ba7e#d%M{78CaVC{_3 zM`(_RPzDx4YGW@}yfi195q5rX6_L6oV%#;fkj!J=+*kSLSDE{YEede8jXYIM%7=W~ z{TU9ph0;qb(gehSIa_i1#E311m;^?mw8rBCz83O=w+z07QysCQ;!-Qwp&)tFtlS&z zXP1rSXbzoB1TDpL`a|BSKm{sG8pgBbGJ=z36fq7`FCbb8n+o=)=M)0~S*in`JEAwv zusFKw$;raq#bw+daBo|eY=IYzm&>?NDP3DU5s|}AqS~sVlor)Bys{P(1&I7O8G*&c zimMSapb)%j>uFjs4)Q(Tc<~s=KTpqACNJmbCqhQ2qJsEW^GZd+F`azM~tvoa%30>#t`dpG)E!uap+}j_(>4ky-pgo8mYPAR` zF=w_knqei0r#SIrM4c5!0Qm)TriWrzR#R%_holjX%7yylwp{j zi)qMuWfp<<0tS4TmLf#?T^Rp>LN(Ssks>|o;v14!##OfV1_jMwEY{zYsD%=vFs6(X zF+77OnWVZ`Bn|)|`0D z_M+9=>XXe8eu#5=IF*qxElk^*^2&C#GCq=zC3q>Q4`R;OY;9HM6NHADgG27^ljlz! zy$e}^v&4*#kTBz{3COPbKr{F~&=&mSR6f*xE>-2%Qe?+ zIE|RJMNJJ+v8_bHcY4YNzlWJE+^+^(NkTsSa3(mp)Lbh)L4TmFE-K=4K+AtG3_TkY z(Jp2vIPh#$7U1ztyBZFK1Q~y;>X!c=X345 zq^HpmiR)x`<#>Si_j$OFR&sMNmO~@CYf*-L%iVE;^|m^3zr({0*xEr*Q^-69SnIVALE8EFNaLK>LEMJO`k^5Miv*4TgshdlHNX z=hG^iq3z%~keK+5=LO&l;ZLWwVusy@?v-SVi#Cht2w;ct=O6P-Vii(c02m?sG&cd* zBZ5U8U^N4jtg=|NiNM30rj;o5VAxlskZ5^?Wd@Fp=b3Bxk`i8mOR@}|mA2In0k3-( zbyCs!%%T#*y7huIDmK%w7spZex)|QGnrr`?m!|4|7bF3Ct`SZ zQe1@~l$;jBbgJ*ndA57wOCsJvN77w2wjk3{Ry5W?Z)^qMD?=7IVq}9_7lGRnRN7{V zQQPt&aaF(MLa#NvC`9gpI6-O-PXf}F!|@fq#o4#6?`}gUX!`1%=t`tpZKE7TAT>ok zsH;(#>)2n32)ub?0eRzUg}%KYloAzKi>tM{)#2!i?V^1R!JCv%6B#KEFOMEo(U2jN zx=$ny~}2QuK5sF+9#%tqPu}jID}#5tQMS^;AiQofbrFu?@v@1buS{HHW@ z_(Pb*Uz2yyuMBf_z)Prp?)(ZqqrZ})*gFxyFUD^&HUWW_kAV@9y}Ls(qi?-$F8~!| zvTFnJy<|BA07Gu`cbOb?Y5+KP!3a zxSy5EF(O@|L&lQ|!b$7%KgqC6307<9g@#sO$_uUB;DuJMd!dz%7g~wNp@M^3x2}Dm zv}J9Cew0mEa+~H)uFqeF?x^U;R540u zMkqk=v@C%NY!o-st#^oNqXB@g6WaL8z6urkgi9~F7^U|tmp#8_y>av7fqu4q$2hI~u#m7q-crXp86S2(H4EJT_oU4|xp~K7PP~ zo^?~+NuG`nFkc(j#?J*LJSiEGCEErKNs>8mf2M%%J@&N2ryW8@wNn!`sfjTb^JRo@+VV0MwJ;3__X*J?gW*H zU-KH|g%$S3Pw^He_BsBUn>ZEmIX8~I@raAOO7qjHh<_3slvPzV74f&A13M|^`B22) z3&#hM>!2F(TMn)+@3P@poC!|2{$dnAQa|INa0^@-rs=n!aS#q$oCHtAr6%S&aJ|OVN zEV0}iukn`LX9PL2e_XS5+<9ZTw=PuryiDFy(LB2sa^y|9;Y_*aOS$Gnx#C1=MeChD z;*)GN0(E9l%s4QNTyuq7^MoAxJmRHD&!?g+xnjUL@}FEYq}(uG3_JIo8SJqX;!gqM zPQG2%62WB`a*xerwh3U1UmLH_aYlex@Wg*gJW`1l=*RgBT;f2F_la%+@8SWC+9-nz z4vD6qc=#Wp{sP^MLw-W8p_cAhBk!={S;IF}1qlK9FB0mQ>7FzGqB8wdfIR<)@9xuR z{D=*XGW1@BcEgwHOYnE=cYwhQcf(dI4DTU1`7DP8zTbhb6vbdChrCM@fa!3d1mB0y_$q4MrX=Nmh2rqwC1l**Xx*MJlx&h zubad&{<1Q8hm%x3sG<(@+qfqTdJ*FfP1U{QBHv!TME6zzxMBRP(yK*~b8Q3!jUe?> zMZ^4<5Lw@g^bDNOF9FY!Kj%Ma|MHMRjDd~cYvXhtzDQ>n+ta+XXU&l(CqouJE9o{x zV|+(wiBj_9JINMDG|lK>B-2an!hWS>y2b!tn*MpcT%sb?5jdi9M6`*(Xtg3R3If5Y zlK8cY9B2XPil2hgRVoGERRk{sN1heLd4fiVRRqnRRApVPRY<=S9u)7Q$k7Va;whbE z5#yIaBL(y4iR5SwO?V#*c!j+y0hsoAaw+9w;BjsL)!d1!Rn?RYC?D+G1#-uML@Wy7Ft| z@FMTsW3at6z5pPl8)R}KZAbPv@3rE|#Rf^^Ed7VR4n%>6>Tak71Sez^Ossln(SCiCQAF>kfzhI^O+2K8(!NuDp4GjF-ERx8C%nxUPK)^MY+ zCS=w(WN7tL5;D-D2wSb6KqTPAftvFfB3{p&T+8L`AOUp6VKhgJ5vWP&9i@ZFK8#8= z5zJ@~)6po5p^@H?f>a(Vldc@tW1aQ`@QeaaD4p~g06|^lAuVmHbGiJeqNS+9xR&V$ zon=LKUpV&O%x9;kU_jWAXcolWE(lx+dRgS+k}wf-uCajQ41T_&?0^_=>opMmy@s@C zPH4Q{)XddXLHT4rn1x42NC+Gqaln?$$~R)nkB+kPr#YIM=SJJmh<=H2EhD;hj6*Fw zk|c->;QtVe@h4d_%p@P<@+I=8=&q$H3=Fjg*=?+`WN8~-QqjohOJhSBuONC zRqtUIkMg+cJjek31VK0-Yf@S zLID{8jsw>7gDiRFt>;%64)^*<*A9B{V$9pYedX+HsTO#AEzp#N4-*%tS9ix!-$`;56+ro=y)yvvg~or3 zB*#OM6SiuM32;xzLaIn5Z2Tw#-0JvU##PDqO}64MRj~^!<_QdaJdU?;g3VOe?f^kB zufZ$ZSaz(!V4P-CF^U7aZve$YbLs74fNk39ta!r|G&lH|>_R_5n=3s2w~@;{5vV}` zGA9=NcPDL@Q`%EZ6Z{9{9i1)iqDBg5+kK-UPq+3#*SQavr=qP6BXyP^0V`6#DN5mi z3h*|A^^mEfpkmxlphIQ>Q&z5$A24`Kn&iEwRV;hISMhe$#lQl{L?eKUmWDq({Zj`fe_QmXUPap@Cv)C*^DRn0iKcE>n-1nF5Gmf4>o^stD3Ma_nw?vr>xU)QRc zA7J}JcWC?o%o6SSfSeVK{|F9dco+!|WCxUWEWflpAk8=pplX2QoaP|L24nOegmNX+ z!0&=}i{6|LZNcJ5=*8pY&<=f>Q^@dwdLQ!w4%4c3n8H%^4pYqPcV9SmhF-5F_BzL2 z1M8AVJ38GAdJSBK(`zpW^}0y%UTzaLB6pco4r6E!4xJ;0)?kUC$EnPI!KM6gNC9$}qmAVOtQ_+p8KR!V3&ptMTOWhcpO(2!Z zq%g}irOS4hRIr_e+RmEbfhC>#>)By}rhJZy7qzR0(UfuEd=QDyS>U2(+u@RS#!JfE z(}ku43V`E;*dahvDSZ*B_Y)J`KnI*}wMqyl0{Ot50kGfsG|3?L7&gP_iG)wA{7=MP zTR5LM?7gnwZd-u(oaRz_WzRrOBjU*_nYQVro})>xo+VZZX|F$zX8q29arnDGV1sxb z&6v)&;{Dop46HH3~HP*e{~GX7aaTl;|Zwc^#rHaTSJ{AXH*YUK?r-`Fg@hHJ-`{Z6R! z$*BN3^+$w9Cb|>A101N@fd!t`tT5HnKO!ya@Os>#vC41i@&Ck5cbH|8akq1_yoMk8 zl;HJ^WSX-EPj^7qW3zBA7`3UCuSr$XyQV9TYz)0&V2TqWMDaaez+CndotE) zWdP5X_hd|sAf>*oNbYH__YKmgzdOmaM4TvSHBjs$<&m0`X>6sHKsnPV`wJB0`KOQm zssON*0*&SpoD|gr0gGjN8Kn8hDCq;GR4*~G(L@AgdM*Oe^K38h`(LE9uonht8Q_Wl zKz$xuPO{+y(ekDu9T5T^^aO)c4>M7=g9mt*j+dm=uEHMq6UUu1kc<6{>NbLIlTP`(a-nepE$L{GRqu^ccEVv zinzv+MHUL3GrgiD5T~G1fUM;8E&ZY*ZF}8R3JKZ;_&6 z!S6XuE`(#CXktMCWc@G!Qgj8O8YBjTgSODPA>`jn>J|dTisS%;n_&{zNMq73CPj=( zOd(@mcm(P3)wOmUvMI@*(YG*5+Iz^pVa`2(SqwmRw}zf5bwM~sNf)P6;I^UIh!6d6 z6MuRW1VIdz%LxA_9men7!=DbWksmb2!EMB*vQY`a1nF158Z;s`9_5bLNH#KFRN2Uh zVM^tlS0L2=6J-IgQ~;P4W?j#6MIg~C0-g2vLSXytMczB!#c(tjCq<9jPZ`>?Rosb6 zJ0aIA;2nB}ZdwlFKV9FkyodGfZZFD}s@EnQqqpz3kReg-@07|JeIW z?_R@gRD$$2udJJ6zhIct%(#EU;cy zTBSNXy9I8~oKP;{%&2|r(Nx)<7_;PHp3E4ZuRyK^yiv6ZlHlkFEh8qybR^_4BZXrc z`B6U>Uaoi!dWUHOPL9YAZwRQmg%gNhXMMqNtAkY;I@&!)8bwGL9lUat0%V@pvT$ec znVV3Gicb<{=xvF0(1@W_^^z@S(Is20YH^)PC&Lgkux=Jj&^@yYKxG>C8t1{nlhpzv z*$qwT1j$vAiN0xeCWmyiiQM1J|t9Eeijvc zvC@NCCJ|yh;1+SbU1w6v00xcqh7J@)JIhNUtB2$ipYkD#-9qW?Hf2zfqL8>D91WGi zdBUKq%7?y;3JmVTA85~LfYppl6m&h>uje#@4pa%Q4_l9you}ZB0UR*CR+$Qo_{jKc zYzOuLT)lrdU_NBKrPdWtIT!BXdF6;LsmmMiWd+$;|B*SNh$;Z>_9DGFyF*ZmfWRG; z+G#gqAuQ;$W?;cTt%t z;`L-O#~4KhzyCVN^M;Q05axUHxsUyp5d|%Sd2%94ugtr<382Avbfdw4(5v}!k zaxjgCe2x^h<8&F03iO+Zs^rHEamMAk3pl=hzy5j#LFPbt3@8+B^+ee9UL~_?E_E*T zw3dr}urX7X(1V{rFD{iuKh`oSi(ws~d6>w)wD^4X^}YSUav5eh8o2IWrVuS*np9OZ z)E2qtWynCNsTHkjFXH=Obh<_IJxEKYY*iaqZO6Q!C-(KH>&dsOCrGAcx zjzd$vofMZEg>;DCUG?F^1EXR1sC6(9rIPr;-({%FzRXFDT`TlU2C>T6a;fuY>(?iZ z9#^r!Zx~<4NG)rImE8p-*>>pgM(;nZQ+DNWcUyJuvsP=1uv=Vp;x|qQ`e6|A@W^+% z?TPqSz_=z$|LCwB?D24SGg|Ils2A9MU2=*y`)!liOpGn@!JY_j1~W+#JX}Q~?ihQ*pD8 zxH;IJuPWI3)loZai=^?W?MfYDt>6`)o%D-_GmovEcK74`51y)CllJAm-=u{JgR{oQ z#cj1t@<;%hm=owtLSZdN*jF@rKO9za>R5!O=#76aFlf*49R1wqGlaRdQz`usHzqsM zo$k!b#J8eM_3)t6YGn@adxXL?Y>Yxus}}JpPN*-8i~cdl1ily7#{=ddr0H5)tq9gJ zMe$iKHx7UspCeZhF1ECxc7M@ zbx0CpxGq=5k`i2MttjAw?B`nSHdh27Jmh2IO4QBcV*F`l=qf5el9YBG?|9{w(o69z z3XRq!1#y!*z41_y5o@d{Vog=0nvy3%$uKq(W9}GTCI^$`lJO^bG-1673X)koS@CHy zQMoU>W%M*TxJ;g6woBH#M6vLM#UBu8Ou!?yVFcjD_r300t#geaKS@i6AI6xiAXcvN z$rQ^~YVHaI8;#Ne+?&Yign(#mfC9K`*u7u$Msz0{d^AGV3x|_5KNVpdX8CMhg*c+K zyie(Cy6-_9XN8hC37(P}C-Bc(h^U}CaGawnXp+F7 zVgkUKpw&-*z%OCf3tQzm0ZaK}tW95vQ-NhCKq}cni7(L_YKhi_Ev6vJS{d=nkh=fl z4BYo{k4zl&tERQ?gu~l|IiElb*fne{;D~7tAoiF-1LG5Bpe5@iwB$jJ)-dwH+2?td zS8}cyNh3ob9g;>V&dRsxISIT3Q*?LzE43)kD(foCAZvA*g?)70^qmNDFc1_$ZVe&D zc|rboYdL#5C6=18zyazS46tlmLs+m%WT?}6UD*-Jk?jjvFH63yBEMo5yE!#T7-YQ? zYgxfO%uV6uQF&JO9Q?P8_3}8^AW!{lz~bB`LVgBpyNszQ#Aby23LRjuk2(cz8U+Vm z$~324KwoLNDY$qub17!@y~}0R1_E3U^0yeNcGD1kx^}JvwevAG)~aG%S|lcX3NTH* zwfU>haGWLi`m{F=>$hNhzF{b?MExBET8Cw(BCcbwP>25e)(KK8dgw0tbXug9M!nLX z@hf7URjxnS&)E$aUB6}@%PT(dqNUv6No&*E$dNZtHF&wZZ-8bSPK)#m^->LbkaKkb z)G^&2>2h$`v+g!s6vzGT#UqVnvKkztl#n8MHp>XLPdrKq9mo_-c!5JAO;D=^EdOE! zmM*dV$&}*bs`@D9lM{6{N2n!rs-oGEp^%{3N2ISv3WYw6?M3^WTEYF4f_T7u; zNx|2}y8JB&qMJpwrgOQ7UW+J_-)bTrXEzLsn_o z;9}MNLxR-y3eAcl@H|6QmO0``E3$QHfPhxox$jI^*PI_REc}tUU7ON1K9}#9Nd4;; z3eFvsH0yLl?$Q-`jjk9XUE$CW6(1s)A;2F+HrDgMiUfHxv?EaZ7oNi4gYk$^`mbXT z_~T@B-(ISt2d|H6vhWs)8e>9lfvrK|doOZ_iuu6>usjpjPfP_#wR-NI9BA-iP7X5v zXxL`HQUsPuVeNvt6u>PEd*Nag##Pruavww7MWd5kth(RGD1+5Nm){#AWofBqE7pw) zvoe;;f=p6P zo>EZ2x_N^E0@hXzn}C9O)uim4q~$It^C7;H(u;cJCXS-MYM%8T3iNcYF^bJ~LEJjH zOehfZE%|@vRJctPtjkb_^{=1>K^21=!Dk%o%Hvrtr8dDH zgU%rD9X51y*dGtNbEBy^rBDW~)}^U?m<%;@ zJx&g`F6~E*l_S>^;n)+^+MGAEIfrpMNrq0VFzY82MPaBFBxBy;r;N|2C;caaj|UbX z;$$mH##G});*{A3eF=YMlTLQ`DY5I-{EBg#g?IP`;}g}=1%EYA!h@%J7`jhHa=P-r z?Xuz9E@3=R!Pq}-wIaKv`={}~*6N+YZOlR+|pp}R<+1b>yJ{gVL~ z;sa(esuQoku4M-oSP;fQor8Z7=nMejZxj?Dc0wK04lno#SS-I%VOxY?{6hVH1<6NO zN5i=UiT$(jlWD7^GuGcQE`*NTaRGJI3+h@gnhH82^-5XdN>NUJep*OGq>H9~yK+z1 z+8EB7$8bhtc$&CoaJ?diLG7-0F}g_BdWXyYRWeoMKZn0VXC8jkCyl}#fuSL)^kSKe<9roLI4+qp_i(k=ksAV4IJm?0 z-YsgqTiw2QrR`Y>J zYb=7t3^;YHc&5ft1<}q;!O%|D}bafd9*s2pJ+|kc|2k;k_GO7kVUGjFlOwY?0 z&rzewEhDnul~oizDyGwPhT%en4L>OpLh+so7`WncK@^fFWN^N+v(-SXco9iFA9Vy~3Ly?K>Q%e^59 z<=elPpQ?>cr}O3Kp9f9polfVoFZS;T|M={SFFX7DUw+y7Jorav|Fip@&w_t+{)z#Z z!`k^rr}GIuveW4V=I{TKe|N+cI&4_*Pf=;LjF|F(><)s(ogfInGrVT{paPDm_wY~K zdzoq=rmglo))i9t!<}GLpw&{TTS*e2|K*38B_`1M zjt@7c$6@Ej%59aA{(?JqZvFM_&g#ych5@hT#f?bMx-)o(0l6I{?&}~qa32*#4#TS@ zQpzWq-7(diXJv_~_t^Bu)|Ww+tw2oMm=8>>tZ{zEZfG|hX|>;jeDnr~>&JXl=ngPQWK_n91Qh zs{%{=l)*GR6LAn@RS7D4g5@&UAt>~n5Edz=wpzi?$+Q@r?S$BjH@rdQJtj#2k&0N* zt7Y5S31UAlG`7RM#kOHYBY%e7TG&l1(AhLtgr5HcD{x%M2cuDt)B|m}t+vP0w0eQk zY^Qo2fMLr3XB&P6J?fM9jX@k>Jh$~8I31w)2>)&$w*Hk~xI03?F!To|iPo%2#8lH) z%LDiu1&*_g+tJClwyD(q$e5Q!^*|#kn|EsKFw?6CqtQ)Wy5WqtsZ+4iYdykY`apmD zbKp+>W4wMgv}dcO#y00SgQida)t&$S^dB}!Dxy!Y3P76~gzY_P7`yO}DXt^B@E|K8#Eg@FnvKQqdN>tM zc8I^?WFgK262m*1B1KVD3DI@vF2rN>&N{v7hxCa6*y#jJK%fBrg6ozJgq1#X^f}RP zw^Mmq;#=ER&lI&8;$T=$gFCd|n_4&_)0^8h+-fzqfgk_&BNoK!;b(hm2RSmlsLNq>j_XBHQ9&Fl$#moo1%UEh4G z=FRzBoTkHTyG8fz-VH`#CIx8hN5S2_=Cu_S)6tVDhgo|vby>_UO7cU$H>3qgfK_;j z8BYY$y1WA+nF#F z`vm!#_qep+JiK9T!uq2H0{?^KwPEqXG&MHeYN-nV`pFDGe3s23hhKw#8+vZ1q;b_^ zf7Vv48g{+?Fr)j)KELy!xvn3aXyBU;C%`Ar`9$scH)P~oJupY!nBgP6F-(RQykd*qH{`=3%|6pa} z#0LqirnQ3y-bLU8=^hxgu(4Pl-VJa))7vImBFll3hDeOBWpR;#`)yR5{LTaAPp@mN zapBm2Yq;U}V^G^<&9OMK9V+ObVd#ffOWQ=Ft>i!UFp#CIF!PSecyp$F9k2@zK zC>`T`(*>=O!A^ev*V)f?`M-Ai|8xhz2loHxUwySE|Nrdszxlua9{InX{s>Zd05A)B zlYm;TBD-mC0owR(&OU+6YAe944j9V;Y=x!dh6f)80E*A&qyeI7i#6@W6BXa|#JGh>d zJFb+;lu#_Z&OvqwJ0u>Vc)<@G)ydWF&N?AN*`jhzVA!BxsflK472OL|-X^jJ-EXkM zTwKyGUE5lJOb#v3F}v;CG#^cc+(>}we4D7SdOhnDnfrbAL{;|2raWul@wV1$Hz|WA z;>m-gR>~lmUV1ozriS3~cza=f=R*|wW%ELKa+Q@;iOZq3cwOw8wV89!ez53`ZIFsPHPOm>pgm&EG4V<< zm%0x&bC3j%1B^}A;t^6+9bFw=WU`v4)5oS&R^yp39=Mg(*n>@VDEAlIE~s%QP9a(Z zPFk>H&XrFLdRk>euSMJUSNZfha=PP`-P;RjjDr($6Aki0o~Kh1LOR0| z9rMOgWLOv+hi(B(T!jrqYJAe2I}P14^|(xuRpT|!rG~`trqgKyP2#~_4ii+ z3lAcD6l)H+;qa<$R_EODPst5u)2-&LVa)Xo+X|XTA2Tqk$SEI-F^pk;>fHP!ux+9X zP^w)L1p&dhFq5)fcC&mhK%}f9jkAWM9a_Z#S$^tF{hFGnv42)93(cR^+QJKUQ|+r)z1t7X*7dp`+U=scl{5In6A%=a=rJedtO6P<7PM&+R`5B>mRFZnpJdbdU9fy**r}d#D>Qnt{-J(w2bNvNy1e-~s&1UZo z)d7#w&*w8IgNIAvKav2?OF~)xdTCqGFXLo)Z;9fO8Bbah=96pcQ|6zYee==WRx;Qy z-5)qOok`D&V&-VY?>O2dEa{TO>vTVY*A2iD4eD_w-|4LSKA8EN7NXKptg)Vc*W3CM zGWELW#C$sa)^vMKBWp-af{KA&yUzd}qqS6a=c{2*oBfoal16xS{*@R%Hl#% z53o)?SVhyZ(Yxs+Yd(S*?JLzXm@=rJk#~SO1KF1I{EiN*$Pg+^l2 zS*VO77v=S_an~Ts1a*(>u8~YNmy_u^ag=p(?!zBw*)-LDitYJRZm{D)k z)~#NwDn+JpWj677@PCTtCc)#_kU|24hHw4Uef;!Je9ZE-YJ74UYXMB8uSnV>$GV!px8#% z3;8a%@X~X!zQ}zY#q~_Y&Pes_@BaQEh*5Rn^SWwNI78HP9yXP--g)*XK@Z{?W=_VP z_6`RzC-aLt4t>;^|Jq!+O_C{+ohdmM2vN02t0vh*!53^_EV@Hi?haMCBgI(%1U;|* z4X_IIE6s62aX_!UiXLvR=wx$6^s1Q&_bb89vS9b7 z_Yc|s3wo}<|J_>uuVOR(b^8C$?>FfG|JMKgH|hV?^dpZ$1Oee8qvTVcbYfbI;bpD~ zAZZ*d=BUZ?hAQ+goRj&<$yAgvN}71xc1hsg3_uS=#)+fAqQgU*uJqpH4ry3&_p;|Ni~Y*X;l9 z-|zfw|MTDK|FJ<6zjj>cH4`mB&=Uh2CFD1v;-DN?k8edqOeW?)j9;4rA2EkZMIlJR zn|bbOl(x?)s!JNU>^as0EJTCaVI?E_8N4SmpyvDOFr z^u|8us`H`(XOWvK-`fjbQ?w>R^%?_{B7$jA%<9=!O(tpPQ#Z^hu0VFj8_(wKcEPgS zIY*MYx!E<=y6ukdeqct-0|n6HrA>bOrlB5n6rtp!TAvk;fXSaBc8yrivmHK5!-UWHd{owC2`pXYo!y5+-Z0h%xSENAW+_E9tBp%hwpp6 zU=f^)vP@4!9PGR-RJuwEPO}Sna6ak^%4$I1lAq9+IV4RNr&pxvh$ zSsmtw`S!xCu@~$-aU^$#;ZAVReSa_5ISio~AV=)%1gm3INh-CmwPrTDX$ZM@slIK+ zgHG_bxaa@Z^WWI$eqsjjX8Z5^oiEnpKleZHeD(MF|CjQA!)Z~95`DBNghFsG5g2yF`_H_ARvvSfmK}1N#-E9a1&3kL(~dQX?5O%PUI~0-+d*-lfrw}y}aMH~%$>Q8hlnu7xe!s)PH5OO+W{riN z4>vXobY_t44C+9GwH0qBw%IP~mfrv+>Kbt=h9bLg4U15Wg(o1+CJzWF6ipP+T4T|q zH#b|!aZ+8DiQv8Jf8x-h@!$F5nNLc(mzN^tAHVe1Bj&!t|n>j)McPZ zz7lYbJtn|+;y2DoP&5iIWjX^45{QHlKs4`NUH?$w zv)UugAc8396r$ascX*dRc=)P;kKEo~Lj;6NpLWGq7psQ_b8Y3@+rP6M~1!X!G_0?Y&()mobwSCNo$}rrkAX<}7_QRti3KbuO zc-Dl2b$^Rv9JvVJgIJY{w9V7*PbA#NooFH}`P-tC!5O|e!{gLs9)q806 z=_lW06`HxhMMXQB^l6U|{JE*QnyhYU-tlmDg!4*@p|)LJ0pQCs;Liq`My-+8vN+F5 zFlj7{Dd3f_ncmdqeQn`rI&a;A^JE3y4tBHVRL!H?O<#G$c`lD9a zO7BK3Z$7xy#SpC51X~JbO3Q3F3~sID>NYOLH-EKa{L8hEb$EfBL>jGDa7%z~k4Ykg zaN?4@wrl67D&WL`S2rkIGl0kA1=j%@$KL$eS?!bA?Prp{)1Ab|Rj%8LT!zh=s*gfL zzxn%VFr81=O=mvzy34q!94=(~-#UT+59I&Z=s$t}_F??DFTYsBf7}1+Z~M=`R{jrQ z0gp(K=m_NvcCQl2pH7RDboy-M$dc-J3RLtUA3e*55^0BLBM+&906yJ%$i0F!q@gIn zO*vuj@)FI71Wd1++S*-)vU zG@KUcS**F63E=VM_on__YXt`G4l>OI zR*OTQ<)(Vzf?M1#K=6YT?qAXXaWJ$L`Qev>y8 zR;~{EncP*&>HKsJXDg~@iWJ%}+4eL9%6+%N5P@~8|(!~n7kJp9W5~{{5n!umPZVW(0{?v(caNAIE1&5 zb?;~o`fzx3c(e@KM@M_hqva74S{@!99YLR$M@L6{{s)?Ga{nFe1MVRo-Q~Z+bQ%#8t!5L5t&VPSuoD~^Z#~@s;b_uraEX-0_1;rSO>TLqk zQOWG|6kPg2d96cNfcB9HvLsX%uptzzieNq)fubP8$ROb6fw;mb3^rMzTiQXxpBWup zgc~m>08oN3C2!J8Gf)3@F&DnGlbx&eV5SX~>PV)lc8F%GoA_i?N@hR*{yhF7(h1km zLM-`l9+2H0QXRAns)@MXK^uvAUba;~O0t_!f_PBMf{~yxvcO7EyO3U_8PfV{BH^fl zQfe_cn~viG$nda~!KF9}(%H{NNEL|8PsO75d^ zYt=mePnQ3>o9k1u0Uwb6+}~fL|L=eG#ozS*Un~E)NvdNq;R&|d4(a4VCMxCUkc%34UB}9s73OA+E=&ix}tIJ$lsy-Ga${7j@c*WdcR)~4LFzz)b z>{IZ6*?ZUSwvA*_^t*lq%#-Vz!$qEc$`{~61LQ^nPy3D`fG$9d zMo?`wx+BaIR(d>jacrS(qWVmy3@$FNFDOi)>qk|qE>aJg&U}WU%@5Fz^f0=7c&D;f zgNJvY#O>;G=Xav*^97s5A)Ul({1HZCq!o`$U`?EN2~wX#+TZg={WTy~%~0hk!6LC5 zf5eFy#hwO_^pqQP$S;+1V1CAO(g0~2o)$loVz!=%EcTf_RLUWfiy$;CVSlIE_zq%E4S4-3_9?i`HMsgoq(VCoHZuK-L!B;Yn)7u7eX1SHgCp*vBFz1=|L@63Hn6Tpc23*JJ zK(jb?*#aT6EYnc7rUq6qf5?K0nA%f~Y`)#Dbqh1JtOs^qGR!+&!7jF5`|I`lf|Z-k zkoy-F%g^_|0uvRFBb=vByrQ(d!Vokqc>3(Sj)5C7(+-z0>0G9ak_ZQm*VFKN)SPF^ zDRPc3^d3&q^RN4Ahj)lE;a)IJ64vtoUbPGP|bB_i4I~rJz z0QwDLxcSJbaJ1A;4U$O0F^oZ?9za-=b;B`G2kK^)I#mfAZAV z|J(cK+ke~t|7+zx_#k;QhVbcmIeQ~xyg}S25{;Aa!ar!k^tv`A-S5FJTjjC!t;HOwn~ zp-IaK?sWL-FiAnWv#6_aA?A(Vp$*X{%nKZ=cB?4-sU!(_#b_EtXZ0;g zgesJlskvT}Sc>}gR1^X%u@8$PPK!c)HgSLOvC7N%>*VWx zN8BohDh?#uM^w7*7Io+My%=EIjJwFb_T3|S(T38xJRDcc))KomF%L(D@`I{sRhy6G z1w;lgP`q?Uwr`umP&Ki`^#$ue@9)zRZG_6WAC8e_jmb7hWvsLy>HV#$%uwA~6^*~6!nSYlFJ-4+c&s=l4WHmIFN6vlyryrWL) z^rnzcyGlV=UQ$hLAC>U}tA<@7@-ud9{dsF^v*`ylHei|0iT8AD2!jHsI5D*SM zejL3-J6b>xrBPc8p)^0zh&q{G=X20xK=}l!fNQU-a%-~Vz*hI`ZsyJP25Y7<6%UJ1b(b2tcD!@Y8e1o_m@ej& zb2ti73|LIWwVZ|Lqdm)XA1E7Q)jwoF4J>FYK-+OjeYB}>9zWI!XT*W0giGmw0K$dz zx|>zYaUpg!Hqs0P4H+AuB#CP)p*3?_LwGV`tp#z`emG z%LWzH2CynS{HDqMFT#t5?ZF5Cw?zy`kKJXnRp(RM`~{?%b(RS;mD z8+_iOpCuC>c(oIdzPX`eCHaVN|NqBFlG^{XylEfLt87__!Q^A}FVp|}_Q{iP{^-B| z{OOy2+kgK{{<}`==;DHWS{bjK^u51BxtW;4)?~8n?Mi_e%_q=$vRrruHKP1-pl#Z8 z6~s(d2Hk?5OuJl#D#)v}kw^^WLV*oTE^o=Cn$N59KTkn?F&QTiUsHQt!tL$iq89&H z=CzRfu-r}NRR$VY@slK)jL*H6#glO#U!>z0AYm7yeX|)xci=H{AU~-_{8uII@2nJc za<2tvz9~GLBp?yg=L+aF#{h_NYjc$N($1|1QkRpl*(fekS`>74Ce|qcEPb@}NJ5q1K@MK5U^j+nam_?igR&Yp=Z!BY{Di0c9%;7pF!HLKr@FeQjt*=W908y+ zR!#kRgZ1dA1-z~E(I3FEh~%RF$yZ+?B2c0s!p(J;^%FtsE|UJY>S~TEV|GV9h%=h@ z?n;QMf1p|nP77cIuI`6N^2$u3wqm{AR>!Arpr_|mmPC2;w#vktDicXWqey{-={LwA zsw{dS6@w1+%8vPp0Ze_68-piBHk{x!)JV37X%jWdUcl{jCeZy^+U8?8X>Xw_0oF;a zl+b`qXj(?cr*8<+G%uvJA4tbg)FZ7h$>1i9j?_9sP*TB9qZjE|6uX2&)d-kWaNa1+ z2VxNQE-pm#22ZRW)&TWmEsbdV2{ZzK5~7~8!|5r>l&-rcPd&NNdN|Yk=7KQD^gfK^ z1G(Jc@4yg)-R`b5UA@-$#HhW&r4|k4&;su@udmAHT~lh0mpXcV zczS9V!=ymDZ+W@Xv%kGQw##8spxl4(a_`=symRWqq(Hf+f8qtd|LOJXi$H0U9zgMp z)z%;X_VWGV+405k&&O}i95i-b0M(zI<{!RzeSGo#`@8H2ru(`Pbe1Cjub=uqG5&WZ*LFZ zygIr#eSP9~ypwzIu85^TqppIu$G&mYGEliXJBKzaVgUTvhemOt+!!0%igq$%EUm~Z>$w|^8T-f z?_WAy(bLI2w^cv9K6!EY`t;(jKfXFUzIaXD_$=zZqHv72iBUX%o4!r*=6j6NnB)z7 zVr0Vaiz;pD=c}?!GBK$#@zeWP=9gAcNkpkK^)Hk826SlOF6U!WCoj`hB&ai!(6N!c zs49?0B;*h~c`s(ipBKrSbdj8jHi77f$*Zz0k{9{xRjE!uXky-wF?DhK*Q>l0a3-MU zmU}#@6E4&xeS@wBGo9#JwC5a<%6a!um5J}`6y6`IWj%oh4p5HTgHsE*7pR(YmoddHJ@YF9(#XpwdhZxFXd8H&2UnEhUea2HN&pt>0 zceE#!i4`7I-B!h(m}2PAdkGpD(G#1dn%<~J>(6bdt>fS(ypJSJ=lMu!Ge3%Ax54N6 z2redpWL4fImWgyxFc1%A_jWykrX97GG4tjQbcpik|Mfj(SNY;qu`fK&>9$^`hRO z77LNKF)XOTJpCMh6YWOQkvu^4B@IdRq~BkCi6Sc8oPQjxF8f^+yp}rUk%CnN7=SOu z&1)JEUCJAykKv*FXbs(dpi5yY=Ds+WYF?zcz-Ye!tbK?8yJ~j1h|Q3pXQk*edK9rSTclYZQvR+}iXer|5Mzm8VDNH5p^z{9ytOqV|mRDX{UjbB(gp>*e;yeY4k~Go1 zrP3nJo1d`J=)m4?J%!%EdiHm?MJ1K!J1Ub9RTXW4;!DC5Y5gUXZ$eO2dPeJaT$npa zkDf&jRJHCZ0_3*dO0r#Wd zg8^i3TGcGIT^#>onO_g19{k>g33%->z#yUesm$BAAXUmIr|oSar0lzEv0SEQKF6l- z<}L7i@Qe1yaaCuc-p%LfOzc+6wh>Mm8Z+5RH+j}xCFAPz?z9k}^^e_IT`d>*S=AW? zG>|Eis#wldh4j%RSX33epO9+pvg*gKC^PfX zEYgyF!P!QiL;?S83d(O&RwZEZX;rqnHv%FgC9u`+PKtECpr2QiTrJWG+(L-Ns$N{B zWs|h&c(=)a5y?%SRX0uY%PxB0Nq(uSc{0t5Vpo-$7FCt$FYq$PKI-a|*v(Rqs=+;j zB~;TXjGRoF!zdT|Jl81>4jjQtYv27#S>0^fyDw>`C-#I-$fpuW(No-rlC;QYB^@$J zTC{9ao}`PsMMXgRl)*Ma8#cUe&ro468P6tFQPpx5CgbH4;+SN~Bwe)2S|pRJw1Bf3 ze<4X~Yo5T>4;Nnow>SI;4AB!pIDrEJ{(|4nSBqb& zQoz*{UQZIBs{SNyuksQmV~yuF{v$gcekx%24I$a8%st-WQ;MIjM4Ew%N;!MwA$XO~ zu9B--O!1#0EoX_@kdk~-l}Vl@N?mgz5_qYf)iA+`bpTESw$J2~xJ6<<AoWiW z9sILQXCjeqcM`bd(%}n}W}cS$R5WcePd~%|lKY?MrTPV7h2Yy9yL1X;r?R%9~ixeCyl*s~WTomak;c@L2>n zgc=q%`~&l;Ny}_peI~ZSFb~8WB+XR{sjw2@pC$ouO+p11q9||%Crv$p|Fa7JVAnPE zgqAw&NKGpiNdref(k$omw7!MChBg;D#BJUdBEhE>@QaqTo2eyur*R?L+d@p(Sl_W(4tnGPST zNzWU3p9C!Z=paJ*52Z;6%ot)d7RwLKG^pKc2{wo7hv&weby^@oRxKLki^70m$~M=7 zmCKXW@LulSuN>DD0bb5B#a=m#53I$P0?!X2dDZ>+;KBLd z=L>bwe;z#rQ9xXa_Pm`h&Yz5A^hkWTnJ>#o1Qh}-BHU?fx3=C65s6<_Y|UiP(1 zn5)~xl_=q14W7+yftOyaa>Fs|1I9N@uSCIX51nIl@Wio2k)t6EefQ`YxTdk7R>OyP zSj%eva*KO-c<407Bj7#UeN)E5@p{U<@Likmuik9Kdns$qc|N+wE(!EM z;(Hzmk@nYabK7lcYzixd7^lA#c|^C2rxK&ODV7}n;@F|&#HkHRX9oK1=fz#Xs;@wW*8{!lv)2aeCg_ySF>S=Pdmt1|5 zTz#8d{V}=vQ*uQ-OPO9L5PtzU!Ysc|vMkApBx{ptJ_D8+ImLViobw`|AP0R_C;1$> zuw$Fa8#&wd`B2Cg|mRAWd zDs2reiLUcZREe06MV63eQG)T?68Ug3Nz3cBNl5!FDJ%IS6Gf7jO`_U9sS5a?I*WMx z7RRE+v1zlUy-HfRXS6MJVG1{v2D(oa8Sq}ncIV|{*(#n^%-{++7ce~4q9xQ7RaY%r zfLzqo49ApMzy$6T*vd?_X%2!ZG=fR+o}rmJF<&6pHZH2kr++Lfkj_ovu_kF@Hn3Nt z)1QCP6Ro+PC3NQ(>hGJ1u4eRgrZjDmNnXPQn-uv1ymQgTXm?Rpps)iM7Lb@V6S$~M z(mG2-QRIt8BvbLCfHqIX5tMk7wsrm)zvSx}RUvAX*1(*B-{mz#bJPW1=F{o25wGC_ z0(?27G{~HTrZ7C0VG3R}5D32OYH@m%W*|6#=RCOMU{u0G`S*qV`-A-ZKADOi(q+@+ zY5Ag90#UC(mXS=w8wl&F{(|xk{DzBrRaCRvWGYVRZao$6s=RC!2_W=b7HQ4sK3!Bg z`wV(-D$bVmcv-+ZK|)VeFn2^gE9D#EEV%-W3;Gvb@}P(XMGE*&TK@pI5xAF1(?f`O zlFUVOh5yWSu4t~%5FUSlSOhi|T%L18tp1@JP*vQ{D*B6c0h+YXb%7N?g4c|y$TPYu zqs#N=CWktj+xfUEa1P@?ICbPr16TL5`~(68xM(ETxwv5y_2-jUFZE2t6@a_p7_Z}U zb<6!ot}(quTC1J*gqv|~J)OdadzQ}DNHO%5Y{@@PfBs>8F}1#|Qa@LDw!WBdEh{IQ zNxBeH3f%kptM8tVTxOX-er)GOOl+XbB{wLk>Gh&Vl26=s6q>*z$}?TnKztSxKxIi> zA0SwOQ*7GW;) zUu=ow&)rD=uW$am9g;si=|)m)*_uD?b>ocxoCWN2QG@3@%8??ggOj?JZ3lJC`$g-P=f~!qCFI+|t)FwsQ0)xMx zmH+qMgXdqqd$7CvWq0@am)+e5`1Md-uLE1b5#flNdSd!Zt=JkO2IILI+jS*T&we7* zn2V}v@NiCRfftA-PLpWtycnrVFLcG&QwC6;6u^?m+7h}oSx^SBpUN8h^rzAaNUiTX#B?F)pG1LkGBFEkm73+#21EX)`0R)oLb5z z;{e3i+I$&~FJBmZ=;_UXe)SbceGsK!6ahYMKyw{O$b1j&VNqS=5KmV}L;X0E{m&r1 zbNX#Zx4aN`5-#)aPdT29HsFIWWO(}X?TAj}^e^JJIg;u}QmgorfcEx1ofIlD?kX|% zOJKVUJg!syc|US}aZnl(4x~S(4T*SC_x0Eo=IG|=0^>~uur(4KTSdu%(}N>r*#cs-!lYc(o5DDNTb4@ zT%oP#mUK&`fUSVi?sn=ShuO+pq}KxpHxs6SKn`>f2U^+)sSxQ1&yqTc_FzF8!IUC} zDHuvnAmRdzXR*nA(fMF7fD~nh+aGB^s4^*u z&Q;}mA@|x6?EI4aPe;$5UrMi}d;6hDBH^uRl7O2VGgpG5xKq(?*GvHHY9@-Rpp_dc z;{yq+#r1*yAaC9kXRcfk(dzxTk-M-1%k$;mt8#mH^s+Y_C~b`8j>O3BAn<@w*!-CwxIPTri9P)<2Q z23XR1G20!%fvHjP8dQ8sT0yFaBRua_C$HoO8XpmD_jrPQQ(Drd?9*YQ~?m z4Nmrc8i4mIE`sP;WCaGWx*auo^q$#PaI~{!Ku-+kG92TZbTP7|45#?+vTgBG(b~md zk}0k(4|_Co5&se`a}D_S>SW1YjBo45mW z7l||t%24_QNL}xPfO94UZwvdfyKD*D}}LL{vI=v3fPkqT#U^`P3)T9 zj`Z7~*r%#B2Pr_P`?*({b71;(wwjJ%_3+y`! zK_Q#qx|@;_D=6;Z@d7Ty#C*}-O6eHTf?@iaN;zH3)WHK5PX!bso3>^zyRswzfZe+R0Y{i6A~XkpH!mmYT)=Z5os_%rhZ{?MKxM1C?GA`N za*cG-$U1ZlNd#sfWSBmQ4x-7Rt$tc8lm%ooWO5MJ^ZFf{X#DUZK0n<3uaQNkhS@|Y zr;H&^qTcSqr@g*ylW7gV(u>=s29R|gw(*m`(?ThmlJ4iNT?aiQsztr2g6JT+tjdRX zhAPZmT|y)mt~Pj;Hb)eWRc~iWR2A9JdShcDQDibIPMBwW`t{|D$$!Rx`JrB3SD#2u z@IA7a@f3WQr}fO5BB2NGY1wJJGcCdPCI&2>Y)mGmI$DsrVL1Et8Ye&GU4XAQe0w$a z;Ku*X8rMA6EULuH-~Zc+%+Nwdr+ga6*bU11&0_WS+4?qp8<%h)ghP55m0mR$6#Wt$ zHk!nlXn)Yj4kN9Rvy!-v@b71eH(+UaifB{5UUa}ShpGU?=L>*hvIe@z%dENyfXR%Z zU43WO=uW}zdTE(H;5QBZI|M>^7X8}>!K=pG6E_iWC-BG4@JzMs1RHqWRjo76Mq%yM z^wR~f_AvEf1JPQ_#MDkUGH(55^`jP|`#@3l@aclPCi(S;HJdRfjD-br!6JIyz`vRnk3%U3@=0P6S? zugmRAg7xiz>bsc!?SeQ=yPeZ68#;Neg589-KlD&fJVyAw3 z0P1?Ie!2igCqVzUL5zggdesU5Ng=WVlb<%Ayc^*YI{vb=ZPh(bm#+gD=booTZ3BCk zXFI_Lc9nk>H^P#f0K1QfPWHcYL-i#9p@-AoM84V=P3f zjPl?VkyuIcVs`K@AJlhb#Pu}S^+hefu;w~%Z^@sL`3^ZsGPzqjOWIh$zs6}28V#$l zwYw|k*GRM|c@y-NREnGEuOj^fVj3=>o=(N2J#Ev;C+1`Ujatb`Hs+huS7*oQ66Y_+ ze~X?)w86$bvRV{e^8M!U9ROW~3jmGyfN2+Grm|Eq3Rs-0%Cv+l>a-kOC@8#=8WjNN zxsRIoO*}1=h+a3)9R|6+(yYE~HDl_xucIhd8Pw@b*s7K)`xZEC6!W|nydOCYwxX9z z92Ds0S>1LyQ*m=P3 z?Effg>s#;56r>SnplJ!RU&j*yuNK`XgpfwoN{6c$pHgdE348k#42cVmE?%=e_)?o+ zv|UsO$w5@-9BrXoWIP>h&=gSFY7}u6E%WTrBR2<4V&Cfk)DQHs-VLZ{yw^!0-lQ(RdDq!ty)@8n9o1 zT=1tuF)adfa<#)jb?8x}nCk8e%~iE5GMrCVH{+QIsqXp4Y~Gd)TRxgjQ%Z)XGBs#I z?ONH!<(@yz8vPBGbe?g}f^r5HbRe;`sgJKoyAA>d$#0cs(Ua(aSdk%S1S?0p+RrB1 zui8PO$?A>O^5u)O%5`?*W!BMp+O}x1&`YzdH}nH{ZSKGhLf943_w|JV78PdLq&x8jFb6KFo4kp! zS)!{o!fWcZ%}r86-d{7*yDUO_#5nyQ+{N2;0(f%rdtf^<@6-IX#H@lFcfBmlHvDbp z4*ackLgI0>=^`z`I*w8azxfn}V{XH-st-XcRUe3MDjkj+w&`%=o5_^`;n#-7Yp~FT z!QJZua+A*cpou0|FH2948{T|~e*q>QwDtq%v7B|<4b#O1DyUstBo`Og7x3;fO)f6- z=I5p0+8N_zUbL@Dl8H6s(Gt(~?%*3S0aMUlt@}lZq*1S2uIiN_OY|*;fRO7xlB4_} zzydY41&?rk|A3D~0UPcLP^^dAB8{=H3jP?xCyOUay01vAq$c zb&ZYCl)LsP2=?q*wEGlzCiUgKNx0S=7D`+|)b73`(en0^kT)NP?b`Qz$DH@zHU$H@ zs21?2xyq-|54~lnf0CB=KVx_u!Nz_^PxFyS#DjFPC~h$Ub+_!kUn92*by1*OlrEFg zsIa(-uWZ;wX&cy6(w0PR5Ro_bN?gf;p?#z8dU(sL#YE)S_NimB{%Y%{OHD5*^nJjn z@{Xf0HUti1yBD%Ly}@gVY6s?6y>y_v=fK@b;l?`D8#8@{fU)TsSbg>R4L8^l%Imhb zqx@G|-zChq+ENJK*Ql><;V>68($tBAo4yJG6d?J%m=^NTT~n6Lo9@=p(&7liGtX|H zb70d>aNsZrDDc6YVSqJ>J6mTj%g%na%i3~1xsBWAfGf8NFmdin0WwIir}slg6v+*L zD#8C9$mH#D2lK+Ndstw5vXC91Um#L&9j+r#S0IfWd{X@tjM(Wn!m}mx4JPbw=QYmr z9lefuJMCiLPS>zbrylR+?7$LW>&>?DHe0EA9NKe%y)df9a#cIt%hcW0pIpl6nLNk1 zHHUp;F))tm^2XaUS*ySdHHy<@xCd#zyDMJnK+$J82J5t3?{X$6Z2(f~wym4au9XlS zM5L%fU*E}YR!=KKXYXWV*WdRyw`I|?{jG8LH+Qh@aa3W_p6LP6fWAYp2h?VtxwM71 zC~>lMzXKc{TgXP+)6t|OcY55dee91rhCunG&mdA^pw{q7v6T#7&?y8 z(?Iw@wwr1loL6b%9Gu&?sk$lgWN)3<2d4_9N0W4I4hKfcJmg9|E@Q6x#;Mmmg&<(` zxDZlBjt3&cD2*UtP%S?2X%DR0sb~;YGDrIs8R(E(tL>{97PV8^e zx{Sf>JE|7a4SQ6TsHoI?c*pGPR=sGN=7q>qoItQ$Q}qunbzsOfXS@|YF5ETuC;cUs z|5sD*1-7#94{W?T1ZSlDkKq!XiIvzxY(!D^?9xn*XFcXzeC|byJK@5_FzJ+6dt0ea zu+2$bhEnDnFcwiJ4LR$eQDTMLqa-f08{?_w>=;4^N(C=PAOv6+92WF9zV3T*tlEc9 zZ5APTna`WkE)G-2R+T4~o{st|%wv|lKm03;R_2^+>CKr&CWRmNY}ar~4;X>m^Fh6& z5gcEg-aQp}YF%&eBBj>Xwq<3l`;Pj~RoV>UpFPy!QR8CDI|by8%JlmIn+rqdGQ;NG zkj-n}Cr~q4RWpj+vr%p!P`B)P|FMyAY(bo|`CFh>kO9ncPRT)ZU-#9V^Yl4Z9h}F* zr*YTRb{P>fo3HZbfAhKA&D(5S)|tz4AeF33(te0?N9H+}`ug>m74LlIY=xFjx(3?$g2I@-+ zg2VX8Sm3e=cI6#6KRLLjDf>GcE`;)ecD?RC(Q3m$caq?-#qEiI-=1UJb)vcEZ|t4h z=}dI-sk+kajGcEFzYjlk?E}%ah`sj_421D21IG^SExR)$vT32c!Mlk<)4S`GBd^uv zwwo?n3kiO;fZ*2%2ezGlbQL%*+ggfn?*2W&e&YlKOYz`51D+Vg4;@kvIsc@>vfB&) z9ik3YZHs@!q_|46R}f~CHuD@ zd>KPyO@cdTmwlh<@YxQ=cx=xn{xfa_+i-yEc^>CE(L9pNz14p!*Co_I$hA&`ky&$C2(~WWFa=vBTB;KHKJQoPChXEUQX^T!Yn* z11cJP)4~!59)_W(h$(Gd<+M47&dmZne=_Rx1$+K<)E`AdYY98ppc-3u)AkJ-=$3II zLfvxiOwLiBC8rb}&`lf`6YJu&Tk$3}JSkds;r7@& z0!nt{PwtDOq*4kpQ8mg@gGe|6k}m{NkmSMEvtxx7!~XE8NlUZLe5l714nSQti7SgDf#b-`R|FjW@4_{BNgLC zB%mccvHD5WBiHNjOWiCI_cEdHJq{jog(T+RxjuGr2bP>v*jJDYg@d0 z9UHV=C-Ob1b=JhD9pQd7TgW{v@bis5pKR{LWMhXUTQ|;rx-sf5=NTOTkFYQif&;km*MuEi9+#Glr$^@M>?U)rhC8Of4Wt%Q{XBOs=Am5}5uk@+#G0Fx6 z(@z~ccv%-aW&$A~CD4<#N=?u=x`$lt5fgT3DIBes=2O0~)M8Hm^6DMai$N`FY2utW z$DnVC6Vj+GKGA268XRw>OS_8>XGltk($$(~dSCjM`;2xTAZ=H5Qd3jycJjROG+;ZA)5J2$3oEhMi_+_md(hRP)thu5zDZzD=o~T9 zQT;Xa(c$GQ>vo^f3Eyxwb=+?ytV07i*?giWfi14T{xu;)hQ;|qC})Fz5~y=gSD$Y! zH$}ERgoVAoonAU=Oij|VtlDTSqIsH`!)X|4?R1HBb-DJjAh^QJ(!Q6H9-4B+LDSOE z#y}Sae`7d0d-d}%-x#*u8KkeL&v^r<{yXS=(v!nM=<+3H!3XNH^21e{4VKhn*j^u~ zkVAv6$UWP)knMuw7-=$##6}D^LVs39Lu=w{%5*nzLk~=JMGXtn5i8f#tQyxn_mES@ zsg?qLpyeKlU-44krL*$jfegm-)mOpO_I}f{y9X>AfxBNp!`5eJ2MshW!UP1y=*t(M z?yz@4Y4)j9JY3vbAqN(|4STPh1W8gQgOtqE`qTUL#%-hASu!rOloKzE zlucQqcNjEN(d+JVau&p4oo8TtF((d~E3&tGZD>Nm_Fh0G%MjvL(&$Rai4?DjQj{AR zM!(F^4VOiOE)tk_E`cRZmpWFwUVl@meGfz{c6Sk{de@_lX(`&>*zj&tz9qYzRCc(w zp2mv$H9k+6D}_)$C>=cDao$kKcu_>w{@w=RDV%ETE$Sw}iOg!h>tN2ox>58Jw z6;~qZLx>l2+nXBcs}#?Um#tbDS(HW#2<{YBr7wv!nnJDb?n9AD>i0ZNduJaq|8nTV z&FBW^N?7a+pYke(&5-yu_$+AMwL)CxHf!ZVRdC_xZ_rezR0J&f%blL2pM=HxH^j=F=L6Tdx84bPPz`GThi={7_im7NJqx`;Y2Ul(QHR?MTd<^b(;Wi*2Dr@eKI_5NDtmID!`->sS^FPcoxoA>&v(}=H& z#DS&I&~!mHolh!w*4w+7-VoGAwYfL(O)5~SwPC8@ZFWd2_T|J|RbBQ>_nrfwSQLOz zzn%D~&R*x_T9HKn%1Q`?jQo770!Dtm^}|JKS0#+e4nQgIBj&>gn#z1H7$S2K za-$G3uca7*kbQgc2kF9~q~-A2jXJ0sq(xpHglbG3Y)Xv{soiE+eozsBeLEow?I$p! zQe~S6`QVB$C#3^?$|0w2)I^N`iQ&KkkqY&`?&Z-5TzLqI*Hw8=ueUl@O*;AHX5rb0 znfNQ1tqy+mVFTeM9O5^Xa#XgA4;YM>X!7qH4e)%!Cl7l{k`9oxCYa2+B(#ZNW$1q;r_E46C1kXHR~){ad;j?d={u`Lb1%Kt$|=>m~ozu z)Oridmz;=;EF>zBS;zc}0@HJ~X5O=iqbME$TYYRGdonNj2-%sbs6DCycej)I1!p{PY(R1Z%Xxe? zi`}}n>+E1BtVc}nrH&Y2cCA*7M7Lr+**o%OYplSK8fIS&u(HLl205|X zqT)uO!K;F&+<7BfXRJ6tMAUa6FZaa+IVzfzOMiC^yIAx z4{kkw(#fO9Ff+|LQrtC8`qgglUom1`z(#!$r>mKoShXkk) z!f2@@qADRwdp=&a;xNlZ)-b-ODvyiz7^dAzwqVVsq;i}S!IU8OCCm@8>S9s2Ge`w= ztEM`G>?BlqZ79L^6ggd+zO|%ODhmT@XLwXP184|{gk;fqUxkX>4b^?#guBZbll8r( zfqCbZ>0E@mP-?Q5adwONjn=|$b-X4{sUtybzE zOAErdtCTq`&Z%#4;f@?W0BPVcQ@?y6x(q`PpMA%L+`5y9HvaXU#h8a+PaV^dug`f{ z#usT4U5ue*e39Zzwoc(^bO8m05^BjXx=1yh=6->6-dyE{h~oHStP$y@7sNDHFI2(R z2O(+cLWBDaX;RfvY4DBQ!A@jyT$PvvJ0EI4Eo-oyC~gVBVxL(+^t5g@fI_J*3aZ9| z{1Io>1TAHd6I_nlS_t{15KXS~B72=TtxC{C)OaZ;a@(g2xv2-~^;zMzT6EHj_XOP^ zh*dWs%bQ76mRiWZu6tJI`kU!G+)du8Ods#Hm{c{M3A*k!{UqK~a%}Fa1~sj5b(|rb zx*9!?p1{>mjcqKmX<0yZi0U;0|Mg0!cz7sdb!(AVww<*HJ*?hvL0ryV)tQAX*FXo3 z=bX=TVK535P5F*>jK{WkSm~%rlQRY+x6~);TOO z%ymgULcCTrVL~Xqw85k*Cuz%Y?mMT~N)B5JjKbS06Namy(L|O&WvsnIX*A|sT^9k{ zsbc#=A=%2@U94Qb0$r#9AYQ!m0(bHGBA=C2E!-oaZ{Doiyj1M9kH*9YR+~7pwzWWc zKbpfD=EcvaR}vH5ZSVU>B`6%?fjp_w09D0qPNf8OdLAfiH8R zG(w2r$${B@&?>;M#tJsgvVdR%Cy}#yaVSL5@7uAREfCPP0_ORA)OWg%0nHGoPknh8 zG5&W5yxUB)8kpEy-)AD@ClGTZdD7pQ%jjU@_KVo&mcU_ zXSg#@imDM!`!a3Q(-v4p)gZA141NGmbU30Dt?pj>aDIu?e&?$32co7 z9}EUwjTQ>LbpxuACpDvS3t{)tRQzN$On4R|34JLtwJzGBF3=pHhuq@Oz1)3U;B|VH zX4TF6s%jmP7wk(oJHb^)T25?WHA~e!AVlKes%lz2^~`);q;RD$<6)%3qkX;EtU=4l z+ZP!=Z%7}pu_l&L)4r4Xj75^e5OAUS%v5FyZ8iBg9gK1sOl}{_h z3Cj|LX_2BiMZ8)baa_Q8tFJG*fXC}vK5FD0XgUGnXGBo9F>$Mfg z-g{1qz=ktjG-}YTws8vzB^p-)keAHim~QYI>Z1tTcc*>1AQij>MPK znOszXgY9Pf5@-}|&jlNwamt5%tfOl{_!j-#zsAgj4V4nIGa-UA@0g?tRxu0yrvuz6 zT~-r#2K2kKB?J{K#o^f6?S|wp-C6rtv0;T-Lko)Az-%i)(3pR83C8HXD zR5O)8VJ79|%~|qPK=6j>8zjlHgUVUtRO652L_ul>8=geIKX@VmoK@;s6-el|66?Fq z)T*@N$X0xz=x$(293(?M8GmH=&y`s{6=uj|jrvCl>t$34%&|L}+ZC+qQ_o6S$yQOf zoY$tF+hHeRXuz(EMfkHb)gI`N;!5?IZaXeH&K2{@9dWpvg&e}0j8-O}lRTqyYLZHt z>Nh%2+2kCI;apV|%b7f|3IzJv5-d;+rKpvB#iDDQbzoI$uqc$Cp}|&bE36d|s>ghC zsg>ak{j11~*jPDnIByLJsI1qb!aUe(o2B?n62IjP13@7G|K+Pmv?I?`D_X|&=le#vT?$B9`! zi9=ce1h1dwRi?9{!h(N;kTmLCV{9(wRsM^}erod~Z*vqi%tWg{LF^WIX{U<)hjm54 z$Y(4vG0jU86&ESFeE9GV3a&1#sH`EoKE3UN2+E_n?*-vCEXI)d3w{*wT@=@$y#%e+ z>#jOqs^mmT-$vA1$4o2F>p3V2C2?5!@rJ?>jg$5jPmhM`;q)S|^-L&&JjP{XF`;dJ zdxVN!fLeTB)S>}@f@au|Bom&Zt#2JMgc<}MZDkcrQgpGWZ_QU|hkXq7$G{6(wfQR{O8~57dV{y@g-LHdIyQjV3~+`;^$uhOtDT*PcMRV_^tCB8jCx;3k9c*DqOW@& z$`9q&(J<=uV0p;d1Qsg!TvSV2rH*Zsd-AICWW!BGz($qIdy3H@K3uldF=Yv5Xw?MN ziL0^=g2uk`;3S<~k;zPF1xBa1t9~t9S@+Pr76XzIeDXHxwX&GEK*Z;^PHAK2S`#V| znJV`>1)-xe-($x8X~NYt3Gw!~ql&OwD^wdji(@sM-@B1!l)NnjgtF6mo)$W1-(nMV zG8j+l54k&Z;A4PRGn|UtZZga~DL!YSe8Z2!$T`UzD7``eg(Z3RAwxoFQS|l8yIvYF z?};1Ydyy^1g>Zx=#(XlpQCD+444M|ZwZm{R=|V49NHyvqu_b6e zCww?+ny^t{p;nTbW%+7)hOx_aGZdwXti*`^(WF8({f(GcF3Y9*N8w^aF6IzNYTJ;Ca-e2#S`RuAa%`g=5M2_5asS!# zyNDluU?vgw!Mn0aQ-^CCHR)8i9bg@Hnk|va>C1AmCp8SMaz+My-n+N}D{`(Q02oPxCSdDBqEjYR_yw*5li|L5$1xa}FXfCaF{95$`k^Ad7Z z)qDY(IQTM)@f0_2n)|MNh>sW7a$>|Bjz4>M_10?DuZosaV&0z`6WYuy<;xbzRkwZw& z!RwovyUpz1XIN~1=JteAb;OJrQAUGW4z|VPL3eYh)2z0tI`h7738OQuSGYAd#w@P| zT4-u{)uI;D{PST$J0`p+ectUcHj47&W~tt`dOcgHX_tQ#Nk~^(`Ht|E;)Z?^60dfh zuXsF7G4~U=vaDhyKv2Bq6%R<-oOrEcF|BG5>ymqpCo&(U0UdSvC8HLCA?f82x!;CY zYeQf3;qu{~LA3gCIlv^8t$nzRZt|kg;UuKtepJ<&s3W}Fh{+PVILLcqy@LDum_~67uCt}V z4xe#c8N?flzGLYVMr}0hNI5Hssyd&+9;_9%PArq&lzEgbDh`iGA&-} z8u=c1ZK;I?Euj4JgE{EK_|{LhMrgEBdl&jM0%g{TRhyys1pPH1+AWA2;@B4vpv!wN z!fs`xmVpHj7Jdw*whE(T5KzkWq9+5)FuMG_3-{#V!#lh5>XQ2wQw(;GgJI&TcNy@I zWOKy2$o>xJ!_#mdDhmO(8`UMSt<$oZR`r~z0#baGJI!C4S-EH9hW>7=vjB=FhK=+s z4trH(Z*hr`XUb4@=MosUJq6xZ`gPT^eOr}u8DXuEwZBX2Mr39h#vN6xTS5In15QiE zx9{q_s`K`iGvciEG{N}3t7n)AZu!z#@wk$m4a>3Kai%<5vwT|#uj;jfU^>yv@(1Dm zB!IrL&e1k9>|EnUI!-f@``=*O!OZV?DT@9?s&4>h+xZ}5Ue4GMJ2p;1Qn>~BC#Qm_ zn+oYEGTb`UE}+qwE615rlO=;wvL^3YiP$BtOHz{P^edBy_g$r z{^XFmcF?uc6_phm#9Bmd0cd+NjWu>9gx$|tuD=P}Zi#tPuslqxvCzinHR&8pKYEc5 zgArP#hP+;2<7}L~p!N8so;Im3mA50}Zn>)?<$q(ST(u=!*BPgE`S#{*NrO*xTusT+nzU-}1BN@2^ylFes<6Kr%hf@){3Dw_nelWn=qGisA zbi1p?{%?Cx?J%2XwW;I8en2I$Pf06+p>iR0=3fEA=q`cGba;{wwjV4cD0OV>7C?RHJ>KgQsu!E#fX~G zFf`PSs~4m>E{o&V4@feTpw+D^aeIQvTqH%LF2>Zi^(muCX7@C!f7i9jdY}om&owum zr;9+pJNGWDgVy=u2y|P8X!IyPcy|8x52Mi^9`+y4@Lfu| zgTW_c*=D%1bTyw;)jO^{c;Au!KkPZIY!Yrk%x}8o!2CXciXOb-Id;doRI<)EU>`ob zZvD{wr2CCBm{VI?ZvZb^Jrk@#Fr^X_+bX>_*$>92lwN33z5?PoC2Dc*5h##>r3W50 z={W2G;$AJL^aCCl=vSW6-?W@uRW<#F!%XIuc}u^hDsx_CqF_HkXNx{@cyg2y<9Bma z-AFW4tt6L#MIpe!WN(M>59Zz&r%A-TDZ+3ac1y8RMp=>J%;E-Te#@u$(v}L z)^#rGffho}7gYoPtU0m?2*nFh_=9vQfg!!E&3$u|7N4wsfrRzGtk#PvuL(;Lw2r;X4K>D!e+bZ9LUzG;gHgHjvMqEjGrY$loV zBcPRe4l0?UAredWp+={Wyyfm|>rG{_S&GjJ=V?AJq|@cqR)bZS&S2GLELH6sCB~q_ z-e1$Y%*z?*=_v)hOH*$Bd@1s&nA}c`yIo{L`4g?}s))Hxm5_beSKz8$5_FX|QCfie z>f2}x0i%s7Iz)p;Yqn^S&cq-(N(<;Kd5ayWI_7X^X|EuTGFSk)77ybbS|<3h|E zsitkHRbm)=n2>Ig2IDHbufhV{VF%%+IoYE1Fhh;84FRabtZJDasy5%{SPKVCNxYU%3of$UU|{ z>8bILrKoRD1(?fK^ z3-kDJTrJx-dDB2R)nYlz)iAF#qh424t6Fm;H!tox5vuFTHxKCyiLzT%nw=s1#MVtq zdb%%T*Ye)#;LX_Ni$ajp{-(OJ>^(yY!8t4hvie}Xc&YLVxaeOovO3TQDc=UYJ*|^S zzU1*L8#~&b5>Vu2c0`5gn3U2+?rTGZPtjnm%29PfLY+ad$@%IVh1w#0e46*^JXE+2CrC8 z_idwL>%c(>_>q{}eHjyKRJpTS-5j+%v+s^T*Ncw5l>!lJo$MvZPWtA(#a_tQlViiH z8o&^lJh%G-o@i7dU(KMQTp)wL#%(-9nFrcE)=|s@HEWlQe!@oY(IeyjnU#mz01&G6 zBSX)OoMC{O>x?%Itn7Wtk3;T&n6uFRPB%g7af&-9rKSW3>pVR5O?rVTX2M800Vv%r za}ZBtd0JGnUJ{-6&Oh(c*C>h3KkqK81!(7wdLyZhd#=R>Mu0%Y=!-+byD;kW(qDvv z-vF}{MJNt(X=4U!l(lDz)%CXQI)`-?OV}$(=uu$p_Rg0|n73lv5z}O#8_X2mM55lq zI|Z}qB^Z=&P6HG@ypv5^$>v`M0{?np_4uspzWczH?JS>sQdgfgpM27W7K%@5eyoZt z?7LN!Un3!3=jT&n{_>@Z+0*XS-^!{Qf8DtbYe0z(H9WbJxoR1Abb9AXyO~OKvvz&^ zM9KGYudnpI+EBpXpiL@!Yv#suprf&-7Ug?$(-j*d2$H5B*Tg%bLbTU;WgM|;|FZ3_IgIyVOSgq`Zk*` znqK1*yUE>?FT^H6S!hQ+eIb*v-}P-#?i#Y{UxQH|$-v3Vq9^LYOXVqzz$!ZVF z>^=6_Pi0nV8m#qbVFy2ZXv9TOmW1A3sa7%RfX^$rGRhLBsVa)5`g%rdCpvI|dOZj! z&XT@-iF!S@$#gU`wtJp;dw<=`U~?FYoUlN!T{FWS#x3T>w5ku$Tcx96$m=wow$2&y zBpO#$K?f-OiVlb-n;>bGy{`WE%H!m3BH_WWrvW|mg?#erG)<+;pjtWw;bpOQj^^xS z!7Aw(${jb;ksPP)>L1**epp(ja_oVwssjs75$li>s6nYlVg>8Kb%z-9y-jCV)7}>T z(RGTcp_A)39WR8o`*hg}18+PG*5Ea)cP*~-YS|F1Rt$C43l(x*HDT%==W}gr%IlCQ z2H2l0d_B&qPvVxVU-Wi&du&d9k{WyCbbJJnPTa7?3hMy1`=!|%ql@us9gLn_TZF}O z9l|Fz%^u^#wTJM%ZRWB)W$exgc#&K`x}Gt->w8xc#-|RnZUabw?9NhDGoDTX^36~w zMH((lk!kwf`7u7otV)_tFB1+`^^x46kZU*uH1FRhi9On2};U z{=92mz@dYTwTksL9^pLGuGh56fbGBnAbbVHJx8l*lev3;$JR1{nXFL7j;ScFP#Y1Q zw*krH0|p3+8DTQUbU-s^e3mz@sLyn7;OhHP6bmV(Vx8e}{A+O>8AieL24NXYalM=^ zaao*7)21^mk#>u-t>18giCV#y0l;Zn)#*$b2vG1+0W!4SFvL%*$SrJ!A@m?ya_(0- z{gi%D2;(m9`}f3)W!9q=Wp+=bqRhHb79zbCTcbnh4Q)r5d$yhMu5CM^y-(W-X{hb$ z9ut@q=Qzc( zE>f}2&1JCuyqvv)oyo51%a^FLD$pO3t7T;JsF6YZC(Cx*3W!{+f#zhe28?B}t|zRz z)CS1);eD!&L7=1sNOd0Q zdN4C)i&EB>M1THcKYAQJePSU5)w)|YIDT=2=z;4fC{1D3jXLGpq67ls_z5i|-Q&$QI_#))4f}H5h)G*{a~wDTX0^2*Fl;jLkoLCx@T^pC^I|B__M_B`u))5 zWh|BT+<-o-Bub~PXC4)qKn(Vz^1G4SUMp(RSu)=OxzV*Gp21)W2f?N=08wVf8at7S zhmvyN87Rn({`Jk}dqh`r+4>4MS1*$-nyVJTYUd^|v+8Cb)d5%>1DLJVY*vWFGCN9} zNty`|-!YzFy86*=RnA|os+%Ob(sm=H6Q$mB_mCNuPubUblaIkX?e-KrSJTxU6AJ@lID|7hd1dhXb+|>AUM6MZkYLutRDQ&+-N?UiQFuu zC9_N+7()0YF=l`6Ue)h!xQ!y#phJcZHb7YyL!H}|!-;bLVxr8nB{R50b)oW-IuKv7 zf?XbmgSs!l&rh6%yniU2Dbw9V4iwIzRjI^e$;H;5{qn@u3>f~|L)J_B z`ctPhf4%T|4#sK*wv&F>_A?->r&u3-mVYDrkio|>*snMY(YqdoogVfJn{;-io^qi{ zai?w4vdpSe#cs3QOa!U)aIc9t9qve!H8w2d5U>XI za?y$mPD*$k%w9;wQhv&TL46GL&Iksz@HoeJXcOPs-nSWk=Xp&=3}rQ%-Gitc*i*|c za$H(IB4r6tX0fH{7}f$bKAU7YYuf4+7>Eg}OXy+JipN}&)>@yw=3$73cxJ69+FYAq zbUsSFWJk|jg-5gajgW^GJ>6)-7}79$Pozfq&_}BHM|LanfgA@ornl8M%_E~LNmNm) z9r4DUQL^_NzS3k`#+C?mv!ez{=Rx4w;i(j`SsPO9X?Uq#m^u6mrO=R6F6MrSzwymk^RIjPQ z`ntA^;NoBDBZX}k){e4yWIM;!wMyusEWFKdN6TH?4O3qU9pE(c6VuU;Xv7(2wdq-H z4r&>9io8*?X6l&%`42D@g$a$-Ed!t_b9hNt|2n3-}L$r4A#3z)K-D= zo;{U@rKS{T=`kov7j@Ab(&b}x?7LwpEkjGIjgFPxhH>kTeMIgA(G7s~KO`L) z>N0!eP~DQ^HEmT3>GDuTFfs)+Uxchj%z?X2B35Sp*j7vqU0Byv>YL!)$5P)UpN54K zkD2R-oR7ShH7JyqvxbX0R2rPbvbW0A^&kb~nd)Keut z(L?R;skzB9NCuAuK1mx9^{|HCP`S0q%j{LTSkm*Fqxy{iwMakhv+|^k%gcLu>78X! zb;~O2n`3oXjOKzOljSAGD>3<`(+H2Gt(xr&XQ&$a&=^FuxxDpc+u5yp*5u9m zG|MYARJR{`m=9zxoG^d4keO!6sJklQk?&`4D82!oRJM@Zw-;~~QeOQCOJdFX-CWRn-W5N~wiL==Sg`6lWlBjsVTL`t0@~-SP%`1NPT5 z9UWj}*BRUoLWY1|5Qqo7+fa4uoFZ7!P%$QV4FOW8)(W|4citw$M6Y|v5|ONSRUq!J zF27DO;?}?l`><*J9z3uIJ2X6^7_{l^t#WtXJ3W4Vd~~K~*K(1itsrCJurZ^xBG@u8 z_b&V5>kcYgG|>T>xM`}rmAb|zl(uP*Sfa-p3CZ_(H!o(}yPHSi)X<*iP}FVe;~;+M*^i<3AZeZDIoGH3=#eyiUAaD_L@}rqs9(Yi(I|x>qlAHC zRlv!uOPQII5}5V0a}UOBP!5nO!WFY31TZUqQmA&o6%BRI21 zQ2P>6NS`E8?+Kv6%>c1ZF@RoK_52;83$4CB2INK2LF6}zT%oF){n%^=I)X+hiovO9 z@rA#KcQ66=x=xE6mYi~VDr#9SZ|3Pj{%pmZA`#+eQB^WyzAV~&p9U(+s3p>gylNyjDnSaV@};uynAh?g-M|DmbkKZ!;h;Fm72* zIy`*M(pEIAM@?=cYIM(rC3i;LP|Dut%?8UBg*WnptF$qo;UFEG4kcc}gY#Zm=jkrm zbwQgW`qq;`ev8kGw9Ht7?xoSXctJDKzM$(VJDYqFymQml>O{`f_JO&?*nYWuSf=~H zkFhnx*wKa|B=H1~sQ_%E@|6$Ke{w^ZSALw)FFgEqx?c+ur%3c#gDg~(SPKa`FpZVJ zEV)W|5bQYQZrzG5ZEA4BYpz_g@n18DTb9vcDp)`Pqh!sf!7vbvvV*sd4jD{ROXuAP z$y)j@eJ)r}JsNMX*AE>?U3{T^&OCS^qg&A#$d)Ds&vE(`(i|Uh{6fsP3Al^hF62dQ zB7u5RO|+;cX<_bZ%Y1h;1j&|qt_wRQ&giVW28eF7l)aT3XfdvquFbVdlp#KE(p>UO zk%W7E*q30f4 zNP(tSudNc1gfM8?gKR+oTClNOdzS#+wze(o?o)}f`-*&?w_MfcOy;laLW=6TO&6k= z#UM4Ex1pONMu{UP_q;4vkjTZUW^HZCF3L77XHHM4#P|+&sUhg*i~0xAqGp}^JQXb& ztRqBTmK*{<5rReY1=0WeW$6T$LS#Rt&DFjhh|*4|fs+&9Fj>jyJylvn0uMiu(3r(3 zKz!&Aw6yZb1tZbKWQUUS7$0C>O{E8(V(&x+{wcNIYZOBu?o1&&so#>HscwT?--LYN%_Ies-W9Q;Iam+)=H1iQMvj-JBt zQv;~z=;$H?yT%s|z02q+7Vx@ZH-+8H?UOa&ndX4VTENXPb|_RLPA#-sw@ArHptVFP zDfpUY+#cIfRRepVp`u}Qu6~dBgHz!7*$&0k-|+B+PhH0SdR1%ZH}o2;-sXZX*+I1^ zCQ2!mgqbrARb^f{zw!$218gK_YGyoLFVqEWXViS^HS&uQ;k2W zYlYf#A-q^1B&($>a~CpI@HA#R;;jZec~;|(wnQM%|8N09a#buGFOcE;CG71coS{^P zwR0_ZoKDuFS}5gLb*<_O)e9qXzp%X91?6?33U|ex?Tp9!J4WO6V)1StiFcbgyer>o z%#OgTA_!~@+$;xz`GvjibZFBDo)5xsV|(NVR^A74ck!n5YdMk zBN;Y@^#5mg9g%E?bDXGjtT55Q#9(@`m~4T{tBnI`l#&e2^v1)N-e?dI7NenJ@0-EX zZwG%ykuL_8J%m?R7EV@2Q%R)0TToDzP=pWYS<*W?1zqQ5AuXmm0<Mi#gmH+EL@_#yW@k7;pFCG`@=8NgNuc_=)IP$l(DYy}O4M~3=BMa`{hxpAtv zG5EPV#!w{bJsrDPdT1o?sGh1V`Yh7U9G^KzwBJ>E6Y?Eh=qR20R*337bv0LN7rCuc zYBUXWnC}pcP}yb%IyX{ZBstr7_(9jHm;X= z+o6RAPKFCU87?~-(Uus+wBZ=VwCUMlKFnhnWL_-l>ho>j70qNC-6fL-TCwaHfN??* z&dKz>nChF76;0enYk9rwz@VpRty0a!sv|rNQv^~q8f`U&M7pEa03H)&K-cqPxsfZ( zifWt|ud+8{Ub#Fo(0T~^sFNCR`mZwfmW*;EURugzkZM0YdKNullsfmsV}O&dy5L3vyXaPQKaZii+@m!Mw|dcX&^Icn7ct(d91wT3trNsMlLv zvXRA7ud)}n@J4r1nz%fPMyNOV#M_W-j49eR`t11uRHP920RPzCH(b+wY>;JJWZQNh z-sRbfHO$Vq)YGBAzvGxIF?%DnVL>9%rS27NMc**KIy2x+_kMjC400wbwq}<#5mn;3 z!Z-?Ss63lJEiINUyDM`|QC@&vju!NKs?I+?;Fk5cTa|sglz6t8`=VfTAL1+PlLY;* zY(${BI*2-C#eRB2IKL;AAJBQSe)RV{i@bx|V}n+W3;!n{`VI`VP?17XmTZcp42nW6 zWm&4Z6gBB@)e=nKziuN4iF*F<;hhFujr7F>pWaBgOEFd<5OGf~fSzRHqbpI1VRY5D zi)Q#(citcq*YYu?dHnDWD1(@?bd+xG@LNZ~cKm&_$k>{Dbe((S1$rp|ev{mfz0@zM z%W~qgk&c#%eoFj7v;lsy7R|Ddsxp92@sgD8Gm{2hbCat6SL(6XBkA(touO)lmkiY1 z1j~O>U)ppM2CdNVVrT0SyAoMJaJJX8koNFFEn2Z)t1Zi(CQQg3^4bDn^06BpxULZjG%1ozE;I}-Gmc9MX9?eKeXpx^rZ zB;7U~fQ+o^1_O}EW^Y&%1HfPQw#A995ls(dt(Y%BSEk1ULmP51*fdDPqOpMe6(d%s z(RZrYbBN;HOCss{3!Q1F7y}w#xX$o*Xpce3#~w{c6xf#Sz?e6q{$V&YxgCgj(zjfLo$&>8|bx7Ajd{Tw4sA$O{?fV_MZWX`LNkW1I-fQJs~=!Y0Ev zDa>+XY9b@e2wrUo1{!9EBOC|Ulhp#`f9WhGWu$#>wxmQJh!G5sBQZdefqIE?3I*z? zB@wrN^7I&O;U=kX>?Pgx^%p0K`_O`M6*Xr)(7#1xQul6qiW)j@SRN z^Agjsy6Xug>r~}V$ML-2I&_j&(u78jbL_h}lJoxEQY|i*MUahJw*hoJV__|_>kG{n z-`ZPk%9u`=yK0haO2(jK4xdc4R;M#|aPNoOP;pWcc0_TvM9q4ayG)(bB5j4b;qkmp?g3?vGd~cC@tY30Pds_J3FKp z0s@)ou&F~}x*2!ZyX4m(LVXXWn&R*(Sr5I_TF&?OcVyy#RKm%#fpw5}9iBm_|Du@o zjH6Ym{!|oGsU;K*tsJI16uKX-t--!Lnu>;@oPFbRk#P@{*Gp8#Y|oC+O~YYED6yCL zvvI3y3+q~$5?hSF6O0EhSL@}gD#(-*Vu}!5V_b2@F8NAtg*-^l)G{MRfm~)_k9~{t zg#^Fa+XRdGp!_P|>Fdo+oi2Vf^N{&~xItStQnQdaWs>D7Vr|Tjtb!mKQl3vNdRDck zw`H4tCU#(Lap@qzMxd?f_O`&VNSh|V77B;#fq_isE>b#4ShUO9Lm-p4KtKlks=POZ zl)b(WX`}2!p#at*kSKY+o96aS5b-rzlpVL&HZ7``np(e!H<)Yg0EZU&!l1#RAf zvI0m;SUoD@=e+6l&!3E7>yZB3);n14a&mRs;;U8!?jP3s|hXXt|z8mV}CV0qn zH9NmT{J8G;PfanGi@xtD`P^>nP_sI~s~Rx})`fJ41vrz0JN4uAb(CC;sVOq~=D=IV8ZkR}t*y1bVzlJxkeobZ5kbYPr*_<7M_O`gg7cg+;QX!l|3+L5MVxjDtj#e2np2m<<6Y1PJMv^03Rn_;eh9*bI8dBEZIz@4yB>kMNWez zZiR7{F^e#R2M{0{>YmH18VBmI&R@tXB}-Q;=gAhvOL8 zur9L$^`69y7+)493l6Qfr!QkfXnYy6h2q4hiCAl28 z4p!zdTJq{ZJx7Q}DjampUby{oj}!DjmPz8_T=Gs;h}LXOU*MPAN@R9NCQgDokmjL! znBP_~HZqJf?y#{z#2Ikl;%1RI7m&VhQdap4VlD%VAyQ8B+DU}9G1eQD13))3NK(x# zXGokR8LU^3me+N4wYOgBRJ{L9I&lL!lJZ$=zYu^neIZnB^NB&W>Dx-xMTHF-nFdzN zWezDr)4u;$=QswBb;`0R*A1lXndX+@6Eg#0>KGU-qn7aoc5+E(L6_=UFjX?*ieG~L zlOkJBZahO@CrokOo26jE$@x3o>5P2eT)CZR18O3iemJi{Xno%9J5wxM5AnFySD5g(zF9{zr`CS-7K+vjc1hNhw8KAn+72;MVN~|($Varvw#5-*gQ$zV5%v{ek~a|dXJEI18GJT98!d`VVY>ScMmRmECmq4Fbqx&VUjiVX z_~nM14{sm=DDrgOwAB*doj9o|b)2g=76wAvH(LoPjr4%ayvQn>rltqz1snBd_dt| zOvT-B_&xpca9(1F_@Bc7+-7NU>m+uyY^atZz0zpb_%aWWtnGRiandAWHE=cj8x#qO(z3;)oOAqG)OoMYcx{n=-o z6TK&l+{zJO1;6Z0b#6X?SNUt&oEBeU9S5uEw#3`ISVk#pLLjLF#SKzR7OmtMxJT)B4zcE&okVD&7MD;z___Pceb|;lD=vLgE5braq!S z07=o@Ya)o?cneRri+1U25+LdSvC$)x*|#r$@y9Odavc!QB^$F)ut^>M0AFQJbf7;6 zh$=<&qM|(iYtde$gYjSpP*9aQj1XgJKxdKgzBsTh7Jhs{DYLZ@eLL%jP#EWkW(4%V zL9R10x1sXhhrd6%PpUEm<9J#k7?u%b{TJXkBZ%?RXUFRMeAL@ zj;zg7$_R1WreChcyMT&0Mw_%#vo80bj!dm)=?Qm9#1L5H=Jy&`jUw@CC8^Z)KVKhgyCv~_Pm=^01b&&Z0(%rj+JeRO3m+Jbt zSU@7zEf_|PS>+)MMi?`MvkDnAi*gmaHsj}KgJE=zFN>9qc3YI^0EZ}9J0n=N)2HIy z<>(%u^}5-B#!HTbj<|ka%$}Oy2Mo}HCo@8SG^xztI1bMRB59n0i&+}UKXmGMD9aPa zn`?0b$J$`EXH3VQFrPjpHVs|ENs7+(MS72)oNjAKQ0SZlD94;x6g6BH$t-T$93Zbe zCvE6ykX_p80z4X2$Ko5gYV~*rZes-0TvS&c=MG7q zV*<1*m)^yx@J%wWek$fl-XN6XnUHMZ)iH4MNCa>s#K;91O_vo!TZ^jv2@*30J0v&( z?N5l$K)RfOJr8;SE;4QpAmtW4V9t$t;T!3i58NXjv`Q$3UMoC#)oGz3zD)D z0|rJ{6w?`Z366{9Qq2oIC|zay?wzJFcT5L#OI+E2?6_Uo+3jBPXI1ST(PveC3)xEC zGP7t^4M@|Vr31E{1l3h-nc%d?VDbe7#EhQo4&4z3&J0QuK%-lhH!*FT{e1*;9TGxmY2ReC`&lA{KOn5#LCxgr1lfT7WeH`d@OS!-a=Vx_ou}>*^ zQB{}gm3_Q8YZsz{hFu@JV>OhW?|vV$T`?}9CY6;7SbnYCwf6_FWB(eF$R|)p3amn$ z6vo1GjYQmGiF~5gMQt!XV_@v^WVs3)&*W~wr)CkT1U$_|VNY&`e|o;KfKjsQG^OR~ zf!S(3$Rqvvn{N{QEBXGhb;;bC9#eo6guWK>QCy1>Yyv|Gy})%* zR3$!U{sQq@PZvZ^t{1+(kgh#YTtnHP)paD^wd3m1coEVMgh8qWN_(~jUoNorf@7yD z6Fh(|9-96S2_m?$V*>0;`)(3EX2#U|(P+f|-v}K!+wn3v%8+eQV#Cpw;zmJ~;-pCa zKx4z~;$c$Uzn^{i*dxtJF&!qwE`z`8*niET-!}5a=Z(AAs2Ck+KKC~c9G`v~9jbn6 z!!ujS3ev=bIE$&zH%1E24W594dQ`OFuS^daHejbQi_pVH%>1iHO?w z$z4rBdPrqL)V|1@mhN^7LfGBm%N;s<>2_2fbJ|tYG*xz7j*7@kqahZjDa_uR#SpBa zB?Uyo;{ls+=Y~l!joB#JnlLh=zx6KF^^5X&g-FqniXVcH=b>NX$ExlqVr$z=9tOob zd&UH_?yQEeE<&vqCB!0{nD3JMwEJ~|`@mm-_7Q2<4|`kbPqF2r7vq){#M!*vau@1P zijwbCaU6l$Tv1xxUH2nha#utKeZ|C3iPVtW68Ln;PKnIhy1u339n*py>h4e=Q@$_@ z(>8&UynJ>!+KigG%(=|#OSFU8f|$396y&a{R^AwdQHR$`i?#6l|azj-j4<$rSUqQGNyoAN9k@K;y#SZPQ{j-hX`0_FON{p0g>vOTR0x+-%r{ z(J7%(>iT0%idE;eHkvc6tb3(80B%5$zYVU{S*@B2P^%ydK^JH<@h>7hM$>HDvc|)< zdsXx4a4@8ceM)k0e>p=gWcYP)NnxbA|F23gl^D3VJOu&(KiM%`rzb{uTiGAbmb$GLN2rn-Dk2Og|bT<$Sb&_eh6 zD+S(krMkkuy)Ey(D@-mClkO6iK=gf)xXr?>)6iN&Sf_43gm`Xg4>|Cm-59Js_G%W} zOhOWflP_Mil6lbZ&9Gr0-;97cPgRm*1UofrfiX{(>xNPj(tE=hn3ovo>s$d79ho9? z@7k-!L0|6MtaO#fcPo*@9Ft-?-TAc8CA_mjtZ-LEbIFfe&r-8rlx4y5tNH~$cHTTs z9w+<5bn@Hq-R9kN`fZjTjpfhm=-$4K>RA`7*WioXP`TgUHQ&BVKfTM|HQ(ObKR>ke zpDc=;Go_Pv&Aa32w@2B#=6-rKF3L~U8UD>5vV99v|B{0<1$GOTrq!Epc3-{AuB=9A zRcmn^BHv??$-z`u$+A>%DZW3P8b~0g*WfOgg+*~Jb=!fIZM7X~3&SM~SZfk$r6!=O zcS!ivb1{AtICP;4GGkEj(H24&o!fUBl95at4zDO^HdEasq?c z31Zde>XA!!mNlU zG;%ucIyg7};B3BdaCROU6RzAF9D%Q3ONL#Tdh1y39nJ+yGTVWgX4vqL!zeAg?{*+) zGwVh+67tL`8n<$P^cAe!cU0ii>db*NW<#Y%=)bzr23n5-1Fh}mKVkSO9mLMu4m#45 z4bLW1?Cl^DC${3Lk(|5Q3VyS8l?q_1YjtKVv>x5U(*4X+eHCY4r1|>n=o!V?XT;68 zl?_CPuG<=MM&2M!;H%*6@kK#7c8LVh%{QwKj`^1S^2Sl_%lECzrdlh=7iYIHv*?Tr z&=XA{klS<&{>FhTv5oCBkPt>B)R%5wX!@SUly&r}!<)ND(cWRo&}q@?47y{A3JtrR zj+=u2$$dWW+2_6fDDqm4If-CTx^C4-u*A;`pV#>D^BO0BUSrM6b{rvs#P74_|2N;7 zzbdsYzZlr6>~{R=w+QaOf}C^CmN@D^^pddzNIBegI=f<=EZj0~Xr4RLNXGf(={jSj zyR=oYtezrj(*+E}@Gs#QJ8&Fge@aj#H_q8TLJA!x3cjrJI$t*9WTK`{l=~RMh6Ap# zyMt7Be8FdSWj(DHi+t4(VrlH}Sg=7+9<5&+3P&+0;y<>KFkuecI$!liITWQ{<_*Ef z%71$U9NNP4STRH82RgJH&&$#q@bKf_y&EV0e|OYFGyiVH@sSK!pO#g<%opZ&b{NV1 z#>5hTUY-}FN^gwFZX63oKGI)@hP#X9qHjYT@h+YTP<4mkOdJ2)R&VmFM8^VZ>Y0eE z?slYW)10o0#k|>HR`d131hAoBf|j6s2y~u+JP(xKFp*EI<+3XObPVSt#M_~Z4CHUm zk6&`LL2Xr5b2Yw$-Y+TFMWx@b|xe@Llq|2M-T^|J}pncMtw624D@W!|xtE z_zInT@ZdpW{{0{FU*|#B%q|qHhFWjmv&Fi(c&*Su$r^E*FF`80gENN#zd?QQBji+b zZM3lWQ2G?;3R5?h5z_fnb!w9xF^2j4x9U{*8(^|!Uf?6KF7yFe6-YEuO|fYnsQX-n z)JZH&?bnr%%wCg2fsV0%*h5Qo`u%+{2(nGuM1h2x4@ozl)rqAUhi0#uo<$d=A%2ErGMZu|Gl|~$e{^yNgeX8>L;tfh2 zf@CMJ2=mBayi38BAPy$Z5{c?i(Bje%))x9hhijEMRd~I6!X%w4SF%bSz_Q8St4;E`A(96}zLT$g$3j8Ml%M=9M!fs~cKbc}LSy5@A` z>gzMllMtlpE;qaO)Sk7T#Zp%(VZcOV8yGM_r7X90e9uePplYm0lu4VTfKaY_?jlDu z&@ottU6^M)Qrd7+U-i!2a%5_^4+pIM>Y`XEqrBn@k97Es=)S2|X-2ORJSk2aRewO? zDO}et`RsYc$!S$$l46k!t6&z_l)>SB1t3ITw-aqH8zR%(p5 z4+IS>7IToN>I5^mO_1`64{Z9N<+HK&hJ0*WtvcI_OuiWpq-we_FPa&J2~4}IGfxBa zcAc9#{NvfH%qpPOtg7)eH+8r9rFuijseNo((zHAEMTGf83>A6k$b7pj;Na`2N z{MQRrzO9S%a|PajkAqtHy>+MeiiUj`FNhnlt2(;{d7XFA_+GMTejbd1lx7YG@9sdW zyo?;i;gmY?0p63TV<8D!F%nSv#X2_3p<~&E(o9-L5T25(GkoF59Jq|SXNh43f029i zf*peE@<};AZmZQZtRb>f;*Cn|It$u{9qekcbAvL4r73Ah*dwC553PHL&n~S9YtQ|E4g5>irouET^an%DupBH!mTq*im3)THE3!6 zm7sT;>%KZ=nK^gFVaw09_YD`!1FNbbE3( z=*jLh1>TX^iqI~>X}?__{~4`!nskvjL^|_KvPB+#hdM2f5#NL@+i8qMckzme4$)@Y zs<1ct!1X~u-2KRkl!o=dQTe$HK~Uz&%;F{I6p)Wv;emJ%kxJky^0qI|g}~A`LnE?4 zPz2z6C)3OuKauGiGi5ZGH;G_fA;bfGw%8(ML=iErw}(Yn^Mj)#)`2invsa)HCFq%nuUC^;9e*tIO`pw(Sq<3*nH`)xKnEWZZzyn!sn zdjw%lVME}rhhCE5!!vl7*g8Zd=3ao^3IGoB$dGq|*xfSJYheRv)A2Anr`&@w8i zZ`PnmwrWRCfG3lP$K73X*NC|FI=8t6iFWRCog7}-UV+HBKG_c1mpF#1n8MeE43DAe zb3g_92oGMiJAJ0HwoHcvi{x1nLXKg=AZfLhJpF2_(Z{{S^qO$?(P$LZ=#aoCGmx5g zgtLxO57N9)p9Nlr7OZXKy^>=?V3@#wxWl9eU%l{L#~1m$x_W~~z5&!Vu1*n+#Afy| zm!nM_T~tl$#FM4CREWJ4E)8+AqP+W-k?V$x=^JNZ0k;U>uhScLZEC>4$&|Z( zTjwhy9&nS{?xDIW!8u40@H!#jI!pj0VurcI=xmX-UH2v0i=@=~4tO}1-QS8WB01+JS{SEYvQ z8o@gbGV2z7`jos;XYd-q+tq&YIt15c(4k?{UK9s}R#v1#%b92Y^ zfT>P2b(SLRB@bCjN3EEvW>yyf6luX&&Q&X_cxG~>EysBjtPcWRnjA+6lMex!MAErx zz1g&1AwTuOjFOHq1)Z=8cWk2nv=DFqyGh$VO zrwtr*$z~dAH0@XD8A1Dg`jq&=)VlPW;);w%iU*m2U3 zxZr_1hJdO1(`Iz<=2Bg6PQJWd0EcXrhkPhhr+?E+^#ccvD%v7n6n|G5Y8D#oVwVK3 z2Z`%+ppE~X_Ee{T!-d7TNz0CdFr*++ z=&RGeiC5&t?w<-j@YO^E#4>jdbctWwQD#BCEE646KhB^Lk-~8?lC^1tH=HGc48VX{ ze~ZTbs87Lg8cje#(|6o+lCzbSds1!fz$kJAtI_!=7p45rxi-lxOphB~iehz+5HMZ0 zn3wX%DG(X!05S{%FK97|)2>}Mf>WubqDdb50fBbfTyeF0tRDMQ3ZE>07d|J>i52CA zBl%-U&|ae>^eCn4ZI;*d^)PAFIqp&zK}?!(%5<2}B)Ilbwia`Fd#du4Is9}e7Xpi- zw-&}Dh@TA!4g=qa7D7kNUWa`#Vq@{aU4dpt*@V=t(6auZ?D?XXq4CI{5Wsx zYfqS7(M!~YNbGKt{{$qE58+4oQ0G{t$z zK(h4h=m;RpRnz3>YMcyyt>}YJO3sT9svHgUin;MCG^ob#>Z%rMMD9A|T#*PoS#0(= zxpzYqkGO)(ojdn#9FMqvvW3RU;GdHFvby`pKMmfM@5+CIIO79$%{io(s>Y_4qAYPV z9`N(z!IsyF((XC>xFPG)C+oIi&mHw6&qxz7;;qUynl%mW^=A3(0zN4(X}EqFA>C}ZRuOe)N9ynZ7s_~B z=`M>sNm8;{CX{$O&RT~KV<3FG)RlGUqSK&*p2+BQ!>cG7fd$!jGB=*9@};~0y~rAV zt{}FEkr#(KW1?fV$flmRK8jR2LJC>o>rrwJew2VpWJaT|mS!4ETYQ(-Xt87DuHuDE z7fnzII=+dy26o*)Cx^BxnRl)2*kmZ-EHyC0Q3csz3CnKbmTDl*Zj4JR8YCne-an4* zt=SP!(+J~frH*)bLiBLfOv9#`CatA2>+;1JsHhMffV2_Rry6Z31vhtZM zJwBS2m>O?BESQ9(=Rjb%cV=fkY0L4Xxh`j7Q&|DbYb{88b(ZFkE76T+K53JUP%>Dr zXDuCk*U0gu{*aLBUcCZVh&voi19bd266*&OQJXGK|n?~T4H^>jPL z4o|dX1AFZ#t4bZ1a%#$U`8C|z#a7k-QA@@bNZ_5EV9t~#C&u#Z#%(& z=qDrL%RFi5!~`YM$Gw4IqZogNkb`HJGGBu7Ya+(mHk?D>yR{`||^L54{5LU$dWV}``@ zk44k6kBnS#(u5#+4ddNbZzCj{6b@1XAF3Af zUp3-cM|Bg<49A|%o@uMNYTf=?mGl@9z2Mqk1N`#bF2l5=ioP9`jhd=%)6?tMby3wt zdo3Ap(R*58^4t~CN-^`fYM*&K9(S^LV0msg-jWx4Rvs(yUEMngb`b33c|1C}#!f#HNq97b(u$E@NgAV3|(5H5aJ?EroQ>D}PrB^}H-3kr` z67L;a6rtG(+koRE4;+F-#ZrpE&RQMR&WT#)I4O44ZE|~VTpiij_d)BCZGcPZx8x&2 z_D#BhI?H6N{&fCC74HU>5! zpzSnGSBGACT*~>hvr51fR7z3ZRE$Mb3+HC4<++s_a{kZog{oRxg~8OH$P^T zYhzxqr>3E;1+gb?UH*LG*|vUR*NVks~!W5;QY-uev;R`pECH`7@mAr>2uU@FfvUl|GXtFt??cFB!WhKrk?%RTHpH>U+qY~h8;i0K zS7O&qFHjylYc(xT?CKdU(;Mbs4cEp@YN@Wsx@aR=Kih`7@$G^P|K;%CMxDeTDYt%O z4gHZ25*Tg~nTrV%Y;)(BCW}{_GroPja?~}^{`p?RE-0?cio^$~4-g~Hi&481cLsAe z*Bf=NKBgI|M$A(ZyAjD~)&;O*Z`AqokE`_Lw|h6Pd}EVNejEQbn%>Xey*oY0oOSm# z80}juNI1cDa1Y#56x<^|2;xPF)FK7<$LS>B`}>o<|NCI?U*GM$A5ZUR`HYNR@#HrM&vGx@KSwQ6s zwDi}R{Y9amvn=Sg;npo!a8_>y_3{}g`rOaxf00nh$N1hM%6`AJ4L?9Z%bUo>o zY30bY=s=pukr||MLZ1!=ogcdCoOaVWHFW&9txE!1nk9RE-+#zfp8+Cz`JJv$!RtvHU1O;4CyPaO z)g+40x!WpPqk!HEQk>n}LXYK?Z-2&hi+t;@Uno zSNY=785c4k=m*_6oCTvl zXc?q+iEu+mp^G7gkBEke;P-SXoCUG5yLtL8`D_BcUg& zF1^XB%ajf8JQK#K`QBgix-80bu-en+dMCDA|9V*zXKHpmvoO!dgi3|2i#R0UOF5?u zFTxGF$eScz)GD7tj1eVG(J|NvI2$CZ{9KKarwsm0B}clBB|KW%3k*~;Qy8&imV*Xh zRad7Ahz>olpf1*GL3@9i@Pen+{8~2f&m}tQw3TSmD0y37BLI1oEUJ9YZDCzVa`Fct z^8E<}=FC6j1@6jG@~oIA*VP&vtig@0t-v`rIYGJ72_Rb&m49lIlRuE7(f211SmlTs zZVoC7%ru~h8RM<8OD1eS8nbW3x3P$H8n4}iDuLYuD?Q76lUg!_#4s#HTZjt7Th5p&3+30UoQDRIhS6u8h#yR8U3)~K`=+Q0{s^)_}Q(0KYPfn|K z>)`lniJ4*4A)t}&%~L+SBzPfI*Y(}1)ddmlxv1?tWAurwJ1a?%U#7nIyfeVtsj+?` z+SF`YU2}|nqRXX?M}Vu3)=N>;{Q^Gu7ou4mjKfIU0dx=FdQve#bpZp%HqY98ff&(7$(a5QA*;oOpEy1R?v)AHE2hj4l)! z9W?Y6njv*_TklW-0ec_@N`OF%et`j=JA3!ZdTO_Z1tyuDI4cbjM%^m5U3dak~u+3jMlyMFXn>3AFpAKBP zx;d78bXMQYL?}vPT7llW|I?Aw^HuZL*{ZO77io*EZb! z(8x!iG>09*1Dn<$EOLeQ=$I@~2N-%$@-> z|COt1!hPq1&VWfB+IvcqbKNtES9;@1yM=*t)4RXJMzOX$7%TIxwk?nvX`3oE&(f6+ zEH-*-(GZ&3p4hWR-ZW(QC++f{QnC$aX0Mz8lVWY{bgc6%6OdNdniRhul0oK2^SuSA zLT`;^J0%*wwVuFeHK1#$r54L;r~U>`+3~zM6ZBhzeg@ZT)VJHO2dpHv3ED$D8JGvz z4^;(Mf_W|Sslio$4n@9_fy2X`0I6wF*@~>Q>U=)uYM+$zH#lN{F6WhLgR>tU>VSZW zE?tz%k7=`*N~Q9*oL^sdoD+$>lnT|}Ei$GvBtmt7)$n;nj7Fsj-{ui>?ke$7;gV1Ig#Q)HLjnT8wZ!@wW(ua;dJR`Nd#wZy?sx zCEM?v=BF6L)y1_*4Al|Z`m+<9XqVvCIv73PTSUcy4v`C+R*ywu9bo>!E^FCcGVb6+ zkRbbKH*k&j5p=m>a_K_WtiQ z+dm&BgL@D6?i~y=H+1hV+ZFT`&Sr&#)X^VxycK9b7Yo;0PWTTBFIkP7kJ&Sam3Oou zlD}tvBm#!Z>vMdnHF0mBvz^LpKf;{I&sGEO#mJVb@nq)^&i&D|#ZlPl{LaEY=}z1k zXd8E^ONj2~} zy-nYVXsqx09TQm<6I^IhhXeLR6t~z+w^+5}#w4?5)s*e=O3jKh;1TXx6DQ+~H7H<@ z10G@QW3_JiE4JcLI?rJJMS1=LPA0diPoI*`s=$7Xcgxg%qfSQYzgoAuRzS_g8fZ;M zYrv2MeIw!2r4K-9Cud8Whxw4gu(I|VLWKUoX(GGZVGA8F=>B2!q3Hn&mSDUr{L(uQUCw>C^O`^2b1Phtgi%OiV)rI!!rB9wg)B zp>Yj?l1@gd^T^bLA5*a@xwc{Qum7AS`^m!x4nZ)jyB3oZ7RMX|0Fr{z>XB(R+79*> zr7R$hpV2llU0yMF526z8zA#H3nAWXpw7z)JUysf<7$y(@*>9)*NSkvfBLDKw9u1W@ zbOoHu)~Cg6?^ON0P<8rX^xZIdFnR$0JAhclCkCtCJ$5q7hWFmC#*kEx#48-ZF%R}o zentY%U~&+D7N=>`oP>GwOKNOJJwk^&Po&JKN9nySmPL84o#w|pI5IHkmdoCtD$>M{ zoeePbhhUtX;0jLf-PqN?$xe2tkS@cZx`*9$Jd|`dvB`Y3ZaZhw;}`z1X7km${aVxM z@-Lq~zFU}>uf0q*{d|qfpXl)zYCm~>=-zCCkdU_hS+rN8FSstp$xT9wWb9BMlI@wF zsLHvUWbrIPb0&nm4n2u0VVzczy={F@4ogFc;O;Fu5>(BV-e$^|=Sg46`cxL(vqc z;GcGV49L~=bjQSn0`$BZVud#Cld@Rmgun#uHDe*@vyP?3?DEM~eht=xc?$%NFRCly zKEu@m8qJmAqlL)LFP|TaA4xK$aMW#_ zFICM}EpK8Yh3sV!%AR=OS<{?FrX@s|y z>}F0QK=4542^@E#LgOPLk6@As*EKvi^yopfl8MM;EVRVB8am%cqfsjIm`c&nj4Ww` z7Ri8h(~M!6CIzWjildRRGIQ*&{Fd}*(zS&l6sg;XA!bPujvfZZ(=b=T7!t=b>RhOq zGgb^mEP>5pVHeRN<}k3>)eF-qwb161wz&-_Wdy~P)L}kKAN>>KNYflUl`#U_U92- zg{w|uoGY!jg)XI?j!5frUL7M-gMU8baf82~m*_mSQZ?}MZ{6``&6{wWf&UCOz?HN! zLNi2tazv^$79N3T`mN?u;E{7EFoSi6d9heqtnC-~Dnc3t)1iyQ<^W7YTN%*qi4q+j zNE9qKBEk^x0rh&-sySSXpbDJ-z+5E%Z3@q;?N!LEqD*aga|@ zqdh&Sl_Pg)xka9r4m>26PapEX`(}{TZ4W#u$dj z&VHi9wHYUq=`cw4jLLO9Gl#i|VXK(sMhC{2rZLcmT1&%_sAd+$6DZ8&Vc*!%*1u^T zS;I(b6{Wi2ZTuC*quvO-LTts@~xIVx-!}wtAgCOt_RJIO|NXQm1Nhx z=6X;H@ z!A|;+3yp;^(hzA&n}$?z1q5{)&HP9E(2 z>-2uMZ1%;g2|s$xSTc-5v>B`!k)4_h+G+(m1yRP~kA*sGh4N$wrVedeEv2q-hk-ws zWof$S>vMI@XF^>ig!KIB|v!GEPjL{$sH!Wx+nk8XRYgtPHNY z!=$YuTQ zW2zR9I-CNdHA1jK?Y;RZ^Y3ZLZ7%6*bIfp0wByi^LFsr^I(_t9^0v%dci1BW5=5VW z1{-1sLsRB}u+$xqUDH;zVz5IUyvP zVHhsF0Ls!BWHJrZXi%sq&l@S~&}ncJ^Ju3x@)@Nw7)WjtMv%eYV)hrae5n=#5PIWZ zXp~A2(PIPd)La!9A43N3p5={723W&jtTAkga{i)Rtx1>W*?D7zTIH9AqCA=2%JP8` zdS_X*({jp&zE|B9qi>*^b9ttCp=OsRjc6J>;V0$X*kOCq8LNi1icwPA%RBnn&S5pH zrfA;e^P)npbXTh*;E#jg!BR=VAMKLSZ&jef$}+eVUjhJ?D-jQ!1AHq~?L$Gs%dt-o zpp6|$Qr!aLwm-<&qd^b~hZ6q3{%Aah!M^hc0hd848M zXX|ql7+~wrS=x7FUVlOShr4pv&`xVY!K-Mh1e4p-05+g?;%mBtx6KPN7+!KaBo|#B zcDrlHKjau}Yve?H*fxK6@4Ay6n;x|owfXt48tHs+{QQ^aPv4r=wO-BhR*`3L+;h>{ z5n>zIcbEI{^(R$qn&gQ5*-Wd0o!a97O2;%vED_#@bILQ`|CWm5-Ty}7)YzHlwW!;# z$0N*!sIm4ijaYMxZ%xdFC}_-1=P5RD`;jSJV_uv+y~xXRHIHQMYC*hOe1FQ#$M{^q z9)xXbj@M%h_ENS#sMoauyd*V$arR@rXs%NSk3ZsZV{kx&)mk)sK0;=uBM}oB`%!z; zw|#@)t<)el^yJsrSonT8I%AgHeVjOk1ioX9Pcqhs`22x6S4U$e`r8x_O|Jps>*J0+ z+HqV6n~fY!#=qA2YNh5{s56G$(P-{4~NO%A&7|J;RFAoA?x5;f$$a5 z#8$R~Gj|3JaF8bYV3-UZ02w?Ckm?!6&MT`yctUiMHP6S0yeK(J!cGzHseaj?+T*|k z&xi#uI94sH^9T5L1BB^jRx8CQp%{UX|6I4#Sv6ZX_U8&soZ**dhv`REwsp1OjLhAd zABv$$=S2fhc5{PZx-6>?`J#X=r}0fy&E<+_nXmZIRxK$AA^u!c6@OT+7j3c9c&AH{ zv(uktwXEiA{lTxLTItS0cN)SO*NCk79Hd$L*MF>wS`)3+2L-UfjHasJ2#l=?2$cXW z0|En!!D`b%@pYcJsu3e8=EyApJUslBrCW( z8RT`5@1YYH^f{s5W)bjj^>LM#bCF<1Y1$Vm=v=iw(DPK>O`!*FIug z5Uwz%NQ{gY@mn-vSHL<8Yqu;dUvi4 zjw5NL^L}t-BYkIp1xM9m=ne*1>_Y0|3*CF>?p+RSMR%asnix078&gR00ORC?SRNDb z7Q0^YLu|&G25b|$sAl=XzSGvl-u4_MTN?Oyby}R!S$7RNy2;Y#oiLx(X|?tptaTzs z_q@sr=9dW5$=Kj_c~WEOpsH-uM?4y|t1||>(jL!;Cg&n2^QVXb0@sy1WhWM)fqiWHz zj(;Ajmb}w3M^TnC2mZtyB9JeL{@<)iFRm04`zdcO4$VZAZbA*5oC0)YEu#8j2ugtT5d5=rz7gIcwMobhx#cM;U|yWD(+Lu`;v>-)5!uNTDmlG_xI$>y`={eq zzm6IdiWO(qO!!a~G5zly_crrxte!-!?+9 zYwGJ5DV#q&fw~a#w@W2#D?1y@h*37)(8yE5jATCFT>Ppp%!r(p0|gvDT~FAl;BQSnKAZ z`0HR=6qSM9wFNU{bH=od{)tjdj3rT%|A6k&K4<|160%I^=1$_INpX%3ewxaRhC#ap zW|2vON7bt<<5!qcG>$-AKZ4{A_&eDa%$J68uX7NTR)~n`2w^a-f3AU1HnE2BhabNe z5)XJvjT+IcS^EydsDsog^Gvzxv#PX%&G)hS^H2ntl5rd~c(FUXcn9lxd3#d}MSIdn zKxXWrSpg!m$H6+W*CZ!dLS0Ld`98veH~p;8XD$R2i$r5}Y-Mg@t_p$0Oh>5I2yAC{`Zrf15E*}X0AX`=;F}Wb0}Gjh9#yt~EY^#Tfag`MpT#rqWl@djadcMrn5 zO9@cJ11se+RqehUr~K)Y8y^e-sa}0#2-PZ z^SsUDQO+TEB(iyMvOLS%+&NZ;`pptOUy$xU*7-t#6m_F}Pa6|Lt6VQtO@V}vq1bW^ zbx#rf&~wBwQpo=Q9G@f1W_Z_$PR9y|CNnXZL0(LDvhvf$%V~|0EY18XpqF2nEFdgK zuf)Nl(ZlaX|B51Cj4AsBw5}qOtO=bmWP7)$plnFtJYZzW?&uh7o!1L?kM4-ZGUTwe z9bZ(-Tv^dxKQGUV61{f9Afdxp1eW0)_BIu@dkO;+pEMKMdBynd17)Gl znp_p_1;}WY`QNHquVV=DWliQi+IIvdWBAD!^AF`UfE{Nc1pKW3Ijp- zTRWw~-xQq@;*QZ@Hf>D0UXzGVDw@XgVm8#wzFFlw!n7RRZWx2!@DsFlJ{D{uWVr{2 z?l?j1(hy?=fCiI;1q}JHAF{mxF}Mdhu&MhFgJ&Kl9O97%A-dj^5~|t%w-0Nz-(2Li znnM)1mj529X1RB|rcki*QMt@70dp*E+8Uwrz+lp-UZpa6iEwqkl8~q_}_IpjG9o34EIr)2a8I)90nv zYIrVa8=<>*q5Ow@;cAp2j$@c-YU17kU{<~m!fm*+PmFSK=&3V@u7Yuh3dNz;3=YK? zt@Jz5Z(>o$L1cW*^h8>8KRFqq3Uz3-r$enc9cne{PzTV)lWVB;qQi?~K3C-s6r4j) zQVz8Oa;SBWL+t8&*r9csPX2}i1b{_^$Pe}Dh#hd(6-hnrW) zS*_IH(N;7W8?gXofAZ!{S9Y*U9h;a1?!(yf?-n@2%^^#HWi%Rxkvq-z9p%;JhGG9#xx~Rb=x24U~o}Fwkhb?fuJgpYJ z1u%yxKtm%7Ku#%a@CmSFf(X!$x0PvVFw`x7qx`b-Tx2RH) z#iYc|8(6S>X%@vyrT^a$oT6+9Xl)_+GvXSjdaCt!Q^Ggjpq~+n8Gg-w9dN?r0qH0Q zdzdm9B=-{(<-I6d!aRQXfTr|P@e)9TY$C1MC#d$!+RxBgs4dQSJU}(`+_ZHN-s++- zR_BwZt*4Ii6Fz?Wl%)1ENZU#zWF9NJ=}QGM+1@;dcCl%V`$^a1b})6CWrRkxvD2}X zhe`T=n9RsT2ZWHWjZQKqgwzc$gp2Fdg(~xw+w|@S16%BU-_|hM8bNfJ4EF9l^r4S4 z-RV`)=DTzosDr`LlQw;jc|E#M_Z_ayhnZR699f-KBUQF2n1_ik*9@ayIz87C+D zoV~~!HdQmbW(aODe=+;V3Kw-U0^fLsr?6bi_s1f0*2P}bhL0x}!~XW!4D5RjK_p*B z*$1!@`m4$>;fmvK(d)+#RYUKr=ln8)loxNGzj^ZZ`TL)r|2vo*DD5QS^Uf!_|&vvSCGb--Gf zJ5!p{+ln+VbcWiea-`pW=5!&dk6>xxL89f~hJ>lih^6mx!f28I-9Upfozz#%bGmB* z*McbueGv90lrVAy_S!dJXSeD&onN=6D&f5<%*vzRT^u5}PN|^VFhjR4=E!LZ70(|+6m)>g<3k`;iracohQb9I6A3+90s}odj_?w@mWJ(U7528yXO|8{MItvB zL;QGz5@)9w?dzi;E|u*CNXpMZT(@xAAZw~tE(Qc>wIi*IR*fVkk{n~6?6LQ+51p@!rh50Q3Gv&DJ$yRNXZ@L4J@0#FjSik6_LDAL{%HH>*sWtCf{vdn+4M z+4R?8|Gx9rfLN$E3D&T2=M7;5)XIm7h&{qYHs=wR;Fn1;9TBL8w{A9fbQS|h1Dnn6 zzXlBean8+9;oSf!@~pSYPKbzX7YVk*=H@t~mMWRyJGF!kmj1r@45Gn#?k#398H>E3 z1!L*!ec;lhjMB3MZ=*4O@G*~OmnmJSLuB6hEn{@0lJcid-j3?fc}h4Sm_xd%&9x1- zXI!VfJUE{wWHUD=^H30r-TQdwXI|xiPDrJ&S{;l;*Qx8%c^buhITh_PL3Satfe=}( ziqXjgX$*Yhd}Jskk%W7hRt&VuUT97xdu!2xGTW&ySL&alV<)@S#_%}X-`6Vvj&`Z8 zW7uBqdMeJrnJidQ=#2drm!-tN-qJb{%ltD}jJx#YJ=_#y_7j-=MXr?0(QGCDD=Ss7*W=TwFk6)8QP85{i(Dyg^)& z8@n{5Td0+3P~ZC-tY;^StTe|rSPv0h>i;80sjpj&YP~-7v}4x}MQhwR=UBZi?aA~g zdMG_|4t+d@``$d9C2})x-tJ2QPVptz}{PPwR&Az9GB)-zD9x9`&nH#JyP|P_wP}d`TqUz{reB^!EWqq`2Ky-{AI1Q znB{a`EZP?($q9g%zKd9Lcdl9?Lk8RLCZ)`5zMv(Ll!(FEOdLZk)YWpLbJfPQ0Q6Y> zqiPuw53*`a!XH(;tps|6up9+;^oz1-YcyQW_0uC>{z)u%ng!43j(mbkD8ec!0^Vex zpS$45!x%>hw3IjP3;sE^my;e%4QS(%)uHf=5w|fuFn5CXtOSMy#LkGRXRUmlN6|1@ zu*tW56oIPBdmHg1BF*c1m<-Mp07Mn&bQ$37@c}_*KXr}m4Qw4?S78ugCx07$!6UF1g<4lL2tJFdZwx!9pNG_+wp~KS8%QQ(tqEyvN^RWbnk? zAw55sTO$KL$HWbO?>B+fh~IAa6)raz8l2*8v_|=iSCcrC=J^=Fet%@RZ{)jcHB-e0 zdw4p`oIa$84IKocrMtEv0sccsguD*}BD>P4^DAU4b&K(f9Eu||2IepQ4IT_*bA-{a zSZ3^}$*g~6@_`YwpVH+mMeByrUl@I!$KFU64%-LNkvZkQ`G&QIY4j+i_ju5zH|lJ` zmqkxGG5cZjY1vCc@pW@3y!KhRjob0ayUFkcj9zg2BpUa1|k^;1kgg zEQ%J5G}F6OgqJghe)kV^l6m~OysGgTde@z>PoH30cnq|62sjiV?12WU8OK|~maA@K zg6)Hp=uZk+Zx``FYOU2bcfyWuj3q)JLvU;u0`FMj4?7(}O>hLUD#<^I(6`0tiFjN1 zv1Wq_)%Xx}Ub`4Lk{W&%ny2n}2t$#nGn*ZOYA7wlk2M&CV!b*}lGRCm$F zc&jTp(x|NX>km9uS~fb?Cx{qz?y=eGWaXoGd*knRe~Ro3P&U#%;h35{Uibj&k;}xm z(ja^}%L-%OLRJeIs33c3H~LI4oe3s?6Wt!`MH@Qo&;!<3r^H@92Kea6j*%^|+BY2b`cCcZcnV zbKqa(jX3c88(dwLc;U1{vgnc1%GhDB`;M$6YAiLvl8w{7^(xXQELbC+E)?tM8*BB8 z_In3xuZebp2V{#VXB$#3ax`*e`r5ieAN?`H;_b%t!#UGo&ZIyM=CN9+(N$iT>B%vM zDO3zljFF+Qlfk_kVd%0MBxiZC082dT!J~|mD4)e2#0*{29{+z}>BQW;=g7)eO zXe;3bCaj9g_035~;#U5k!A|&&FmyC?zl{#v_Hi=D<~kNsM2>SrA!;kkfFA|7_gWiW zy*f)j$Q%D$wf7#nw?phXQZ&agZa`lb`lzEox0=1jU;E!CZ=U=$`F8(Kl1v?->9?jZ zJ;vRx>xSm6yD!s@XyonD`U#f@LmKB+q zv$SJ)B&KuhvV3B>jKvz%9fu0Z08TVg9;LjTp-LW6SLE&9y&)~`=@xQ2mq(G81J8_9 z_UNVJsO2`^&2#Tz+OYuTJ!IG1MbZ4Duek6k+p&mPaXbh5^~{Yt(jzJ!Vtxt)Shq=r zq_n~T<1oGZyT<)Lrk@XSwCKG&cTpFfT7&f>C4DFNpJabI&t5ZU;V5Ho`{ zALr5G4Bh+Iy>a=@-HFrr%1r4F3{Cef?+M+l28hovR}mEY{L=oT5grngN+&A+fbv5# zl757MV8UOt~9!qEtPCE{@6fWtMhq{LRlc8&;gJsKn1OEU|!o)?xM1l4Fmz(XV%jUz;=Q{h})LL3q){HIlHxwE5pi^4mLx0;P^kBMfp zkGR=?^KXg)?3ktZUf;B019Pf$iMo1ev(0AxJiedX#i2^wph*qmX!A>ZdHn823K#3y z^glh_CNpb0MZSMmkFmNG@ysH6Ps*sC%!rX3hXgXOsmEere85t_sWI3OOM;SlXhWRN zaTYHN96W9gzY|Y}JE;R|Z?z0Zc!#0Ln9SppY7{Hi4cjgqEm(umE}I4Jfx+*7tY=s6 zHYIY2-?7A1NpbGbY*`G~CB9A7yQalFQ*b3UdTD(CbITpWFU#sJLo_EH*;Z4>czT7& zLW!Q3{FFLiT(ZA0gdhAhD*JF`0fJ}jj)ddIgZjgFGvb4P8b*0CDhi|ZopRI>gU(Wa+=@U4Fy}1 z8tmfQuyqVgf2JYOnhw$u%f7cynibh{$|`COh;F(^0s|N8qR1e(1>PD%AHCW6eOxY=YI{GqTbE)}jNv@<@$K{R zR?Wq8$-O;2RrLq2jXY1)3qH0;KJeEjb+81@D(x8AE;)z89HC7d6f~>I#>=vfqfc*X z6h|v+_;j&Lb=Y8?DNH-p{o~r`VJOJU0rs&tlFMtCk>}*k8U*g4- zti5Z2(vctixSRZjuRNKGH#%_U>cmc-iF^E01C-(+LqoUehz}^u0eoBf_KBN}8~x;om4O(LN{WHA zS>_y;U4V?}pOCYDI=3aS5W|40i`x-Z1h}N zd9C!N*?pQlmr{jn6NV_!W@(ZWbHds)BWpa8yUg8-zB%BVTjo#K^wH*t4c#&B9`>S+ z879#nq~@*C91=ler;@T+bWzz1mf(D}JQ)7Yq60!Gej~okoLXk+xm08>G;)Z{dSxT~ z`INxa?|mT0ZFwTsF+dyntgL?A?lOd&v6*{pszl~(;> zP9o1)^t#e|l_w{_vBO&U0tB`jv4lyw(jp1G-A5-EaHaxDS_L~s-u6mL(;j^d6x%t}PO z9naXGJYR32JVc+Sf9wraMP1&;M1tm&Akd;Gwa=`9{{2U1G^IRmmuC48k8c@6bLSew z0llx30eVuyVy19D^p}h?8mH#IF?_aJvz2JqAEuQS`p;`MYDgY)YJQfZqN(%0Bg04L z{NG7K&G6_vw0%v5NS$l4U`zm{TQ(037FdQg2+;2Y^KXd&B|^ul2XWU`jYC!mH7w_F zy%4{N@tZsWFH|iF!qUv)jOm%O(*4A1y6)=lZq32T^IXlOH<|0LPDTxOCE%SHz$prm z8KAgN1rC;7S_XZJ$3vkd%Pj4ts|w6t^d5V92^>yx>2jwtAjL+G1$qtpV?g9>^{1`% zH?$5|Yl-3cK#H5@+^>@Hnu+2t6UP0f-LfcBqLtbh*Zo-Fn=xk{T#txjGJ+XT^l5>& zh#}x9Q*zLC+R#DoeA|^LN&dUhRM{7J`sF3k++Bv|q>|fqMFItOwN^^!N(M*e7=CaF zmLnB-3h{;=I@3=8$C%~&Qo@H) ziWO4s2bu0G7*Jl3`4{q&C)wf0`8NqFg(evrb-8oM33t!J1V75*KC=h0YaM#YB|^l$ zq{;P`FMpVS3_{=xQ#p2b6BL_HDsdn_^F=zmgZD zO-8qp8xN&gvrI*$b(=Fk@rtp&>UdZq;FkayI^_m@b+vwQ;vm*xFb4v#TXF<7_$^MN z&1nSQU`RSDBsf#<&xa;@A(8*ii}cBDdVABCSV63&G!n5aVy!;_3zEW_TOU&AAjSCO zln04a7kx`$ma=yyTA#Ktxz=>UsKoUzfC}#L1Rug(k+#j4xDfh)d*RV}HY#|In0a{H za8*=etbyd$Cre&=IKd}yt_Z+`e<^K>RxL zUZXi!7@L`Qfv6=YUuOe+yWWCdy==F?LX}!&27KTmhfG0E3wrSnyFpbFUu`Mb%?5j}T>kE-y8U2F=?IS@o&|4#NH(4x}c+QCH{ez8_`bPKKOAKPVPPkpH^@ev9cW%!OD+;BtxM%q;LKg=}}xVkT>PG!;{luI)R9*Ew0)QtEr z?YaV|*U^LKzVgY9mgzZBEUD9%&k0yWd~8OI>4_TWs#V~QL6y=)3+1un{0lB=?uL!8 zmZcJ(dvU!-#7Q(+z>#x_!aPs)1X9o{WYSVkLZpI=>vGw1?QmDO#CIGI2`EbGcTvPc zBw9i%0^oKLXHOXc%T|jgF?6||YPk4&gD7p?AxdcEh5FQ~mL$CCf52;$>EGk3|4Y2) zsI17v5mp{*{6FTk?Amvp@*CDGF{^yCH)=@DUp@}IQ3S6{3Od`UnGYTwcI_=2ry^JG}Nib1p6gJUWvV;@?I~rpkvdi_L&?Kil zx+m9gY1>7JZk;Ch`PJ&XQqnP8w_@>=WdT!NAW;y=GW{i}e2ba{A}{MU#ihwO%XgKz z=)YrDEX?`mq2s&TgWX=>z>Ffl8-*EXe8IU7L9-qQdp9JEIH%^!wKYK`HDxDA%?2i)P(8-zi0!B!I8}%l^c#UsANTuNB3B0BGFD_OQ}{c%jhWJ# zp9TO;XVG7074sQa^;@zIPM)NefB{QN3ZL;t$3k7wam!vvY=>SO7&Pq7yLo*uiKRB) zZQU}voWlAGI~yN(yzQoSFkkS8St70YLO*TYkfU7n9Lb~~LI?eMC*K4dhj4Px$@`hy zpQ&e4!KJIoe!rE@zhU$9cVj9KWK27%_5a(<&KB>J-aw_&9K3GwWc{z0tw)*NrYoqayRx*j?OCjJ_sxDb1Ur}d8~(dO{~Xq{bdOn; ze@yJHR@PBo&5O6tZ`MAJV`91bh;@lEx>YZwiFDi&n8>lPI~bpAhNXZEUSff-Y}@orLoT#PpBMt#8p zSb3@q+W0ETe**`{si+m)n6dJaIX@U!B}IY3#PQFxS}`ir2k9zi4JS+!?eoAJfnUkj zhM2^62m8$zz$OF(`5QbI&!`(|reD=j^C>qY(OH>JOO{qye4Qk}Xtn?@8=2HX*P#1) z!YDa7VqO)99n3G7y1rS|+JEkn zVKpGg)P#)-z(wUvD>C7Uv7CJ#XAUKM{4Yj(@~MDU7206c1WZ6P+9h@fwlZ7L06J5+ zDFo7(u9nRD-B8SH4Xu!`ka%jw@nCKjoeVc zd^z0-CiB#1ea=eE>HcJi$F5KfOa_wMdhK+q-inJyce)fYf@JZ_t8+L?0W(5A(KtVQOclkWl{kJd_`;y3B7P^_L^+s7@O z5b%>D81m=;;M#e=xOTvQacw5Ksu%NTr+&gsB^(-+!&Jr}trtgV9$ zOwe+Ff~A|}3^w9D@PPZaxK{nimgGim63H4g~s>zLAvgBO+R9)FP@5NPVBM1~QgFoU`SiX+21`g3OzMjpo7Vw#ZN>59^m@g@!;Re!7Y-RM3%Q zCf0kHWh~qx5s-kZzniOZPD^__;M_Cc$fj?;5c4iMB(WOycf#7UKDVr>TR8$nz;~kt zfoU!++E4!ag<#*K@hzX>Qeza-|FOB-{>SFl{UqdU36af6>pV82lHkpXbMYDg&QY7=C~|odrGa zbM*oowZe{nEfYBbFai8yGN*G!lf?tpZpOD}6Z)Axj3D?#3sV>(3Ksk+4U!xt`0_*G z-glv9fq~-Eu!q8NhGufZN z?sCv!nj=NfMH%)7G%!)8H4hj#DfzZ;Mqxt|MO8{pbM4WEM{#2K`%GQRE5IwC&~xs- zsw@1ePd{(<5w4=Ejs@h=m|(V@Sj< z^m}6dO)Jeo^#7vX1EvKy!i=LyiO<%{XRIQ?opdKliErA|xpem>3Wj2z^cl%dJ$S7v zk>-r=`da|i+)IMH@=L9i2VVa&?f<^)(V&8V4C;8*Q1JEu;B%*fvp}nDrUC!4_Ew`z zL%*PKAs`LZYJucrF;My?b)D)1MQXHeW3XPSa*2Iq;A2D6)Cprv^BWA=Pg@F;28QwV z&??!_aY)YVfOC#)=-zmVZJLqG_i;;oK;rqq*s=cvk24xvp!7YsOD$)h9e~1HfWTWQ zrVrjRh1Q%aG05$ukIfgwe+rlShBfebcWI!9IaI-_mXA1J8a$jS87{Jb`52x@KH~9n zZY|Fx%yBBC&dX1X*P)7j2n~KJyChB!r4I*W0tcp?%)|1+HcSRX@&cD6yunGG$!wjZ zWoG)=w<@x}y($Os6|I>KV)w`4wvJH$Da1)P26Q!EfO8pv5bG1&zMS9Qw$g;x=o^$= zcPW+hR#ZiD)3VN>=`DNkKGuw|m{~9+X+MAdkNr*dU;CRM{@4B{X`;T_9&WxZ{vZ2W zm+aU6PO;wG#co3~=#Wja2PxE!({Ry9W|(C(P^t$hJ;k|9+=Xc3m$mLkupm4Ic z^-t1z^l#@l53w7u;qd&@!kwXJUBc*w*EE@VEP#9+CLY|2XzM%T#vBQbQT^tckFDn6E4jq(qRlmkj&RP; z2&(`s7|1!XLzsI^Ux%5DYWU!uph|T1s{yZwU@FN8=qfJlx@+jsjS=W7%}||u&ZCwT z+&d4J#>B=@MoAOv%z&ap>v~?vw+YX7D1Xy{LW)GFhtv#sXjdYJa8t*~Ep`4$959QG z)X1Z?=2`!Wz^@>*K*a&2ofMapGkprP=xCNw%h@GO0=#+J)s!AZ{C*FzfqtYr(2w`Q zi+Ix2dG64+(2fj8^}h)CYQldJaQk$ZQ+VD&&b1}%Og5v2{~+Mhy0L)p z{4b=JEVasTjm4c&o?CyT3HhE>i!6W(5fpG13aIWZ9#XsL0D=+6$5D-p-r$hEO4^p2 z_$#eOBUbtoh-VQTi0wyYj2AGhW|w#0LBR~{@0CpgOY1`v7k>x^MdVyBgMAoxWdq5l z(=_?9qT7D3^To`J^vJ|98)!XkIldG(sUQNfP{#4it<&6iWFkIZ8tL)9ia04t28&ir zZryM{YB?V_#Cl%49MHK@N9b~Azp{bB%W}m=D{|Lwc`FwU8%tZQyn#GQ4H>{E*eOeL zE)rdAi|STl@yWg5fB=3SaO#3eB;#~mUK!!0?s&KYA2p>oIF*<{?*LL#O`^YCE{sK!P!Mvz1cNx*euRJupyiE-{g(IvTMCIv|=glMo`!4SS@| z7q3gfu7Ujk*U^i0_%E}H`G-?tb)&RY5zHQ3C%2M<${D?q$NSc76f~c7Um|txe;II{ z$^SCoJ4j=$V}Je+0}glf%Yf_se=*=J$!melt&tM{ivhR&F9S|K0DuR!3+S$z1Q4o8 ze(4R1SO<$w5oT0Ys@pL9X_J)O-!J}U&otL|>$cH^5&eR{(>T5o*?3}Yd$VveLCucM zX5cI(kkaw3o@lt_Q5}5@Lw%uJE*E6wqFQ+j^pAE-XbyB;<}*?vcAjOUrRuKkN_WYd z3kT-RFgHPYQ*R~?n$tHs8xQ7~Pb%i{cc3M;Vdg3eNn{eAp|jgWMUZceV$l5#LjfF) zyQXuu5b%f=flIf+U+Y@K^xMx~Q&BMDD ziF1mPvScw^9$IIBn|cV!t(YbCv*Rg>^P^Y;EwHyj@7CyK+9~{)Lx#K;cE@I0wsgnz zY+n6)D(3nM`^;JEKLU8&{~rO|^(R4?9~2BWr9`FRR{&p|{H_tRRk8Yuvw!km0UTqY zLH~aQa1g>@0bJ=<0H-WtYsHmVQ{9MfDbs22XJyV;wFK($3=7udML!S^5JvAHU7Ezfynnc4tQQ_^@}k z@>>#SC{Scab9fsz03(dst<*K8XZ0HkC>bJ^Wc_vo4gx}lsiTbyjva)vt|I5`$Dzz{ zUOq1bGY6`sefS-CZQlGmUzHzLKmCRN{Pp4?EcV;D^PeOdMJlR zsbff6Q%&dkg?8g2b~sPH-j5w!m8|LB4zAF`Q8NO-y#0_s26Cgjb~t&+8>@WCPzI(# zK4;JQB{vo_VdA-5U4354ceo?Jp^w^`GVajIxrUAMe#-5<&HINO+MSCrBKnIoH%Qmw zi<;;mv(GLDpJ@EjS_hyf)@2}@QA8r`FFtmEzlSY=3PosRk&8%e>G|3&~CBVPbV==We$8E6LackbT+Cqm8E6t&iRuJp)wrna7AIO z6-eph(Iu|2{^Fy51L-TfW>Tj)(#y(k+0F#c>;br3-5So}%wMPIn8Jy2Yq5j`qQ7t> zZ7C)S>#c?NXu(83N70bTY7DhbD!=O@3jR%Y5vpsx+1ZJRYNhEio0ff!MYzIyYIqXU z_-=N(9UT7Lz%`m5=PK?nx51_v@s*-#Em>$RO?n&nnuW!0+DZ8TI`p%X4+Y9+(eY=| z(|YIApG74+RxEN*SW*uY*xZEmkv=56e^=a%Mla@WYY36K? z(sM{j@Hb_#7)Ib^YRhTtE5<}X16nmTvRlE51-oqgx9-V<)Lit}rZR5e|>v^>_%K<0MvYTv4RWz$s-X;I2YmM-KQ zaf&~P&YyzJZO`4ZvSBuW zkS#v7pANx4Kgoa^oeC~LIll!IXYj>MoC!Ka_Jb1WBk#)nmD0jIoa?40`eRMv8X4eM zJ*V9jgw|dQD)>j<@x=?IJk?YiaqCX?+pib7Ez`un)94CE1t;F$QDI?lpYqhJfj-QB zT|s!!aYjbHB6(%5Wvdew`3M@WF`$vYagw;GO1$xE3L>jMxlxNcX?m`)cO3h& z73~^MG)mwWJe43Glz?4QSmv;=I7bXrQ&FQUh#dPJydKXrQj_>BT~N$}H-hMfIzUf< zSYsX&p6CYF15TRG9Ku(a?tY(J7fA6#tL78XdAAo$xMpRI*twN=y{X3ZXT{tCRMbvy6y z6i->~+|VO)qk03*iqN^|5g9T0ruwCZP~Y&qGW^_=PD(qkUnek5>{qL(d86Hv`D z%nRqhe@Suk0WcT`nq70-Pu@ezs8!Ei&m(vRm}ID+Y$mqy@f#RB*4J)!WMfAL=4a%L1&Q$aEnd(B%3{ z->A=34300<2!MCXnZY@Yy^C~?yi509wM|&)Gl?iDFNnleuD@H;bU>`dE?QRq18@GL z6`5dxg;B?6tSmby{{(uRn#$1!iS*{=mbNoEC9}B}*m{)UI6Jwk%|NAWl6KL}M7ega zEph^eEqfZe@()?o-|0b3N{cwhsr-j&MGNieeND}DyP?w!GfD}jF&<2fNdoq18Z-&x zL1$VNMYLy)x#t@xNQ-SugxXS11YDGTnrxpoIj|GQ!lxf&ROs=zr(M0~S2gIsUb20{ zyecL7?KLraH)+^gbd>qK?h``pd{wk-fq`P(EQJWuv@4LV7_3EyVI%1+63Qq}D5~Dq zIx$ZZ>NA9Xj>Oz7S;=5+W;YKsDkRds+C+4m zNmpRf$ebpKo6W}iqk~1#!5P6wti^N*4G=ydD|z)Ts#oj`qQW#!$m*@OC^9vfDA`^wc1`hH2O4zaeaj4JX&37evHUa$jH*;c3d zDRT+2^BM{TdbV;s(+qJNxxmdX^yGrxR62qijm_}VhEdom*o>= z_~HdmXBSjTkt&@~{V}~_$G&dSJX5Dc|J3let&|oYdBi7T$l zw`qgPHQD5n2BrwOTGLUD1)U-SVUgl9eJg$x6hBJ#r|E~PDg7RO;NA|2=1g;R!$L4l z`ZdcZF&RPTNG%-2)r`S(w~Ur&fGO8>K|%{S;z~GF!%fsYBP_;b!qvUPMxrzXy9!PLMGl0f)uyh>d`8opR*Z0^b4G zoR5kXJt5H|ExKxM$I{+83N~D&+t5elG<|XU4H%IG{t5Gff8ZVzjOB3-_c;$p52QaV z{j9oK7Ec9iK3O7~OoU677X3=A76S^4V|)Q_Aa=rnG>n+-(h7(q(x;tFy~}G5=oIHh z2Xmkgo+KFQ9Z*EIJ}OlV8jJpI4P4vizb~JmY{BEt@cV-aJJ60*MG-@;6!DJ@6kZi; zyS1jSy_fkJsraiK`eTBfF<^@lJfs#!A-{q1AdT=C5o()M7?(DpqOg_!sZd%zI~436 zwP!!)P2jeRVShdT6RYrLs#P%(ex_`HZQJV4-`tVpINlg>xJP!n-b^6qlO-fJ5_zhx zYM35q(BFK9%Tw+lburf>s>rNhVN>u}K3QFUPLw7$^X_U0^1>MVQ_nOUQUOU98tFUTh&zNwq7QBGfN$>ncK`7d9N zoPxNMZ)7s!X4}kA9nPyzA?YQ*QC)O3d1BX7}rUqT0k72!Zhkzq|1u{A|C6C=hOf|4!I?P z88K+g3MlrG5#Q;VH3j60-O2B@i*)>^Q;8s@buCy1y{9>b04I^=NxR|7L3k!P-yr{? zdTvP1tDx50Y_91Z2W3+!oxX9qJ#>nHXLF0^-_*wX|I^Y3BWcL*Za(H6R)P>`d0Fj% z(GBPG-nv1)tH*Pv7g4_N%E&;sG-WAf8ofelMS}oekQg{hR1KjsSfE`dFWp(KWQqNq z%~L}%KVX`+iQkg6kZw%5_!a`;M1RDQEi{m`CccegvP327mW#K6n7!4bquNhE_8Itq z6(h58UCPH2tdU>|Cls8|5SwIG>rjD&S5HGB)~D_a$3y3V4W)dP@4EzN!p1 zeY`vhqRf_NIHjM81+^YEBD#Jl<%xBk-8Idko_bUZ&`4H1kn|#@i>@UaS6Tf}TeH?+GS%w@g*j;RfU3F#@HJi< z`t;CnG(6Cvag6&^Rb;RSN-ayeidP!W03rDi2@o3&m~W|_zAjz(pg`_s-Q6*hooLaM zao4!1@qGieA;OqsGu>aByWKOX6Vy@eiPbAW!8Ot+zMLX-ph-mHv14{1+rr870WaSW zHES>8I-k;2JNO>xOpw)(q+|o8Gej3E+EnT9?NgJYSr|@H zLN}Rgj2_I5LM-8L7-{(#uU1{AeG+;}f+O!5Ne0Il!EUdoIO5<3bSU8k6eIkr{!|=_ zb7ULRQhNC1Sh3y`1HCKX^ITDamx`8WW+VTU>8W*bH)I@XcMDRXm<2&gIbjX3XMAVW zTgUF1UTKT}(QvuLB~dkou%H(=L$MX`FWRUqrOx*+vxpx-U9L-`oXds8_m8wE(D(za zQ83u2(<>-QT!xJ!pD2u%LhPd^5ilsX;w4C<7|4Tm+o#VXD^rSP!L}z=R2f_4D0-aZ zon_V^$8$2MieB``{dKJqlD6S~9}&08_jC{XceRy*<~@d&GG*YTA% zbzqgJn6Z?%q$C)7S0>vZ`B$pMQDr*2U(D+PI!F=#C|4!!XTF+n{;@(lk?PBAU;wq% zk=LA`MlV-LH|grLd5{s|Aam+-biq+<0}bkm1d9gEV1}9#JyNjEqRb;Wh<5yx5h3D2 zNTH&D5B4ywj7*D2u28R8W0#UsY0wsr+~h$%%L?d9W|z7!C}3zzOz_p}-Mpmh{i zNFZka?ct*ux2Wl``1?cV(TCzBMs6ph1vs;X?QnnSh==pl(al4*(to>hyK;R>=k@db zI>r^eL1(wYbaPBAPp3Uca*+Pkjc)kw4ZO4W(e>x!>pINdIrR#VbBg+OYp-_erEq>r z9|6`6l)l5J#ce>faxZ*T0n)Gq$F(wpRrx2>ks$1&5aG??T!w8-CW0{n2AV`n!Z2D( zcIAYRr>`1ZZQ`j1-iY;1&BXYc;F}KXy=l)!E(Q`R9hElvivu?o2;i?!j>_13@Nltk zz${~2?^D9CYNH|r#( z#S<)J-edcW2hv;Rk%g{{V_cL65FR<+GdT-JM^<3sWcmPk{t1uLNuEklgjs)^W-=gI zm_4~(z%SVW=5mW?HU=30<{mS*Fgg5=j(>(st#T=`?}so1^l4(svh|{Sx^}LHZHt(??5rApvq<)C@_qvmR-I8hhD4#(P(fSjIxHH1b$6^7(gDlr5y9#S1 zEWPbPdYFN8?`o;LyX8~T2}62TA3?bz`YAaq%lxPUR5;ItN#^zIZ z-v?+3G0KwX)Jx?fJa=7tV5N$;6}$q1RHxT-P2v3X!at>eh502Cc0t0Q?b2oD4{8ky zXilD)xYToiXC1%BhKB4SymCs)M#F5LKs?gmuVI}s)C`-*huD8-O;^dEhNcT&XW%3L zS{wVtq%J3-ac9_k-sc~2eV~PGX?>Jr^8@ueC9r`X0eB+h5XJuOAjiZlg9hy~#8fIp zr;t%O!4K_=+X7gZGg;tJ_i|Ob5g85iTVNhX=NUG)`AQB78<~%Iw0Y`dz-`8y~Cm@FgYxbIp zDSDC_a9!}?<;XCNbOtiLN9uBlMK5M2_VTs!(x^#Z`b0cvdoU-Qy-VY@r;YI!uBlJg z%?z0da%`@^-`fF^1&z?1a6q`q|yo1OfXI6ccid5k|zh8LYWH}~p>bLUo+1$XdyqT}~qE$X74 zm_C2&5_VGjP1up;(T6rzxs}K3+}_Ksi`&=gITe;^(x(^&Bi^Cl)IjxDi^5jL=dv$^ z3d^KfU9TX4#UtnQpz|76r+y!D)0FCzd;ZR1jm-?JO8!<+CmCZ}yZY1aIf|>vG}0eq zVX#H+%#5E)OX%8-L%%ZFZyn8zVZS2Q;$g7j%xWiKO5lV?1Fi@VK*u0Hre?jq@9P?P z{vY(D|VhC_u9G1B3m{kaj8Z-0~;?|QBZZqqFW+0_v+C&J2!CiLD z$9c=ItU<^ItuHbc8PCT?JNTV^Vl){1%Woz_FY?Wu?VYV2Z~(#Ke8tBY$* z%Ovl-;Yo8v#1nG&SQHwVI0LjRy){o>$gEaf!>rxakhJ+*Qb9JA?Rp^i5eBT0;K#(HZ*)=GWSNAL;a~+6LMeb@DB?5_*Xky79SeaM6FWTjyz8qr3WM?kw}Y<44FM?Gy!}yAag0)_`Ufs6v-)``ITwgu^YH%~6TzZAlb8_W9`p_u8s_+W7v|zf{dkpcK|K+ z0)38aF=sW8hd9rEGC122{9F~S4ILNjUQHJqBj1|z4BLroov4<+NmZnZ%3c#n=%zSb z(QC4tk&ZLkY(Hrh36|DDEovr8NxoN#=Z2=Xn=8<$agpm3sTGP6S*hGtaMTmE*o^H! zI|;2=D?+DeDnpm8eFzsNUFTJ9+$ND8kHfLz_aK9<^elB#P8#Aync|O@2zMj==LyM3 z2k;N~UqUD*z{|7tO1v_H>bm!*`#b{!lw{?oK1c3t3s9wuly#9{-se%`O$S1>{3#3^(X_Rg+CxIAhU3;u&%0qh70QIH*A#+u%==0YCn}n&?F5ONQ zB~YVOUKuBxLEg?jv4kwYLxAq9OXAR&+_B7>CZ&W+Hn%4=WresqE0>P@QVgk{v9WF!li=e@dR2$~nty&db1 z6<9<(prmO+!huQ{omuah^{vB?$Ei*&OwWsc4frNpQP~AdE`~mEY=-i$kpUVJJO-tq zHjwH+bI!5=Sden*yHjyK#$*%kO~;|VV%82GM0A65=}&Q==4e;O0Blk1sIw;nx?uck zS)8J%`Df+6yuo}-V0Uy}f_iFMd-_lL5>MCVxrrt-+yd{m=M~0_fH=IW2-W4+UkXCF zg#u+nvAr?Y_LB3f=}y0$1Gp9gL8;YVV0@gN2F=|~TMe_`yf0ksjU)Bg_IaaDI@Rfd zZ9RgtKv}ox&7(>c{fRv>?M2n5Vp>}8jO7?~xBhEP<@g$ammr%75-M7HN_SSBiRN`j zbF=nNexUOZLx5eLQ`UcH2>J#S6$|EMsG>`mqRDw?3CET)kDL0d8(%l`I&9R>ajfMu zJ->gCE=T>yXXq-W%#V}Sa1+iRj!+37A~KWb`NLlaRWyz1U*^@(N891Qt6{sM7^;4! zw1lm2Vt2n?2$v)s;noe%rR_#RNx#s*?`H3Q+G$z;jJ4BU%vD=u&2_b!fn#nEZ}a&T z9>u^5wPY%?SU5+Mf_s?fCG6rpK$W6<4)qej8I@Lr*B_Zq7=Dx)fSrnS=}{lpvprTm z$BMnM0o!jKDJd~?DE=!g07wfL{y?&RMb-czZ7;Ki-_m z)@fV8$&m-X_`tGTE19tgcsbUzH%Din>pkG6-;zQ2YXChyHo z(^Hr}587!>%qYh2?-jA?2Xk(|Y6z-zzwh_MMjHFZiN=U^j~wr?eO^v`j!gi~w=v?0=z z5(nKsWv2qOWOw%i^h7XONpPFI413c)X{_*y=Z>I5T$?c@PoaLX&;9r?-);e*W^c0X zL<*U0y+kHo)vrW{`#q%%h{qY~37%4_tB_{i(J>tB!jA}0WCIHfsiA%|x-i5O!qBWE z1|sH>rRmqdc?p?X%Dn^8chB!{7r5Y-HEhSHpNO*mZj1dhe;P6HwMImFgaiAs08==u z&tT3kmvCv=A(3F11ziQBbbN<8=80k_;p)%ax?3Rsrj)+8D+^ ze8eM1zGTO2g{|qH-THQW0j+|FaN*1PM=a<)^_IqNF4U~OeYMqd$?6Slsj=l6W-`A1 zJ0H^Tbor2s>j@M5zQsD3Pg^_1gNLp#iNE9pkYh}+L6FfTL*5p%R#MZ_Y6z>I32hh) zNmwVK-AD$IP01rIq4YtOUgVx*bsa0^d`vSEl58y{MKj%-U_m1i2!;2NNZD>;jDo`h zZB0t?9&V-M4@&Fv-dXm=${)Bmh2;whr|8n2#`y|OEPzk}NuiP453BhB7$V^nBm z$yf9FzzS;1zx@aBMkOFmU;$tCmtS!t;B?5K`^N}fs9WN*!X{VG$PxoBI@~Kej)RT1 zgH+!r-H^V9X_er3^iRTH$glk0B69L88bF!^{)ko3>a#^B^FZ}DybQ)#@_Q?}_5Seo&U}4ms=j>4UZk?G$QM|Vz8@wBh8uN3S#S3aI3)%0P=-Ww(J_LMC|I8FzI0bYgvXt@0}Ui;3TEE+3h z@kx+}nXvi6z`*{&jBZp3COS!^p~a_}kRb$tt`4Fd7!9O`i(^28hlD^Px#>+M@gy&z zE1eLr2LQHxW1{tdUbcS~9&h}&*=q-pMeR*``44DEU=O%oB`bOqO!AanSNIP+zP-Lw zc2d?T@=(HxYuTO?pyaHg2B{77HPK|)jsyF>&}#;E2StR-%&(&Kr}kvq~JG?he#pZ&#uNh$-EQElyl$QfL%MPC?OAx z2P{|tQgo>N9bO5$$tyFv1LgqyW?OkEGTj1`oIDbuFEK}%>jV?xo?9Lx*9_X3daxhF zWMRz3F5(_1_-J&iqc>VfATn4_T5#;YT!R#+hAY_I6K}NKF$ecy#sTlwOx5!#$C!Mq zf!1?)ZGUmjuoCjUi!8gKE?RGrS5OGKLE%?oPUsy+nIFaziR|yOl~02tDe00Ef!{JZ zxqc?ZM1Z0Bfe2*dxFO?z7n8Lxl7Y8YZww*&R=cX@H}3&aa4jwO`Odi!g?~z8<1i#J zRc?rMy}s;Uk_Ye72cD#Zwe+U2t77BoL?2sVin@La4MyxApbp;Chn|T4R;nS`Sor)S z|5TPE%ln*c;0!avU7_cz!I#AO;MofaH~UKgRkO{Gqo<|ad)wx*!f?3>Z`x4$`}Ylj z0C$frN{fi|htSQmrcw4*;d9}Y!8D<+Xczs5%rT9WZl3xMSNou^8vbtxpxzqdv?d{8 z&~OoD7Up+hnV&$ZMy_6bq0ul9D+W&$hb^XXdc1K|?__?A`b*Xwd+}MRQ05zbPp5q2 z9XFf0#N%+V>sRWX^zAm*^X}K-&3kst&5mg3y#^GvcQ1G$D$9eqCS;%%TbJN*IGofY zJc&E??JDSSMpTshfeGRl!5rmqIN!(u1TPqCy?A)d{{w$OfWLu>jP~9?tV~58H|t0w z_jkAV>*k}3zbH-K@oAhtD1#nz+qf$XG!NsiP1S>=EZttdKxa*WYhnClsdtMYSJ41i z8bQjKlBW4F!CSr)@fo;M9|J}vKjXh>Z)u+bVu3x}YvV%gzli4;CDMGXXwAYA9^ngE z7IdAW;kqRNLq;X~A!o}4Ei-z{`R_>Itj8kg!E#mB#xI5RW8t9MF3S`xCM$HH znG6^|78-PypG7%G(_uodSi&dl9Xz!7S#&Ih#}-uFx&qP4yK)ju<=wkH?;=5cBgTuo z5FkT#5_oPc185%2xZ&?SzK9^pL?G5N&-FyPs63_C*(@PrSgs>7XVBI)OMPwRQLmqW zWt>CuyLTBJFYPT4pyLMQo0!`SdvfMtInQOlmS@T-B@>Kit`2F|_TAcGd4+xpxnN5B z84v~|E!yhY56_pDZgeXXtJPWkji~gZj}>wt>!+*LV?x*y6)3EJoDirfm|JPE(_G=j z9*PoBN&#r_46N)Wx)7W(euiscoc_jSb}OI9fyp%SETNFz<&sz&X#iqUdb3nmgN#bviK)X^vJg zS|5z!8lP2u6yr*DlS2mD6n>}k4G0CS7)c90NAT#mm20({A4C8hI1UzQUja2KEnRMb zn~#G+Ed(=qwszsiK7d zYjQW!FFMPbY@~4Rot2j-Ctz&Xl4v%>qS_F+6ZE!7#RUQWrCg))h7$gKOW6T@y{gxs z;SXBUf(7B@_G7bF2@%o{5*Rc8!v|ypK78N+oG6PoV#+^!kj0M+xKh$o@2_@fM~uQ4 z(5+*N5L3Bl@X9;ywy(dZ}6E#xcEG}lr}a9{8EIW0_OKM(MAt^GzkEy zdR&yx)UT1_L5r#yb0wg@Ux_VF2|&|Egyu!03`PkQ`l|wgp0JF4s_T&~k1u4xqe!XD|Q~ zZ+W3qUBf#nUAuxDFkwZJsO^Gl+EBy?WSelD^~RtZ$=ANOc(=G2i0^lNZcTH&=as*v4R#72G^k1J zz@XA-v|#i{h?_7-3FTU{@Ph;gP>><7R@G`l6b3ek!)GNxO>Y&e)zO;W;}j0jE#5PUP8^Ojc*B)5)Px!8H-* zrB)Y%ccbMp>a=B}1zYt6TR(zIWwqK;o1lci2EbbQr*VP)XckL#XmiV$Nwd@(Zzp{n zg}*RfN9k3dEaxO+3r{8L7Oo_B2OC~UKxGb}NUlnThjPsysA4Br%n=#7=n{uO!R9J} zG=M<>d&P7Ad9uhG~NTf^SFbh}#&5!a8>EG=L|BxWV&>M&Lw@6fMU{JQ+|? zinNn~XBO-SsYpS|xSK#kW(iAHtfOBr4oe)RgWt+9A8eQ5^Ae75L62yJBT=&$2O6V_ zIXSXM4uF@3C!vHl5f!maeT2bIP`U=VxYVdPTnIU6qE0Q8Dpl833FAoBMu#PE5+vim zddmR;(+d{rKUS+;(M!udMbsf2tjSe1vHg{uC zBY{;aqRedDm@eCKRKjr<>NsnL2ex$V?q}->l<)DH6PZ-F{;!_coJ-&$RDk%*`CCbEj48v_C_Ff)7KOHUi%(M-}>&IrUi4> ztpMTaOwd^1Acy2&NqXfl-SF$DALP)wLb|l%u0X9{!SZwI#!!MXgkVx7Iip1?$KPO*Y)^6vC}D;b`g12>om zzI!C=bR>X{rGq@AMvzl~saWnJ)#rvp@K!Qyk!mDy4dVG!d2gmIDW8Iu%|K&xA*Mp6w-$|3y6a2R?+9 z#T^0g@Z7sNmE%*yeoI6=Av`ze2?l!}OHp*a2Y8oG)B~Hc=LfktRm*@jK`KMi95-sRfi!CkY$-_+t3-CWqUkR zRHCu`@oimoB>K#t+rpe;0R@cGl6x`lA3B09j(U6_<-S&2#CniMfD zFokSs;U`FsFR!$lO_dTo4YpyGw69I|h1u}4(uP~^*PsA%T@dIf>Ed(>+%^<%@TnU- z;i0`iPXUsDwF>axsK@x-yZEPt9PUPhu|Np1DLE-1=pOwwEJv-dgLB)JLQag&k(^lJ zK&ia-352>2Q5FDO1z=`j)%7Zu1bM9Dt5yC{2-Cj3Ob1815Fmk~e&~7oO`=6x$*lmd z6LLC&N6sMAP3NQVq3vmv4zS+c?Pal6^;QW-=o|Z8pfuHbn4rd`zm#$G#K9E8z9@_Y zV|zWR#&6F|HL+fmqDAe28-f?VL1Yoy>$XELep-V-@G9^Dk{5(UtUpTw|EaS2pF022 zx7TnRp&kFc@s~BAnYrk%0*Ehp@FY0;Ia>f3+$r&n8*T$c;K+l(7TE$I_VYr;3Lr#Q zxCDK`e)ScOpm!>A5qhU(IWNLJ6K%db5$Ai9qu8VHH()Q(C zbaI6Y&^sg%hBJlt@P;6zTeyHYy2?Qck2;u@p)cBV}G6LakaMoixB2F+SKUWAGz3?nQn*eS zCRKTy7eR)hO85iqN1U-nxY5AG7uS>jdO_=Hp?u(Wsj79d^B5d7;6aR^GmVmneg=5= zTF5tKyrni4usP>8_55IY6H}izfUycPwSFqC$T}rpP&7R?Q2$g1FoUgV;q1oW_Zy8VWboss`q5mUV6_Q$^TJ278Q2VBqLa zQzSS<+kIH@&4hnVK)@7(i>+-=s}Oa}B8b*B259Rs+yhIX^#N`6MRbq^W4=HZ+j2$p zCmH%s1ZDJ4B5HQt^q9uae>6YOA!ZtAj{&ryt9}s_yHm-$T;w{J`qjuqe#)3hM)bqY zpf{JwqJP#hDU)FxGI<>3!@L5st3Kb|AFWotOwq!1_aX-H|0EJ+FxDoyv%+T})zq5S z#)t6U=e>RwVN}C}t!wkD4VgFer272V_2fI%6Dzu+Mc&T!++|T5WIQlLPuV9pN zuWL&Ugt)bIlR~!@&v4Qk#gTuIiUzqiK*^=ZXv>HR-v~g!_-bE=RVfZnvkMK`IYtMs z`t%{eXqi509V|qtBst62dA^b85dEWsfLm;}qFh`yFHM>Z=+G2?VxxS^a@I~i+I z@EXm_qE?F6&voFgj0?c%7-lV`hc$@Ky>S0V;^s72ef;N}tiY|{$XCHvI~@qs2^d&b z_^=!b>*xHw;+=>7xXcsFGAl-p?3ut&Fyk4xM;D7ZLbFy2mAf-;OlNX?F{P7deqRiK0|Z6ZA! z^+kk-N2%VIFd?D#V)uEG#Kmdo@A~hk&|Ks)n}iVJXPnJuap-fOi{BS<68e$P zXEI$Bp}*_%MLH3AF$VV?|ASw}Ndq~M;BHuSw9!*iA9ON^B4aY1uZ;~OxU*J|@Ead~ zjTm6|<6AiRy!0MWB;N@eubv^7K?X9MxNo_AD~FIlj9*b)(|n_cZGPg$HzW^OYefNT zt18wkG;t{A4KXuOy@HGAU>034ewhX{HkhFZmxZ%6PokO1ebFz1?dael+Qw`bY;b`B z*%=FO5GBmO8?|Ky;Hq^2Z#tb18t42pF07X*#{L9>ZtaCiF+p?vtO1ci>pLA!Bfc?w zz+E;FHe5Aq(|8KYr^JhcMr8u9Qfh;c5lWQ*R=(Fr_*uoQ0mIRRGQLR19Rfdd;|L9+;^7!v?z1Fdg{BYpusz@n;h zMi@x0IBC;Jabd75#K>J&g(6q7G}e+O=nF`c)6J{!pByE1T_VWFK<<5@x06Vb>DMq73YH$RoXQB*x>t?}%LNd*1$IMq683`_BX)a7j z<6W&}w-lEKLE$@$hPr7A|GIWg1hw;LYOGbo`m{+ZZH9Yk4H`>XU+hpuX>$j(&hU3& z9ll{!PDK41#Px<9rviLqY)bnvWyM4l1N7&7nq+aQv6}RVen6zI(stPTU0#DM^@IGg z&$>$|^v5->u2otWU(7Ac5Ap!^7hvtiNfw`>;;2PY@j=}TbtJM!x*XgBtiMe+$kA|n z`AFlAtVc&E#iPKH?J_*=w2o3d3*JQYXW%hN3)Jbr>wLKeQ0B;? zXCm-JpP%6(q3=B|5^{N0X>^-#ELG%CSq&;PTz#?DDy&k=?w0HRcR5H^FVXHN0M{@C zPFWxqxgl_0}{f6;K~u`-xyp6rSlp{-<>C;oOV(zMRk2vqouP^ z)EpDQ3LFiJ7JHT29tqO26*v=DPfP_-xqj|Y4z$QHl!NRuT8^2kw2;%_YgrlY^eGyd z?+^Ut+7HXV2^_wFh=4|ExLo&t&jSg&f$qT<#N*Odm21`yGP5&QtBh=1@VyGtvp}JZ zeyzTNIOuOhP-thY*I@cqq^nx%QrHuM2{(!DT{me{U%!u&ClC!Nk%y zvTx)>=$%QwA{!gME$SEA7!mwnngP0?6AWkuYO@&R4Fl_kGWI7msA>!()m78*tF>uc z(x%f?4`Afn9O3nv$(H*)n*+Xe9)DwsGl z`=1H0ewYu607FUec@=)^Rslk6w6EI~fN)zJ48OzqAf`6KV1h3IzJ=ak65Y7B7M%|G zRwwA{VlU@ROeAi*PA4#xfM#V5mKSrVYM)_f37{hwv--N*+BI099r-hGc09wKDZ2;} z13E+a*(78rvYm)aUu|I!Sc6j=;1*y)l4WO$IcOZ|^nXicOQ3CF`rWA{bY#UGSjCua z4G>ovX@?omO*)BG-hFL;E9+X(!th>&%}&<%(*i9l`Y5nn{v^>Qvp7(I^wuLm=O$4j zNwht*(OqhBKN3rf>mGu6@|e>GB)gKa$x{R*#)}+;{LiLOl1@kG ztAAnqdmWMD5UQw`)0G~!l+%;7Rt@H2nv3G}r>9wt=y5?ZtakJ<1Efo5{^srTH>deK ziEN9tK^DS<_M1Hn4x>ir@Ho7R5}Nx3{1c064TvQ{sbQu~*m$eq8vmw@-UP2$|8)=x z{!Iz4hp}iIY;>4bcZw#Jhp7#;c%JeH1hx20IXq8C(SsB$pVZ>(nT~}vxjKix$;3+C z7vJ0W1)A`|Rsot-S;`yl2=2+!n=J}T_2_*CdnislNig_6j%WD&D2hdGaklNniHF<6 z>1cH0B10}VV*Sjyloz(7=V!|tdyfahU-|p>`_bs{EEt6LPd3=z<9W3A>-*yG?}OEQ z_P+T0_TC9kqrG7GzIcB)`ul*rFYX3|P^PPFihrvQY>xw+GCd;|x`VXerSPQaXT)Vh za(Hi~q#oL_cDO$>YWW-k#L#yLA*rgZumj>jKvhMBDOB2pGMb+#hH7<1!+Rrm+jPfS z0#^$ z$hAV`Wp2k6m(c-wsz}!A&rwtk9fkJjC@eAR|7bBPfcY{dY=#IBB+b4>RO?nRgaHX5 zLuFfVjh4yU#X-|ZT+1n(M2vUXVo+`chpF_E)_3G*OM~TLY-iB`y>6wjjrLe!yyo*b zk7q@Qq}FJIi-0U~%QEmEWl0jxF&xIY7CjeULD`Zwj%J}qUA;Uct43w(MJ z7iVC%Wc_13dP@W1J=x^Ra=H7jb!B5Nlalo@sgA@PER9>?_kt_R_kGS4Ds>1@ zu<=EBsth-tq|rnC<1&e!qO}~E!nVKp_04ggBFY<;X^~oUR*HZ1Anr2vFjVeO`U=qg=&Cy%l@jTBe zN<54CerMfYjcPp*3HR!i$*HHq{pn9$6h+>Su3<;#)Va4jksw#BgJsly(|B|V_glTg zkYnwkK(T71>W3#1lWI?6Yc=houZ3=CnatY4-D*zjVIpv9!JxE#g7uV{h>VYFbJzIb zMc#6KbNL~(Ibzp;RvWBZjScy3F>UpJ3h=qwW5 z9jaQrP6wtx6%A57x;T}I@B;UA4K(C;9n3gtN z1hsZmZ_sr}jI$gs5Yvq^J`-;!S@)yX3Z3!*PmiiVZ5!{v>v^4yr5j=dZdk{;c>%E1-7x5K`Izc8j&$F>83cHdxNxYd^ z7zs3Ac*oEk51SXB0HmWSRGeRxrx4`3+jWRh`+OjE+BA!GqO`Z?z05B3E5hn3_78et z`e?Uq5r_?)IPiL`v0x_P{H;isiPaOd-+0aKuZh38BJ~d4bnxz2JH3GSRn$^+1HGZo z+P)c!>thFwoFqWDjwF1*05ti{C(@0*WR7I5-c4lY`cPP#YE$%{F+(a7#J@Q4_mw=tvWJS`isH{b&FN3 zh`!C@dA)+xJVev8+@p|6UgY)qp7g%5j+D~3c@SW!#%Y*LLpjo>*~TEXexWi1ca!Nt z+_|QbtGOV~+uVCQb$Z|wuxF0le7sZ$gpAXK zN44suxF|@BPvdg|Ld0XhiUSKj87J}&G4YPC6zQrHr;18okHBdr&d*??Hg(1sEMWdZ zLFQyz*O)bDvZ^iiFPbX4zi>QoF~!v57zK|PB1t&S)l9^2pOib0#fLHZ7WgQj=Us@M zBmtzpa{=n&7*r>l=FgcGfS<%!VJ$;y%%Q^erD@!d-JDftnr|HbX$_&?hQw5!$ez|5w9;{H0w+C1g6JS=n*zkW&DW` zFMB0g_ESZ+ZyZsBx>sXre!6y4&Fs#&u;15p8c3zMs9q!PZie29#I+)(sQ_l%EOr!m z2>aGDULbg^-r^(lC!K6+Rf$zLHs02b)_&fu14{OBLkDiz%DT>UY6h;pGDa8x%@*8) zx3jZJ+GP}&o87T5iuzu@vF4_%s3tF{8gg6O+w zFjs9n9%l;$Zgc@ppGBdyZ<-$HTFpxyL0u)`uPQTo4U$xJy{Dkb<5XE}?kK!|CO%jJ zK;85BL~va(-cY$X0jFAc-o*-+HcmZ+FUE#q&!_eAxmRQ`go`*Wp)UY=>XoNiq4vby zo)^4TZ?@Aoog`4@F&y&aD{Q=?^1v%i&`vr|;8nwP1&0E@G)Hlf176m|N9(E+QP%sd zdl@IxYiN$j;c#{m68a*N!sCtX$Vz;`}%*CfC0StLf&FRD|1Rg`2 zQken}jTfg^wEYwx6{rpsj7q@t$#V1D9g8HpFmq?1gRQBwS5zssF#%`=tN>DF2~EsFdR~^e9u3AQB&alO>aD zrQmwM1ePjG@v>_oZ`TM!tNK?f&vI3AnPFuN^HQ}`1cu|#Y=?&VC1C8CEJP6GKweEP zW^}epxT%K=Sl<%h6#C~H|7{lzHhb3?oFmdw(#onsT9HwuRMN?o_5dyB`o@$r?jJk< zi&7@V-pAJef4=|a{l@v<|8oD+`TxlJe*#F;+W(#Io=)1iV{&OsGb@))YT5}|VptC! z4XB>rp0T5Bs*y3^sDikmfS{xvHy?>L1f3pAwanoamKa?H3MJ)o^)iu#mq_gVsYvD` z_b_N|;VlY$j!Y(|!*L?ZD@X*sQ+H>l&{gR^%(7f~(?nd#AoSFT(jv&mjZ$Rs3a?s9w5qc^aR%NOPSIDMcyPU53kZPz6>yhT~~Go z;Z1j}(wKneskbPt@J^w;4$-QTG(oz)ZN&DoORJ>YlqPT=DBRdJ^y%8tS}WUHUVk(@ z=;0MDi+-i*Z2*R()$!?6XJo40qo74AU_eT#C#rgq>_m<|SpqI5ynNEE)XnK?CgvPY zIN7yB4T}PHWMxDm3tQtSrzVppF6W?S*VP`02GHpdZ1q!wV}{oy2ri3KCW%VJU7@O! zdDSF7LQOBb*Uz*jC5}J_Uq<4xe4$E!C{Pz3+SQPkk5$Xv{SojDJ2rsgj#DqBc#$WI zmy4OmB~FB-rS?=aYbZC@2Yac!?( zPk8^i8Bua!BiIod@wi>fh1pw>kWp-njQlm_5*sGj`^wt~!M8~ztYk=TuVQb{doa;r zEe~yXU)8el;Xg?YX&$aXGPpoc@v^0>oH_;WxY^DAn|0n2GWg=Ty zu^2|USfC-Mo)eAiN~td7w+EQtPBj%re0?=H;DLLAGWHsu#>DW$A2oDvr{U_J)(t!D zZh1BI4&A#>$J_GA5mcu+^`432__%FAu;+$5-VWdSuw%ijs$7`GP*2ZJ-87*M1afZ& zA;@={5aiy@?oJJZyfzv2)8|aaa7P{a9q#SmfwxDXo-%HIje}dS5uH0P%b7?QJA4P} z!Vce=Ws5@Wj4bT|mbL*Z>n;7`r`UQMpNT8YgcUYZs1Z7(dz8#38S0l3_o`P9_Y9N8 zJ>q@1w*w3b{Hq^k@Ck@D_vm(qEb1PI3e@9oWDniZ3c`M}yM3!yf&TIgI9xCCY85oz z1E#Q(QNepSm2vAtIsF z7PP}FTNrpZqA84rp0nNdxw6bhdxpwbB2;P%%v5FJ!RCVo9=v>vm=i!+1)0kNHpQF{ z(?8q+WZ=%k)!WQ`Zs5*46ABRY?R|c$U*jl*QiQ-WAR5{1Sf@H$p34OP0cd(Wxw8p=25kMEkzJpHb3JUUx7nB`DrQ-u1FwB!>m1sK zx<;nXS}H|KwRKIGTv}=t>zV?#)vqPr$Dxy_ZE~duY<=vTD{YyBhV}A;M8<`OaSf1z zRACK3tU~bu8cbbP9hySUCOh_ zRA2Fz^%aA3RG;am^_`-3K!&c8w$WFxwACS81497(KfKMOPC$(Mb zD>FP3S0f_0&J)WH3Q{jaKsN34%_dc*4V^H;qi$~}{YCMAZM4(e6rYSo{^RUFwElPe z7Xv`tVE=Ky#s1^|m!IrEK92oI)9ZIEEVbiwlW7NC4UiGt;;f5S-*URfplRwx#p=>2 zQyJcAjZe@G2pXDP1>hLRy*NL?hiqYqr`oJ>r?)L^#7F;co`1r!`KzrzZan|@zUbA@ z|NfV~Pxc@GF8mk2t00d2e$xbr_DIlXx1rPEK~;;qWO5HG^>OG8_1}sMH5JrtTd8;K z6VLFkJ^#5usQQ@q|7Tx**}VV1=zad_{C|}D-q`u7?&c(8bj}!3(t5KO{deR^)joK_mqv0jp+HxBNLJJz3z<(<7 zKMfS@%niERR;}27Ump$xhHDr0{xSLdRC8ICweXAl|K4mwn!(cjtUqA?-BBB zvN+B=jt$*hsB(=dc17ycn>XONEx@@jL>?zAHQC|HvEG&>(HKkd43>A5@?7cv*# zL_mD|!ox8m8tNi_q5wHtYOcOkBX=8Z;4~Y^tvH~FeD%{|#@vG6@lKFkNtuz&KS7sZJ78!F zLe!ad0|5Uqu!OAFfrgQ)gf>>0gBivGyu=C*OgZRGiF7PQ?GW97z0$DurwDUqxmw(1 zEeB=HywTCRjm~Z?Z0%!v6IC+db(?j>#^X`#-lcsrapxA}*1g0vq36g=+x(xoNIok5 z=l*^0^=aULetQ3XJpUiWYI!ZvNgXN%KhzWHfE$RvBreLQkWWms@_Ki_h37KtrOo4c z8t}_Xw3Qj=^KaR)tH!`Dm9hoCE36-K^ZCtyh+!i35Uoj?FgIzQ5e*a!W{`dL4PxAkNkNh7u0eO?Vrf@TnpFjk=O8~JPCs`qi^05v@ zsNK(7s7O4SV8E8Q8HPq62my#L2*Lx!$Go(f(`Yy*%pqJgl)u~UHX3&7Kp0G@YjmxJ zXvi z1HVN>YDAi-VvtvvS<|AhwVU13tZ4dnsDaO8z{#^0+J2~wT3&DMZCLf(Fn_RDuPoWZ zt@bhC>a^kjTBW>zg2oiJnw1aoj}nX$1gb$Qc6x`7~i@y(sbyuV%+t z(p{uLIzXsbxRx&c#>-uI4y|XQtI#)R7^LS$vG`qkpLP6cL)ygUU zMohr~36YYjT-9Lv&S(JzsWDoPPgq=@O#}DK3?9yYh4`qPG1NY3j9YgL8gyTadbMOa zX`hf=j0#5v6mYrb?w)|*xKcnpv<>TDw9kloGq+9*nb~RgjDKKse{#v2!&c14mPN?b`0Q44+Q9B77jT(4XCK8EW zPAG`pI+FzoP=wY(GAp zjQ=a1#FNjz`0vSoeRh2R%P+>C-S3GnzWlH8{Yf1EcW?jm`=9SAZm+OZJ;?bT$AwUS zDMhy|{=et@_y3E5NAv%jFNBr3&G4`qFpPwj$?OodXK;PsVbI@nBw)wc6RiuHlj=BQ zxaTaWS;&I`RoAPjDfGw{tU$(YE+&hy2!eP#=48s`FbeT_tWB!yvr85Pm64oPYdjw7 zE`zDWfBf^p;s47l|M>j>eoOp^{m<`zivRF&@gD%`3&S&w1)Sx=iUJ|4WWQf242_Pg z%2`ngUnv7&ODQA_B5X=<8)y zhw6J)E6oa)M-=Glz+ZmXFV8GuvZ5EYbm0h=kjzHW;{1fNHl zEegtQp#;?ewNODTEX$d=+7Ok+tx~g$BI)8`!rzV}_niv$0MCHm;`5j|XcIqqTohW5 z3*rXIg;5@~53*fVfzHj|@uDQiP*rOoN_|c63Uw7KDGCtG&n*2b)RNiItm7;_7kODV z2#M$&vOA6o3ji0KiNH6y5ZZ+%_8uJ`dgpO2K@z|%1Z=y`j6%piy@DOy!BRUd;U%sU zNxH|*-A`?9?bja+$0~DtDrhr0DAlT)W@dC;p5n$#8%D6PEcF0Mv~ z-X1~Sv0dROAQqJJyMQ~_xL?$*jH>$Uv=*9BE*z>-s;y?aQ&rIFNz$PDPJVon>QVo{ z;26?8{}*v`_EG)6zPR7C|M|T4N&fRu{%e{(CTBNA(7aZSD3Y6%h?epSz_}n0P%hGn z)rP1e2Q6K>F~kxg%D3ByLq&ApRVE+}Hm<;#oV(Vn2DcrVCp6YmkqjR}j~z(gw5Hd@ zzLTok@ue$lLqk|LA#h(hRXFIBnqQC0F27yiHI{-4NPpuD`;yWBmN z>EtgW12_18ef~vL{`ckmPxikbSN~%cpL;SbGw#h|E4UDfB&PC`Qz%NAo`AHb@)YY@ zo5z9+7a%u?OYuA|ZNIYap8AmGR%R1w)6S?v0hxTTFRRaVWkH%%5xx-~O0-ffUMZQH zOyOUZ`Z~|fCE#kZM@1eGV2lYZ0UjlWW z2weoKbJmfd+|3}Yf>t42f6;WqXjvYtQkSS^#d?8}pIkNK7Pr&<2f(`>`gEJ)Nek*P2fL-?+Z^dZTJZ}{95mt`K0TdeEqT&<5v`7Ct^C7UM4TTk zKP=KohB25PC4fI?ZG-w3%Z|O4LFpcvC6y4ESs{t{_PqZovq=`}5L?eMMl!^}_TtHT zoR;wk2A6_WQp-5bMGUKoc10>0S9vO+t~5(`X+s)fMKQ`y2e6Yvl&{j8EWlAA#<08> zS$^h~7jlddELEYds^eCKZZjowVqMx^nC_Wjt*Mso=DKqj0co{}t~z)jJ;+ELZLTjO z={p8omf1r0>#2nk-2O~lJ+bz=mG3`_Bi_jWjgu_?i{wBzy#GGyHSj;~_rCn(|Nn2} z|A@D_k-58pU$c)CZl)dXkzl9QRkKdCTQqB$=c3veGF_iuaQ~w74_*Wx<^6YmzbXIy z3}eE5I{*LC=YNyMAQZ4n2AvYXoG9On<^v42oZUb?nSkW@5Wz$<=lz(-sTg046XDdZ z+I$4TwmE`(XCRrf{(LS@;_;PT(-<{JNH8@|Bg5Cc9!cMFSHq>3UL(X?vc?Ps@JSN;enk6 zgBVw(C)NhrP>Hh#gO&o^=|in@>mgR-0j+0hTQ6)(bgIaCBCS@QNk#R9ZcTH!&J2)G zoHKI0#U4{JV-rh`Ywh-_b{VTGc#(jf*7e?l(Y6$@lZIrKO3z<=mfAAi@ZfTbsEE%u zR&DZGVgVSA^{RrGo=Y!+0L~is%FOBmAb00+T1-Wbm^Plclm)(t@NdGVBScpyu3HO-7m3eHI(p9V4?t0a)kLBtF4d z)$$}o&=A!!b_ZVig$)FOf=n#aWH7+ zhVjvKFl$3cRmbJji_@$;6*<(S619E8<5y1(U+%o6HPgYu$JuP2r4ZQRjskJdMO=VW ztLGGc_v(k|k5y4wP@K05;hp61wDfXNl&aQfMqw8~g-G2g#vov)z=|A;DMS@Ky_)3o z_{MI)2F34P>oIyus$4UkR~KOm7_|n|wk}HMpiJBL9wGr_^cz?3lltd>EB{Yo#J}VP zaFhKPVExtJ|Mx!m|9oWrAM772%5fcwZ1%~m^*{Vpnjo81rR}%F>;2lqyDI28n{`!! z@M}fp#0q>m|IPD1&HgeNsB6#vz0dA{(ZK)s?2G+R_Mad3{NMiF`=^w;x2F}iejWp7 z`4$;I#puJ2n}h?mQ!X#j{Kd{v*+f~HZl;wsDVt_FJi4!=TkR@eG`B594paIvo}sVG zJkAU8G|Ma9LeS6v+B(B$rI@vAaGjdF67sP|@$b;{qEYxx4YrTWMIgqaJwS*81sanH zB0*(~96eLOI#9$DP*~}DC*7K!%vGLHSgMJ*ByAtFH9p`e@g2E*D-aP%PqOR`VJ(e< z$X1IcSvGG@0(WPP+p}^{`Y8XZMuUFhA!E3~$FV4nJ|9w-6Z5Yy2% zn#@0%HIrS?Qf*@FD}GACZf$ z_ZBp;ry|Zv=Rgn~=dmndi^B^NBE**%ARbXMEmOM!$S$3a`jMJ1@_AOEUUwFsLAx5G zJuXU$Ii3Js0zO2GuKV&|%#l*17`hpv-itg=imvzMGW76jk9-OJ+3)rIJ8m!~9V(6p z0z%s1TphG{KdD)??RnK=U}1upVcb&?N_TbKxSGBm8>XsUcWhn%aO4`Nn8V%JskV=w zA`2V#I&NmT*(}mg!;l!dPJ7Q%FUuz+zX94L@^j%me)U2l8W#{N=iDK1%4dn$)mVOZ zClJgO1Ohr77d8yUlJ*dtAlNF_@JMcumbf_!;axza_G5wcwa6Cvm}~*^MR|%HY@g}TAfNH0auLK@Z91Xs2(Wr}D>aE;MLe`= zPg@79KwVZiIkG%pWa+x|SQ$Ey2V<7f`Pi`ptM&h|HpG~;oC+P%Ye|8Zga{eZc)Hy# zrmvllY!#3{QZ(@)< zf^0C=6dj1iEKcQok*KKN%2WrKN}}#?o2meoc4eBzfFa8nI3bKrJc>BPe1(q2Klp!U7-GArd+sM)7=Oi!vy4C4`cCmE1@S-|cQ;7wg~ z;MP)SSTHf2%CQt_nOtFZPp0FX!pA4blf=0!Otew$!me&iGc^hU5vKrxb?V{RAr4v1 za1^jt>D92K`LO@Ly=!Z3+ep^$`W0hVDkK~lk{n-Z=~c9j<0RS>+o{OA`yexGh!~O+ zfdCf(r7*V6Z$H)DbH{*$99ePp$PbAK%pE;FJ$?DQ@5~jaASg>K-Kaoazf8(n`LM|o zizOLM`6%UiEv9RaZi(jcz#L3hkg3=?izBoU!ZzNb=H}r&O+p*WETZnt3QQJUQG(Lsz}Of@>n1hUQ#|i0_at8?L5Ta##PjPhfq+;F%_EzsGKVCxc zS!l%jkf*q<;X4x(sK}ci4EcG0{dp`h9oa2Wuyl8xh(-Mec`LUrn>C8@`- z>b20*_rT?82jJJyaz#=CizMsA();L&npkm)cfgX>rI^jYndx**_H@?M^Y3l`GM&Cl z%l>BvEc~TVgO_P8Y1V-rjRS=ZXEdc%Kh4G$NqL64pR|}@7=KI-=Q4tI&`-sAKe{SK zDEDv0OiA#6i*i2efdP~uYu6W_%l3{P(Z_o~%khDn6P%&&bc;?X9hBvsD^bOr8M)HJT0M40Y~JAy~4t>rR`_ znUFjjz)PMWvFXxu;)}{^DaUP+@xO?d=f#DLX+VCMq$xVFsQtQmkD;uxZHpNx21Yj~ z*QfB-f}*Hf9OyIaz!rBQ&7q}(9H}iOOOJg>*dr2qDPN=sv9wia`YW4S00=% zc=aHH5uB^pJ@(ZymbbU{GZ zGqa*Gy4~cpbPs9^&37-HWL!FyBQjGS$@vBT z-NFGqeVZ=b353jzkg^7=@W`53j6AhcKq}kLwIz)ZXTiw_crAHPRZ8wrI?Eo|@_5=~ zDDs!fIRYop-4NZK;($o$I|N7+7cvUU5ky;GI9L>daIn|Ay5|f*hRA5%#`V7 zu!$?v8eq8-F&=k5R#c7lLj|r8B=u#*%qZRmaI|0$9jW=}eBH+<)h3?$Y?gp7 zjsboL1CLP4$<~J>?=@vht1zf9Cu{OQSN!kC?0$Tg0nj(7eveK9y3iTr&r#lPi?XxD<2dV%`I zgJ$@rX*9A!1Z9mTUW*1BmSLi?vSi2_C-=v3U5dpZvMuZjtHAzPBh5Ke9|C+`pe`<{ z8b7pUMs60|P8elZabtgJw5l033a6pQD0EGg9La7TCL_!G#x|?a{c#|^HU^A!PDJLn z>aL-kM%2?lcvA}CdzQR%zyPmievrbNGCvrn?SFA%#Y$u(Uu(&ytAg=lGPkU2b4*YO zWssljIC701f#pnK*;rA3PM6u$q{efJ4Nj&-F=VGnIgX5T_wLVcPU5?7#-HuRHLvPH z(*Xm`t)5vM9{U0Rxzv(3ThLU~Kj{;#=m%r@02ER6pg-)BA&+dc@`KvoMA)X5I|zwWoq)|*m{TxF>ZM7g!vjUDN=(45dXuvcxCAxlz9 zyUnesdldfB){67zkcz9n-!v&9b3%uqU6Usn<^K&hB{PTX?Z|1=eX*bwptxV ztkAcePWxY^*9HQ(ZT~-R#(zK9zuo^gyZ_y2NR!h=x-|DTxODd%PGOa-czuMS%1&rF zf^1ovU!)thz7B=;;s9(}_~48kj=2hImYg6?>R z)*77<;QraKp^Js67G$vu#f*xyYDS9+bjq=cSh18__W$&T_W%8dkM{!m{}%t{X7~S< zqB^<CBJtwTA_n~1OV)|iG9TH8hPG)eiaZ?tccZ_XFQi^9WMKc(NH=kmL zkCb5;@?TZquUMVT`W_y@)sW+)fByv-D#8gdd_$2525vJE*;XB{EjKYfBD2mm6HeF~ z^X}4f{M;;^@l6JbYcft;*)U-nB^pbp_5J|}xua>|6J6cwqRm)C7ObmyS~LkDpm;ZF zedSSSK^)EnLScuNrzkS%Oj_Rlk}r4TCYRD)OVL__9#@!vhUe41HQAtB|qK$pNslt^nZ^6`G0Qp zf1eEgo5u&9IJbbKTr}|6dbX@?mv51;jgH|7Vw>4*2C&U2=V=4m3?FxgiwNlzET@nb zh~hv>!#j6ut2LQ*Tzo1U9PCnO0cqMox7GM4NENO7e^I17y8-;y`+Iv28u$+n_V;h` z|8H{tL-;=I3sF6r^U2w10Y>%erz91{kX6Z){|JsIUl#>BqF-(S;QvSn5nV6);EVHG zOwR0L>Y1%@P*_*(ST{xP&(ybE;5IRefEM)YE=UikIe861f3+314m>F&tw5Z5Uc0F> z{T5;>^ip|#1|a3XqoR&(z-q=-68M#mdA;)ijMJ*Vx$n21y)(^SvIF+-f1_=EUSugR z-$uSWK+6!E^i+*9gXo$EI*7ImwXFj!QG4HN#_NC5wX|_RmM%}Ltlnuf`W>&$byg$h z+lf4_vqx0l@w(>aG5%Kq_=6ua{ROE*@$M@-^V4pb5e6>MzkiSvu^t*>I5Pl%3;Hp? z!SE>S1E@O!?A36{FT@DLkCL~TA!1^_eWkVGtL%bHis;|)oQ^p&=#UbCr}%s zG81(0!w}#hJLQvP<)$IV-jb~3NxH_A5efC3vYwPBdZGR|E3CBo$Aub%$pGhTWSqqp zO`c^NK(+st=X5V2C1q|A1h533unOhSAxl_EXCe53$`f)&13I85DL^phYLTR$EZ{hR zN54UYfOkESFIRPsUea2eC#5WVut7Uaq=G}TZmRF!vy;9W6gV9b(TvS$AxX-q5;|0} z3f!lu090=u2j1VY-0U2{%~AqU(#qm=Xjve#oRU+k{1Oud&0L9m!nHSYFuaN` z%awr?Rk)avvMdooQJ_XARrWU%TOsm_mo>UDOY_?|uU^FL6%bBt4mAsBBL<$hpkzPCKm|)K81QE5XXKs(aDIdZre9Fo7AJ=KAQ(iEo5{LO zNSKN&>qn8pM^CKOE|8@ioYX8*vHEc6;Qf092bENYxS|bT#m4yT%AJT>^1sC@tHmP9_pJjNvDnh&B1*XNTB%naxol#7e>%Dx>X=i7rjj zR8UIYLZY~m;HTt~p`u^#SYwN1jg2!QSpvjEXB8&mI^%>Klz{8F$<-qe2{+{v%H1Q__jaCV=q z1ME*NS(~H)60)%#8)(L-nn(*?5hOCjh^kr;C7Q{h0a!tvvehz8YW?Mk+}vZTLiPl3 zPX)&q4~{ihu|n8{YMoDP>mrOMYRF))a6iEeEcS(b3d_UE0WbSQb}|m|DRiokLvp?d zF=1`zAk6`YU>~|UqsF=2B!<9j)V4Nz`6&RNfLt^ylf@#b#RUB>N`Z%bF;zP-wkVZm zb6`!JgkN9KM7h}v+DcVsv#dcQW1GXvd?9t{n5b|()1sUZx&H$R1SAC6XnQsaAg0k{x(T3jH+*bDi692-Du%CA25)|Vl37GX)9 zz29)FiSV5lm8zolkvmtbQlYmb*+sIhSZ~H_aQMMMD>-H0{eavxYyoab3)tA@Dksq- zVU?&?BnuH9iIlsQ9LbY1Tl=_i8b*`)6v!-QMGag`#y6FOF`kNw;u_=;Ks6mu7+@Rf zb6KQr_xVjhfD)+!m#6ZD#d?8^MBN9Rg$Z~rxbzhgTvj=pWUES?gA>H(pPPVCnw<0$ zh(&drEV#@}5VhHc{T*cQItgv)1faCbU?j{*JNG~Boh|u6`+a72m!b5e;N9{%PI;Y( zjHR>#Ax^NWR$PATqv!kkTJlBEda8wJKnZ)cs_J6#+}t2ka78jrO2Lv`t(TTC28wIc3p$Zod8XI&`<7b!4G7#|bdcPEB?n z+2zi4F#&(aX%2YZ+p9s#AJh}L8*J=Gwdx0Kcj4li1l!O}jf;~>O4P=&?9dHDr9Lnm z;ZWijWJOY2RYoYkI6e|4l#^P4Gg7uU((O4TYCGSMik`}Q;n$uRPcPYlG(~;=5yMIC zb{z}XdEk0Mti=|0zf4;IXSn9HwPX(F{IfQg_R?!2a7QmonMIWfPZC8ZNt_SS$(u&=Y_E9A? zsTCOL%%8G(yPSL%OvxRN!@zIaRJEby3_&#W1j)#?|DxKe#wZ&EW-cz+DL`yeCJUS0 zxLDCT<04sL6m)b_lSZ+af?jh~@#zX-?=H$@$^MF3$FDRxpojWub+(It5fH9XS4usb z&dw+&gfAWRXfh>mL}gV+W|Bd!4@&ECx^_%_pu7dO(4@$xD`RHsY$O|aP}{|-RwQm) zUTo!^E_U1~R`GhrvkzrFsrYlbl^5iVpmw%vc5Moc(nzB3LUL?_$ToWNq0 z`?aT&G_FhZ~cG&F#mtBK7rf8 zQoB}FDkxwG8Ei{1Z2b%PcGvumog9HXXP|8H%5?@ju*W&*|E*8c8mK6OA|Lh9&}@q< zMcy7kAk)%paPVytS?nNOQ{`}TL@pay5mqV-Mp0fQW!gAd0S6G$C}dTGuqF?dFbZuXsz4K<`OF8CuzCdY4^91j#Yv0(E{fZmZm<#RqT%#hpz{)?~7{0 zPWafuO4(_1JFq!vfvx-$0w%mu@!gW?t1Ag(pJw8 z^DO!FQ;PBYp#2Zw?l%(u`C$J+L;ia}Tj_TH-`xKHgJZS|@B+VV%5@VDbp^=s3gK2) zK#2<>qEPmhlFQ7Wbs-(->@1$HBd}bWZ0$P)lMBx>Tg^lc9;auV^_~@3mMkmYBXE~< zUS5=<=9Fj+6(jYWi;@)=kZ)xsCQZNGwD?}6JaT>o0)@7sqg3@eLj%B%7(`O9A%{n` z%#t-k6yzX&4}&oSI)xXeJ+OYVQOJeIjl*zfay&0CBInnYXCg&knhk-Aqr|%}{RZfT zoEgc&IJYQ_=10<=6=8z)`yK6Qx<6{Bxw5T^1EY@kDE3#kta}oOpn@d?_+|_sZUk+G z*VkVD4NYqA%S$^N1XO#Vf?HCAhP_SD*}UTQYq8+qv`h)sFq5k7FAae<6Amge={%1d zVV*3PJU5Bi-28u#!i^=Wg}+TQF*%Eqi)5{wG0z3Rz)e;04AKrJ70y04F5ELK!UO9+ zy-3z*Dy+q6#vNKTlNdlNmI7A#cO4)%8+ZsZ2Vb>8$qbt%-?e2cpzlB(J~%wbv@aKm<~v<5t_|B!_4+{wkF|n(nDLt62nIo1#}CI~ zgfOZ!g>;|1KfWpKMuhO%%^BU=#S?cZA5MpVxF>(wt zQx7w7yD~IxOZ?#m&jg0_uaN^gqxTn9BOf+T< zbz?RB47$=5?fq!S#n<#S@9&ziESBC}p&MhHFM~NFXuL^ERkY$-*J$X?b$@YH7fZOb zBs1K4gJ2Ftyi7rO7q&mZtb9OXl+>dr2OMM1hm1$jHgs}ozXRYWrbz~8h@%|%!~s*K zYKh39_iezDAqb~ZV&1EMD8PdP#x>1T9!5d|bt7d^Y$6|3gS-+`^{**=qchBCr=y!E z-hMg_4na<@V{9=*dLE0@OysjI?#h@)Tg*fr>Wqet>_p*>2C*^Pul43Tw|`_qm|+lB z?>gl|vRY&~a7{lh$NF)l*is0Fr3)k8uD`G*>vkTTb$I_cs_UI?y|#e825@4Xz9?)C zCoV_$tjOy`txg$70#qe4r;`Dj`LXWZR9vT%dPxTTyZzZ|G(Z4{^P?3 zP5k%Ak8klGKUM$FE97+nBKq&h>jE=*MuKxNlWLb-D}S#0zgAuSZZ5DH|MAh|M-Bh) zhxhN_`hVT*{+I5IFD1Z7HDusutbcC7QmlCWEK912S4|_{gkHF%kHIh!D2xV^>XR@v znjK(R*mf_lt0K!wK(b5$-cM@p<`nc^BcVtXI2oE-vH&?#%hLwF>Rx{ zXKIQ#u8T66@fXR`L5Jyc$Q;xk!5tLq%V+~BzZ*7+<>Hj`TpqBA+Al+|se9eMWjs0O9ooTio2Q9II!}`t+%!#UuiG@Kwcs@6{VDLlPmqqr1B*c$ z><(L?1F<_qBBbqLLCVo|!yUX@)m?kQAyXGbip?%Ig5jA3ak`5_X#v=QH3SKmFc31` zuuSU$`Ii6cjFi%k%eD309NMRJ&13qi+nC@}0B&RTGy^oH7BX2T6H%`>QBpNYv`2() z^A*)&2d0a*uc~@*8#snmJ5=|3m(3{?r8AgAw#P=(+ad(|tgY|VtdAx7L$)WY@0pRK3TR`r^8Z>MG+KWwxDYI-~(-cDAhVsh`4|0Z}j*oz+w z*ZCgp@ z!S;1WlNiZX4_YA}9HDYgZ4-7tg zXZFu!^!6&Fpu@Li4Ex(U-gi6m@s+*PWh04rqRO9xL|(1xk3=J`pmj~(XIpn0iQ$?E zQeGxD%4@FF^{vKemC={#oOpK`S$DcSjafyFC(i6}13>%<5#7jXUIthB4|$%tk3e3v zY!Asfzt$E-K9DvpgGoP>mL>Bf3T+phaB~d{(6r{L`(&-xGamh0Np_+zeD5i z+2T8BQt3)}(Q=jNO>a?JZLlDr%iJgDCu(s2>f{tb{$%Xr1!v1r6s4#EO1$L%S@EiV znu`VU?r%%5(O=BPWDcgjb)_21#e@TpI{17`2-iealJJiTn;ZZ@Ek9Ztf>eV~*X)e* zWu+HaVd1IBxCVC^v0Zpr?M~LH5!|IVEMvqycpuN} zh1+o3lEc@alN}zEf|4P$Ulbx0H1PgC>sAk!|1n+M<>+uVA$ zBtP-G`1qsn)6RGS1V4y#ejxyv7@cu2u$e{xN5_w4u@DuveaWoH8r&iQrE(;DI zbxe@~uP!+ObA{qz+-ZYMU_=2G35?AQ@L2O)qx=S7t7v?5=W$FY@-qO%&2)@0ilUIA z3KiV=F5{^=X-DW=hThcy1LR1}HFn0>3F$@p3PO0L3ADuU5+~a-us-=R3MOrlFV$B< z2cVMYQQHd&C-*(qLM16!97eN|x;>=&FjOwI`xHbCYZwHK+Kyv6Ny-xRENYFDEE}8z zO9u&c*jhA>yC+7Juiz3#Ia{tEL{_#%qYYcF0>H-UHbsUTW3NpwpEnB=^AC z8RTyh;%t_3ULKHVd|eH^Qy1jAJ>Zmf8TW(@7O_rKD&S?zdj2b)tRR4!l3anvPXRq7 zx8#MjZYHXSmShk;p_Q;svgYvx1ON%Xk7;G$&u`>n!{5(y=}o=lC1@>PyPwI*as%}n z*(P+$;QwYmDScvGo>2M;THIh`-zc4z-(b8q4?F03lh2JHa@ z*nnMXpN$ttCVOmV=C}-QobZQ1!%}74TAw1OzPatFG!Um8j^u33dPMMu_|=Qq3!cz@ zRA-FPH`vLfSV>Pn0Wx8^BD`Ui)a>aGKghmSCI!3GBv18K&yvs~bA(J%Q#Y%{7EYY( z4Kjj2)`vMyzm|#t`BT#?h1unznyawJD+c76zMLREmvK9Th~&&;wf{EMc%BD=jb#H! zp&_Jp*VSdP5iDA^rC5M}pLv=h)PIt*o~WJ`d5+N0J;I#@>l~XT6}gM++)?Chn>x2H zjW!3_$U-sAR_l6&^rmo!<($$EI}SLh?X(H3w%*c~QUNAsJPf`Xs3`)v8mgk4iX^(3 zGS+UVT{9vQGfE_A=VA_5#6h4rd4CG`Rn5h;9z*~G_e7E~u<}bwW}>IVZhq);$t8pK zAE!18Qmg8CPz|(GZ$MWOL|nA?P7rVLWl{5y%=po-0(Jvq7Q7HLMIk1PniPu#1Y-m0 zk*mJZracEVQKl>@*D7t5RY7$T%UIf3nVcdWV>l72@IWIWQ=74brFNPN+nUG@m_&`9 z@ft3XtD2|i{(w?ob9!;f(O-VBJB;t{qT7ys)UW#ZAYylo)zP=pcI*yHU#TzWE1Kz% zVA65ATK(6UZF_f{S+UPmEmmL&HWnjpFzjxm+lhR;Io>*a+>_FGwy<O7j@|P`KK(O-==8MY9K%3Ys~JhSf;01RwZQ;C(wvt{-KpTPoxDW4*Hmo zm-p;SvMXdgHi=mT_4EcWu%YsiXA$nn6RRHy|o zF4pqtnLB@B$#v%bj}IXDj`usr843?)rxb4mk5@~iV#gEQ{n(h8%X;GQSWNk5f&8l+aD$V*dw&^! z5iP1+7yJO<>#5+cll^h)RHQRZ%cv*wM391-+-jsxUy)ZK8m^0Fj8{W9Q)<61mY7c$ zUcnz3pVpT9%@Dk2>$+Iju4o--{r)&=Nhf%sR%^&QwKI-?DMUW#vwllhox-y=nGst2 z|Ks?>F<0$Pm-S_C~CiU>ew8svZf&J(&*{UVRzw zKZyULUZlL5lmaiT@DhQ#SF;)1C>H|cgN*$>IZuwU5SxOa06EH4^?9-2U`>U(^SWME zqupKAcbxKbRj%aAq7rpcu6rYE%k;n?4RvRt?iqQ$w<U#F?1zyu!h~IC`y~E)s$3l!~eb z(D7Bq?S;)2wu7C^|$jC-<7E$MUAuk l-!J3+c)zC&|F>8uxBqVc-Tu4%_y6hN{{fPhHaP$q2>|g`VcY-! literal 0 HcmV?d00001 diff --git a/Dockerfile b/Dockerfile index 907ae40..d9c5bc7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -51,15 +51,8 @@ RUN go mod download # the lint stage above. COPY . . -# Fetch the third-party browser assets the UI serves. They are not committed -# (REPO_POLICIES.md forbids minified bundles in version control) and -# .dockerignore keeps any host copy out of the build context, so this step is -# the only way they enter the image. Each download is checked against a -# hardcoded sha256 and the build fails on mismatch; make test re-checks the -# hashes against the bytes go:embed actually put in the binary. -RUN script/fetch-assets - -# Run tests and build +# Run tests and build. Both first run script/assets, which extracts Alpine.js +# from its tarball in 3p/. RUN make test # Version stamped into the binary. .dockerignore excludes .git/, so @@ -67,8 +60,8 @@ RUN make test # host and passes it in. The default is what a bare `docker build .` # with no --build-arg gets, and it names no tag the tree may not be at. # -# Declared here, below the test and asset steps, so a changed version -# does not invalidate their cached layers. +# Declared here, below the test step, so a changed version does not +# invalidate its cached layer. ARG VERSION=unknown RUN make build VERSION="$VERSION" diff --git a/Makefile b/Makefile index ceed419..b5438f8 100644 --- a/Makefile +++ b/Makefile @@ -28,7 +28,7 @@ setup: @script/setup assets: - @script/fetch-assets + @script/assets test: @script/test @@ -45,13 +45,13 @@ fmt-check: check: @script/check -build: +build: assets go build -ldflags '$(strip -X main.version=$(VERSION) $(GO_LDFLAGS))' -o bin/webhooker ./cmd/webhooker run: build ./bin/webhooker -dev: +dev: assets go run ./cmd/webhooker deps: diff --git a/README.md b/README.md index f6a682d..9af5688 100644 --- a/README.md +++ b/README.md @@ -21,9 +21,6 @@ before deploying one. - Go 1.26.1+ (the version in `go.mod`) - Docker (for linting, for the test stage of the CI gate, and for containerized deployment) -- `curl`, used by `script/fetch-assets` to download the third-party - browser assets, which are not committed (`make bootstrap` installs - it if missing) golangci-lint is not a prerequisite and must not be installed on the host: `script/bootstrap` does not install it, and `make lint` runs the @@ -36,9 +33,7 @@ digest-pinned linter image via `Dockerfile.lint`. git clone https://git.eeqj.de/sneak/webhooker.git cd webhooker -# Install Go dependencies and the third-party browser assets. -# `make deps` alone is not enough: it only runs go mod download/tidy, -# and the checks below need the fetched assets. +# Install the Go toolchain if missing, and the Go dependencies make bootstrap # Run all checks (test, lint, format check) @@ -58,7 +53,7 @@ make docker ```bash make bootstrap # Install all dependencies (idempotent) make setup # Bootstrap + install git pre-commit hook -make assets # Fetch + verify third-party browser assets +make assets # Extract Alpine.js from 3p/ (test, check, build, dev run it) make fmt # Format code (gofmt + goimports) make fmt-check # Fail if gofmt would change anything (writes nothing) make lint # Run golangci-lint in Docker (Dockerfile.lint) @@ -1221,14 +1216,15 @@ This repository adheres to the standard: normalized scripts in `script/` are the entrypoints for the development workflow. Ten of the Makefile's seventeen targets are thin shims that call them; `build`, `run`, `dev`, `deps`, `clean`, `css` and -`version` are inline commands with no script behind them, though -`build` and `version` both take their value from `script/version`. +`version` are inline commands with no script behind them, though `build`, +`run` and `dev` first run `script/assets`, and `build` and `version` both +take their value from `script/version`. -`make check` needs the third-party browser assets in `static/`, which -are not committed, so run `make bootstrap` (or just `make assets`) once -after cloning. Without them the tests fail with a message naming that -remedy. `make check` does not fetch them itself because it must not -change any files in the repo. +`script/test`, `make build` and `make dev` each run `script/assets` +first, which writes the ignored `static/js/alpine.min.js` (see +[Third-party browser assets](#third-party-browser-assets)), so +`make test`, `make check` and the pre-commit hook work on a fresh clone +without a separate step. We provide: @@ -1236,8 +1232,8 @@ We provide: - `script/setup` — make a fresh clone ready for development (bootstrap, then install-precommit) - `script/projectname` — output the project name ("webhooker") -- `script/fetch-assets` — download the third-party browser assets into - `static/`, verifying each against its pinned sha256 +- `script/assets` — extract Alpine.js from its tarball in `3p/` (see + [Third-party browser assets](#third-party-browser-assets)) - `script/test` — run the test suite - `script/lint` — run golangci-lint in Docker (see Linting below) - `script/fmt` — format all code (writes) @@ -1259,24 +1255,25 @@ We provide: ## Third-party browser assets -The web UI serves one third-party script, Alpine.js. It is **not** committed: -a minified bundle in the tree is unreviewable, and `REPO_POLICIES.md` bars -both committed build artifacts and unpinned external references. +The web UI serves one third-party script, Alpine.js. Its npm package tarball +is committed as `3p/alpinejs-3.14.9.tgz`, byte for byte as the npm registry +publishes it. It is a dependency, not this repo's build output, so +`REPO_POLICIES.md`'s rule against committed build artifacts does not apply. +The directory is `3p/` rather than `vendor/` because Go treats a root +`vendor/` directory as its module vendor directory. -Instead `script/fetch-assets` downloads it from a pinned URL, checks the -download against a hardcoded sha256, and installs it under `static/`. The -sha256 of every installed asset is recorded in `static/vendor.sha256`, and -`static/vendor_test.go` re-hashes the bytes `go:embed` put in the binary -against that manifest — so the pin is enforced on what actually ships, not -merely written down. Any mismatch fails the build. +`script/assets` (`make assets`) extracts the browser build, +`package/dist/cdn.min.js`, from the tarball to `static/js/alpine.min.js`, +where `go:embed` picks it up. `script/test`, `make build` and `make dev` run +it first, and the Dockerfile builds through `make test` and `make build`, so +nothing downloads Alpine.js. The extracted file is not committed, and +`.dockerignore` keeps any host copy out of the build context. -`make bootstrap` runs the fetch for local development, and the Dockerfile -runs it in the build stage; `.gitignore` and `.dockerignore` keep the -artifact out of both the repo and the build context. - -To move to a new version: update the version, URL, and tarball sha256 in -`script/fetch-assets` and the asset sha256 in `static/vendor.sha256`, then -run `make assets && make check`. +To move to a new version: download +`https://registry.npmjs.org/alpinejs/-/alpinejs-.tgz`, check it +against the `dist.integrity` hash listed at +`https://registry.npmjs.org/alpinejs/`, replace the tarball in `3p/` +with it, update its file name in `script/assets`, and run `make check`. ## Rationale @@ -2755,6 +2752,8 @@ imports. The entry point is `cmd/webhooker/main.go`. ``` webhooker/ +├── 3p/ +│ └── alpinejs-3.14.9.tgz # Alpine.js npm package, extracted by make assets ├── cmd/webhooker/ │ └── main.go # Entry point: subcommand dispatch; no args locks DATA_DIR and wires fx ├── internal/ @@ -2848,8 +2847,7 @@ webhooker/ │ ├── css/tailwind.css # Generated stylesheet the pages load │ ├── css/style.css # Older hand-written stylesheet, no longer loaded │ ├── js/app.js # Progressive-enhancement copy-to-clipboard -│ ├── js/alpine.min.js # Alpine.js, fetched by script/fetch-assets, not committed -│ └── vendor.sha256 # Pinned hashes the fetched assets are verified against +│ └── js/alpine.min.js # Alpine.js, extracted from 3p/ by make assets, not committed ├── templates/ # Go HTML templates (base, login, sources, etc.) ├── script/ # Scripts to Rule Them All entrypoints ├── Dockerfile # Three stages: lint, test+build, Alpine runtime @@ -3161,14 +3159,14 @@ version is fixed independently of the compiler's: `make fmt-check`, then `golangci-lint config verify` and `golangci-lint run`, both with `--network=none`. 2. **Builder stage** (`golang:1.26.1-bookworm`) — depends on the lint - stage passing (it copies a file from it), runs `script/fetch-assets` - to download and verify the third-party browser assets, then runs - `make test` and `make build`, and finally rebuilds the binary with - `CGO_ENABLED=1` and static linking so it runs on musl. Both builds - go through `make build`, the relink adding its `-extldflags` via - `GO_LDFLAGS`, so neither can drop the `-X` that stamps the version. - The version arrives as the `VERSION` build arg, since the context - has no `.git` (see [Version stamping](#version-stamping)). + stage passing (it copies a file from it), runs `make test` and + `make build` (both extract Alpine.js from `3p/` first), and finally + rebuilds the binary with `CGO_ENABLED=1` and static linking so it + runs on musl. Both builds go through `make build`, the relink adding + its `-extldflags` via `GO_LDFLAGS`, so neither can drop the `-X` that + stamps the version. The version arrives as the `VERSION` build arg, + since the context has no `.git` (see + [Version stamping](#version-stamping)). 3. **Runtime stage** (`alpine:3.21`) — copies the static binary and `deploy/docker-entrypoint.sh`, creates the `/var/lib/webhooker` directory for all SQLite databases, exposes port 8080, and includes diff --git a/internal/server/static_assets_test.go b/internal/server/static_assets_test.go index c7d389f..884977b 100644 --- a/internal/server/static_assets_test.go +++ b/internal/server/static_assets_test.go @@ -13,9 +13,9 @@ import ( // TestBaseTemplateScriptsAreServed walks every /s/ script the base // template loads on each page and fetches it through the real router. -// Alpine.js is fetched at build time rather than committed, so nothing -// in the repo guarantees it is present: this is the check that the page -// still gets the JavaScript it asks for. +// Alpine.js is extracted from its tarball in 3p/ at build time, so the +// file is not in the tree: this is the check that the page still gets +// the JavaScript it asks for. func TestBaseTemplateScriptsAreServed(t *testing.T) { t.Parallel() diff --git a/script/assets b/script/assets new file mode 100755 index 0000000..69db60d --- /dev/null +++ b/script/assets @@ -0,0 +1,16 @@ +#!/bin/sh +# script/assets: extract Alpine.js from its npm package tarball, committed +# in 3p/, to static/js/alpine.min.js, where go:embed reads it. The +# extracted file is not committed. script/test, make build and make dev run +# this first. +set -eu + +ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" + +main() { + cd "$ROOT" + tar -xzOf 3p/alpinejs-3.14.9.tgz package/dist/cdn.min.js \ + >static/js/alpine.min.js +} + +main "$@" diff --git a/script/bootstrap b/script/bootstrap index 49001f8..1f014a1 100755 --- a/script/bootstrap +++ b/script/bootstrap @@ -4,9 +4,7 @@ # installed tools are skipped. Base tooling comes from nix, apt, brew, # or apk (detected in that order); assumes NOTHING is present (not git, # make, or go). golangci-lint is deliberately not installed: linting runs -# only in docker, via script/lint and Dockerfile.lint. Finishes by running -# script/fetch-assets, which installs the hash-pinned third-party browser -# assets the repo does not commit. +# only in docker, via script/lint and Dockerfile.lint. set -eu ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" @@ -69,11 +67,6 @@ main() { go mod download - # Third-party browser assets are not committed; fetch and verify them - # so a fresh clone can build and test. - if missing curl; then pkg_install curl curl curl curl; fi - "$ROOT/script/fetch-assets" - echo "bootstrap complete" } diff --git a/script/check b/script/check index cc046f7..ad86da3 100755 --- a/script/check +++ b/script/check @@ -1,6 +1,7 @@ #!/bin/sh # script/check: run all checks (test, lint, fmt-check). Our own -# extension to scripts-to-rule-them-all. Must not modify any files. +# extension to scripts-to-rule-them-all. +# Writes only the ignored static/js/alpine.min.js, through script/test. # Generic: usually needs no adaptation. set -eu diff --git a/script/fetch-assets b/script/fetch-assets deleted file mode 100755 index 5148b4b..0000000 --- a/script/fetch-assets +++ /dev/null @@ -1,104 +0,0 @@ -#!/bin/sh -# script/fetch-assets: download the third-party browser assets the web UI -# ships and install them under static/. Minified bundles are not committed -# (REPO_POLICIES.md: no build artifacts in version control), so the build -# fetches them here. Every download is verified against a hardcoded sha256 -# before it is installed, and any mismatch aborts. Idempotent: an asset -# already present with its pinned hash is left alone. -set -eu - -ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" - -# The sha256 of each installed asset lives in static/vendor.sha256, in -# sha256sum(1) format, with paths relative to static/. That file is the -# single source of truth: this script verifies against it, and -# static/vendor_test.go asserts the bytes embedded into the binary match -# it, so the hash cannot rot into a value nothing checks. -MANIFEST="static/vendor.sha256" - -# Alpine.js 3.14.9, 2026-08-17. Fetched from registry.npmjs.org, the -# publisher of record; the jsDelivr and unpkg copies are mirrors of this -# same tarball. dist/cdn.min.js is the browser build Alpine publishes for -# a \nline two", + } + require.NoError( + t, db.DB().Omit(clause.Associations).Create(wh).Error, + ) + + body := renderSourceDetailPage(t, h, sess, wh.ID) + + assert.Contains(t, body, + "Delete webhook "Bob\\u0027s \\u0022best\\u0022 \\\\ hook"+ + "\\u003c\\/script\\u003e\\nline two"?") +} diff --git a/internal/handlers/source_management.go b/internal/handlers/source_management.go index 01dcb50..5b6049c 100644 --- a/internal/handlers/source_management.go +++ b/internal/handlers/source_management.go @@ -612,8 +612,9 @@ func (h *Handlers) renderSourceDetail( // The host is the client's Host header, unvalidated. It is // inert only because source_detail.html renders BaseURL as - // text inside a element; putting it in an href or any - // other URL context needs it constrained first. + // text, inside a element and in an entrypoint's delete + // prompt; putting it in an href or any other URL context + // needs it constrained first. baseURL := scheme + "://" + r.Host // The template calls Webhook methods, which take pointer diff --git a/templates/source_detail.html b/templates/source_detail.html index a27c382..eeea81a 100644 --- a/templates/source_detail.html +++ b/templates/source_detail.html @@ -20,7 +20,11 @@
Full Event Log Edit -
+ +
@@ -83,7 +87,7 @@ {{if .Active}}Deactivate{{else}}Activate{{end}} -
+
@@ -249,7 +253,7 @@ {{if .Active}}Deactivate{{else}}Activate{{end}} -
+
-- 2.54.0 From ea8384f4a29619b8271350d6ad4526fa35fd8d46 Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Sat, 3 Oct 2026 03:43:27 +0200 Subject: [PATCH 084/126] Event log: only the newest event expanded, and only the 50 most recent (closes #349) The event log now loads only the 50 newest events, limited in its query, and only the newest starts expanded; the rest start collapsed. Paging is removed rather than capped, since 50 events never need a second page: the Previous and Next links, the `page` query parameter and the page number Replay and Resubmit carried back are gone, and a `?page=` left in an old link shows the 50 newest. A webhook with more than 50 events reads "50 most recent of N events" beside the heading. Comments and a README line that called `page` the only query parameter the service reads now name `next` and `notice`. Model: opus-5-5 --- README.md | 28 ++++---- internal/handlers/delivery_replay.go | 20 ++---- .../handlers/delivery_result_view_test.go | 8 +-- internal/handlers/event_log_view_test.go | 4 +- internal/handlers/export_test.go | 9 +-- internal/handlers/handlers.go | 5 +- internal/handlers/page_title_test.go | 6 +- internal/handlers/source_logs_test.go | 55 +++++++++++++++ internal/handlers/source_management.go | 69 ++++--------------- internal/handlers/target_retries_test.go | 17 ----- internal/handlers/ui_copy_test.go | 21 +++--- internal/middleware/middleware.go | 13 ++-- internal/server/alpine_browser_test.go | 44 +++++++----- internal/server/routes_test.go | 14 +--- internal/server/sentry.go | 5 +- static/css/tailwind.css | 2 +- templates/source_logs.html | 22 ++---- 17 files changed, 157 insertions(+), 185 deletions(-) diff --git a/README.md b/README.md index 2b2f6bc..5576bf9 100644 --- a/README.md +++ b/README.md @@ -1364,17 +1364,18 @@ 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; an event in the -event log 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. 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. +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. 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 @@ -2536,8 +2537,9 @@ 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: `page`, on the authenticated pagination links, is the only query -parameter this service reads. +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. Client-supplied request content does not leave the host by the other route either. The Sentry SDK attaches the request to every event it diff --git a/internal/handlers/delivery_replay.go b/internal/handlers/delivery_replay.go index be4c6d6..4d0473a 100644 --- a/internal/handlers/delivery_replay.go +++ b/internal/handlers/delivery_replay.go @@ -2,7 +2,6 @@ package handlers import ( "net/http" - "strconv" "github.com/go-chi/chi" "gorm.io/gorm" @@ -329,24 +328,15 @@ func replayBody(body string) *string { } // redirectToEventLog redirects a replay or resubmit back to the event -// log it was triggered from, carrying the outcome as its notice and -// the page number the form submitted. +// log it was triggered from, carrying the outcome as its notice. func redirectToEventLog( w http.ResponseWriter, r *http.Request, webhook database.Webhook, code noticeCode, ) { - dest := withNotice("/hook/"+webhook.ID+"/events", code) - - // The page is read from the form rather than the query string: - // this is a POST, and its query string is what logs and Referer - // headers record. - if page := pageOrFirst( - r.PostFormValue("page"), - ); page > 1 { - dest += "&page=" + strconv.Itoa(page) - } - - http.Redirect(w, r, dest, http.StatusSeeOther) + http.Redirect( + w, r, withNotice("/hook/"+webhook.ID+"/events", code), + http.StatusSeeOther, + ) } diff --git a/internal/handlers/delivery_result_view_test.go b/internal/handlers/delivery_result_view_test.go index 8b4a388..66fc386 100644 --- a/internal/handlers/delivery_result_view_test.go +++ b/internal/handlers/delivery_result_view_test.go @@ -435,9 +435,7 @@ func TestHandleSourceLogs_BoundsRenderedAttempts(t *testing.T) { }).Error) } - views := h.LoadEventLogViewsForTest( - httptest.NewRecorder(), *wh, 1, - ) + views := h.LoadEventLogViewsForTest(httptest.NewRecorder(), *wh) require.Len(t, views, 1) require.Len(t, views[0].Deliveries, 1) @@ -489,9 +487,7 @@ func TestHandleSourceLogs_BoundsOversizeResponse(t *testing.T) { stored := strings.Repeat("A", responseCap*4) + tail seedFailedDeliveryWithResponse(t, dbMgr, wh.ID, tgt.ID, stored) - views := h.LoadEventLogViewsForTest( - httptest.NewRecorder(), *wh, 1, - ) + views := h.LoadEventLogViewsForTest(httptest.NewRecorder(), *wh) require.Len(t, views, 1) require.Len(t, views[0].Deliveries, 1) require.Len(t, views[0].Deliveries[0].Results, 1) diff --git a/internal/handlers/event_log_view_test.go b/internal/handlers/event_log_view_test.go index 4c5b0e4..a906d4d 100644 --- a/internal/handlers/event_log_view_test.go +++ b/internal/handlers/event_log_view_test.go @@ -75,9 +75,7 @@ func seedAndProject( wh := seedWebhook(t, db) seedEventWithBody(t, dbMgr, wh.ID, body) - views := h.LoadEventLogViewsForTest( - httptest.NewRecorder(), *wh, 1, - ) + views := h.LoadEventLogViewsForTest(httptest.NewRecorder(), *wh) require.Len(t, views, 1) return views[0] diff --git a/internal/handlers/export_test.go b/internal/handlers/export_test.go index a635d55..649e092 100644 --- a/internal/handlers/export_test.go +++ b/internal/handlers/export_test.go @@ -51,12 +51,6 @@ const ( SidecarLeftMsgForTest = sidecarLeftMsg ) -// PageOrFirstForTest exposes pageOrFirst for use in the handlers_test -// package. -func PageOrFirstForTest(s string) int { - return pageOrFirst(s) -} - // DummyVerificationsForTest reports how many equivalent-cost // verifications were charged for usernames that do not exist. It // lets a test prove the anti-enumeration path ran without timing @@ -79,10 +73,9 @@ func TrimPartialRuneForTest(b []byte) []byte { func (s *Handlers) LoadEventLogViewsForTest( w http.ResponseWriter, webhook database.Webhook, - page int, ) []EventLogView { views, _, _ := s.loadEventsWithDeliveries( - w, newRequestForTest(), webhook, nil, page, + w, newRequestForTest(), webhook, nil, ) return views diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go index c16173c..151d8c4 100644 --- a/internal/handlers/handlers.go +++ b/internal/handlers/handlers.go @@ -30,10 +30,9 @@ import ( const ( // maxBodyShift is the bit shift for 1 MB body limit. maxBodyShift = 20 - // recentEventLimit is the number of recent events to show. + // recentEventLimit is the number of most recent events that a + // webhook's page and its event log show. recentEventLimit = 50 - // paginationPerPage is the number of items per page. - paginationPerPage = 25 // tmplKeyError is the template data key for an error message. tmplKeyError = "Error" diff --git a/internal/handlers/page_title_test.go b/internal/handlers/page_title_test.go index 81bd9a6..8c11f98 100644 --- a/internal/handlers/page_title_test.go +++ b/internal/handlers/page_title_test.go @@ -57,7 +57,11 @@ func TestEveryPageRendersItsOwnTitle(t *testing.T) { }, { "source_logs.html", - map[string]any{dataKeyWebhook: webhook, "TotalEvents": int64(0)}, + map[string]any{ + dataKeyWebhook: webhook, + dataKeyEvents: []handlers.EventLogView{}, + "TotalEvents": int64(0), + }, "Full Event Log - orders - Webhooker", }, { diff --git a/internal/handlers/source_logs_test.go b/internal/handlers/source_logs_test.go index d0b5b78..79cdaff 100644 --- a/internal/handlers/source_logs_test.go +++ b/internal/handlers/source_logs_test.go @@ -2,9 +2,12 @@ package handlers_test import ( "context" + "fmt" "net/http" "net/http/httptest" + "strings" "testing" + "time" "github.com/go-chi/chi" "github.com/stretchr/testify/assert" @@ -153,3 +156,55 @@ func TestHandleSourceLogs_MasksSlackWebhookURL(t *testing.T) { assert.Contains(t, body, tgt.Name) assert.Contains(t, body, "delivered") } + +// TestHandleSourceLogs_ShowsFiftyNewestEvents proves the event log +// holds the 50 newest events, newest first, and not one more, and says +// how many events there are in all. +func TestHandleSourceLogs_ShowsFiftyNewestEvents(t *testing.T) { + t.Parallel() + + f := newRecentEventsFixture(t) + base := time.Now().Add(-time.Hour) + + for i := range 51 { + f.event( + t, fmt.Sprintf("application/x-log-%02d", i), "{}", + base.Add(time.Duration(i)*time.Second), + ) + } + + body := renderSourceLogsPage(t, f.h, f.sess, f.webhook.ID) + + assert.Equal(t, 50, strings.Count(body, `role="button"`)) + assert.NotContains(t, body, "application/x-log-00") + assert.Contains(t, body, "application/x-log-01") + assert.Less( + t, + strings.Index(body, "application/x-log-50"), + strings.Index(body, "application/x-log-49"), + ) + assert.Contains(t, body, "50 most recent of 51 events") +} + +// TestHandleSourceLogs_OnlyNewestStartsExpanded proves that of the +// events in the log only the newest starts expanded. +func TestHandleSourceLogs_OnlyNewestStartsExpanded(t *testing.T) { + t.Parallel() + + f := newRecentEventsFixture(t) + now := time.Now() + + f.event(t, "application/x-older", "{}", now.Add(-time.Minute)) + f.event(t, "application/x-newer", "{}", now) + + body := renderSourceLogsPage(t, f.h, f.sess, f.webhook.ID) + + assert.Equal(t, 1, strings.Count(body, " data-open>")) + + open := strings.Index(body, " data-open>") + newer := strings.Index(body, "application/x-newer") + older := strings.Index(body, "application/x-older") + + assert.Less(t, open, newer, "the newest event is not the open one") + assert.Less(t, newer, older) +} diff --git a/internal/handlers/source_management.go b/internal/handlers/source_management.go index 5b6049c..121689b 100644 --- a/internal/handlers/source_management.go +++ b/internal/handlers/source_management.go @@ -1113,30 +1113,17 @@ func (h *Handlers) HandleSourceLogs() http.HandlerFunc { return } - page := h.parsePage(r) - evts, total, ok := h.loadEventsWithDeliveries( - w, r, webhook, targets, page, + w, r, webhook, targets, ) if !ok { return } - totalPages := int(total) / paginationPerPage - if int(total)%paginationPerPage != 0 { - totalPages++ - } - data := map[string]any{ tmplKeyWebhook: &webhook, "Events": evts, - "Page": page, - "TotalPages": totalPages, "TotalEvents": total, - "HasPrev": page > 1, - "HasNext": page < totalPages, - "PrevPage": page - 1, - "NextPage": page + 1, } h.renderTemplate(w, r, "source_logs.html", data) @@ -1196,15 +1183,11 @@ func (h *Handlers) loadTargetMap( return targetMap, nil } -// parsePage extracts a page number from the query string. -func (h *Handlers) parsePage(r *http.Request) int { - return pageOrFirst(r.URL.Query().Get("page")) -} - -// loadEventsWithDeliveries loads paginated events and their -// deliveries from the per-webhook database. Events come back -// as capped projections rather than database.Event rows: see -// eventLogColumns for why the cut happens in SQL. +// loadEventsWithDeliveries loads the recentEventLimit newest events +// and their deliveries from the per-webhook database, and the total +// number of events stored. Events come back as capped projections +// rather than database.Event rows: see eventLogColumns for why the +// cut happens in SQL. // // The bool reports whether the load succeeded. It is false // once this has answered the request with an error, and the @@ -1214,7 +1197,6 @@ func (h *Handlers) loadEventsWithDeliveries( r *http.Request, webhook database.Webhook, targetMap map[string]eventLogTarget, - page int, ) ([]EventLogView, int64, bool) { if !h.dbMgr.DBExists(webhook.ID) { return nil, 0, true @@ -1229,9 +1211,7 @@ func (h *Handlers) loadEventsWithDeliveries( return nil, 0, false } - rows, totalEvents := loadEventLogRows( - webhookDB, webhook.ID, page, - ) + rows, totalEvents := loadEventLogRows(webhookDB, webhook.ID) result, ok := h.eventLogViews( w, r, webhookDB, webhook.ID, rows, targetMap, @@ -1304,10 +1284,11 @@ func (h *Handlers) eventLogViews( return result, true } -// loadEventLogRows reads one page of the event log projection, newest -// first, and the total number of events the pager counts against. +// loadEventLogRows reads the event log projection of the +// recentEventLimit newest events, newest first, and the total number +// of events stored. func loadEventLogRows( - webhookDB *gorm.DB, webhookID string, page int, + webhookDB *gorm.DB, webhookID string, ) ([]eventLogRow, int64) { var totalEvents int64 @@ -1321,9 +1302,7 @@ func loadEventLogRows( eventLogColumns, maxRenderedBodyBytes, ).Where( "webhook_id = ?", webhookID, - ).Order("created_at DESC").Offset( - (page - 1) * paginationPerPage, - ).Limit(paginationPerPage).Find(&rows) + ).Order("created_at DESC").Limit(recentEventLimit).Find(&rows) return rows, totalEvents } @@ -1332,9 +1311,9 @@ func loadEventLogRows( // events have been resubmitted from it. // // One grouped query covers the page rather than one query per event. -// A page holds paginationPerPage ids, far below SQLite's bound -// parameter ceiling, so it needs no chunking as the delivery result -// load does. +// The page shows at most recentEventLimit events, far below SQLite's +// bound parameter ceiling, so it needs no chunking as the delivery +// result load does. func resubmitCounts( webhookDB *gorm.DB, eventIDs []string, ) (map[string]int, error) { @@ -1760,24 +1739,6 @@ func (h *Handlers) setTargetFromForm( return "", nil } -// pageOrFirst parses a paginated page number, answering 1 for -// anything empty, unparseable or out of range. -// -// Falling back rather than rejecting is correct here and only here: -// a page number is where to send the browser next, not configuration -// the operator is storing, and the actions that submit one have -// already completed by the time it is read — answering 400 would -// report a failure that did not happen. Anything an operator SETS -// must be validated instead; see parseMaxRetries. -func pageOrFirst(s string) int { - v, err := strconv.Atoi(strings.TrimSpace(s)) - if err != nil || v < 1 { - return 1 - } - - return v -} - // targetFormInput carries the raw values of a target form. Both the // create and the edit path fill one and hand it to setTargetFromForm, // so neither can come to validate a target differently from the diff --git a/internal/handlers/target_retries_test.go b/internal/handlers/target_retries_test.go index db8f539..686af04 100644 --- a/internal/handlers/target_retries_test.go +++ b/internal/handlers/target_retries_test.go @@ -383,20 +383,3 @@ func TestTargetRetries_CreateAndEditAgreeOnEveryCase(t *testing.T) { ) } } - -// TestPageOrFirst_CoercesRatherThanRejects pins the one place a -// non-numeric form value legitimately falls back. A page number says -// where to send the browser after an action that has already -// happened, so it is not configuration and rejecting it would report -// a failure that did not occur. -func TestPageOrFirst_CoercesRatherThanRejects(t *testing.T) { - t.Parallel() - - for _, s := range []string{"", "abc", "0", "-1", "2.7", " "} { - assert.Equal(t, 1, handlers.PageOrFirstForTest(s), - "%q should fall back to the first page", s) - } - - assert.Equal(t, 4, handlers.PageOrFirstForTest("4")) - assert.Equal(t, 4, handlers.PageOrFirstForTest(" 4 ")) -} diff --git a/internal/handlers/ui_copy_test.go b/internal/handlers/ui_copy_test.go index 28eca73..21800d6 100644 --- a/internal/handlers/ui_copy_test.go +++ b/internal/handlers/ui_copy_test.go @@ -21,6 +21,7 @@ import ( const ( dataKeyWebhook = "Webhook" dataKeyError = "Error" + dataKeyEvents = "Events" ) // testWebhookID is the identifier given to the webhook under test on @@ -144,9 +145,10 @@ func TestEventLogPageIsCalledFullEventLog(t *testing.T) { t.Cleanup(app.RequireStop) // A pointer, as in the handlers: source_detail.html calls - // Webhook.RetentionLabel, a pointer method. Both pages only range - // over their lists, and a list left out renders as empty, so the - // lists are left out. + // Webhook.RetentionLabel, a pointer method. The webhook page only + // ranges over its lists, and a list left out renders as empty, so + // its lists are left out. The event log also counts its events, so + // it gets an empty list. webhook := &database.Webhook{Name: "wh", RetentionDays: 14} webhook.ID = testWebhookID @@ -169,6 +171,7 @@ func TestEventLogPageIsCalledFullEventLog(t *testing.T) { logBody := renderPage(t, h, sess, "source_logs.html", map[string]any{ dataKeyWebhook: webhook, + dataKeyEvents: []handlers.EventLogView{}, "TotalEvents": int64(0), }) @@ -317,9 +320,9 @@ func TestEntrypointCopyButtonIsProgressiveEnhancement(t *testing.T) { "Entrypoints": handlers.NewEntrypointViews( []database.Entrypoint{entrypoint}, ), - "Targets": delivery.NewTargetViews(nil), - "Events": []database.Event{}, - "BaseURL": "https://hooks.example.com", + "Targets": delivery.NewTargetViews(nil), + dataKeyEvents: []database.Event{}, + "BaseURL": "https://hooks.example.com", }) assert.Contains( @@ -384,9 +387,9 @@ func TestTargetFormMaxRetriesCopyMatchesBehaviour(t *testing.T) { "Entrypoints": handlers.NewEntrypointViews( []database.Entrypoint{entrypoint}, ), - "Targets": delivery.NewTargetViews(nil), - "Events": []database.Event{}, - "BaseURL": "https://hooks.example.com", + "Targets": delivery.NewTargetViews(nil), + dataKeyEvents: []database.Event{}, + "BaseURL": "https://hooks.example.com", }, ) diff --git a/internal/middleware/middleware.go b/internal/middleware/middleware.go index a1a312b..3c2e3ff 100644 --- a/internal/middleware/middleware.go +++ b/internal/middleware/middleware.go @@ -278,12 +278,13 @@ func (lrw *loggingResponseWriter) Unwrap() http.ResponseWriter { // after the '?'. Keeping the path and dropping the query is what makes // this branch as bounded as the pattern branches below. // -// Nothing debuggable is lost. One route in the service reads a query -// parameter at all — `page`, on the authenticated pagination links in -// internal/handlers/source_management.go — and the alternatives that -// would preserve more (a key count, a key allowlist) all require -// parsing an attacker-sized query on every request, which is work an -// unauthenticated client would then be choosing for us. +// Nothing debuggable is lost. The only query parameters the 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. The +// alternatives that would preserve more (a key count, a key +// allowlist) all require parsing an attacker-sized query on every +// request, which is work an unauthenticated client would then be +// choosing for us. func concreteLogURL(r *http.Request) string { path := r.URL.EscapedPath() diff --git a/internal/server/alpine_browser_test.go b/internal/server/alpine_browser_test.go index 49db16a..5467d88 100644 --- a/internal/server/alpine_browser_test.go +++ b/internal/server/alpine_browser_test.go @@ -765,14 +765,15 @@ func checkRecentEvents(ctx context.Context, t *testing.T, url string) { "the event's own page does not show its body") } -// checkEventLog loads the event log and checks an event's row. Clicking -// its ID expands the event, and in there clicking its delivery shows the -// delivery's attempts and clicking again hides them. Clicking the row's -// caret collapses the event, clicking it again expands it, and clicking -// the ID again collapses it. While the event is expanded the row says so -// and its caret is turned up, and while it is collapsed neither. It then -// runs checkEventSelection on the log's last event, lastEventID, and -// checkEventKeyboard on eventID. +// checkEventLog loads the event log and checks that of its events only +// the newest, eventID, starts expanded: its row says so and its caret is +// turned up, and the log's last event, lastEventID, starts collapsed. In +// the newest event, clicking its delivery shows the delivery's attempts +// and clicking again hides them. Clicking the row's caret collapses the +// event, clicking it again expands it, clicking its ID collapses it and +// clicking the ID again expands it. While the event is collapsed the row +// says so and its caret is turned down. It then runs checkEventSelection +// on lastEventID and checkEventKeyboard on eventID. func checkEventLog( ctx context.Context, t *testing.T, @@ -790,20 +791,19 @@ func checkEventLog( caretUp := caret + `[contains(@class, "rotate-180")]` caretDown := caret + `[not(contains(@class, "rotate-180"))]` expanded := `form[action$="/` + eventID + `/resubmit"]` + lastExpanded := `form[action$="/` + lastEventID + `/resubmit"]` deliveryRow := `//span[text()="` + targetName + `"]` attempt := `//span[text()="Attempt 1"]` require.NoError(t, chromedp.Run(ctx, loadPage(url))) - assert.True(t, hidden(ctx, expanded), "the event starts expanded") - - click(ctx, t, id) - assert.True(t, shown(ctx, expanded), - "clicking the event's ID does not expand it") + assert.True(t, shown(ctx, expanded), "the newest event starts collapsed") assert.True(t, shown(ctx, row+`[@aria-expanded="true"]`), "the expanded event's row does not say it is expanded") assert.True(t, shown(ctx, caretUp), "the expanded event's caret does not turn up") + assert.True(t, hidden(ctx, lastExpanded), + "an older event starts expanded") assert.True(t, hidden(ctx, attempt), "the delivery's attempts start shown") @@ -829,7 +829,11 @@ func checkEventLog( click(ctx, t, id) assert.True(t, hidden(ctx, expanded), - "clicking the event's ID again does not collapse it") + "clicking the event's ID does not collapse it") + + click(ctx, t, id) + assert.True(t, shown(ctx, expanded), + "clicking the event's ID again does not expand it") checkEventSelection(ctx, t, url, lastEventID) checkEventKeyboard(ctx, t, url, eventID) @@ -878,8 +882,11 @@ func checkEventSelection( `window.getSelection().removeAllRanges()`, nil, ) + // The newest event starts expanded, which can push this one below + // the short window, where the mouse cannot reach it. require.NoError(t, chromedp.Run( ctx, chromedp.EmulateViewport(shortWidth, shortHeight), loadPage(url), + chromedp.ScrollIntoView(id, chromedp.BySearch), )) selectText(ctx, t, id) @@ -922,8 +929,9 @@ func checkEventSelection( } // checkEventKeyboard loads the event log and checks that Tab from the -// page's Back link reaches the event's row, the first after it, and that -// Enter then expands the event and Space collapses it. +// page's Back link reaches the row of the newest event, the first after +// it, and that Enter then collapses that event, which starts expanded, +// and Space expands it again. func checkEventKeyboard( ctx context.Context, t *testing.T, url, eventID string, ) { @@ -945,10 +953,10 @@ func checkEventKeyboard( "Tab from the Back link does not reach the event's row") require.NoError(t, chromedp.Run(ctx, chromedp.KeyEvent(kb.Enter))) - assert.True(t, shown(ctx, expanded), "Enter does not expand the event") + assert.True(t, hidden(ctx, expanded), "Enter does not collapse the event") require.NoError(t, chromedp.Run(ctx, chromedp.KeyEvent(" "))) - assert.True(t, hidden(ctx, expanded), "Space does not collapse the event") + assert.True(t, shown(ctx, expanded), "Space does not expand the event") } // selectText selects the text of the element matching an XPath diff --git a/internal/server/routes_test.go b/internal/server/routes_test.go index dac68de..3d0dd62 100644 --- a/internal/server/routes_test.go +++ b/internal/server/routes_test.go @@ -1379,9 +1379,9 @@ func TestHook_ResubmitFromEventLog(t *testing.T) { // TestHook_LinksBetweenPages follows each link to a webhook page that // the tests above do not: the navbar's "Webhooks" links, the back and -// Cancel links, the list's link to a webhook, the "Full Event Log" -// link beside the recent events, and the event log's page links. Each -// must point where it should, and that page must render. +// Cancel links, the list's link to a webhook, and the "Full Event Log" +// link beside the recent events. Each must point where it should, and +// that page must render. func TestHook_LinksBetweenPages(t *testing.T) { t.Parallel() @@ -1392,12 +1392,6 @@ func TestHook_LinksBetweenPages(t *testing.T) { wh := env.seedWebhook(t, userID) tgt := env.seedTarget(t, wh.ID) - // The event log shows 25 events a page; one more gives it a second - // page, so it renders its Next and Previous links. - for range 26 { - env.seedEvent(t, wh.ID, "paged") - } - list := "/hooks" newForm := list + "/new" page := "/hook/" + wh.ID @@ -1429,8 +1423,6 @@ func TestHook_LinksBetweenPages(t *testing.T) { {targetEdit, back, page}, {targetEdit, cancel, page}, {events, back, page}, - {events, `href="([^"]+)"[^>]*>Next →<`, events + "?page=2"}, - {events + "?page=2", `href="([^"]+)"[^>]*>← Previous<`, events + "?page=1"}, } { got := env.urlFrom(t, link.from, link.pattern, cookies) assert.Equal(t, link.want, got, "%s: %s", link.from, link.pattern) diff --git a/internal/server/sentry.go b/internal/server/sentry.go index 9056246..b02dc68 100644 --- a/internal/server/sentry.go +++ b/internal/server/sentry.go @@ -218,8 +218,9 @@ func keptSentryHeaders(headers map[string]string) map[string]string { // sentryKeepsHeader reports whether a request header is routing or // content metadata rather than client-chosen payload. Referer is kept -// on the reasoning that it is browser-set, that this service emits -// only ?page= in its own links, and that Referrer-Policy is set to +// on the reasoning that it is browser-set, that the only query +// parameters in this service's own URLs are the login page's `next` +// and `notice`, and that Referrer-Policy is set to // strict-origin-when-cross-origin. X-Request-Id ties the event to the // local access log line, which holds the rest of the detail. func sentryKeepsHeader(name string) bool { diff --git a/static/css/tailwind.css b/static/css/tailwind.css index 8efe77f..f031acf 100644 --- a/static/css/tailwind.css +++ b/static/css/tailwind.css @@ -1,2 +1,2 @@ /*! tailwindcss v4.2.1 | MIT License | https://tailwindcss.com */ -@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-space-y-reverse:0;--tw-divide-y-reverse:0;--tw-border-style:solid;--tw-font-weight:initial;--tw-tracking:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-duration:initial}}}@layer theme{:root,:host{--font-sans:ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--color-red-500:oklch(63.7% .237 25.331);--color-red-600:oklch(57.7% .245 27.325);--color-red-700:oklch(50.5% .213 27.518);--color-red-800:oklch(44.4% .177 26.899);--color-yellow-600:oklch(68.1% .162 75.834);--color-green-600:oklch(62.7% .194 149.214);--color-gray-50:oklch(98.5% .002 247.839);--color-gray-100:oklch(96.7% .003 264.542);--color-gray-200:oklch(92.8% .006 264.531);--color-gray-300:oklch(87.2% .01 258.338);--color-gray-400:oklch(70.7% .022 261.325);--color-gray-500:oklch(55.1% .027 264.364);--color-gray-600:oklch(44.6% .03 256.802);--color-gray-700:oklch(37.3% .034 259.733);--color-gray-900:oklch(21% .034 264.665);--color-white:#fff;--spacing:.25rem;--container-md:28rem;--container-2xl:42rem;--container-4xl:56rem;--container-6xl:72rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--text-xl:1.25rem;--text-xl--line-height:calc(1.75 / 1.25);--text-2xl:1.5rem;--text-2xl--line-height:calc(2 / 1.5);--text-3xl:1.875rem;--text-3xl--line-height:calc(2.25 / 1.875);--font-weight-light:300;--font-weight-medium:500;--tracking-wide:.025em;--radius-md:.375rem;--radius-lg:.5rem;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono);--color-primary-50:#e3f2fd;--color-primary-500:#2196f3;--color-primary-600:#1e88e5;--color-primary-700:#1976d2;--color-primary-800:#1565c0;--color-error-50:#ffebee;--color-error-500:#f44336;--color-error-700:#d32f2f;--color-success-50:#e8f5e9;--color-success-500:#4caf50;--color-success-700:#388e3c}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab, currentcolor 50%, transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}}@layer components{.btn-primary{border-radius:var(--radius-md);background-color:var(--color-primary-600);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 2);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);color:var(--color-white);--tw-shadow:0 1px 3px var(--tw-shadow-color,#0000001f), 0 1px 2px var(--tw-shadow-color,#0000003d);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));--tw-duration:.2s;justify-content:center;align-items:center;transition-duration:.2s;display:inline-flex}@media (hover:hover){.btn-primary:hover{background-color:var(--color-primary-700);--tw-shadow:0 3px 6px var(--tw-shadow-color,#00000029), 0 3px 6px var(--tw-shadow-color,#0000003b);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}}.btn-primary:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);--tw-ring-color:var(--color-primary-500);--tw-ring-offset-width:2px;--tw-ring-offset-shadow:var(--tw-ring-inset,) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-outline-style:none;outline-style:none}.btn-primary:active{background-color:var(--color-primary-800)}.btn-primary:disabled{cursor:not-allowed;opacity:.5}.btn-secondary{border-radius:var(--radius-md);border-style:var(--tw-border-style);border-width:1px;border-color:var(--color-gray-300);background-color:var(--color-white);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 2);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);color:var(--color-gray-700);transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));--tw-duration:.2s;justify-content:center;align-items:center;transition-duration:.2s;display:inline-flex}@media (hover:hover){.btn-secondary:hover{background-color:var(--color-gray-50)}}.btn-secondary:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);--tw-ring-color:var(--color-primary-500);--tw-ring-offset-width:2px;--tw-ring-offset-shadow:var(--tw-ring-inset,) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-outline-style:none;outline-style:none}.btn-secondary:active{background-color:var(--color-gray-100)}.btn-secondary:disabled{cursor:not-allowed;opacity:.5}.btn-danger{border-radius:var(--radius-md);background-color:var(--color-error-500);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 2);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);color:var(--color-white);--tw-shadow:0 1px 3px var(--tw-shadow-color,#0000001f), 0 1px 2px var(--tw-shadow-color,#0000003d);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));--tw-duration:.2s;justify-content:center;align-items:center;transition-duration:.2s;display:inline-flex}@media (hover:hover){.btn-danger:hover{background-color:var(--color-error-700);--tw-shadow:0 3px 6px var(--tw-shadow-color,#00000029), 0 3px 6px var(--tw-shadow-color,#0000003b);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}}.btn-danger:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);--tw-ring-color:var(--color-red-500);--tw-ring-offset-width:2px;--tw-ring-offset-shadow:var(--tw-ring-inset,) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-outline-style:none;outline-style:none}.btn-danger:active{background-color:var(--color-red-800)}.btn-danger:disabled{cursor:not-allowed;opacity:.5}.card{border-radius:var(--radius-lg);background-color:var(--color-white);--tw-shadow:0 1px 3px var(--tw-shadow-color,#0000001f), 0 1px 2px var(--tw-shadow-color,#0000003d);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);overflow:hidden}.card-elevated{border-radius:var(--radius-lg);background-color:var(--color-white);--tw-shadow:0 1px 3px var(--tw-shadow-color,#0000001f), 0 1px 2px var(--tw-shadow-color,#0000003d);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);transition-property:box-shadow;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));overflow:hidden}@media (hover:hover){.card-elevated:hover{--tw-shadow:0 3px 6px var(--tw-shadow-color,#00000029), 0 3px 6px var(--tw-shadow-color,#0000003b);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}}.input{border-radius:var(--radius-md);border-style:var(--tw-border-style);border-width:1px;border-color:var(--color-gray-300);width:100%;padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 3);color:var(--color-gray-900)}.input::placeholder{color:var(--color-gray-500)}.input{transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.input:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);--tw-ring-color:var(--color-primary-500);--tw-outline-style:none;border-color:#0000;outline-style:none}.label{margin-bottom:calc(var(--spacing) * 1);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);color:var(--color-gray-700);display:block}.form-group{margin-bottom:calc(var(--spacing) * 4)}.badge-success{background-color:var(--color-success-50);padding-inline:calc(var(--spacing) * 2.5);padding-block:calc(var(--spacing) * .5);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);color:var(--color-success-700);border-radius:3.40282e38px;align-items:center;display:inline-flex}.badge-error{background-color:var(--color-error-50);padding-inline:calc(var(--spacing) * 2.5);padding-block:calc(var(--spacing) * .5);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);color:var(--color-error-700);border-radius:3.40282e38px;align-items:center;display:inline-flex}.badge-info{background-color:var(--color-primary-50);padding-inline:calc(var(--spacing) * 2.5);padding-block:calc(var(--spacing) * .5);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);color:var(--color-primary-700);border-radius:3.40282e38px;align-items:center;display:inline-flex}.app-bar{background-color:var(--color-white);padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 4);--tw-shadow:0 1px 3px var(--tw-shadow-color,#0000001f), 0 1px 2px var(--tw-shadow-color,#0000003d);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.alert-error{margin-bottom:calc(var(--spacing) * 4);border-radius:var(--radius-md);border-style:var(--tw-border-style);border-width:1px;border-color:#f4433633}@supports (color:color-mix(in lab, red, red)){.alert-error{border-color:color-mix(in oklab, var(--color-error-500) 20%, transparent)}}.alert-error{background-color:var(--color-error-50);padding:calc(var(--spacing) * 4);color:var(--color-error-700)}.alert-success{margin-bottom:calc(var(--spacing) * 4);border-radius:var(--radius-md);border-style:var(--tw-border-style);border-width:1px;border-color:#4caf5033}@supports (color:color-mix(in lab, red, red)){.alert-success{border-color:color-mix(in oklab, var(--color-success-500) 20%, transparent)}}.alert-success{background-color:var(--color-success-50);padding:calc(var(--spacing) * 4);color:var(--color-success-700)}}@layer utilities{.start{inset-inline-start:var(--spacing)}.mx-1{margin-inline:calc(var(--spacing) * 1)}.mx-3{margin-inline:calc(var(--spacing) * 3)}.mx-auto{margin-inline:auto}.mt-1{margin-top:calc(var(--spacing) * 1)}.mt-2{margin-top:calc(var(--spacing) * 2)}.mt-3{margin-top:calc(var(--spacing) * 3)}.mt-4{margin-top:calc(var(--spacing) * 4)}.mt-6{margin-top:calc(var(--spacing) * 6)}.mt-8{margin-top:calc(var(--spacing) * 8)}.mr-1{margin-right:calc(var(--spacing) * 1)}.mr-2{margin-right:calc(var(--spacing) * 2)}.mr-4{margin-right:calc(var(--spacing) * 4)}.mb-1{margin-bottom:calc(var(--spacing) * 1)}.mb-2{margin-bottom:calc(var(--spacing) * 2)}.mb-3{margin-bottom:calc(var(--spacing) * 3)}.mb-4{margin-bottom:calc(var(--spacing) * 4)}.mb-6{margin-bottom:calc(var(--spacing) * 6)}.mb-8{margin-bottom:calc(var(--spacing) * 8)}.block{display:block}.flex{display:flex}.grid{display:grid}.hidden{display:none}.inline{display:inline}.inline-flex{display:inline-flex}.h-3{height:calc(var(--spacing) * 3)}.h-4{height:calc(var(--spacing) * 4)}.h-5{height:calc(var(--spacing) * 5)}.h-6{height:calc(var(--spacing) * 6)}.h-16{height:calc(var(--spacing) * 16)}.min-h-screen{min-height:100vh}.w-3{width:calc(var(--spacing) * 3)}.w-4{width:calc(var(--spacing) * 4)}.w-5{width:calc(var(--spacing) * 5)}.w-6{width:calc(var(--spacing) * 6)}.w-16{width:calc(var(--spacing) * 16)}.w-24{width:calc(var(--spacing) * 24)}.w-32{width:calc(var(--spacing) * 32)}.w-full{width:100%}.max-w-2xl{max-width:var(--container-2xl)}.max-w-4xl{max-width:var(--container-4xl)}.max-w-6xl{max-width:var(--container-6xl)}.max-w-md{max-width:var(--container-md)}.flex-1{flex:1}.flex-shrink-0{flex-shrink:0}.flex-grow{flex-grow:1}.rotate-180{rotate:180deg}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-center{align-items:center}.items-start{align-items:flex-start}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.gap-2{gap:calc(var(--spacing) * 2)}.gap-3{gap:calc(var(--spacing) * 3)}.gap-4{gap:calc(var(--spacing) * 4)}.gap-6{gap:calc(var(--spacing) * 6)}:where(.space-y-2>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 2) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-3>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 3) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 3) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-6>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 6) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 6) * calc(1 - var(--tw-space-y-reverse)))}:where(.divide-y>:not(:last-child)){--tw-divide-y-reverse:0;border-bottom-style:var(--tw-border-style);border-top-style:var(--tw-border-style);border-top-width:calc(1px * var(--tw-divide-y-reverse));border-bottom-width:calc(1px * calc(1 - var(--tw-divide-y-reverse)))}:where(.divide-gray-100>:not(:last-child)){border-color:var(--color-gray-100)}:where(.divide-gray-200>:not(:last-child)){border-color:var(--color-gray-200)}.overflow-x-auto{overflow-x:auto}.rounded-md{border-radius:var(--radius-md)}.border{border-style:var(--tw-border-style);border-width:1px}.border-t{border-top-style:var(--tw-border-style);border-top-width:1px}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-gray-200{border-color:var(--color-gray-200)}.bg-gray-50{background-color:var(--color-gray-50)}.bg-gray-100{background-color:var(--color-gray-100)}.bg-white{background-color:var(--color-white)}.p-2{padding:calc(var(--spacing) * 2)}.p-3{padding:calc(var(--spacing) * 3)}.p-4{padding:calc(var(--spacing) * 4)}.p-6{padding:calc(var(--spacing) * 6)}.p-8{padding:calc(var(--spacing) * 8)}.p-12{padding:calc(var(--spacing) * 12)}.px-4{padding-inline:calc(var(--spacing) * 4)}.px-6{padding-inline:calc(var(--spacing) * 6)}.px-8{padding-inline:calc(var(--spacing) * 8)}.py-2{padding-block:calc(var(--spacing) * 2)}.py-3{padding-block:calc(var(--spacing) * 3)}.py-6{padding-block:calc(var(--spacing) * 6)}.py-8{padding-block:calc(var(--spacing) * 8)}.py-12{padding-block:calc(var(--spacing) * 12)}.pt-3{padding-top:calc(var(--spacing) * 3)}.pt-4{padding-top:calc(var(--spacing) * 4)}.text-center{text-align:center}.text-left{text-align:left}.font-mono{font-family:var(--font-mono)}.text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}.text-3xl{font-size:var(--text-3xl);line-height:var(--tw-leading,var(--text-3xl--line-height))}.text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xl{font-size:var(--text-xl);line-height:var(--tw-leading,var(--text-xl--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.font-light{--tw-font-weight:var(--font-weight-light);font-weight:var(--font-weight-light)}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.tracking-wide{--tw-tracking:var(--tracking-wide);letter-spacing:var(--tracking-wide)}.break-all{word-break:break-all}.whitespace-pre-wrap{white-space:pre-wrap}.text-gray-300{color:var(--color-gray-300)}.text-gray-400{color:var(--color-gray-400)}.text-gray-500{color:var(--color-gray-500)}.text-gray-600{color:var(--color-gray-600)}.text-gray-700{color:var(--color-gray-700)}.text-gray-900{color:var(--color-gray-900)}.text-green-600{color:var(--color-green-600)}.text-primary-500{color:var(--color-primary-500)}.text-red-600{color:var(--color-red-600)}.text-red-700{color:var(--color-red-700)}.text-yellow-600{color:var(--color-yellow-600)}.uppercase{text-transform:uppercase}.shadow-\[0_-4px_6px_-1px_rgba\(0\,0\,0\,0\.1\)\]{--tw-shadow:0 -4px 6px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.transition-transform{transition-property:transform,translate,scale,rotate;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}@media (min-width:48rem){.md\:flex{display:flex}.md\:hidden{display:none}}@media (min-width:64rem){.lg\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}}}@property --tw-space-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-divide-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-duration{syntax:"*";inherits:false} \ No newline at end of file +@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-space-y-reverse:0;--tw-divide-y-reverse:0;--tw-border-style:solid;--tw-font-weight:initial;--tw-tracking:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-duration:initial}}}@layer theme{:root,:host{--font-sans:ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--color-red-500:oklch(63.7% .237 25.331);--color-red-600:oklch(57.7% .245 27.325);--color-red-700:oklch(50.5% .213 27.518);--color-red-800:oklch(44.4% .177 26.899);--color-yellow-600:oklch(68.1% .162 75.834);--color-green-600:oklch(62.7% .194 149.214);--color-gray-50:oklch(98.5% .002 247.839);--color-gray-100:oklch(96.7% .003 264.542);--color-gray-200:oklch(92.8% .006 264.531);--color-gray-300:oklch(87.2% .01 258.338);--color-gray-400:oklch(70.7% .022 261.325);--color-gray-500:oklch(55.1% .027 264.364);--color-gray-600:oklch(44.6% .03 256.802);--color-gray-700:oklch(37.3% .034 259.733);--color-gray-900:oklch(21% .034 264.665);--color-white:#fff;--spacing:.25rem;--container-md:28rem;--container-2xl:42rem;--container-4xl:56rem;--container-6xl:72rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--text-xl:1.25rem;--text-xl--line-height:calc(1.75 / 1.25);--text-2xl:1.5rem;--text-2xl--line-height:calc(2 / 1.5);--text-3xl:1.875rem;--text-3xl--line-height:calc(2.25 / 1.875);--font-weight-light:300;--font-weight-medium:500;--tracking-wide:.025em;--radius-md:.375rem;--radius-lg:.5rem;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono);--color-primary-50:#e3f2fd;--color-primary-500:#2196f3;--color-primary-600:#1e88e5;--color-primary-700:#1976d2;--color-primary-800:#1565c0;--color-error-50:#ffebee;--color-error-500:#f44336;--color-error-700:#d32f2f;--color-success-50:#e8f5e9;--color-success-500:#4caf50;--color-success-700:#388e3c}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab, currentcolor 50%, transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}}@layer components{.btn-primary{border-radius:var(--radius-md);background-color:var(--color-primary-600);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 2);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);color:var(--color-white);--tw-shadow:0 1px 3px var(--tw-shadow-color,#0000001f), 0 1px 2px var(--tw-shadow-color,#0000003d);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));--tw-duration:.2s;justify-content:center;align-items:center;transition-duration:.2s;display:inline-flex}@media (hover:hover){.btn-primary:hover{background-color:var(--color-primary-700);--tw-shadow:0 3px 6px var(--tw-shadow-color,#00000029), 0 3px 6px var(--tw-shadow-color,#0000003b);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}}.btn-primary:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);--tw-ring-color:var(--color-primary-500);--tw-ring-offset-width:2px;--tw-ring-offset-shadow:var(--tw-ring-inset,) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-outline-style:none;outline-style:none}.btn-primary:active{background-color:var(--color-primary-800)}.btn-primary:disabled{cursor:not-allowed;opacity:.5}.btn-secondary{border-radius:var(--radius-md);border-style:var(--tw-border-style);border-width:1px;border-color:var(--color-gray-300);background-color:var(--color-white);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 2);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);color:var(--color-gray-700);transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));--tw-duration:.2s;justify-content:center;align-items:center;transition-duration:.2s;display:inline-flex}@media (hover:hover){.btn-secondary:hover{background-color:var(--color-gray-50)}}.btn-secondary:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);--tw-ring-color:var(--color-primary-500);--tw-ring-offset-width:2px;--tw-ring-offset-shadow:var(--tw-ring-inset,) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-outline-style:none;outline-style:none}.btn-secondary:active{background-color:var(--color-gray-100)}.btn-secondary:disabled{cursor:not-allowed;opacity:.5}.btn-danger{border-radius:var(--radius-md);background-color:var(--color-error-500);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 2);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);color:var(--color-white);--tw-shadow:0 1px 3px var(--tw-shadow-color,#0000001f), 0 1px 2px var(--tw-shadow-color,#0000003d);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));--tw-duration:.2s;justify-content:center;align-items:center;transition-duration:.2s;display:inline-flex}@media (hover:hover){.btn-danger:hover{background-color:var(--color-error-700);--tw-shadow:0 3px 6px var(--tw-shadow-color,#00000029), 0 3px 6px var(--tw-shadow-color,#0000003b);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}}.btn-danger:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);--tw-ring-color:var(--color-red-500);--tw-ring-offset-width:2px;--tw-ring-offset-shadow:var(--tw-ring-inset,) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-outline-style:none;outline-style:none}.btn-danger:active{background-color:var(--color-red-800)}.btn-danger:disabled{cursor:not-allowed;opacity:.5}.card{border-radius:var(--radius-lg);background-color:var(--color-white);--tw-shadow:0 1px 3px var(--tw-shadow-color,#0000001f), 0 1px 2px var(--tw-shadow-color,#0000003d);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);overflow:hidden}.card-elevated{border-radius:var(--radius-lg);background-color:var(--color-white);--tw-shadow:0 1px 3px var(--tw-shadow-color,#0000001f), 0 1px 2px var(--tw-shadow-color,#0000003d);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);transition-property:box-shadow;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));overflow:hidden}@media (hover:hover){.card-elevated:hover{--tw-shadow:0 3px 6px var(--tw-shadow-color,#00000029), 0 3px 6px var(--tw-shadow-color,#0000003b);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}}.input{border-radius:var(--radius-md);border-style:var(--tw-border-style);border-width:1px;border-color:var(--color-gray-300);width:100%;padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 3);color:var(--color-gray-900)}.input::placeholder{color:var(--color-gray-500)}.input{transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.input:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);--tw-ring-color:var(--color-primary-500);--tw-outline-style:none;border-color:#0000;outline-style:none}.label{margin-bottom:calc(var(--spacing) * 1);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);color:var(--color-gray-700);display:block}.form-group{margin-bottom:calc(var(--spacing) * 4)}.badge-success{background-color:var(--color-success-50);padding-inline:calc(var(--spacing) * 2.5);padding-block:calc(var(--spacing) * .5);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);color:var(--color-success-700);border-radius:3.40282e38px;align-items:center;display:inline-flex}.badge-error{background-color:var(--color-error-50);padding-inline:calc(var(--spacing) * 2.5);padding-block:calc(var(--spacing) * .5);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);color:var(--color-error-700);border-radius:3.40282e38px;align-items:center;display:inline-flex}.badge-info{background-color:var(--color-primary-50);padding-inline:calc(var(--spacing) * 2.5);padding-block:calc(var(--spacing) * .5);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);color:var(--color-primary-700);border-radius:3.40282e38px;align-items:center;display:inline-flex}.app-bar{background-color:var(--color-white);padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 4);--tw-shadow:0 1px 3px var(--tw-shadow-color,#0000001f), 0 1px 2px var(--tw-shadow-color,#0000003d);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.alert-error{margin-bottom:calc(var(--spacing) * 4);border-radius:var(--radius-md);border-style:var(--tw-border-style);border-width:1px;border-color:#f4433633}@supports (color:color-mix(in lab, red, red)){.alert-error{border-color:color-mix(in oklab, var(--color-error-500) 20%, transparent)}}.alert-error{background-color:var(--color-error-50);padding:calc(var(--spacing) * 4);color:var(--color-error-700)}.alert-success{margin-bottom:calc(var(--spacing) * 4);border-radius:var(--radius-md);border-style:var(--tw-border-style);border-width:1px;border-color:#4caf5033}@supports (color:color-mix(in lab, red, red)){.alert-success{border-color:color-mix(in oklab, var(--color-success-500) 20%, transparent)}}.alert-success{background-color:var(--color-success-50);padding:calc(var(--spacing) * 4);color:var(--color-success-700)}}@layer utilities{.start{inset-inline-start:var(--spacing)}.mx-1{margin-inline:calc(var(--spacing) * 1)}.mx-3{margin-inline:calc(var(--spacing) * 3)}.mx-auto{margin-inline:auto}.mt-1{margin-top:calc(var(--spacing) * 1)}.mt-2{margin-top:calc(var(--spacing) * 2)}.mt-3{margin-top:calc(var(--spacing) * 3)}.mt-4{margin-top:calc(var(--spacing) * 4)}.mt-6{margin-top:calc(var(--spacing) * 6)}.mt-8{margin-top:calc(var(--spacing) * 8)}.mr-1{margin-right:calc(var(--spacing) * 1)}.mr-2{margin-right:calc(var(--spacing) * 2)}.mr-4{margin-right:calc(var(--spacing) * 4)}.mb-1{margin-bottom:calc(var(--spacing) * 1)}.mb-2{margin-bottom:calc(var(--spacing) * 2)}.mb-3{margin-bottom:calc(var(--spacing) * 3)}.mb-4{margin-bottom:calc(var(--spacing) * 4)}.mb-6{margin-bottom:calc(var(--spacing) * 6)}.mb-8{margin-bottom:calc(var(--spacing) * 8)}.block{display:block}.flex{display:flex}.grid{display:grid}.hidden{display:none}.inline{display:inline}.h-3{height:calc(var(--spacing) * 3)}.h-4{height:calc(var(--spacing) * 4)}.h-5{height:calc(var(--spacing) * 5)}.h-6{height:calc(var(--spacing) * 6)}.h-16{height:calc(var(--spacing) * 16)}.min-h-screen{min-height:100vh}.w-3{width:calc(var(--spacing) * 3)}.w-4{width:calc(var(--spacing) * 4)}.w-5{width:calc(var(--spacing) * 5)}.w-6{width:calc(var(--spacing) * 6)}.w-16{width:calc(var(--spacing) * 16)}.w-24{width:calc(var(--spacing) * 24)}.w-32{width:calc(var(--spacing) * 32)}.w-full{width:100%}.max-w-2xl{max-width:var(--container-2xl)}.max-w-4xl{max-width:var(--container-4xl)}.max-w-6xl{max-width:var(--container-6xl)}.max-w-md{max-width:var(--container-md)}.flex-1{flex:1}.flex-shrink-0{flex-shrink:0}.flex-grow{flex-grow:1}.rotate-180{rotate:180deg}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-center{align-items:center}.items-start{align-items:flex-start}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.gap-2{gap:calc(var(--spacing) * 2)}.gap-3{gap:calc(var(--spacing) * 3)}.gap-4{gap:calc(var(--spacing) * 4)}.gap-6{gap:calc(var(--spacing) * 6)}:where(.space-y-2>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 2) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-3>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 3) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 3) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-6>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 6) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 6) * calc(1 - var(--tw-space-y-reverse)))}:where(.divide-y>:not(:last-child)){--tw-divide-y-reverse:0;border-bottom-style:var(--tw-border-style);border-top-style:var(--tw-border-style);border-top-width:calc(1px * var(--tw-divide-y-reverse));border-bottom-width:calc(1px * calc(1 - var(--tw-divide-y-reverse)))}:where(.divide-gray-100>:not(:last-child)){border-color:var(--color-gray-100)}:where(.divide-gray-200>:not(:last-child)){border-color:var(--color-gray-200)}.overflow-x-auto{overflow-x:auto}.rounded-md{border-radius:var(--radius-md)}.border{border-style:var(--tw-border-style);border-width:1px}.border-t{border-top-style:var(--tw-border-style);border-top-width:1px}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-gray-200{border-color:var(--color-gray-200)}.bg-gray-50{background-color:var(--color-gray-50)}.bg-gray-100{background-color:var(--color-gray-100)}.bg-white{background-color:var(--color-white)}.p-2{padding:calc(var(--spacing) * 2)}.p-3{padding:calc(var(--spacing) * 3)}.p-4{padding:calc(var(--spacing) * 4)}.p-6{padding:calc(var(--spacing) * 6)}.p-8{padding:calc(var(--spacing) * 8)}.p-12{padding:calc(var(--spacing) * 12)}.px-4{padding-inline:calc(var(--spacing) * 4)}.px-6{padding-inline:calc(var(--spacing) * 6)}.px-8{padding-inline:calc(var(--spacing) * 8)}.py-2{padding-block:calc(var(--spacing) * 2)}.py-3{padding-block:calc(var(--spacing) * 3)}.py-6{padding-block:calc(var(--spacing) * 6)}.py-8{padding-block:calc(var(--spacing) * 8)}.py-12{padding-block:calc(var(--spacing) * 12)}.pt-3{padding-top:calc(var(--spacing) * 3)}.pt-4{padding-top:calc(var(--spacing) * 4)}.text-center{text-align:center}.text-left{text-align:left}.font-mono{font-family:var(--font-mono)}.text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}.text-3xl{font-size:var(--text-3xl);line-height:var(--tw-leading,var(--text-3xl--line-height))}.text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xl{font-size:var(--text-xl);line-height:var(--tw-leading,var(--text-xl--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.font-light{--tw-font-weight:var(--font-weight-light);font-weight:var(--font-weight-light)}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.tracking-wide{--tw-tracking:var(--tracking-wide);letter-spacing:var(--tracking-wide)}.break-all{word-break:break-all}.whitespace-pre-wrap{white-space:pre-wrap}.text-gray-300{color:var(--color-gray-300)}.text-gray-400{color:var(--color-gray-400)}.text-gray-500{color:var(--color-gray-500)}.text-gray-600{color:var(--color-gray-600)}.text-gray-700{color:var(--color-gray-700)}.text-gray-900{color:var(--color-gray-900)}.text-green-600{color:var(--color-green-600)}.text-primary-500{color:var(--color-primary-500)}.text-red-600{color:var(--color-red-600)}.text-red-700{color:var(--color-red-700)}.text-yellow-600{color:var(--color-yellow-600)}.uppercase{text-transform:uppercase}.shadow-\[0_-4px_6px_-1px_rgba\(0\,0\,0\,0\.1\)\]{--tw-shadow:0 -4px 6px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.transition-transform{transition-property:transform,translate,scale,rotate;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}@media (min-width:48rem){.md\:flex{display:flex}.md\:hidden{display:none}}@media (min-width:64rem){.lg\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}}}@property --tw-space-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-divide-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-duration{syntax:"*";inherits:false} \ No newline at end of file diff --git a/templates/source_logs.html b/templates/source_logs.html index d18ae87..b1fce27 100644 --- a/templates/source_logs.html +++ b/templates/source_logs.html @@ -8,14 +8,15 @@ ← Back to {{.Webhook.Name}}

Full Event Log

- {{.TotalEvents}} total event{{if ne .TotalEvents 1}}s{{end}} + {{if gt .TotalEvents (len .Events)}}{{len .Events}} most recent of {{.TotalEvents}} events{{else}}{{.TotalEvents}} total event{{if ne .TotalEvents 1}}s{{end}}{{end}}
- {{range .Events}} -
+ + {{range $i, $event := .Events}} +
@@ -51,7 +52,6 @@
-
@@ -79,7 +79,6 @@ {{if and .Status.Terminal (not .Target.Deleted)}}
-
{{end}} @@ -100,18 +99,5 @@ {{end}}
- - - {{if or .HasPrev .HasNext}} -
- {{if .HasPrev}} - ← Previous - {{end}} - Page {{.Page}} of {{.TotalPages}} - {{if .HasNext}} - Next → - {{end}} -
- {{end}}
{{end}} -- 2.54.0 From d8c60c9b67e5fae73bb0d0466cb96f891cd8dfbb Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Sat, 3 Oct 2026 04:12:27 +0200 Subject: [PATCH 085/126] Event log: show attempt and delivery times, label replays, zone event times (closes #386) In the event log and on an event's page, attempts and deliveries showed no time, event times had no zone, and a replay looked like the original it repeated. Each attempt now shows when its result was recorded and each delivery when it was created, as how long ago with the full UTC time on hover, and the event log's event times read the same way. A delivery created by Replay records it in a new `replay` column and is labelled a replay in the event's summary line and its delivery list; replays made before this change are not labelled. A delivery's row is now drawn by one template, `delivery_row`, that both pages share. Model: opus-5-5 --- README.md | 8 +++- internal/database/model_delivery.go | 4 ++ internal/handlers/delivery_replay.go | 1 + internal/handlers/delivery_replay_test.go | 56 +++++++++++++++++++++++ internal/handlers/delivery_result_view.go | 8 ++++ internal/handlers/event_log_times_test.go | 52 +++++++++++++++++++++ internal/handlers/event_log_view.go | 11 ++++- internal/handlers/handlers.go | 6 ++- internal/handlers/source_management.go | 13 ++++++ internal/handlers/target_list.go | 3 +- templates/delivery_attempts.html | 1 + templates/delivery_row.html | 16 +++++++ templates/event_detail.html | 10 +--- templates/source_logs.html | 20 +++----- 14 files changed, 181 insertions(+), 28 deletions(-) create mode 100644 internal/handlers/event_log_times_test.go create mode 100644 templates/delivery_row.html diff --git a/README.md b/README.md index 5576bf9..ef36bf5 100644 --- a/README.md +++ b/README.md @@ -1831,6 +1831,7 @@ status across potentially multiple attempts. | `target_id`| UUID | Foreign key → Target | | `status` | DeliveryStatus | One of: `pending`, `delivered`, `failed`, `retrying` | | `finished_at` | timestamp | When the delivery became `delivered` or `failed` (nullable; empty while `pending` or `retrying`) | +| `replay` | boolean | Whether the delivery was created by **Replay** | **Relations:** Belongs to Event. Belongs to Target. Has many DeliveryResults. @@ -1850,7 +1851,8 @@ NEW `pending` delivery for the same event and target and hands it to the engine on the ordinary path — same retries, same SSRF guard, same circuit breaker as a first attempt. It never touches the delivery it repeats: that row's status, timestamps and recorded attempts stand as -the record of what happened. +the record of what happened. The new delivery records `replay`, and the +event log and the event's page label it a replay. What is re-sent is the stored event body, against the target's configuration **as it stands now** — the point of a replay is to @@ -1904,6 +1906,10 @@ A `database` or `log` target sends no HTTP request, so in the event log and on the event's page its attempts show no status: a successful one reads "archived" or "written to the log". +The event log and the event's page show when each attempt was recorded and +when each delivery was created, as the recent events list shows when an event +arrived: how long ago, with the full UTC time on hover. + **Relations:** Belongs to Delivery. #### EventTotals, TargetTotals and EntrypointTotals diff --git a/internal/database/model_delivery.go b/internal/database/model_delivery.go index ffaba8a..5f22557 100644 --- a/internal/database/model_delivery.go +++ b/internal/database/model_delivery.go @@ -56,6 +56,10 @@ type Delivery struct { // the index. FinishedAt *time.Time `gorm:"index:idx_deliveries_status,priority:3" json:"finishedAt,omitempty"` + // Replay is set on a delivery created by the event log's Replay + // action, so the pages can tell it from the delivery it repeats. + Replay bool `gorm:"not null;default:false" json:"replay"` + // Relations. No model marshals the record it belongs to: // Event.Deliveries and Target.Deliveries lead back here, and the // JSON could loop. diff --git a/internal/handlers/delivery_replay.go b/internal/handlers/delivery_replay.go index 4d0473a..48f7f5b 100644 --- a/internal/handlers/delivery_replay.go +++ b/internal/handlers/delivery_replay.go @@ -282,6 +282,7 @@ func createReplayDelivery( EventID: event.ID, TargetID: target.ID, Status: database.DeliveryStatusPending, + Replay: true, } err := webhookDB.Transaction(func(tx *gorm.DB) error { diff --git a/internal/handlers/delivery_replay_test.go b/internal/handlers/delivery_replay_test.go index 332aa59..2c4865a 100644 --- a/internal/handlers/delivery_replay_test.go +++ b/internal/handlers/delivery_replay_test.go @@ -3,6 +3,7 @@ package handlers_test import ( "net/http" "net/http/httptest" + "strings" "testing" "github.com/stretchr/testify/assert" @@ -528,3 +529,58 @@ func TestHandleSourceLogs_RendersReplayControlAndBanner(t *testing.T) { assert.NotContains(t, unknown, "alert-success") assert.NotContains(t, unknown, "made-up") } + +// TestHandleDeliveryReplay_LabelsTheReplay proves a delivery created +// by Replay is labelled as a replay in the event's summary line in the +// event log, and in the list of the event's deliveries there and on +// the event's page, while the delivery it repeats is not. +func TestHandleDeliveryReplay_LabelsTheReplay(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) + + wh := seedWebhook(t, db) + tgt := seedConfiguredTarget( + t, db, wh.ID, database.TargetTypeHTTP, + `{"url":"`+replayTargetURL+`"}`, + ) + + event, original := seedFailedDelivery(t, dbMgr, wh.ID, tgt.ID) + + w := postReplay(t, h, sess, wh.ID, original.ID) + require.Equal(t, http.StatusSeeOther, w.Code) + + eventLog := renderSourceLogsPage(t, h, sess, wh.ID) + + assert.Contains(t, eventLog, tgt.Name+": failed") + assert.Contains(t, eventLog, tgt.Name+" (replay): pending") + + w = serveEventPage(t, h, sess, wh.ID, event.ID) + require.Equal(t, http.StatusOK, w.Code) + + // In each delivery list a row names the target, then the label if + // it is a replay, then its status: the replay is still pending, the + // original failed. + replayRow := tgt.Name + ` ` + + `replay ` + + `pending` + originalRow := tgt.Name + ` ` + + `failed` + + for _, page := range []string{eventLog, w.Body.String()} { + page = strings.Join(strings.Fields(page), " ") + + assert.Contains(t, page, replayRow) + assert.Contains(t, page, originalRow) + } +} diff --git a/internal/handlers/delivery_result_view.go b/internal/handlers/delivery_result_view.go index 4b0e51f..f635b92 100644 --- a/internal/handlers/delivery_result_view.go +++ b/internal/handlers/delivery_result_view.go @@ -3,6 +3,7 @@ package handlers import ( "time" + "github.com/dustin/go-humanize" "sneak.berlin/go/webhooker/internal/delivery" ) @@ -47,6 +48,11 @@ type DeliveryResultView struct { AttemptNum int Success bool + // Ran is how long ago the attempt was recorded, and RanUTC the + // full timestamp the page shows on hover. + Ran string + RanUTC string + // StatusCode is 0 when the attempt never got a response, // which is why the page asks HasStatusCode rather than // printing the number. @@ -160,6 +166,8 @@ func (r *deliveryResultRow) view( return DeliveryResultView{ AttemptNum: r.AttemptNum, Success: r.Success, + Ran: humanize.Time(r.CreatedAt), + RanUTC: r.CreatedAt.UTC().Format(time.DateTime) + " UTC", StatusCode: r.StatusCode, Error: redactor.Redact(r.Error), DurationMS: r.Duration, diff --git a/internal/handlers/event_log_times_test.go b/internal/handlers/event_log_times_test.go new file mode 100644 index 0000000..7066c95 --- /dev/null +++ b/internal/handlers/event_log_times_test.go @@ -0,0 +1,52 @@ +package handlers_test + +import ( + "net/http" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "sneak.berlin/go/webhooker/internal/database" +) + +// TestEventLog_TimesCarryTheirZone proves that the event log shows +// when an event arrived, and that it and the event's page show when +// each delivery was created and each attempt recorded: each as how +// long ago, with the full UTC time on hover. +func TestEventLog_TimesCarryTheirZone(t *testing.T) { + t.Parallel() + + f := newRecentEventsFixture(t) + target := seedTarget(t, f.db, f.webhook.ID, database.TargetTypeHTTP) + + now := time.Now().UTC().Truncate(time.Second) + receivedAt := now.Add(-3 * time.Hour) + createdAt := now.Add(-90 * time.Minute) + ranAt := now.Add(-30 * time.Minute) + + event := f.event(t, contentTypeJSON, "{}", receivedAt) + dlv := f.deliveryQueuedAt( + t, event, target.ID, database.DeliveryStatusDelivered, createdAt, + ) + f.attempt(t, dlv, http.StatusOK, ranAt.Sub(createdAt)) + + w := serveEventPage(t, f.h, f.sess, f.webhook.ID, event.ID) + require.Equal(t, http.StatusOK, w.Code) + + eventPage := w.Body.String() + eventLog := renderSourceLogsPage(t, f.h, f.sess, f.webhook.ID) + + received := receivedAt.Format(time.DateTime) + assert.Contains(t, eventLog, `title="`+received+` UTC">3 hours ago`) + assert.NotContains(t, eventLog, received+"", + "an event's time must not be written without its zone") + assert.Contains(t, eventPage, received+" UTC") + + for _, page := range []string{eventLog, eventPage} { + assert.Contains(t, page, `title="`+createdAt.Format(time.DateTime)+ + ` UTC">created 1 hour ago`) + assert.Contains(t, page, `title="`+ranAt.Format(time.DateTime)+ + ` UTC">30 minutes ago`) + } +} diff --git a/internal/handlers/event_log_view.go b/internal/handlers/event_log_view.go index 3079493..4a3446e 100644 --- a/internal/handlers/event_log_view.go +++ b/internal/handlers/event_log_view.go @@ -3,6 +3,8 @@ package handlers import ( "time" "unicode/utf8" + + "github.com/dustin/go-humanize" ) // eventLogColumns is the event log's projection. The casts to @@ -28,10 +30,14 @@ const eventColumns = "id, created_at, method, content_type, " + // DeliveryView and TargetView. type EventLogView struct { ID string - CreatedAt time.Time Method string ContentType string + // Received is how long ago the event arrived, and ReceivedUTC + // the full timestamp. + Received string + ReceivedUTC string + Body BodyView // ResubmittedFromID names the event this one was copied @@ -77,9 +83,10 @@ func (r *eventLogRow) view(webhookID string) EventLogView { return EventLogView{ ID: r.ID, - CreatedAt: r.CreatedAt, Method: r.Method, ContentType: r.ContentType, + Received: humanize.Time(r.CreatedAt), + ReceivedUTC: r.CreatedAt.UTC().Format(time.DateTime) + " UTC", Body: newBodyView( "/hook/"+webhookID+"/events/"+r.ID, r.Body, r.BodyBytes, ), diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go index 151d8c4..c1d52db 100644 --- a/internal/handlers/handlers.go +++ b/internal/handlers/handlers.go @@ -165,10 +165,12 @@ func New( ), "source_edit.html": parsePageTemplate("source_edit.html"), "source_logs.html": parsePageTemplate( - "source_logs.html", "event_body.html", "delivery_attempts.html", + "source_logs.html", "event_body.html", "delivery_row.html", + "delivery_attempts.html", ), "event_detail.html": parsePageTemplate( - "event_detail.html", "event_body.html", "delivery_attempts.html", + "event_detail.html", "event_body.html", "delivery_row.html", + "delivery_attempts.html", ), "target_edit.html": parsePageTemplate("target_edit.html"), "error.html": parsePageTemplate("error.html"), diff --git a/internal/handlers/source_management.go b/internal/handlers/source_management.go index 121689b..8b3fe1d 100644 --- a/internal/handlers/source_management.go +++ b/internal/handlers/source_management.go @@ -11,6 +11,7 @@ import ( "strings" "time" + "github.com/dustin/go-humanize" "github.com/go-chi/chi" "github.com/google/uuid" "gorm.io/gorm" @@ -95,6 +96,14 @@ type DeliveryView struct { Status database.DeliveryStatus Target delivery.TargetView + // Replay is set on a delivery the Replay action created. + Replay bool + + // Created is how long ago the delivery was created, and + // CreatedUTC the full timestamp the page shows on hover. + Created string + CreatedUTC string + // Results is this delivery's attempts in attempt order, // bounded by maxRenderedAttempts. Without them a failure // renders as the status word alone and says nothing about @@ -1410,6 +1419,7 @@ func (h *Handlers) newDeliveryViews( for i := range deliveries { target := targetMap[deliveries[i].TargetID] rows := attempts[deliveries[i].ID] + created := deliveries[i].CreatedAt results, omitted := renderedAttempts( rows, target.Redactor, @@ -1419,6 +1429,9 @@ func (h *Handlers) newDeliveryViews( ID: deliveries[i].ID, Status: deliveries[i].Status, Target: target.View, + Replay: deliveries[i].Replay, + Created: humanize.Time(created), + CreatedUTC: created.UTC().Format(time.DateTime) + " UTC", Results: results, AttemptCount: len(rows), AttemptsOmitted: omitted, diff --git a/internal/handlers/target_list.go b/internal/handlers/target_list.go index 9333b2e..f6a3cad 100644 --- a/internal/handlers/target_list.go +++ b/internal/handlers/target_list.go @@ -88,8 +88,7 @@ func (h *Handlers) deliveryPausedView( } // newPausedView is a PausedView of deliveries paused until the given -// time. A time not on the current UTC day is written with its date, as -// the event log writes its times. +// time. A time not on the current UTC day is written with its date. func newPausedView(until time.Time) *PausedView { until = until.UTC() diff --git a/templates/delivery_attempts.html b/templates/delivery_attempts.html index 59d3b72..835efd0 100644 --- a/templates/delivery_attempts.html +++ b/templates/delivery_attempts.html @@ -9,6 +9,7 @@
Attempt {{.AttemptNum}} + {{.Ran}} {{if not .Success}}failure{{else if eq $.Target.Type "database"}}archived{{else if eq $.Target.Type "log"}}written to the log{{else}}success{{end}} {{/* A database or log target sends no HTTP request, so its attempts have no status code. */}} {{if not (eq $.Target.Type "database" "log")}} diff --git a/templates/delivery_row.html b/templates/delivery_row.html new file mode 100644 index 0000000..eaa33d2 --- /dev/null +++ b/templates/delivery_row.html @@ -0,0 +1,16 @@ +{{define "delivery_row"}} + + + + {{.Target.DisplayName}} + {{if .Replay}}replay{{end}} + {{with .Paused}}waiting: target paused after repeated failures, next try no earlier than {{.Until}} ({{.Relative}}){{else}}{{.Status}}{{end}} + + + created {{.Created}} + {{.AttemptCount}} attempt{{if ne .AttemptCount 1}}s{{end}} + + +{{end}} diff --git a/templates/event_detail.html b/templates/event_detail.html index 14241b1..3756cad 100644 --- a/templates/event_detail.html +++ b/templates/event_detail.html @@ -21,7 +21,7 @@
Received
-
{{.CreatedAt.UTC.Format "2006-01-02 15:04:05"}} UTC
+
{{.ReceivedUTC}}
Method
@@ -66,13 +66,7 @@
{{range .Deliveries}}
-
- {{.Target.DisplayName}} - - {{with .Paused}}waiting: target paused after repeated failures, next try no earlier than {{.Until}} ({{.Relative}}){{else}}{{.Status}}{{end}} - {{.AttemptCount}} attempt{{if ne .AttemptCount 1}}s{{end}} - -
+ {{template "delivery_row" .}}
{{template "delivery_attempts" .}}
diff --git a/templates/source_logs.html b/templates/source_logs.html index b1fce27..a87938c 100644 --- a/templates/source_logs.html +++ b/templates/source_logs.html @@ -33,10 +33,10 @@ {{range .Deliveries}} - {{.Target.DisplayName}}: {{if .Paused}}waiting{{else}}{{.Status}}{{end}} + {{.Target.DisplayName}}{{if .Replay}} (replay){{end}}: {{if .Paused}}waiting{{else}}{{.Status}}{{end}} {{end}} - {{.CreatedAt.Format "2006-01-02 15:04:05"}} + {{.Received}} @@ -64,17 +64,11 @@ {{range .Deliveries}}
- {{if and .Status.Terminal (not .Target.Deleted)}}
-- 2.54.0 From 8b617efa632e51ab1f5b7a7da82c32b473077873 Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Sat, 3 Oct 2026 04:18:37 +0200 Subject: [PATCH 086/126] Rotate a database target's archive monthly, daily or hourly (closes #379) A database target's rotation setting (none, monthly, daily or hourly) puts the UTC period of each event's receive time in its archive file name, so each file holds exactly its period's events. It is on the new webhook page and both target forms, and shown in the target list. Renames move every one of a target's files and move them all back if one fails. The sweep prunes one file at a time under the target's lock and deletes a rotated file it leaves empty. Download opens one file at a time, oldest period first, finding each again under the target's current name. The target list names the current file and totals all of them. Model: opus-5-5 --- README.md | 125 ++++-- internal/delivery/archive_sweeper_test.go | 15 +- internal/delivery/engine.go | 13 +- internal/delivery/export_test.go | 19 +- internal/delivery/target_config_edit.go | 27 +- internal/delivery/target_config_view.go | 13 +- internal/delivery/target_config_view_test.go | 42 +- internal/delivery/target_database.go | 37 +- internal/delivery/target_database_archive.go | 278 +++++++++---- internal/delivery/target_database_export.go | 223 +++++++--- .../delivery/target_database_export_test.go | 255 +++++++++--- internal/delivery/target_database_rotation.go | 198 +++++++++ .../delivery/target_database_rotation_test.go | 389 ++++++++++++++++++ internal/delivery/target_database_test.go | 39 ++ internal/delivery/target_headers_test.go | 7 +- internal/handlers/archive_expiry.go | 18 +- internal/handlers/archive_expiry_test.go | 25 +- internal/handlers/archive_rotation.go | 42 ++ internal/handlers/archive_rotation_test.go | 229 +++++++++++ internal/handlers/export_test.go | 13 +- internal/handlers/handlers.go | 4 +- internal/handlers/handlers_test.go | 41 +- .../handlers/source_create_targets_test.go | 4 +- internal/handlers/source_management.go | 79 ++-- internal/handlers/target_download.go | 54 ++- internal/handlers/target_download_test.go | 94 ++++- internal/handlers/target_edit.go | 4 + internal/handlers/target_edit_test.go | 9 +- internal/handlers/target_list.go | 87 ++-- internal/handlers/target_paused_test.go | 12 +- internal/server/alpine_browser_test.go | 64 ++- static/css/tailwind.css | 2 +- static/js/app.js | 7 +- templates/source_detail.html | 38 +- templates/sources_new.html | 15 +- templates/target_edit.html | 10 + 36 files changed, 2125 insertions(+), 406 deletions(-) create mode 100644 internal/delivery/target_database_rotation.go create mode 100644 internal/delivery/target_database_rotation_test.go create mode 100644 internal/handlers/archive_rotation.go create mode 100644 internal/handlers/archive_rotation_test.go diff --git a/README.md b/README.md index ef36bf5..2780aec 100644 --- a/README.md +++ b/README.md @@ -736,7 +736,8 @@ The app runs as a non-root user (`webhooker`, UID 1000), exposes port The `/var/lib/webhooker` volume holds all SQLite databases: the main application database (`webhooker.db`), the per-webhook event databases (`events-{uuid}.db`), and any archive databases written by `database` -targets (`archive-{webhook_name}-{target_name}-{target_uuid}.db`). Mount +targets (`archive-{webhook_name}-{target_name}-{target_uuid}.db`, with +`-{period}` before `.db` for a target that rotates). Mount this as a persistent volume to preserve data across container restarts. @@ -976,9 +977,11 @@ is both the simplest and the only complete rule: - `events-{webhook_uuid}.db` — **one per webhook**. Events, deliveries, delivery results. - `archive-{webhook_name}-{target_name}-{target_uuid}.db` — **one per - `database` target**. Archived events. The two names are made safe for - a file name, and the file is renamed when the webhook or the target is - (see [Database Architecture](#database-architecture)). + `database` target**, or one per month, day or hour for a target that + rotates, with `-{period}` before `.db`. Archived events. The two names + are made safe for a file name, and the files are renamed when the + webhook or the target is (see + [Database Architecture](#database-architecture)). `{webhook_uuid}` and `{target_uuid}` are UUID primary keys in their canonical 36-character hyphenated form, so a real filename looks like @@ -1612,8 +1615,9 @@ The new webhook form can also give the webhook its first targets: an optional HTTP target URL creates an `http` target named `HTTP`, and the archive checkbox creates a `database` target named `Archive` whose `expiry` is the pruning chosen beside it (never, 1h, 12h, 24h, 30d, 90d -or 365d). Both are validated as on the add target form, and the webhook -and its targets are created together or not at all. +or 365d) and whose `rotation` is the rotation chosen below that (none, +monthly, daily or hourly). Both are validated as on the add target form, +and the webhook and its targets are created together or not at all. | Field | Type | Description | | ---------------- | ------- | ----------- | @@ -1722,12 +1726,14 @@ events should be forwarded. own archive database (`archive-{webhook_name}-{target_name}-{target_uuid}.db`) for long-term retention, with an optional creation-validated expiry (default: keep - forever). The new webhook form, the add target form and the target edit - form all offer the same expiries: never, 1h, 12h, 24h, 30d, 90d or 365d. - The target list shows the expiry in plain units, such as "30 days". No - external delivery and no retries; an archive write failure fails the - delivery. See the database target section under - "Per-Webhook Event Databases" for the full semantics. + forever) and rotation (default: none, one file). The new webhook form, + the add target form and the target edit form all offer the same + expiries: never, 1h, 12h, 24h, 30d, 90d or 365d, and the same + rotations: none, monthly, daily or hourly. The target list shows the + expiry in plain units, such as "30 days", and the rotation. No external + delivery and no retries; an archive write failure fails the delivery. + See the database target section under "Per-Webhook Event Databases" + for the full semantics. - **`log`** — Write the event to the application log (stdout). Useful for debugging. @@ -2104,15 +2110,31 @@ single `-`, no `-` at either end, cut to 40 characters, and `unnamed` when nothing is left. The target UUID keeps the file name unique. A webhook named `Orders (EU)` with a target named `Long-term archive` archives into `archive-orders-eu-long-term-archive-{target_uuid}.db`. -Renaming the webhook or the target renames the file, under the same -lock the archive writes and the archive sweeper take. Webhook edits, -target edits and target creation run one at a time, so no edit can -rename the file between another's rename and save, and the name on disk -matches the UI. A rename never replaces a file: if one already has -the new name, the edit is refused with an error naming that file, and -the stored name stays. If the archive is not there (the operator moved -it away), the rename is not an error, and the next write creates the -file under the new name. + +An optional `rotation` in the target's config JSON (e.g. +`{"rotation":"daily"}`) is `none`, the default, which keeps the one +file, or `monthly`, `daily` or `hourly`. A target that rotates writes +each event to a file named for the period of the event's receive time, +in UTC, put before the `.db`: +`archive-orders-eu-long-term-archive-{target_uuid}-2026-10.db` monthly, +`…-2026-10-01.db` daily and `…-2026-10-01-19.db` hourly. Each file holds +exactly its period's events, and the first event of a new period starts +the next file, so a finished period's file can be moved away like any +archive. A changed rotation applies from the next event: the files +already written keep their names and stay, pruned, shown and downloaded +with the rest, since every file named for the target is its archive, +whichever rotation wrote it. + +Renaming the webhook or the target renames every one of the target's +files, each keeping its period, under the same lock the archive writes +and the archive sweeper take. Webhook edits, target edits and target +creation run one at a time, so no edit can rename the files between +another's rename and save, and the names on disk match the UI. A rename +never replaces a file: if one already has a new name, the edit is +refused with an error naming that file, nothing is moved, and the +stored name stays. If the archive is not there (the operator moved it +away), the rename is not an error, and the next write creates the file +under the new name. The file is moved just before the new name is saved. If the process stops between the two, the archive is left under the new name while the @@ -2151,18 +2173,29 @@ interleave with a write, and it leaves the archive closed afterwards so the move-the-file-away workflow keeps working. Archives with no expiry, or the expiry `never`, are not touched by the sweep at all. +For a target with several files, the write path prunes only the file it +writes to, and the sweep prunes every one of them, taking the target's +lock for one file at a time, so a write to the target waits for at most +one file's prune. A file that is gone by the time the sweep reaches it +is skipped. A file named for a period that the sweep leaves empty is +deleted, with any `-wal` and `-shm` beside it; the file without a period +is kept even when empty, as it always has been. + Because each `database` target has its own archive file, a target's `expiry` governs only its own archive. Two `database` targets on one webhook with different expiries keep two archives, each pruned on its own schedule. -The webhook page shows, for each `database` target, its archive file's -name, its size on disk and when it was last written. The size counts -the `.db` and its `-wal` together, and the last write is the later of -their two modification times, since a write lands in the `-wal` first. -Both are read from the files' metadata; the archive is never opened. -Before the first write, and after the file has been moved away, the page -shows `not created yet` beside the name. +The webhook page shows, for each `database` target, the name of the +archive file an event received now would go to, the size on disk of all +the target's archive files together, how many there are when there is +more than one, and when the latest of them was last written. The size +counts each `.db` and its `-wal` together, and the last write is the +latest of their modification times, since a write lands in the `-wal` +first. All are read from the files' metadata; the archive is never +opened. While the named file does not exist — before the first write, +before the first event of a new period, and after the file has been +moved away — the page shows `not created yet` beside the name. Each `database` target on the webhook page has a **Download** button, which returns its archive as one gzipped JSON file, @@ -2170,22 +2203,31 @@ which returns its archive as one gzipped JSON file, names made safe as above and the time in UTC. The file holds one object: `webhook` and `target`, each an `id` and a `name`; `exported_at`; and `archived_events`, one object per archived row with -every column, keyed by column name. A body that is not valid UTF-8 is -written in base64, with `"body_encoding": "base64"` beside it. An -archive that does not exist yet, or was moved away, downloads with an -empty `archived_events`; the download never creates the file. +every column, keyed by column name. The rows come from every one of the +target's files: the file without a period first, then the others in +the order of their periods, oldest first, and each row from a file named +for a period has that `period` beside its columns. A body that is not +valid UTF-8 is written in base64, with `"body_encoding": "base64"` +beside it. An archive that does not exist yet, or was moved away, +downloads with an empty `archived_events`; the download never creates a +file. The download streams: each row is read and written out compressed before the next is read, so neither the archive nor the JSON is held in -memory. It reads on a connection of its own, inside one read-only -transaction, so the file holds the archive as it stood when the -download started, and archive writes go on meanwhile, since under WAL a -reader never blocks a writer. While it runs, the `-wal` cannot be -checkpointed past what it reads, so a long download lets the `-wal` -grow. It finds the file by the stored names under the lock that webhook -edits, target edits and target creation hold, and lets go once the file -is open: a rename during the download moves the file without affecting -it. +memory. When it starts it lists the target's files by the stored names, +under the lock that webhook edits, target edits and target creation +hold, and lets go. It then opens one file at a time, only when its rows +are about to be written out, and closes it before it opens the next, so +it never has more than one of the target's files open. To open each, it +takes the lock again just long enough to find the file by its period +under the names stored then, so a rename during the download loses no +file; a file that is gone by then, emptied by the sweep or moved away, +is skipped. Each file is read on a connection of its own inside one +read-only transaction, so it is written out as it stood when it was +opened, and archive writes go on meanwhile, since under WAL a reader +never blocks a writer. Until the open file is closed its `-wal` cannot +be checkpointed past what the download reads, so a long download lets +that `-wal` grow. Deleting a webhook releases its archives: the delivery engine's cached archive writers are dropped and their file handles closed, so nothing @@ -3152,6 +3194,7 @@ webhooker/ │ │ ├── target_slack.go # Slack/Mattermost incoming-webhook target │ │ ├── target_database.go # Database archive target │ │ ├── target_database_archive.go # Archive file lifecycle and pruning +│ │ ├── target_database_rotation.go # Archive rotation and file names │ │ ├── target_database_export.go # Archive download as gzipped JSON │ │ ├── target_log.go # Log target (stdout) │ │ ├── target_config_view.go # Masked target config for templates diff --git a/internal/delivery/archive_sweeper_test.go b/internal/delivery/archive_sweeper_test.go index c596692..5563349 100644 --- a/internal/delivery/archive_sweeper_test.go +++ b/internal/delivery/archive_sweeper_test.go @@ -163,6 +163,17 @@ func (env *archiveEnv) seedArchiveRows( t.Helper() path := env.archivePath(tgt) + seedArchiveFile(t, path, tgt.WebhookID, archivedAt...) + + return path +} + +// seedArchiveFile creates the archive file at path and inserts one row +// per supplied archived-at timestamp, as seedArchiveRows does. +func seedArchiveFile( + t *testing.T, path, webhookID string, archivedAt ...time.Time, +) { + t.Helper() sqlDB, err := sql.Open( "sqlite", fmt.Sprintf("file:%s?mode=rwc", path), @@ -182,7 +193,7 @@ func (env *archiveEnv) seedArchiveRows( for i, at := range archivedAt { row := delivery.ExportArchivedEvent{ EventID: fmt.Sprintf("ev-%d", i), - WebhookID: tgt.WebhookID, + WebhookID: webhookID, Method: http.MethodPost, Body: `{"seeded":true}`, ArchivedAt: at, @@ -191,8 +202,6 @@ func (env *archiveEnv) seedArchiveRows( } require.NoError(t, sqlDB.Close()) - - return path } // archivedEventIDs returns the event ids currently stored in an diff --git a/internal/delivery/engine.go b/internal/delivery/engine.go index a12ff9e..ec78d7a 100644 --- a/internal/delivery/engine.go +++ b/internal/delivery/engine.go @@ -295,12 +295,13 @@ func (e *Engine) EvictTarget(targetID string) { e.dbTarget.evict(targetID) } -// Rename implements Archives. It renames a database target's -// archive file to ArchiveFileName(webhookName, targetName, -// targetID), under the lock the target's archive writes and the -// idle sweep take. It never replaces a file: if one already has the -// new name, the error is ErrArchiveNameTaken. The caller renames -// before it saves the new name: see databaseTarget.rename. +// Rename implements Archives. It renames every one of a database +// target's archive files to ArchiveFileName(webhookName, targetName, +// targetID), each keeping the period in its name, under the lock the +// target's archive writes and the idle sweep take. It never replaces +// a file: if one already has a new name, the error is +// ErrArchiveNameTaken. The caller renames before it saves the new +// name: see databaseTarget.rename. func (e *Engine) Rename( targetID, webhookName, targetName string, ) error { diff --git a/internal/delivery/export_test.go b/internal/delivery/export_test.go index 2a6a1e9..8c9aeb4 100644 --- a/internal/delivery/export_test.go +++ b/internal/delivery/export_test.go @@ -501,23 +501,32 @@ func NewExportArchiveWriter( return &ExportArchiveWriter{w: w} } -// Write archives a row through the writer. +// Write archives a row through the writer, into the file named +// without a period. func (e *ExportArchiveWriter) Write( row ExportArchivedEvent, expiry time.Duration, ) error { - return e.w.write(row, expiry) + return e.w.write(row, expiry, "") +} + +// WritePeriod archives a row through the writer, into the file for +// period. +func (e *ExportArchiveWriter) WritePeriod( + row ExportArchivedEvent, expiry time.Duration, period string, +) error { + return e.w.write(row, expiry, period) } // Open opens the archive file, pruning when expiry is positive. func (e *ExportArchiveWriter) Open(expiry time.Duration) error { - return e.w.open(expiry) + return e.w.open(e.w.path, expiry) } // Reopen closes and reopens the archive file. func (e *ExportArchiveWriter) Reopen( expiry time.Duration, ) error { - return e.w.reopen(expiry) + return e.w.reopen(e.w.path, expiry) } // SetNow replaces the clock the writer measures its reopen @@ -547,7 +556,7 @@ func (e *ExportArchiveWriter) Path() string { func (e *ExportArchiveWriter) OpenExisting( expiry time.Duration, ) error { - return e.w.openMode(archiveModeExisting, expiry) + return e.w.openMode(e.w.path, archiveModeExisting, expiry) } // SweepExpired runs an idle sweep of the archive. diff --git a/internal/delivery/target_config_edit.go b/internal/delivery/target_config_edit.go index 3753e3c..a62429b 100644 --- a/internal/delivery/target_config_edit.go +++ b/internal/delivery/target_config_edit.go @@ -41,6 +41,8 @@ type TargetConfigForm struct { Timeout string // Expiry is the database (archive) target's row expiry. Expiry string + // Rotation is the database (archive) target's rotation. + Rotation string } // NewTargetConfigForm parses a target's stored configuration into @@ -85,11 +87,13 @@ func NewTargetConfigForm( } } -// databaseConfigForm parses an archive target's optional expiry. -// An absent, empty or never expiry yields an empty expiry, on which -// the edit form starts at never; saving it unchanged stores never, -// which means the same as an empty expiry. An expiry that is set -// but not a valid duration is an error, not a blank field. +// databaseConfigForm parses an archive target's optional expiry and +// rotation. An absent, empty or never expiry yields an empty expiry, +// on which the edit form starts at never; saving it unchanged stores +// never, which means the same as an empty expiry. An absent rotation +// is empty too, and the form starts at none. An expiry that is set +// but not a valid duration, or a rotation that is not one of the +// four, is an error, not a blank field. func databaseConfigForm( configJSON string, ) (TargetConfigForm, error) { @@ -106,8 +110,15 @@ func databaseConfigForm( ) } + err = ValidateArchiveRotation(cfg.Rotation) + if err != nil { + return TargetConfigForm{}, err + } + + form := TargetConfigForm{Rotation: cfg.Rotation} + if cfg.Expiry == "" || cfg.Expiry == archiveExpiryNever { - return TargetConfigForm{}, nil + return form, nil } err = ValidateArchiveExpiry(cfg.Expiry) @@ -115,5 +126,7 @@ func databaseConfigForm( return TargetConfigForm{}, err } - return TargetConfigForm{Expiry: cfg.Expiry}, nil + form.Expiry = cfg.Expiry + + return form, nil } diff --git a/internal/delivery/target_config_view.go b/internal/delivery/target_config_view.go index 295cfb1..575706e 100644 --- a/internal/delivery/target_config_view.go +++ b/internal/delivery/target_config_view.go @@ -192,8 +192,9 @@ func maxRetriesField(t *database.Target) ConfigField { // databaseConfigFields describes an archive target by its // expiry in plain units, such as "30 days", or "never" when -// the archive is kept forever. An expiry that is set but not -// a valid duration is reported as unavailable rather than +// the archive is kept forever, and by its rotation. An expiry +// that is set but not a valid duration, or a rotation that is +// not one of the four, is reported as unavailable rather than // echoed back. func databaseConfigFields(configJSON string) []ConfigField { expiry, err := parseArchiveExpiry(configJSON) @@ -201,6 +202,11 @@ func databaseConfigFields(configJSON string) []ConfigField { return unavailableConfigFields() } + rotation, err := parseArchiveRotation(configJSON) + if err != nil { + return unavailableConfigFields() + } + value := archiveExpiryNever if expiry > 0 { value = plainDuration(expiry) @@ -209,6 +215,9 @@ func databaseConfigFields(configJSON string) []ConfigField { return []ConfigField{{ Label: "Archive Expiry", Value: value, + }, { + Label: "Archive Rotation", + Value: rotation, }} } diff --git a/internal/delivery/target_config_view_test.go b/internal/delivery/target_config_view_test.go index 5f8d987..923bd78 100644 --- a/internal/delivery/target_config_view_test.go +++ b/internal/delivery/target_config_view_test.go @@ -328,13 +328,49 @@ func TestNewTargetViews_Database(t *testing.T) { assert.Equal( t, - map[string]string{"Archive Expiry": tc.want}, + map[string]string{ + "Archive Expiry": tc.want, + "Archive Rotation": rotationNone, + }, fieldMap(view.Config), ) }) } } +// TestNewTargetViews_DatabaseRotation proves the target list shows a +// database target's rotation, none when it has none stored. +func TestNewTargetViews_DatabaseRotation(t *testing.T) { + t.Parallel() + + // Each stored config, and the rotation the list shows for it. + tests := map[string]string{ + "": rotationNone, + `{"rotation":""}`: rotationNone, + } + + for _, rotation := range []string{ + rotationNone, rotationMonthly, rotationDaily, rotationHourly, + } { + tests[`{"rotation":"`+rotation+`"}`] = rotation + } + + for config, want := range tests { + t.Run(config, func(t *testing.T) { + t.Parallel() + + view := viewFor(t, database.Target{ + Type: database.TargetTypeDatabase, + Config: config, + }) + + assert.Equal( + t, want, fieldMap(view.Config)["Archive Rotation"], + ) + }) + } +} + func TestNewTargetViews_Log(t *testing.T) { t.Parallel() @@ -382,6 +418,10 @@ func TestNewTargetViews_Unpresentable(t *testing.T) { Type: database.TargetTypeDatabase, Config: `{"expiry":"a fortnight"}`, }, + "invalid archive rotation": { + Type: database.TargetTypeDatabase, + Config: weeklyConfig, + }, } for name, target := range tests { diff --git a/internal/delivery/target_database.go b/internal/delivery/target_database.go index 483e317..862b7e8 100644 --- a/internal/delivery/target_database.go +++ b/internal/delivery/target_database.go @@ -20,7 +20,8 @@ const archiveNameMaxLen = 40 // from the per-webhook event database. The event is already // persisted in the per-webhook event DB by the time delivery runs; // the database target additionally writes a durable long-term copy -// into the file ArchiveFileName names and then records a single +// into the file ArchiveFileName names, with a period added when the +// target rotates (see archivePeriodPath), and then records a single // attempt whose outcome reflects whether the archive write // succeeded. See archiveWriter for the close/reopen, auto-recreate, // and expiry semantics. @@ -146,8 +147,9 @@ func (t *databaseTarget) Deliver( } // archive writes the full event as a row into the target's -// archive database, honouring the optional per-target expiry -// parsed from the target config JSON. +// archive database, honouring the optional per-target expiry and +// rotation parsed from the target config JSON. With rotation, the +// event goes to the file for the period of its receive time. func (t *databaseTarget) archive(d *database.Delivery) error { webhookID := d.Event.WebhookID if webhookID == "" { @@ -159,6 +161,19 @@ func (t *databaseTarget) archive(d *database.Delivery) error { return err } + rotation, err := parseArchiveRotation(d.Target.Config) + if err != nil { + return err + } + + // An event whose stored row was gone before its delivery ran has + // no receive time (see Engine.hydrateEvent), and goes to the + // file for now. + receivedAt := d.Event.CreatedAt + if receivedAt.IsZero() { + receivedAt = time.Now() + } + w, err := t.writerFor(d.TargetID) if err != nil { return err @@ -174,7 +189,7 @@ func (t *databaseTarget) archive(d *database.Delivery) error { ContentType: d.Event.ContentType, } - return w.write(row, expiry) + return w.write(row, expiry, archivePeriod(rotation, receivedAt)) } // writerFor returns the archive writer for a database target, @@ -275,10 +290,10 @@ func (t *databaseTarget) releaseSweepWriter( delete(t.writers, targetID) } -// newWriter builds the writer for a database target's archive. The -// file is the one ArchivePath gives for the webhook and the target as +// newWriter builds the writer for a database target's archive. Its +// path is the one ArchivePath gives for the webhook and the target as // the main database names them now; from then on only rename changes -// the name the writer uses. It does not touch the archive file. +// the name the writer uses. It does not touch the archive files. func (t *databaseTarget) newWriter( targetID string, ) (*archiveWriter, error) { @@ -306,10 +321,10 @@ func (t *databaseTarget) newWriter( return w, nil } -// rename moves a database target's archive file to the name for -// webhookName and targetName. It goes through the target's writer, -// so the move holds the lock that writes and the idle sweep take, -// and later writes use the new name. +// rename moves every one of a database target's archive files to the +// name for webhookName and targetName. It goes through the target's +// writer, so the move holds the lock that writes and the idle sweep +// take, and later writes use the new name. // // The writer is created if there is none, and it stays cached. The // handlers rename before they save the new name, so until the save diff --git a/internal/delivery/target_database_archive.go b/internal/delivery/target_database_archive.go index c58664c..506003b 100644 --- a/internal/delivery/target_database_archive.go +++ b/internal/delivery/target_database_archive.go @@ -85,6 +85,10 @@ type databaseTargetConfig struct { // archived rows are pruned, or "never" (the default) to // keep them forever. Expiry string `json:"expiry"` + + // Rotation is none (the default), monthly, daily or hourly: see + // archivePeriod. + Rotation string `json:"rotation"` } // archivedEvent is one fully captured webhook event stored in a @@ -178,7 +182,7 @@ func ValidateArchiveExpiry(expiry string) error { return nil } -// archiveWriter owns one database target's archive SQLite file. +// archiveWriter owns one database target's archive SQLite files. // It serialises writes, and after each write closes and reopens // the file (debounced to at most once per debounce window) so // an operator can move the file away for offline archiving. The @@ -186,8 +190,16 @@ func ValidateArchiveExpiry(expiry string) error { // file is opened create-if-missing and its schema is migrated // on every open. type archiveWriter struct { - mu sync.Mutex - path string + mu sync.Mutex + + // path is the target's archive file as ArchivePath names it. A + // target that rotates writes to the files archivePeriodPath names + // for path and a period instead. + path string + + // current is the file db is open on. + current string + log *slog.Logger debounce time.Duration db *gorm.DB @@ -236,12 +248,14 @@ func newArchiveWriter( } } -// write appends the event as a row, then applies the debounced -// close/reopen. It recreates the archive file if it was moved -// or removed since the last open. A positive expiry prunes rows -// older than it on each (re)open. +// write appends the event as a row to the archive file for period +// (see archivePeriodPath), then applies the debounced close/reopen. +// When period names a different file from the one open, the open one +// is closed first. It recreates the archive file if it was moved or +// removed since the last open. A positive expiry prunes rows older +// than it on each (re)open. func (w *archiveWriter) write( - row archivedEvent, expiry time.Duration, + row archivedEvent, expiry time.Duration, period string, ) error { w.mu.Lock() defer w.mu.Unlock() @@ -252,8 +266,10 @@ func (w *archiveWriter) write( ) } - if w.db == nil || !fileExists(w.path) { - err := w.reopen(expiry) + file := archivePeriodPath(w.path, period) + + if w.db == nil || w.current != file || !fileExists(file) { + err := w.reopen(file, expiry) if err != nil { return err } @@ -264,41 +280,41 @@ func (w *archiveWriter) write( err := w.db.Create(&row).Error if err != nil { return fmt.Errorf( - "archiving event to %s: %w", w.path, err, + "archiving event to %s: %w", file, err, ) } if w.now().Sub(w.lastReopen) >= w.debounce { - return w.reopen(expiry) + return w.reopen(file, expiry) } return nil } -// open opens (creating if missing) the archive file, migrates +// open opens (creating if missing) an archive file, migrates // its schema, records the reopen time, and prunes expired rows // when expiry is positive. -func (w *archiveWriter) open(expiry time.Duration) error { - return w.openMode(archiveModeCreate, expiry) +func (w *archiveWriter) open(file string, expiry time.Duration) error { + return w.openMode(file, archiveModeCreate, expiry) } -// openMode opens the archive file with the given SQLite URI +// openMode opens an archive file with the given SQLite URI // mode, migrates its schema, records the reopen time, and // prunes expired rows when expiry is positive. The write path // passes archiveModeCreate so a missing file is recreated; the // idle sweep passes archiveModeExisting so a missing file is an // error rather than a newly conjured empty archive. func (w *archiveWriter) openMode( - mode string, expiry time.Duration, + file, mode string, expiry time.Duration, ) error { // Opened through database.OpenSQLite so an archive file carries // the same WAL journaling, busy timeout, immediate-transaction // locking, and pool bounds as every other database file. See // internal/database/sqlite_open.go. - sqlDB, err := database.OpenSQLite(w.path, mode) + sqlDB, err := database.OpenSQLite(file, mode) if err != nil { return fmt.Errorf( - "opening archive database %s: %w", w.path, err, + "opening archive database %s: %w", file, err, ) } @@ -314,7 +330,7 @@ func (w *archiveWriter) openMode( return fmt.Errorf( "connecting to archive database %s: %w", - w.path, err, + file, err, ) } @@ -323,11 +339,12 @@ func (w *archiveWriter) openMode( _ = sqlDB.Close() return fmt.Errorf( - "migrating archive database %s: %w", w.path, err, + "migrating archive database %s: %w", file, err, ) } w.db = gdb + w.current = file w.lastReopen = w.now() w.reopens++ @@ -338,12 +355,12 @@ func (w *archiveWriter) openMode( return nil } -// reopen closes any open handle and opens the file afresh. The +// reopen closes any open handle and opens file afresh. The // fresh open recreates the file if it was moved away. -func (w *archiveWriter) reopen(expiry time.Duration) error { +func (w *archiveWriter) reopen(file string, expiry time.Duration) error { w.close() - return w.open(expiry) + return w.open(file, expiry) } // close closes the underlying handle, if any. @@ -360,22 +377,56 @@ func (w *archiveWriter) close() { w.db = nil } -// sweepExpired prunes an archive that may have gone idle, with -// no write to trigger the usual on-reopen prune. It takes the -// writer's own mutex for the whole operation, so a sweep is -// ordered against concurrent writes rather than reaching around -// them to the file. +// sweepExpired prunes the target's archive files, which may have +// gone idle, with no write to trigger the usual on-reopen prune. It +// lists the files under the writer's own mutex, then takes the mutex +// again for one file at a time, so a write waits for at most one +// file's prune, and each prune is ordered against concurrent writes +// rather than reaching around them to the file. // -// It never creates the archive file: a missing file is skipped, -// and the reopen uses archiveModeExisting so SQLite itself -// refuses to create one if the file disappears between the -// check and the open. +// It never creates an archive file: it prunes only the files +// archiveFiles lists, skips one that is gone by the time it is +// reached (moved away, or renamed since the listing), and opens each +// with archiveModeExisting so SQLite itself refuses to create one if +// the file disappears between the check and the open. A file named +// for a period that the prune leaves empty is deleted. // // The archive is left CLOSED afterwards. An idle archive holding // no handle is what keeps the operator's move-the-file-away // workflow working; the next write reopens (and recreates) the // file as it always has. func (w *archiveWriter) sweepExpired(expiry time.Duration) error { + w.mu.Lock() + files, err := archiveFiles(w.path) + w.mu.Unlock() + + if err != nil { + return err + } + + var errs []error + + for _, file := range files { + err = w.sweepFile(file, expiry) + if errors.Is(err, errArchiveWriterEvicted) { + return err + } + + if err != nil { + errs = append(errs, err) + } + } + + return errors.Join(errs...) +} + +// sweepFile prunes one of the target's archive files for sweepExpired, +// holding w.mu while it does. It skips a file that is gone, and deletes +// the file, with its -wal and -shm, when it is named for a period and +// the prune leaves it empty. +func (w *archiveWriter) sweepFile( + file archiveFile, expiry time.Duration, +) error { w.mu.Lock() defer w.mu.Unlock() @@ -385,7 +436,7 @@ func (w *archiveWriter) sweepExpired(expiry time.Duration) error { ) } - if !fileExists(w.path) { + if !fileExists(file.path) { return nil } @@ -393,26 +444,56 @@ func (w *archiveWriter) sweepExpired(expiry time.Duration) error { // freshly opened file, matching the write path's semantics. w.close() - err := w.openMode(archiveModeExisting, expiry) + err := w.openMode(file.path, archiveModeExisting, expiry) if err != nil { return err } + if file.period == "" { + w.close() + + return nil + } + + var rows int64 + + err = w.db.Model(&archivedEvent{}).Count(&rows).Error + w.close() + if err != nil { + return fmt.Errorf( + "counting rows in archive %s: %w", file.path, err, + ) + } + + if rows > 0 { + return nil + } + + for _, suffix := range []string{"", "-wal", "-shm"} { + err = os.Remove(file.path + suffix) + if err != nil && !errors.Is(err, fs.ErrNotExist) { + return fmt.Errorf("deleting empty archive file: %w", err) + } + } + + w.log.Info("deleted empty archive file", "path", file.path) + return nil } -// rename gives the archive file a new name in the same directory, -// and the writer uses the file under that name from now on. The -// handle is closed first, which folds the -wal into the .db; any -// -wal or -shm still beside the file (left by a crash) is moved with -// it, because SQLite finds them by name. A missing file is not an -// error: the operator may have moved it away, and the next write -// creates it under the new name. +// rename gives every one of the target's archive files the new +// name, keeping the period in the name of each (see +// archivePeriodPath), and the writer uses the files under that name +// from now on. The handle is closed first, which folds the -wal into +// the .db; any -wal or -shm still beside a file (left by a crash) is +// moved with it, because SQLite finds them by name. A target with no +// files is not an error: the operator may have moved them away, and +// the next write creates its file under the new name. // -// If a file already has the new name, nothing is moved and the -// error is ErrArchiveNameTaken. If one file fails to move, those +// If a file already has one of the new names, nothing is moved and +// the error is ErrArchiveNameTaken. If one file fails to move, those // already moved are moved back before the error is returned, so the // archive is never split across two names. func (w *archiveWriter) rename(name string) error { @@ -430,38 +511,53 @@ func (w *archiveWriter) rename(name string) error { return nil } - suffixes := []string{"", "-wal", "-shm"} + files, err := archiveFiles(w.path) + if err != nil { + return err + } - for _, suffix := range suffixes { - if fileExists(path + suffix) { + // from[i] moves to to[i]. + var from, to []string + + for _, file := range files { + renamed := archivePeriodPath(path, file.period) + + for _, suffix := range []string{"", "-wal", "-shm"} { + from = append(from, file.path+suffix) + to = append(to, renamed+suffix) + } + } + + for _, taken := range to { + if fileExists(taken) { return fmt.Errorf( - "%w: %s", ErrArchiveNameTaken, name+suffix, + "%w: %s", ErrArchiveNameTaken, filepath.Base(taken), ) } } w.close() - for i, suffix := range suffixes { - err := os.Rename(w.path+suffix, path+suffix) + for i := range from { + err = os.Rename(from[i], to[i]) if err == nil || errors.Is(err, fs.ErrNotExist) { continue } - for _, moved := range suffixes[:i] { - backErr := os.Rename(path+moved, w.path+moved) + for j := range i { + backErr := os.Rename(to[j], from[j]) if backErr != nil && !errors.Is(backErr, fs.ErrNotExist) { w.log.Error( "failed to move archive file back", - "from", path+moved, - "to", w.path+moved, + "from", to[j], + "to", from[j], "error", backErr, ) } } return fmt.Errorf( - "renaming archive %s to %s: %w", w.path+suffix, path+suffix, err, + "renaming archive %s to %s: %w", from[i], to[i], err, ) } @@ -499,7 +595,7 @@ func (w *archiveWriter) prune(expiry time.Duration) { if res.Error != nil { w.log.Error( "failed to prune expired archive rows", - "path", w.path, + "path", w.current, "error", res.Error, ) @@ -509,48 +605,76 @@ func (w *archiveWriter) prune(expiry time.Duration) { if res.RowsAffected > 0 { w.log.Info( "pruned expired archive rows", - "path", w.path, + "path", w.current, "rows_deleted", res.RowsAffected, ) } } // ArchiveFileInfo is what the metadata of a database target's archive -// file says about it. +// files says about them. type ArchiveFileInfo struct { - // Size is the bytes on disk of the file and its -wal together. + // Files counts the files. + Files int + + // Size is the bytes on disk of the files and their -wal together. Size int64 - // Written is when the file or its -wal was last modified, whichever - // is later: a write lands in the -wal first. + // Written is when a file or a -wal was last modified, whichever is + // latest: a write lands in the -wal first. Written time.Time } -// StatArchive reads the metadata of the archive file at path and of -// its -wal, without opening the archive. With no file at path, which is -// so before the first write and after the operator moved it away, the +// StatArchive reads the metadata of a database target's archive +// files, given the path ArchivePath gives it (see archiveFiles), and +// of their -wal, without opening them. With no files, which is so +// before the first write and after the operator moved them away, the // error wraps fs.ErrNotExist. func StatArchive(path string) (ArchiveFileInfo, error) { - file, err := os.Stat(path) + files, err := archiveFiles(path) if err != nil { return ArchiveFileInfo{}, err } - info := ArchiveFileInfo{Size: file.Size(), Written: file.ModTime()} + var info ArchiveFileInfo - wal, err := os.Stat(path + "-wal") - if errors.Is(err, fs.ErrNotExist) { - return info, nil + for _, file := range files { + db, err := os.Stat(file.path) + if errors.Is(err, fs.ErrNotExist) { + continue + } + + if err != nil { + return ArchiveFileInfo{}, err + } + + info.Files++ + info.Size += db.Size() + + if db.ModTime().After(info.Written) { + info.Written = db.ModTime() + } + + wal, err := os.Stat(file.path + "-wal") + if errors.Is(err, fs.ErrNotExist) { + continue + } + + if err != nil { + return ArchiveFileInfo{}, err + } + + info.Size += wal.Size() + + if wal.ModTime().After(info.Written) { + info.Written = wal.ModTime() + } } - if err != nil { - return ArchiveFileInfo{}, err - } - - info.Size += wal.Size() - - if wal.ModTime().After(info.Written) { - info.Written = wal.ModTime() + if info.Files == 0 { + return ArchiveFileInfo{}, fmt.Errorf( + "no archive file for %s: %w", path, fs.ErrNotExist, + ) } return info, nil diff --git a/internal/delivery/target_database_export.go b/internal/delivery/target_database_export.go index 231aff7..48a6f92 100644 --- a/internal/delivery/target_database_export.go +++ b/internal/delivery/target_database_export.go @@ -6,10 +6,14 @@ import ( "database/sql" "encoding/base64" "encoding/json" + "errors" "fmt" "io" + "io/fs" "log/slog" + "os" "path/filepath" + "sync" "time" "unicode/utf8" @@ -50,21 +54,46 @@ func ArchiveExportFileName( at.UTC().Format("20060102T150405Z") + ".json.gz" } -// ArchiveExport is a database target's archive opened for download. -// It reads the file on its own connection, inside one read-only -// transaction, so it writes out the archive as it stood when -// OpenArchiveExport returned. +// ArchiveExport is a database target's archive listed for download. It +// opens one of the target's files at a time, only when its rows are +// about to be written out, and closes it before it opens the next, so +// an export holds at most one file open however many the target has. // -// Archives are in WAL mode, where a reader works from a snapshot and -// never blocks a writer: archive writes go on while an export is open, -// and the export does not see them. SQLite cannot checkpoint the -wal -// past an open snapshot, so the -wal grows until the export is closed. +// Each file is read on its own connection inside one read-only +// transaction, so its rows are written out as the file stood when it +// was opened. Archives are in WAL mode, where a reader works from a +// snapshot and never blocks a writer: archive writes go on while a file +// is open, and the export does not see them. SQLite cannot checkpoint +// a -wal past an open snapshot, so the open file's -wal grows until the +// export has written that file out. type ArchiveExport struct { + // periods are the periods of the target's files when the export + // was listed, "" for the file without one, in the order + // archiveFiles lists them. + periods []string + + // lock is held while currentPath is called and a file is opened, + // so that a rename, which holds it too, cannot move the file in + // between. + lock sync.Locker + + // currentPath returns the path ArchivePath gives the target under + // the names stored for it now, which a rename may have changed since + // the export was listed. + currentPath func() (string, error) + + log *slog.Logger +} + +// exportFile is one archive file opened for an export. +type exportFile struct { db *sql.DB tx *gorm.DB - // empty is true when there is nothing to read: no file, or a file - // without the archive's table yet. + // period is the period in the file's name, "" for none. + period string + + // empty is true for a file without the archive's table yet. empty bool } @@ -74,26 +103,46 @@ type exportedName struct { Name string `json:"name"` } -// OpenArchiveExport opens the archive file at path for export and -// takes the snapshot the export reads. It never creates the file: with -// no file at path, the export has no rows. +// NewArchiveExport lists a database target's archive files for export, +// given the path ArchivePath gives it (see archiveFiles). It opens none +// of them. Its caller holds lock, which every rename of the target's +// files runs under, from reading the names path is made of until it +// returns, so the files it lists are the ones those names give. // -// Once it has returned, the file is open, so a rename or a move of it -// does not affect the export, which reads the same file under its new -// name. -// -// The transaction lasts as long as ctx does, so ctx must last for the -// whole export. -func OpenArchiveExport( - ctx context.Context, path string, log *slog.Logger, +// WriteGzipJSON, called without lock held, finds each file again by its +// period under the path currentPath gives, holding lock while it does +// and while it opens the file, so a rename during the export loses no +// file. A file that is gone by then, emptied by the sweep or moved +// away, is skipped. The export never creates a file: with no files, it +// has no rows. +func NewArchiveExport( + path string, + lock sync.Locker, + currentPath func() (string, error), + log *slog.Logger, ) (*ArchiveExport, error) { - if !fileExists(path) { - return &ArchiveExport{empty: true}, nil + files, err := archiveFiles(path) + if err != nil { + return nil, err } - db, err := database.OpenSQLite(path, archiveModeExisting) + x := &ArchiveExport{lock: lock, currentPath: currentPath, log: log} + + for _, file := range files { + x.periods = append(x.periods, file.period) + } + + return x, nil +} + +// openExportFile opens one archive file for an export and takes its +// snapshot. The transaction lasts as long as ctx does. +func openExportFile( + ctx context.Context, file archiveFile, log *slog.Logger, +) (*exportFile, error) { + db, err := database.OpenSQLite(file.path, archiveModeExisting) if err != nil { - return nil, fmt.Errorf("opening archive %s: %w", path, err) + return nil, fmt.Errorf("opening archive %s: %w", file.path, err) } gdb, err := gorm.Open( @@ -106,7 +155,7 @@ func OpenArchiveExport( if err != nil { _ = db.Close() - return nil, fmt.Errorf("opening archive %s: %w", path, err) + return nil, fmt.Errorf("opening archive %s: %w", file.path, err) } // ReadOnly makes the driver begin a deferred transaction in place @@ -116,7 +165,9 @@ func OpenArchiveExport( if tx.Error != nil { _ = db.Close() - return nil, fmt.Errorf("reading archive %s: %w", path, tx.Error) + return nil, fmt.Errorf( + "reading archive %s: %w", file.path, tx.Error, + ) } // The transaction's first read is what takes the snapshot. @@ -127,22 +178,27 @@ func OpenArchiveExport( _ = tx.Rollback() _ = db.Close() - return nil, fmt.Errorf("reading archive %s: %w", path, err) + return nil, fmt.Errorf("reading archive %s: %w", file.path, err) } - return &ArchiveExport{db: db, tx: tx, empty: tables == 0}, nil + return &exportFile{ + db: db, tx: tx, period: file.period, empty: tables == 0, + }, nil } // WriteGzipJSON writes the export to w as one gzipped JSON object: // webhook and target, each an id and a name; exported_at; and -// archived_events, one object per archived row, keyed by column name. -// A body that is not valid UTF-8 cannot be a JSON string, so it is -// written in base64, with "body_encoding": "base64" beside it. +// archived_events, one object per archived row, keyed by column name, +// the files in the order archiveFiles lists them. A row from a file +// named for a period has "period" beside its columns. A body that is +// not valid UTF-8 cannot be a JSON string, so it is written in base64, +// with "body_encoding": "base64" beside it. // // Each row is written out before the next is read, so neither the -// archive nor its JSON is ever held in memory whole. After an error -// the gzip stream is left unfinished, so what was written does not -// decompress as a whole file. +// archive nor its JSON is ever held in memory whole, and each file is +// closed once its rows are written, before the next is opened. When it +// returns, no file is open. After an error the gzip stream is left +// unfinished, so what was written does not decompress as a whole file. func (x *ArchiveExport) WriteGzipJSON( ctx context.Context, w io.Writer, @@ -169,15 +225,35 @@ func (x *ArchiveExport) WriteGzipJSON( return zw.Close() } -// Close ends the export's transaction and closes its connection. -func (x *ArchiveExport) Close() error { - if x.db == nil { - return nil +// openFile finds the target's archive file for period under the path +// currentPath gives now, and opens it for the export, holding x.lock +// for both. For a file that is gone, the error wraps fs.ErrNotExist. +func (x *ArchiveExport) openFile( + ctx context.Context, period string, +) (*exportFile, error) { + x.lock.Lock() + defer x.lock.Unlock() + + path, err := x.currentPath() + if err != nil { + return nil, fmt.Errorf("finding archive file: %w", err) } - _ = x.tx.Rollback() + file := archiveFile{path: archivePeriodPath(path, period), period: period} - return x.db.Close() + _, err = os.Stat(file.path) + if err != nil { + return nil, err + } + + return openExportFile(ctx, file, x.log) +} + +// close ends the file's transaction and closes its connection. +func (f *exportFile) close() error { + _ = f.tx.Rollback() + + return f.db.Close() } // writeJSON writes head with archived_events added as its last key, @@ -207,46 +283,77 @@ func (x *ArchiveExport) writeJSON( return err } -// writeRows writes each archived row to w, oldest first, one per line, -// separated by commas. +// writeRows writes the archived rows of each file to w, one per line, +// separated by commas, opening each file in turn and closing it once +// its rows are written. func (x *ArchiveExport) writeRows(ctx context.Context, w io.Writer) error { - if x.empty { - return nil + sep := "\n" + + for _, period := range x.periods { + f, err := x.openFile(ctx, period) + if errors.Is(err, fs.ErrNotExist) { + continue + } + + if err != nil { + return err + } + + sep, err = f.writeRows(ctx, w, sep) + + err = errors.Join(err, f.close()) + if err != nil { + return err + } } - rows, err := x.tx.WithContext(ctx). + return nil +} + +// writeRows writes the file's archived rows to w, oldest first, the +// first after sep and each other after ",\n". It returns what goes +// before the next row: sep again when the file had no rows. +func (f *exportFile) writeRows( + ctx context.Context, w io.Writer, sep string, +) (string, error) { + if f.empty { + return sep, nil + } + + rows, err := f.tx.WithContext(ctx). Model(&archivedEvent{}).Order("id").Rows() if err != nil { - return err + return "", err } defer func() { _ = rows.Close() }() - for sep := "\n"; rows.Next(); sep = ",\n" { + for ; rows.Next(); sep = ",\n" { var ev archivedEvent - err = x.tx.ScanRows(rows, &ev) + err = f.tx.ScanRows(rows, &ev) if err != nil { - return err + return "", err } _, err = io.WriteString(w, sep) if err != nil { - return err + return "", err } - err = writeRow(w, &ev) + err = writeRow(w, &ev, f.period) if err != nil { - return err + return "", err } } - return rows.Err() + return sep, rows.Err() } // writeRow writes an archived row to w as a JSON object keyed by -// column name, its body in base64 when it is not valid UTF-8. -func writeRow(w io.Writer, ev *archivedEvent) error { +// column name, its body in base64 when it is not valid UTF-8, with +// the period of its file beside them unless that is "". +func writeRow(w io.Writer, ev *archivedEvent, period string) error { row := map[string]any{ "id": ev.ID, "event_id": ev.EventID, @@ -264,6 +371,10 @@ func writeRow(w io.Writer, ev *archivedEvent) error { row["body_encoding"] = "base64" } + if period != "" { + row["period"] = period + } + line, err := json.Marshal(row) if err != nil { return err diff --git a/internal/delivery/target_database_export_test.go b/internal/delivery/target_database_export_test.go index e3d63fe..8484be3 100644 --- a/internal/delivery/target_database_export_test.go +++ b/internal/delivery/target_database_export_test.go @@ -12,6 +12,8 @@ import ( "os" "path/filepath" "runtime" + "strings" + "sync" "testing" "time" @@ -29,14 +31,8 @@ const ( exportTargetName = "Long-term archive" ) -const ( - // binaryBody is a body that is not valid UTF-8. - binaryBody = "\xff\xfe\x00\x01binary\x80" - - // openedEventID is the event the snapshot tests archive before - // they open the export. - openedEventID = "opened" -) +// binaryBody is a body that is not valid UTF-8. +const binaryBody = "\xff\xfe\x00\x01binary\x80" // writeExportTo writes export to w as the archive of the export tests' // webhook and target, exported at 2026-10-02T12:03:04Z. @@ -59,19 +55,41 @@ func writeExportTo( ) } +// listExport lists the archive at path for export, as the archive of a +// target whose names do not change. +func listExport(t *testing.T, path string) *delivery.ArchiveExport { + t.Helper() + + return newExport(t, path, &sync.Mutex{}, func() (string, error) { + return path, nil + }) +} + +// newExport lists the archive at path for export, to find each file +// again under the path currentPath gives, holding lock while it does. +// Nothing else takes lock while it lists, so it does not hold lock. +func newExport( + t *testing.T, + path string, + lock sync.Locker, + currentPath func() (string, error), +) *delivery.ArchiveExport { + t.Helper() + + export, err := delivery.NewArchiveExport( + path, lock, currentPath, archiveTestLogger(), + ) + require.NoError(t, err) + + return export +} + // exportArchive runs a whole export of the archive at path and returns // its JSON, decompressed and parsed. func exportArchive(t *testing.T, path string) map[string]any { t.Helper() - export, err := delivery.OpenArchiveExport( - t.Context(), path, archiveTestLogger(), - ) - require.NoError(t, err) - - defer func() { require.NoError(t, export.Close()) }() - - return writeExport(t, export) + return writeExport(t, listExport(t, path)) } // writeExport writes an opened export and returns its JSON, @@ -241,66 +259,204 @@ func TestArchiveExport_Empty(t *testing.T) { } } -// TestArchiveExport_ReadsOneSnapshot proves an export writes the -// archive as it was when it was opened, and holds up no archive -// write: a row written while the export is open is stored, and is not -// in the export. A write held up for the whole busy timeout would -// fail. +// unlockHook is a sync.Locker that runs fn each time it is unlocked. An +// export unlocks its lock right after it opens a file. +type unlockHook struct { + sync.Mutex + + fn func() +} + +func (u *unlockHook) Unlock() { + u.Mutex.Unlock() + u.fn() +} + +// TestArchiveExport_ReadsOneSnapshot proves an export writes a file +// out as it was when the export opened it, and holds up no archive +// write: a row written after the export was listed but before the file +// was opened is in the export, and one written while the file is open +// is stored, and is not. A write held up for the whole busy timeout +// would fail. func TestArchiveExport_ReadsOneSnapshot(t *testing.T) { t.Parallel() path := filepath.Join(t.TempDir(), "archive.db") w := delivery.NewExportArchiveWriter(path, archiveTestLogger(), 0) - require.NoError(t, w.Write(delivery.ExportArchivedEvent{EventID: openedEventID}, 0)) + require.NoError(t, w.Write(delivery.ExportArchivedEvent{EventID: "listed"}, 0)) - export, err := delivery.OpenArchiveExport( - t.Context(), path, archiveTestLogger(), - ) - require.NoError(t, err) + opened := &unlockHook{fn: func() { + require.NoError(t, w.Write(delivery.ExportArchivedEvent{EventID: "during"}, 0)) + }} + export := newExport(t, path, opened, func() (string, error) { + return path, nil + }) - defer func() { require.NoError(t, export.Close()) }() - - require.NoError(t, w.Write(delivery.ExportArchivedEvent{EventID: "during"}, 0)) + require.NoError(t, w.Write(delivery.ExportArchivedEvent{EventID: "before-open"}, 0)) assert.Equal(t, - []string{openedEventID}, exportedEventIDs(t, writeExport(t, export)), + []string{"listed", "before-open"}, + exportedEventIDs(t, writeExport(t, export)), ) var stored int64 require.NoError(t, openArchiveDBForRead(t, path). Model(&delivery.ExportArchivedEvent{}).Count(&stored).Error) - assert.Equal(t, int64(2), stored) + assert.Equal(t, int64(3), stored) } -// TestArchiveExport_SurvivesRename proves that renaming the archive -// while an export of it is open, as renaming its webhook or target -// does, leaves the export reading the same file. -func TestArchiveExport_SurvivesRename(t *testing.T) { +// TestArchiveExport_FindsFilesAfterRename proves that renaming the +// archive after an export has listed it, as renaming its webhook or +// target does, loses no file: the export finds each file again by its +// period under the new name. A file moved away by then is skipped. +func TestArchiveExport_FindsFilesAfterRename(t *testing.T) { t.Parallel() - path := filepath.Join(t.TempDir(), "archive-old.db") + dir := t.TempDir() + path := filepath.Join(dir, "archive-old.db") w := delivery.NewExportArchiveWriter(path, archiveTestLogger(), 0) - require.NoError(t, w.Write(delivery.ExportArchivedEvent{EventID: openedEventID}, 0)) + for _, period := range []string{"", dayPeriod, hourPeriod} { + require.NoError(t, w.WritePeriod( + delivery.ExportArchivedEvent{EventID: "in-" + period}, 0, period, + )) + } - export, err := delivery.OpenArchiveExport( - t.Context(), path, archiveTestLogger(), - ) - require.NoError(t, err) - - defer func() { require.NoError(t, export.Close()) }() + current := path + export := newExport(t, path, &sync.Mutex{}, func() (string, error) { + return current, nil + }) require.NoError(t, w.Rename("archive-new.db")) - require.NoError(t, w.Write(delivery.ExportArchivedEvent{EventID: "after"}, 0)) - require.NoFileExists(t, path) + + current = filepath.Join(dir, "archive-new.db") + + removeArchiveFiles(t, periodPath(current, dayPeriod)) assert.Equal(t, - []string{openedEventID}, exportedEventIDs(t, writeExport(t, export)), + []string{"in-", "in-" + hourPeriod}, + exportedEventIDs(t, writeExport(t, export)), ) } +// TestArchiveExport_EveryFileOldestFirst writes a row to a target's +// file without a period and to its files for a month, an hour and a +// day, and proves the export holds every row: the file without a +// period first, then the others oldest period first, each row from a +// file named for a period carrying that period. A file made after the +// export was listed is not in it. +func TestArchiveExport_EveryFileOldestFirst(t *testing.T) { + t.Parallel() + + path := filepath.Join(t.TempDir(), "archive-wh.db") + w := delivery.NewExportArchiveWriter(path, archiveTestLogger(), 0) + + // Written in an order that is not the export's. + for _, period := range []string{nextDayPeriod, "", hourPeriod, "2026-03"} { + require.NoError(t, w.WritePeriod( + delivery.ExportArchivedEvent{EventID: "in-" + period}, 0, period, + )) + } + + export := listExport(t, path) + + require.NoError(t, w.WritePeriod( + delivery.ExportArchivedEvent{EventID: "later"}, 0, "2026-03-06", + )) + + events := exportedEvents(t, writeExport(t, export)) + ids := make([]string, 0, len(events)) + periods := make([]any, 0, len(events)) + + for _, ev := range events { + ids = append(ids, fmt.Sprint(ev["event_id"])) + periods = append(periods, ev["period"]) + } + + assert.Equal(t, + []string{"in-", "in-2026-03", "in-" + hourPeriod, "in-" + nextDayPeriod}, + ids, + ) + assert.Equal(t, + []any{nil, "2026-03", hourPeriod, nextDayPeriod}, periods, + ) + assert.NotContains(t, events[0], "period", + "a row from the file without a period has no period") +} + +// openFilesPeak is an io.Writer that discards what it is given and +// records the most archive files in dir the process had open at any +// write, as /proc/self/fd lists the files a process has open. +type openFilesPeak struct { + dir string + max int +} + +func (p *openFilesPeak) Write(b []byte) (int, error) { + fds, err := os.ReadDir("/proc/self/fd") + if err != nil { + return 0, err + } + + open := map[string]bool{} + + for _, fd := range fds { + file, err := os.Readlink(filepath.Join("/proc/self/fd", fd.Name())) + if err == nil && filepath.Dir(file) == p.dir && + strings.HasSuffix(file, ".db") { + open[file] = true + } + } + + p.max = max(p.max, len(open)) + + return len(b), nil +} + +// TestArchiveExport_OneFileOpenAtATime exports a target with a file for +// each of 24 hours and proves the export never had more than one of +// them open, and had one open while it wrote. Each file holds a row of +// 48 KiB of random base64, which gzip shrinks little, so the export +// writes output while it reads each file. +func TestArchiveExport_OneFileOpenAtATime(t *testing.T) { + t.Parallel() + + if runtime.GOOS != "linux" { + t.Skip("only Linux lists a process's open files in /proc/self/fd") + } + + // Readlink gives each open file's path with no symbolic link in it. + dir, err := filepath.EvalSymlinks(t.TempDir()) + require.NoError(t, err) + + path := filepath.Join(dir, "archive-wh.db") + w := delivery.NewExportArchiveWriter(path, archiveTestLogger(), 0) + random := make([]byte, 36<<10) + + for hour := range 24 { + _, _ = rand.Read(random) + + require.NoError(t, w.WritePeriod(delivery.ExportArchivedEvent{ + Body: base64.StdEncoding.EncodeToString(random), + }, 0, fmt.Sprintf("2026-10-01-%02d", hour))) + } + + // The writer's own handle on the last file is not the export's. + w.Evict() + + // Through a buffer, the open files are listed once per 8 KiB of + // output, a few times for each file, rather than at each of gzip's + // small writes, which takes far longer. + peak := &openFilesPeak{dir: dir} + buffered := bufio.NewWriterSize(peak, 8<<10) + + require.NoError(t, writeExportTo(t, listExport(t, path), buffered)) + require.NoError(t, buffered.Flush()) + assert.Equal(t, 1, peak.max) +} + // heapPeak is an io.Writer that discards what it is given and records // the largest heap it saw at a write. It collects garbage before each // reading, so the heap it reads is what is still held. @@ -338,12 +494,7 @@ func exportHeapGrowth(t *testing.T, rows, bodySize int) uint64 { }, 0)) } - export, err := delivery.OpenArchiveExport( - t.Context(), path, archiveTestLogger(), - ) - require.NoError(t, err) - - defer func() { require.NoError(t, export.Close()) }() + export := listExport(t, path) runtime.GC() diff --git a/internal/delivery/target_database_rotation.go b/internal/delivery/target_database_rotation.go new file mode 100644 index 0000000..e16180e --- /dev/null +++ b/internal/delivery/target_database_rotation.go @@ -0,0 +1,198 @@ +package delivery + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "slices" + "strings" + "time" + + "sneak.berlin/go/webhooker/internal/database" +) + +// The archive rotations: how often a database target starts a new +// archive file. Every rotation but none puts the period of an event's +// receive time, in UTC, in the name of the file the event goes to, +// written in the layout beside it. +const ( + archiveRotationNone = "none" + archiveRotationMonthly = "monthly" + archiveRotationDaily = "daily" + archiveRotationHourly = "hourly" + + archiveMonthLayout = "2006-01" + archiveDayLayout = "2006-01-02" + archiveHourLayout = "2006-01-02-15" +) + +// errArchiveRotationUnknown is returned for a rotation that is not one +// of the four. +var errArchiveRotationUnknown = errors.New( + "rotation must be none, monthly, daily or hourly", +) + +// archiveFile is one of a database target's archive files, and the +// period in its name: "" for the file named without one. +type archiveFile struct { + path string + period string +} + +// ValidateArchiveRotation checks a user-supplied archive rotation for +// a database target: empty or none (both meaning one file), monthly, +// daily or hourly. +func ValidateArchiveRotation(rotation string) error { + switch rotation { + case "", archiveRotationNone, archiveRotationMonthly, + archiveRotationDaily, archiveRotationHourly: + return nil + default: + return fmt.Errorf("%w: %q", errArchiveRotationUnknown, rotation) + } +} + +// parseArchiveRotation reads the rotation from a database target's +// config JSON. An empty config or an empty rotation is none. +func parseArchiveRotation(configJSON string) (string, error) { + if configJSON == "" { + return archiveRotationNone, nil + } + + var cfg databaseTargetConfig + + err := json.Unmarshal([]byte(configJSON), &cfg) + if err != nil { + return "", fmt.Errorf("parsing database target config: %w", err) + } + + err = ValidateArchiveRotation(cfg.Rotation) + if err != nil { + return "", err + } + + if cfg.Rotation == "" { + return archiveRotationNone, nil + } + + return cfg.Rotation, nil +} + +// archivePeriod returns the period, in UTC, that a rotation puts an +// event received at receivedAt in: "2026-10" for monthly, +// "2026-10-01" for daily, "2026-10-01-19" for hourly, and "" for none. +func archivePeriod(rotation string, receivedAt time.Time) string { + switch rotation { + case archiveRotationMonthly: + return receivedAt.UTC().Format(archiveMonthLayout) + case archiveRotationDaily: + return receivedAt.UTC().Format(archiveDayLayout) + case archiveRotationHourly: + return receivedAt.UTC().Format(archiveHourLayout) + default: + return "" + } +} + +// archivePeriodPath returns the path of a database target's archive +// file for a period: path, as ArchivePath gives it, with "-" and the +// period put before its ".db". The period "" gives path itself. +func archivePeriodPath(path, period string) string { + if period == "" { + return path + } + + return strings.TrimSuffix(path, ".db") + "-" + period + ".db" +} + +// ArchivePathAt returns the archive file a database target writes an +// event received at receivedAt to: ArchivePath's file, with the +// period in its name when the target rotates. +func ArchivePathAt( + dbMgr *database.WebhookDBManager, + webhook *database.Webhook, + target *database.Target, + receivedAt time.Time, +) (string, error) { + rotation, err := parseArchiveRotation(target.Config) + if err != nil { + return "", err + } + + return archivePeriodPath( + ArchivePath(dbMgr, webhook, target), + archivePeriod(rotation, receivedAt), + ), nil +} + +// archiveFiles lists the database target's archive files that exist, +// given the path ArchivePath gives it: the file at path, then each +// file archivePeriodPath names for path and a period, oldest period +// first. Which rotation wrote a file does not matter, so the files of +// an earlier rotation setting are listed too. +func archiveFiles(path string) ([]archiveFile, error) { + dir := filepath.Dir(path) + + entries, err := os.ReadDir(dir) + if err != nil { + return nil, fmt.Errorf("listing archive files: %w", err) + } + + stem := strings.TrimSuffix(filepath.Base(path), ".db") + + var files []archiveFile + + for _, entry := range entries { + period, ok := archiveFilePeriod(stem, entry.Name()) + if ok { + files = append(files, archiveFile{ + path: filepath.Join(dir, entry.Name()), + period: period, + }) + } + } + + // A month sorts before the days and hours in it. + slices.SortFunc(files, func(a, b archiveFile) int { + return strings.Compare(a.period, b.period) + }) + + return files, nil +} + +// archiveFilePeriod reports whether name is the name of an archive +// file of the target whose file name without a period is stem+".db", +// and the period in it. +func archiveFilePeriod(stem, name string) (string, bool) { + rest, ok := strings.CutPrefix(name, stem) + if !ok { + return "", false + } + + rest, ok = strings.CutSuffix(rest, ".db") + if !ok { + return "", false + } + + if rest == "" { + return "", true + } + + period, ok := strings.CutPrefix(rest, "-") + if !ok { + return "", false + } + + for _, layout := range []string{ + archiveMonthLayout, archiveDayLayout, archiveHourLayout, + } { + _, err := time.Parse(layout, period) + if err == nil { + return period, true + } + } + + return "", false +} diff --git a/internal/delivery/target_database_rotation_test.go b/internal/delivery/target_database_rotation_test.go new file mode 100644 index 0000000..8bfb7c3 --- /dev/null +++ b/internal/delivery/target_database_rotation_test.go @@ -0,0 +1,389 @@ +package delivery_test + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "sneak.berlin/go/webhooker/internal/database" + "sneak.berlin/go/webhooker/internal/delivery" +) + +// The archive rotations, and the configs of a daily target and of one +// whose rotation is not one of the four. +const ( + rotationNone = "none" + rotationMonthly = "monthly" + rotationDaily = "daily" + rotationHourly = "hourly" + + dailyConfig = `{"rotation":"daily"}` + weeklyConfig = `{"rotation":"weekly"}` +) + +// The periods the tests archive into most: two days, and an hour of +// the first. +const ( + dayPeriod = "2026-03-04" + nextDayPeriod = "2026-03-05" + hourPeriod = "2026-03-04-05" +) + +// periodPath returns the archive file for a period of the target whose +// file without a period is path. +func periodPath(path, period string) string { + return strings.TrimSuffix(path, ".db") + "-" + period + ".db" +} + +// deliverReceivedAt delivers to a database target an event whose +// receive time is receivedAt, and returns the event's id. The receive +// time is what decides a rotated archive's file, so setting it is how +// these tests move the clock across a period boundary. +func (env *archiveEnv) deliverReceivedAt( + t *testing.T, tgt *database.Target, receivedAt time.Time, +) string { + t.Helper() + + webhookDB := testWebhookDB(t) + event := seedEvent(t, webhookDB, `{"n":1}`) + event.CreatedAt = receivedAt + + env.eng.ExportDeliverDatabase( + webhookDB, seedDatabaseTargetDelivery(t, webhookDB, event, tgt), + ) + + return event.ID +} + +// TestDeliverDatabase_RotatesAtEachPeriodBoundary delivers, for each +// rotation, an event received in the last second of a period and one +// received in the first second of the next, and checks each lands in +// the file named for its own period, in UTC. Rotation none keeps both +// in the one file. +func TestDeliverDatabase_RotatesAtEachPeriodBoundary(t *testing.T) { + t.Parallel() + + berlin := time.FixedZone("CEST", 2*60*60) + + cases := []struct { + name string + rotation string + before, after time.Time + // periods are the periods of before and after. + periods [2]string + }{ + { + rotationMonthly, rotationMonthly, + time.Date(2026, 1, 31, 23, 59, 59, 0, time.UTC), + time.Date(2026, 2, 1, 0, 0, 0, 0, time.UTC), + [2]string{"2026-01", "2026-02"}, + }, + { + rotationDaily, rotationDaily, + time.Date(2026, 3, 4, 23, 59, 59, 0, time.UTC), + time.Date(2026, 3, 5, 0, 0, 0, 0, time.UTC), + [2]string{dayPeriod, nextDayPeriod}, + }, + { + // The same instants, received in a zone two hours ahead + // of UTC, where they fall on 5 March: the period is UTC's. + "daily in another zone", rotationDaily, + time.Date(2026, 3, 5, 1, 59, 59, 0, berlin), + time.Date(2026, 3, 5, 2, 0, 0, 0, berlin), + [2]string{dayPeriod, nextDayPeriod}, + }, + { + rotationHourly, rotationHourly, + time.Date(2026, 3, 4, 5, 59, 59, 0, time.UTC), + time.Date(2026, 3, 4, 6, 0, 0, 0, time.UTC), + [2]string{hourPeriod, "2026-03-04-06"}, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + env := setupArchiveTest(t) + tgt := env.seedDatabaseTarget(t, `{"rotation":"`+tc.rotation+`"}`) + path := env.archivePath(tgt) + + first := env.deliverReceivedAt(t, tgt, tc.before) + second := env.deliverReceivedAt(t, tgt, tc.after) + + assert.Equal(t, []string{first}, + archivedEventIDs(t, periodPath(path, tc.periods[0]))) + assert.Equal(t, []string{second}, + archivedEventIDs(t, periodPath(path, tc.periods[1]))) + assert.NoFileExists(t, path, + "a rotated target never writes the file without a period") + }) + } + + t.Run(rotationNone, func(t *testing.T) { + t.Parallel() + + env := setupArchiveTest(t) + tgt := env.seedDatabaseTarget(t, `{"rotation":"`+rotationNone+`"}`) + + first := env.deliverReceivedAt(t, tgt, cases[0].before) + second := env.deliverReceivedAt(t, tgt, cases[0].after) + + assert.ElementsMatch(t, []string{first, second}, + archivedEventIDs(t, env.archivePath(tgt))) + }) +} + +// TestDeliverDatabase_EventWithoutReceiveTime proves an event whose +// receive time is not known goes to the file for the time it is +// archived, rather than to one for the year 1. +func TestDeliverDatabase_EventWithoutReceiveTime(t *testing.T) { + t.Parallel() + + env := setupArchiveTest(t) + tgt := env.seedDatabaseTarget(t, `{"rotation":"`+rotationMonthly+`"}`) + path := env.archivePath(tgt) + + before := time.Now().UTC().Format("2006-01") + id := env.deliverReceivedAt(t, tgt, time.Time{}) + + file := periodPath(path, time.Now().UTC().Format("2006-01")) + + _, err := os.Stat(file) + if err != nil { + // The month turned during the delivery. + file = periodPath(path, before) + } + + assert.Equal(t, []string{id}, archivedEventIDs(t, file)) + assert.NoFileExists(t, periodPath(path, "0001-01")) +} + +// TestDeliverDatabase_RotationChangeKeepsOldFiles changes a target's +// rotation from none to daily between two events, and checks the +// second goes to the daily file while the first stays where it was. +func TestDeliverDatabase_RotationChangeKeepsOldFiles(t *testing.T) { + t.Parallel() + + env := setupArchiveTest(t) + tgt := env.seedDatabaseTarget(t, "") + path := env.archivePath(tgt) + at := time.Date(2026, 3, 4, 12, 0, 0, 0, time.UTC) + + first := env.deliverReceivedAt(t, tgt, at) + + tgt.Config = dailyConfig + second := env.deliverReceivedAt(t, tgt, at) + + assert.Equal(t, []string{first}, archivedEventIDs(t, path)) + assert.Equal(t, []string{second}, + archivedEventIDs(t, periodPath(path, dayPeriod))) +} + +// TestArchiveSweep_PrunesEveryFile gives a daily target three files: +// the file without a period, left from before it rotated, and two +// daily files. Each holds a row older than the expiry, and one daily +// file also a newer row. The sweep prunes the old row from every file, +// deletes the daily file it leaves empty, and keeps the file without a +// period although it is empty too. +func TestArchiveSweep_PrunesEveryFile(t *testing.T) { + t.Parallel() + + env := setupArchiveTest(t) + tgt := env.seedDatabaseTarget( + t, `{"expiry":"1h","rotation":"`+rotationDaily+`"}`, + ) + path := env.archivePath(tgt) + emptied := periodPath(path, dayPeriod) + kept := periodPath(path, nextDayPeriod) + + now := time.Now() + old := now.Add(-48 * time.Hour) + + seedArchiveFile(t, path, tgt.WebhookID, old) + seedArchiveFile(t, emptied, tgt.WebhookID, old) + seedArchiveFile(t, kept, tgt.WebhookID, old, now.Add(-time.Minute)) + + env.sweeper.ExportSweep(t.Context()) + + assert.Empty(t, archivedEventIDs(t, path)) + assert.Equal(t, []string{sweepRowNew}, archivedEventIDs(t, kept)) + + for _, suffix := range archiveFileSuffixes() { + assert.NoFileExists(t, emptied+suffix) + } +} + +// TestRename_MovesEveryFile renames a daily target that also has a +// file without a period, and checks every file moves to the new name +// with its period, rows and all, and that a later write uses the new +// name. +func TestRename_MovesEveryFile(t *testing.T) { + t.Parallel() + + env := setupArchiveTest(t) + tgt := env.seedDatabaseTarget(t, "") + oldPath := env.archivePath(tgt) + day := time.Date(2026, 3, 4, 12, 0, 0, 0, time.UTC) + + unrotated := env.deliverReceivedAt(t, tgt, day) + + tgt.Config = dailyConfig + first := env.deliverReceivedAt(t, tgt, day) + second := env.deliverReceivedAt(t, tgt, day.Add(24*time.Hour)) + + require.NoError(t, env.eng.Rename(tgt.ID, "Orders", "Long Term")) + + newPath := filepath.Join( + env.dataDir, "archive-orders-long-term-"+tgt.ID+".db", + ) + + for _, old := range []string{ + oldPath, + periodPath(oldPath, dayPeriod), + periodPath(oldPath, nextDayPeriod), + } { + assert.NoFileExists(t, old) + } + + assert.Equal(t, []string{unrotated}, archivedEventIDs(t, newPath)) + assert.Equal(t, []string{first}, + archivedEventIDs(t, periodPath(newPath, dayPeriod))) + assert.Equal(t, []string{second}, + archivedEventIDs(t, periodPath(newPath, nextDayPeriod))) + + third := env.deliverReceivedAt(t, tgt, day.Add(48*time.Hour)) + assert.Equal(t, []string{third}, + archivedEventIDs(t, periodPath(newPath, "2026-03-06"))) +} + +// TestRename_NeverReplacesARotatedFile plants a file at the new name +// of a target's daily file, and proves the rename is refused and moves +// none of the target's files. +func TestRename_NeverReplacesARotatedFile(t *testing.T) { + t.Parallel() + + env := setupArchiveTest(t) + tgt := env.seedDatabaseTarget(t, dailyConfig) + oldPath := env.archivePath(tgt) + day := time.Date(2026, 3, 4, 12, 0, 0, 0, time.UTC) + + env.deliverReceivedAt(t, tgt, day) + env.deliverReceivedAt(t, tgt, day.Add(24*time.Hour)) + + newPath := filepath.Join( + env.dataDir, "archive-orders-long-term-"+tgt.ID+".db", + ) + planted := periodPath(newPath, nextDayPeriod) + require.NoError(t, os.WriteFile(planted, []byte("planted"), 0o600)) + + require.ErrorIs( + t, env.eng.Rename(tgt.ID, "Orders", "Long Term"), + delivery.ErrArchiveNameTaken, + ) + + assert.FileExists(t, periodPath(oldPath, dayPeriod)) + assert.FileExists(t, periodPath(oldPath, nextDayPeriod)) + assert.NoFileExists(t, periodPath(newPath, dayPeriod)) +} + +// TestStatArchive_EveryFile proves StatArchive counts and adds up every +// one of a target's files, takes the latest write of any of them, and +// leaves out files whose names only look like the target's. +func TestStatArchive_EveryFile(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + path := filepath.Join(dir, "archive-wh.db") + files := []string{ + path, periodPath(path, "2026-03"), periodPath(path, hourPeriod), + } + written := time.Date(2026, 3, 4, 5, 6, 7, 0, time.UTC) + + var size int64 + + for i, file := range files { + require.NoError(t, os.WriteFile(file, make([]byte, 100*(i+1)), 0o600)) + + size += int64(100 * (i + 1)) + at := written.Add(-time.Duration(i) * time.Hour) + require.NoError(t, os.Chtimes(file, at, at)) + } + + for _, other := range []string{ + "archive-wh-2026-13.db", "archive-wh-2026-3.db", + "archive-wh-other.db", "archive-wh-2026-03.json", + "archive-whx.db", + } { + require.NoError(t, + os.WriteFile(filepath.Join(dir, other), []byte("x"), 0o600)) + } + + got, err := delivery.StatArchive(path) + require.NoError(t, err) + assert.Equal(t, len(files), got.Files) + assert.Equal(t, size, got.Size) + assert.True(t, written.Equal(got.Written), got.Written) +} + +// TestArchivePathAt names the file each rotation writes an event to. +func TestArchivePathAt(t *testing.T) { + t.Parallel() + + dataDir := t.TempDir() + dbMgr := database.NewTestWebhookDBManager(dataDir) + webhook := &database.Webhook{ + BaseModel: database.BaseModel{ID: "wh-id"}, Name: "Orders", + } + at := time.Date(2026, 10, 1, 19, 30, 0, 0, time.UTC) + + cases := map[string]string{ + "": "", + `{"rotation":"` + rotationNone + `"}`: "", + `{"rotation":"` + rotationMonthly + `"}`: "-2026-10", + dailyConfig: "-2026-10-01", + `{"rotation":"` + rotationHourly + `"}`: "-2026-10-01-19", + } + + for config, period := range cases { + target := &database.Target{ + BaseModel: database.BaseModel{ID: "tgt-id"}, + Name: "Archive", + Config: config, + } + + got, err := delivery.ArchivePathAt(dbMgr, webhook, target, at) + require.NoError(t, err, config) + assert.Equal(t, + filepath.Join( + dataDir, "archive-orders-archive-tgt-id"+period+".db", + ), + got, config, + ) + } + + _, err := delivery.ArchivePathAt(dbMgr, webhook, &database.Target{ + Config: weeklyConfig, + }, at) + require.Error(t, err) +} + +// TestValidateArchiveRotation accepts the four rotations, and empty, +// and refuses anything else. +func TestValidateArchiveRotation(t *testing.T) { + t.Parallel() + + for _, ok := range []string{ + "", rotationNone, rotationMonthly, rotationDaily, rotationHourly, + } { + require.NoError(t, delivery.ValidateArchiveRotation(ok), ok) + } + + for _, bad := range []string{"weekly", "Daily", "hourly "} { + require.Error(t, delivery.ValidateArchiveRotation(bad), bad) + } +} diff --git a/internal/delivery/target_database_test.go b/internal/delivery/target_database_test.go index 516e579..ca70885 100644 --- a/internal/delivery/target_database_test.go +++ b/internal/delivery/target_database_test.go @@ -218,6 +218,7 @@ func TestStatArchive(t *testing.T) { got, err := delivery.StatArchive(path) require.NoError(t, err) + assert.Equal(t, 1, got.Files) assert.Equal(t, file.Size()+wal.Size(), got.Size) assert.True(t, written.Equal(got.Written), got.Written) @@ -720,3 +721,41 @@ func TestArchiveWriter_RenameMovesBackOnFailure(t *testing.T) { assert.NoFileExists(t, filepath.Join(dir, newName)) assert.Equal(t, oldPath, w.Path()) } + +// TestArchiveWriter_RenameMovesBackEveryFile renames a target with a +// file without a period and a file for a month, and proves that when +// the month's file fails to move, the file already moved is moved back: +// both files are under the old name with their rows, and nothing is +// under the new name. The new name is 251 bytes, so the file without a +// period, with its -wal and -shm, can take it, but the month's file, +// eight bytes longer, cannot. +func TestArchiveWriter_RenameMovesBackEveryFile(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + oldPath := filepath.Join(dir, "archive-old.db") + monthPath := filepath.Join(dir, "archive-old-2026-03.db") + w := delivery.NewExportArchiveWriter(oldPath, archiveTestLogger(), 0) + + require.NoError(t, w.WritePeriod( + delivery.ExportArchivedEvent{EventID: "in-none"}, 0, "", + )) + require.NoError(t, w.WritePeriod( + delivery.ExportArchivedEvent{EventID: "in-month"}, 0, "2026-03", + )) + + require.Error(t, w.Rename(strings.Repeat("a", 248)+".db")) + + assert.Equal(t, []string{"in-none"}, archivedEventIDs(t, oldPath)) + assert.Equal(t, []string{"in-month"}, archivedEventIDs(t, monthPath)) + + entries, err := os.ReadDir(dir) + require.NoError(t, err) + + for _, entry := range entries { + assert.True(t, strings.HasPrefix(entry.Name(), "archive-old"), + "%s is not under the old name", entry.Name()) + } + + assert.Equal(t, oldPath, w.Path()) +} diff --git a/internal/delivery/target_headers_test.go b/internal/delivery/target_headers_test.go index ac72b86..4250dee 100644 --- a/internal/delivery/target_headers_test.go +++ b/internal/delivery/target_headers_test.go @@ -204,10 +204,11 @@ func TestNewTargetConfigForm(t *testing.T) { form, err = delivery.NewTargetConfigForm(&database.Target{ Type: database.TargetTypeDatabase, - Config: `{"expiry":"720h"}`, + Config: `{"expiry":"720h","rotation":"daily"}`, }) require.NoError(t, err) assert.Equal(t, "720h", form.Expiry) + assert.Equal(t, rotationDaily, form.Rotation) form, err = delivery.NewTargetConfigForm(&database.Target{ Type: database.TargetTypeLog, @@ -248,6 +249,10 @@ func TestNewTargetConfigForm_UnreadableConfigErrors(t *testing.T) { Type: database.TargetTypeDatabase, Config: `{"expiry":"soon"}`, }, + { + Type: database.TargetTypeDatabase, + Config: weeklyConfig, + }, {Type: database.TargetType("nope")}, } diff --git a/internal/handlers/archive_expiry.go b/internal/handlers/archive_expiry.go index 060737d..38a328b 100644 --- a/internal/handlers/archive_expiry.go +++ b/internal/handlers/archive_expiry.go @@ -10,10 +10,10 @@ const ( tmplKeyArchiveExpiryChoices = "ArchiveExpiryChoices" ) -// archiveExpiryChoice is one entry of a database target's archive -// expiry select: the expiry stored, the label shown, and whether the -// select starts on it. -type archiveExpiryChoice struct { +// archiveChoice is one entry of a database target's archive expiry +// or archive rotation select: the value stored, the label shown, and +// whether the select starts on it. +type archiveChoice struct { Value string Label string Selected bool @@ -21,8 +21,8 @@ type archiveExpiryChoice struct { // archiveExpiryChoices lists the archive expiries offered by the new // webhook page, the add target form and the target edit form. -func archiveExpiryChoices() []archiveExpiryChoice { - return []archiveExpiryChoice{ +func archiveExpiryChoices() []archiveChoice { + return []archiveChoice{ {Value: archiveExpiryNever, Label: archiveExpiryNever}, {Value: "1h", Label: "1h"}, {Value: "12h", Label: "12h"}, @@ -37,7 +37,7 @@ func archiveExpiryChoices() []archiveExpiryChoice { // empty expiry selects never. An expiry that is not one of the // choices comes first as its own selected entry, so saving the form // unchanged keeps it. -func archiveExpiryOptions(expiry string) []archiveExpiryChoice { +func archiveExpiryOptions(expiry string) []archiveChoice { if expiry == "" { expiry = archiveExpiryNever } @@ -52,7 +52,7 @@ func archiveExpiryOptions(expiry string) []archiveExpiryChoice { } } - own := archiveExpiryChoice{Value: expiry, Label: expiry, Selected: true} + own := archiveChoice{Value: expiry, Label: expiry, Selected: true} - return append([]archiveExpiryChoice{own}, options...) + return append([]archiveChoice{own}, options...) } diff --git a/internal/handlers/archive_expiry_test.go b/internal/handlers/archive_expiry_test.go index 70bf476..9484543 100644 --- a/internal/handlers/archive_expiry_test.go +++ b/internal/handlers/archive_expiry_test.go @@ -5,6 +5,7 @@ import ( "net/http/httptest" "net/url" "regexp" + "strings" "testing" "github.com/stretchr/testify/assert" @@ -49,21 +50,39 @@ func expiryShown( } // expirySelected returns the target edit page and the expiries its -// select starts on. +// expiry select starts on. func expirySelected( t *testing.T, env *sourceTestEnv, webhookID, targetID string, ) (string, []string) { t.Helper() + page := targetEditPage(t, env, webhookID, targetID) + + return page, selectedIn(page, "expiry") +} + +// targetEditPage returns a target's edit page. +func targetEditPage( + t *testing.T, env *sourceTestEnv, webhookID, targetID string, +) string { + t.Helper() + w := serveTarget( env, http.MethodGet, "/hook/"+webhookID+"/targets/"+targetID+"/edit", nil, ) require.Equal(t, http.StatusOK, w.Code) - page := w.Body.String() + return w.Body.String() +} - return page, matched(`
-

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.

+

How many times each delivery is attempted in all, the first attempt included. 0 means a single attempt with no retries and no circuit breaker.

@@ -251,7 +251,7 @@
{{.Name}}
- {{.Type}} + {{if eq .Type "database"}}archive{{else}}{{.Type}}{{end}} {{if .Active}} Active {{else}} @@ -275,7 +275,7 @@
{{with .Paused}}
- Deliveries Paused: + Deliveries paused: {{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}}
{{end}} @@ -287,17 +287,17 @@ {{end}} {{with .Archive}}
- Archive File: + Archive file: {{.Name}} {{with .Note}}({{.}}){{end}}
{{if .Files}}
- Archive Size: + Archive size: {{.Size}}{{if gt .Files 1}} in {{.Files}} files{{end}}
- Last Written: + Last written: {{.Written}}
{{end}} diff --git a/templates/sources_new.html b/templates/sources_new.html index b3964ea..9911b5c 100644 --- a/templates/sources_new.html +++ b/templates/sources_new.html @@ -46,9 +46,9 @@ Archive -

When checked, the webhook is created with a database target that keeps a copy of every event.

+

When checked, the webhook is created with an archive target that keeps a copy of every event.

- + {{range .ArchiveExpiryChoices}} @@ -69,7 +69,7 @@
- + -

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.

+

How many times each delivery is attempted in all, the first attempt included. 0 means a single attempt with no retries and no circuit breaker.

{{end}} -- 2.54.0 From 9079a3219dc09c7474ea86b135fc7397d1a7dc9a Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Sat, 3 Oct 2026 05:22:14 +0200 Subject: [PATCH 089/126] Show an event's entrypoint and request headers in the event log and on its page (closes #389) The receiver stored each event's request headers and entrypoint, but no page showed them, so with several entrypoints the operator could not tell which sender sent an event. An expanded event in the event log, and the event's own page, now show the entrypoint by its description ("Entrypoint" without one, "deleted entrypoint" once deleted), never its URL, and the headers as one escaped block, sorted, whitespace kept. A resubmitted copy says the request it copies arrived there. The event log reads at most 32 KiB of headers per event, the body's limit, and links to the event's page beyond it. Both pages share one template, `event_request.html`. Model: opus-5-5 --- README.md | 2 +- internal/handlers/event_detail.go | 4 +- internal/handlers/event_log_view.go | 124 ++++++++- internal/handlers/event_request_test.go | 332 ++++++++++++++++++++++++ internal/handlers/handlers.go | 8 +- internal/handlers/source_management.go | 25 +- templates/event_detail.html | 9 + templates/event_request.html | 22 ++ templates/source_logs.html | 3 + 9 files changed, 511 insertions(+), 18 deletions(-) create mode 100644 internal/handlers/event_request_test.go create mode 100644 templates/event_request.html diff --git a/README.md b/README.md index ab4cbda..fbbb1db 100644 --- a/README.md +++ b/README.md @@ -3107,7 +3107,7 @@ returns to the page that was asked for. | `POST` | `/hook/{id}/edit` | Edit webhook submission | | `POST` | `/hook/{id}/delete` | Delete webhook | | `GET` | `/hook/{id}/events` | Full Event Log | -| `GET` | `/hook/{id}/events/{eventID}` | One event's own page: its details, its whole body and every delivery of it | +| `GET` | `/hook/{id}/events/{eventID}` | One event's own page: its details, the entrypoint it arrived at (for a resubmitted copy, the one the request it copies arrived at), its request headers, its whole body and every delivery of it | | `GET` | `/hook/{id}/events/{eventID}/body` | Download an event's stored body. The pages show a body as text, cut at 32 KiB in the recent events and the event log, and leave a binary one out, so this is the only route that serves the stored bytes; it is offered wherever a body is cut or binary | | `POST` | `/hook/{id}/deliveries/{deliveryID}/replay` | Replay a finished delivery: creates a new delivery for the same event against the target's current configuration (30 per minute per bucket, then `429`) | | `POST` | `/hook/{id}/events/{eventID}/resubmit` | Resubmit a stored event: creates a new event copying it and fans that out to every currently active target (30 per minute per bucket, then `429`) | diff --git a/internal/handlers/event_detail.go b/internal/handlers/event_detail.go index 9ee452c..a64fc19 100644 --- a/internal/handlers/event_detail.go +++ b/internal/handlers/event_detail.go @@ -1,6 +1,7 @@ package handlers import ( + "math" "net/http" "github.com/go-chi/chi" @@ -59,8 +60,9 @@ func (h *Handlers) HandleEventDetail() http.HandlerFunc { return } + // The page shows every request header. views, ok := h.eventLogViews( - w, r, webhookDB, webhook.ID, rows, targets, + w, r, webhookDB, webhook.ID, rows, targets, math.MaxInt, ) if !ok { return diff --git a/internal/handlers/event_log_view.go b/internal/handlers/event_log_view.go index 4a3446e..52735af 100644 --- a/internal/handlers/event_log_view.go +++ b/internal/handlers/event_log_view.go @@ -1,10 +1,15 @@ package handlers import ( + "encoding/json" + "net/http" + "slices" + "strings" "time" "unicode/utf8" "github.com/dustin/go-humanize" + "sneak.berlin/go/webhooker/internal/database" ) // eventLogColumns is the event log's projection. The casts to @@ -12,16 +17,20 @@ import ( // bytes rather than characters, so the cap bounds the page in // bytes whatever the payload's encoding. Cutting in SQLite // rather than in Go is the point of the projection — an -// oversized body never becomes a Go string at all. +// oversized body or set of request headers never becomes a Go +// string at all. const eventLogColumns = "id, created_at, method, content_type, " + - "resubmitted_from_id, " + + "resubmitted_from_id, entrypoint_id, " + + "substr(cast(headers as blob), 1, ?) AS headers, " + + "length(cast(headers as blob)) AS headers_bytes, " + "substr(cast(body as blob), 1, ?) AS body, " + "length(cast(body as blob)) AS body_bytes" // eventColumns is eventLogColumns for the event's own page, which -// shows the whole body. +// shows the whole body and every request header. const eventColumns = "id, created_at, method, content_type, " + - "resubmitted_from_id, " + + "resubmitted_from_id, entrypoint_id, headers, " + + "length(cast(headers as blob)) AS headers_bytes, " + "cast(body as blob) AS body, " + "length(cast(body as blob)) AS body_bytes" @@ -40,6 +49,22 @@ type EventLogView struct { Body BodyView + // Entrypoint names the entrypoint the event arrived at. A + // resubmitted copy, even a copy of a copy, did not arrive; it + // names the one the request it copies arrived at. The name is + // the entrypoint's description, "Entrypoint" when it has none, + // or "deleted entrypoint", never its URL, which is the + // entrypoint's secret. + Entrypoint string + + // Headers is the event's request headers as text, one + // "Name: value" line per value, sorted by name. HeadersCut + // reports headers left out because they hold more than + // maxRenderedBodyBytes, stored or as text; only the event log + // leaves them out. + Headers string + HeadersCut bool + // ResubmittedFromID names the event this one was copied // from, empty for an event that arrived on the receiver. ResubmittedFromID string @@ -60,27 +85,35 @@ func (v EventLogView) ResubmittedFrom() bool { } // eventLogRow is one row of the event log projection, or of -// eventColumns. In the event log its body column arrives -// already cut to the cap by SQLite, with the true size beside -// it. +// eventColumns. In the event log its headers and body columns +// arrive already cut to the cap by SQLite, each with its true +// size beside it. type eventLogRow struct { ID string CreatedAt time.Time Method string ContentType string ResubmittedFromID *string + EntrypointID string + Headers string + HeadersBytes int64 Body []byte BodyBytes int64 } // view projects a loaded row of the webhook's events for -// rendering. -func (r *eventLogRow) view(webhookID string) EventLogView { +// rendering. It shows the request headers when the row holds them +// whole and their text holds at most maxHeaderBytes. +func (r *eventLogRow) view( + webhookID string, maxHeaderBytes int, +) EventLogView { var from string if r.ResubmittedFromID != nil { from = *r.ResubmittedFromID } + headers, fit := requestHeaderLines(r.Headers, maxHeaderBytes) + return EventLogView{ ID: r.ID, Method: r.Method, @@ -90,10 +123,83 @@ func (r *eventLogRow) view(webhookID string) EventLogView { Body: newBodyView( "/hook/"+webhookID+"/events/"+r.ID, r.Body, r.BodyBytes, ), + Headers: strings.Join(headers, "\n"), + HeadersCut: !fit || r.HeadersBytes > int64(len(r.Headers)), ResubmittedFromID: from, } } +// requestHeaderLines turns an event's stored request headers, the +// JSON the receiver writes, into one "Name: value" line per value, +// sorted by name. Headers that do not parse, as when the event log +// has cut them, show as none. It reports false, with no lines, when +// the lines, each with the newline that follows it, would hold more +// than maxBytes: a header sent many times is stored with its name +// once but shown with it on every line. +func requestHeaderLines(headersJSON string, maxBytes int) ([]string, bool) { + var headers http.Header + + if json.Unmarshal([]byte(headersJSON), &headers) != nil { + return nil, true + } + + names := make([]string, 0, len(headers)) + for name := range headers { + names = append(names, name) + } + + slices.Sort(names) + + var lines []string + + size := 0 + + for _, name := range names { + for _, value := range headers[name] { + line := name + ": " + value + + size += len(line) + len("\n") + if size > maxBytes { + return nil, false + } + + lines = append(lines, line) + } + } + + return lines, true +} + +// entrypointNames maps each of the webhook's entrypoints to the name +// an event that arrived at it shows: its description, or "Entrypoint" +// when it has none, as the webhook page names it. A deleted +// entrypoint is left out. +func (h *Handlers) entrypointNames( + webhookID string, +) (map[string]string, error) { + var entrypoints []database.Entrypoint + + err := h.db.DB().Where( + "webhook_id = ?", webhookID, + ).Find(&entrypoints).Error + if err != nil { + return nil, err + } + + names := make(map[string]string, len(entrypoints)) + + for i := range entrypoints { + name := entrypoints[i].Description + if name == "" { + name = "Entrypoint" + } + + names[entrypoints[i].ID] = name + } + + return names, nil +} + // trimPartialRune drops a trailing UTF-8 sequence that the // byte-wise cut left incomplete, so a multi-byte rune severed // at the cap does not surface as a mojibake tail. diff --git a/internal/handlers/event_request_test.go b/internal/handlers/event_request_test.go new file mode 100644 index 0000000..f2bab00 --- /dev/null +++ b/internal/handlers/event_request_test.go @@ -0,0 +1,332 @@ +package handlers_test + +import ( + "encoding/json" + "net/http" + "slices" + "strings" + "testing" + "time" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/gorm/clause" + "sneak.berlin/go/webhooker/internal/database" +) + +// arrivedAt is how a page names the entrypoint an event arrived at. +func arrivedAt(name string) string { + return `Arrived at ` + name + `` +} + +// copiedRequestArrivedAt is how a page names, for a resubmitted copy, +// the entrypoint the request it copies arrived at. +func copiedRequestArrivedAt(name string) string { + return `The request it copies arrived at ` + + name + `` +} + +// headerBox is how a page shows an event's request header lines: as +// one block of text in a single box. +func headerBox(lines ...string) string { + return `
` + strings.Join(lines, "\n") + `
` +} + +// showHeadersLink is the event log's link to an event's own page for +// request headers it leaves out. +func showHeadersLink(webhookID, eventID string) string { + return `Show the request headers` +} + +// entrypoint records one of the fixture webhook's entrypoints. +func (f *recentEventsFixture) entrypoint( + t *testing.T, description string, +) *database.Entrypoint { + t.Helper() + + ep := &database.Entrypoint{ + WebhookID: f.webhook.ID, + Path: uuid.NewString(), + Description: description, + Active: true, + } + + require.NoError(t, f.db.DB().Omit(clause.Associations).Create(ep).Error) + + return ep +} + +// eventAt records an event that arrived at the entrypoint with the +// given request headers, stored as JSON as the receiver stores them. +func (f *recentEventsFixture) eventAt( + t *testing.T, + ep *database.Entrypoint, + headersJSON string, + receivedAt time.Time, +) *database.Event { + t.Helper() + + event := &database.Event{ + WebhookID: f.webhook.ID, + EntrypointID: ep.ID, + Method: http.MethodPost, + Headers: headersJSON, + Body: "{}", + BodyBytes: 2, + ContentType: contentTypeJSON, + } + event.CreatedAt = receivedAt + + require.NoError(t, f.webhookDB.Omit( + clause.Associations, + ).Create(event).Error) + + return event +} + +// TestEventRequest_EachEventShowsItsOwnEntrypointAndHeaders proves two +// events that arrived at two entrypoints each show their own +// entrypoint and request headers, in the event log and on their own +// pages, with the headers sorted by name, escaped and keeping their +// whitespace, and never the entrypoint's URL. +func TestEventRequest_EachEventShowsItsOwnEntrypointAndHeaders( + t *testing.T, +) { + t.Parallel() + + f := newRecentEventsFixture(t) + billing := f.entrypoint(t, "Billing sender") + unnamed := f.entrypoint(t, "") + + // Stored in reverse name order. + older := f.eventAt(t, billing, + `{"X-Shop-Event":["order.created"],`+ + `"User-Agent":["shop/1 build\t7"],"Accept":["*/*"]}`, + time.Now().Add(-time.Minute)) + newer := f.eventAt(t, unnamed, + `{"X-Shop-Event":["order.paid"],"X-Note":["hi"]}`, + time.Now()) + + olderShows := func(t *testing.T, page string) { + t.Helper() + + assert.Contains(t, page, arrivedAt("Billing sender")) + assert.Contains(t, page, headerBox( + "Accept: */*", + "User-Agent: shop/1 build\t7", + "X-Shop-Event: order.created", + ), "headers are sorted by name") + assert.NotContains(t, page, "order.paid") + assert.NotContains(t, page, billing.Path) + } + + newerShows := func(t *testing.T, page string) { + t.Helper() + + assert.Contains(t, page, arrivedAt("Entrypoint")) + assert.Contains(t, page, headerBox( + "X-Note: <b>hi</b>", + "X-Shop-Event: order.paid", + )) + assert.NotContains(t, page, "hi") + assert.NotContains(t, page, "order.created") + assert.NotContains(t, page, unnamed.Path) + } + + // The log lists the newer event first, so everything between + // the two events' first mentions belongs to the newer one. + _, rest, found := strings.Cut(renderSourceLogsPage( + t, f.h, f.sess, f.webhook.ID, + ), newer.ID) + require.True(t, found) + + newerPart, olderPart, found := strings.Cut(rest, older.ID) + require.True(t, found) + + newerShows(t, newerPart) + olderShows(t, olderPart) + + w := serveEventPage(t, f.h, f.sess, f.webhook.ID, newer.ID) + require.Equal(t, http.StatusOK, w.Code) + newerShows(t, w.Body.String()) + + w = serveEventPage(t, f.h, f.sess, f.webhook.ID, older.ID) + require.Equal(t, http.StatusOK, w.Code) + olderShows(t, w.Body.String()) +} + +// TestEventRequest_DeletedEntrypoint proves an event whose entrypoint +// has since been deleted says so in the event log and on its own page. +func TestEventRequest_DeletedEntrypoint(t *testing.T) { + t.Parallel() + + f := newRecentEventsFixture(t) + ep := f.entrypoint(t, "Retired sender") + event := f.eventAt(t, ep, `{}`, time.Now()) + + require.NoError(t, f.db.DB().Delete(ep).Error) + + page := renderSourceLogsPage(t, f.h, f.sess, f.webhook.ID) + assert.Contains(t, page, arrivedAt("deleted entrypoint")) + assert.NotContains(t, page, "Retired sender") + + w := serveEventPage(t, f.h, f.sess, f.webhook.ID, event.ID) + require.Equal(t, http.StatusOK, w.Code) + assert.Contains(t, w.Body.String(), arrivedAt("deleted entrypoint")) + assert.NotContains(t, w.Body.String(), "Retired sender") +} + +// TestEventRequest_ResubmittedCopy proves a resubmitted copy and a copy +// of that copy each say the request they copy arrived at the +// entrypoint, in the event log and on their own pages, and never that +// they did. +func TestEventRequest_ResubmittedCopy(t *testing.T) { + t.Parallel() + + f := newRecentEventsFixture(t) + ep := f.entrypoint(t, "Billing sender") + original := f.eventAt(t, ep, `{}`, time.Now().Add(-2*time.Minute)) + copied := f.eventAt(t, ep, `{}`, time.Now().Add(-time.Minute)) + copyOfCopy := f.eventAt(t, ep, `{}`, time.Now()) + + require.NoError(t, f.webhookDB.Model(copied).Update( + "resubmitted_from_id", original.ID, + ).Error) + require.NoError(t, f.webhookDB.Model(copyOfCopy).Update( + "resubmitted_from_id", copied.ID, + ).Error) + + // The log lists the newest event first, and each event's Resubmit + // form comes before its entrypoint, so cutting the page at the + // copy's and the original's forms leaves each event's entrypoint + // in its own part. + copyOfCopyPart, rest, found := strings.Cut( + renderSourceLogsPage(t, f.h, f.sess, f.webhook.ID), + "/events/"+copied.ID+"/resubmit", + ) + require.True(t, found) + + copyPart, originalPart, found := strings.Cut( + rest, "/events/"+original.ID+"/resubmit", + ) + require.True(t, found) + + for _, part := range []string{copyOfCopyPart, copyPart} { + assert.Contains(t, part, copiedRequestArrivedAt("Billing sender")) + assert.NotContains(t, part, arrivedAt("Billing sender")) + } + + assert.Contains(t, originalPart, arrivedAt("Billing sender")) + assert.NotContains(t, originalPart, + copiedRequestArrivedAt("Billing sender")) + + for _, event := range []*database.Event{copied, copyOfCopy} { + w := serveEventPage(t, f.h, f.sess, f.webhook.ID, event.ID) + require.Equal(t, http.StatusOK, w.Code) + assert.Contains(t, w.Body.String(), + copiedRequestArrivedAt("Billing sender")) + assert.NotContains(t, w.Body.String(), arrivedAt("Billing sender")) + } +} + +// TestEventRequest_HeadersOverTheLimit proves the event log leaves out +// request headers that hold more than it shows of a body, whether +// stored or as lines, and links to the event's own page, which shows +// them all. +func TestEventRequest_HeadersOverTheLimit(t *testing.T) { + t.Parallel() + + // The receiver stores each "<" as six bytes of JSON, so this + // header is over the limit stored but not as a line. + const lessThans = bodyCap/6 + 1 + + // A header sent many times is stored with its name once, and + // shown with it on every line. + repeatedName := "X-Repeated-" + strings.Repeat("r", 1000) + + tests := map[string]struct { + headers http.Header + line string + }{ + "stored": { + headers: http.Header{"X-Long": {strings.Repeat("<", lessThans)}}, + line: "X-Long: " + strings.Repeat("<", lessThans), + }, + "as lines": { + headers: http.Header{repeatedName: slices.Repeat([]string{""}, 41)}, + line: repeatedName + ": ", + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + + headersJSON, err := json.Marshal(tc.headers) + require.NoError(t, err) + + f := newRecentEventsFixture(t) + ep := f.entrypoint(t, "Billing sender") + event := f.eventAt(t, ep, string(headersJSON), time.Now()) + + page := renderSourceLogsPage(t, f.h, f.sess, f.webhook.ID) + assert.Contains(t, page, showHeadersLink(f.webhook.ID, event.ID)) + assert.NotContains(t, page, tc.line) + assert.Less(t, len(page), 4*bodyCap) + + w := serveEventPage(t, f.h, f.sess, f.webhook.ID, event.ID) + require.Equal(t, http.StatusOK, w.Code) + assert.Contains(t, w.Body.String(), tc.line) + assert.NotContains(t, w.Body.String(), "Show the request headers") + }) + } +} + +// TestEventRequest_ManyShortHeaderLines proves that for many short +// request header lines the event log writes no more than its limit, +// apart from escaping: lines that fill the limit show as one block of +// text, and one line more is left out with a link to the event's own +// page. +func TestEventRequest_ManyShortHeaderLines(t *testing.T) { + t.Parallel() + + // Each "A: " line and the newline after it hold four bytes, so + // this many lines fill the limit exactly. Each line in its own + // element would make the page many times the limit. + const fill = bodyCap / len("A: \n") + + tests := map[string]struct { + lines int + shown bool + }{ + "filling the limit": {lines: fill, shown: true}, + "one over the limit": {lines: fill + 1, shown: false}, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + + headersJSON, err := json.Marshal(http.Header{ + "A": slices.Repeat([]string{""}, tc.lines), + }) + require.NoError(t, err) + + f := newRecentEventsFixture(t) + ep := f.entrypoint(t, "Billing sender") + event := f.eventAt(t, ep, string(headersJSON), time.Now()) + + page := renderSourceLogsPage(t, f.h, f.sess, f.webhook.ID) + box := headerBox(slices.Repeat([]string{"A: "}, tc.lines)...) + link := showHeadersLink(f.webhook.ID, event.ID) + + assert.Equal(t, tc.shown, strings.Contains(page, box)) + assert.Equal(t, !tc.shown, strings.Contains(page, link)) + assert.Less(t, len(page), 4*bodyCap) + }) + } +} diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go index f132349..2b689e6 100644 --- a/internal/handlers/handlers.go +++ b/internal/handlers/handlers.go @@ -167,12 +167,12 @@ func New( ), "source_edit.html": parsePageTemplate("source_edit.html"), "source_logs.html": parsePageTemplate( - "source_logs.html", "event_body.html", "delivery_row.html", - "delivery_attempts.html", + "source_logs.html", "event_request.html", "event_body.html", + "delivery_row.html", "delivery_attempts.html", ), "event_detail.html": parsePageTemplate( - "event_detail.html", "event_body.html", "delivery_row.html", - "delivery_attempts.html", + "event_detail.html", "event_request.html", "event_body.html", + "delivery_row.html", "delivery_attempts.html", ), "target_edit.html": parsePageTemplate("target_edit.html"), "error.html": parsePageTemplate("error.html"), diff --git a/internal/handlers/source_management.go b/internal/handlers/source_management.go index e8130d3..845580a 100644 --- a/internal/handlers/source_management.go +++ b/internal/handlers/source_management.go @@ -1231,13 +1231,17 @@ func (h *Handlers) loadEventsWithDeliveries( result, ok := h.eventLogViews( w, r, webhookDB, webhook.ID, rows, targetMap, + maxRenderedBodyBytes, ) return result, totalEvents, ok } // eventLogViews projects loaded events for rendering, each with -// its deliveries and how many times it has been resubmitted. Like +// its deliveries, how many times it has been resubmitted and the +// entrypoint it arrived at (for a resubmitted copy, the one the +// request it copies arrived at), and with its request headers only +// when their text holds at most maxHeaderBytes. Like // loadEventsWithDeliveries, it reports false once it has answered // the request with an error. func (h *Handlers) eventLogViews( @@ -1247,6 +1251,7 @@ func (h *Handlers) eventLogViews( webhookID string, rows []eventLogRow, targetMap map[string]eventLogTarget, + maxHeaderBytes int, ) ([]EventLogView, bool) { result := make([]EventLogView, len(rows)) eventDeliveries := make([][]database.Delivery, len(rows)) @@ -1256,7 +1261,7 @@ func (h *Handlers) eventLogViews( eventIDs := make([]string, len(rows)) for i := range rows { - result[i] = rows[i].view(webhookID) + result[i] = rows[i].view(webhookID, maxHeaderBytes) eventIDs[i] = rows[i].ID webhookDB.Where( @@ -1290,11 +1295,25 @@ func (h *Handlers) eventLogViews( return nil, false } + entrypoints, err := h.entrypointNames(webhookID) + if err != nil { + h.serverError(w, r, "failed to load entrypoints", err) + + return nil, false + } + for i := range rows { result[i].Deliveries = h.newDeliveryViews( eventDeliveries[i], targetMap, attempts, ) result[i].ResubmitCount = resubmits[rows[i].ID] + + name, ok := entrypoints[rows[i].EntrypointID] + if !ok { + name = "deleted entrypoint" + } + + result[i].Entrypoint = name } return result, true @@ -1315,7 +1334,7 @@ func loadEventLogRows( var rows []eventLogRow webhookDB.Model(&database.Event{}).Select( - eventLogColumns, maxRenderedBodyBytes, + eventLogColumns, maxRenderedBodyBytes, maxRenderedBodyBytes, ).Where( "webhook_id = ?", webhookID, ).Order("created_at DESC").Limit(recentEventLimit).Find(&rows) diff --git a/templates/event_detail.html b/templates/event_detail.html index 3756cad..30756cb 100644 --- a/templates/event_detail.html +++ b/templates/event_detail.html @@ -50,6 +50,15 @@
+
+
+

Request

+
+
+ {{template "event_request" .}} +
+
+

Body

diff --git a/templates/event_request.html b/templates/event_request.html new file mode 100644 index 0000000..ca72f6b --- /dev/null +++ b/templates/event_request.html @@ -0,0 +1,22 @@ +{{define "event_request"}} + +
+ {{if .ResubmittedFrom}} +

The request it copies arrived at {{.Entrypoint}}

+ {{else}} +

Arrived at {{.Entrypoint}}

+ {{end}} + {{if .HeadersCut}} +

The request headers are larger than the event log shows. Show the request headers

+ {{else if .Headers}} +

Request headers

+
{{.Headers}}
+ {{else}} +

No request headers.

+ {{end}} +
+{{end}} diff --git a/templates/source_logs.html b/templates/source_logs.html index a87938c..07a88b1 100644 --- a/templates/source_logs.html +++ b/templates/source_logs.html @@ -55,6 +55,9 @@
+
+ {{template "event_request" .}} +
{{template "event_body" .Body}} {{if .Deliveries}} -- 2.54.0 From 7e779f7fce70c72834574d8da57cb9fdb01fcd04 Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Sat, 3 Oct 2026 05:40:34 +0200 Subject: [PATCH 090/126] Event log: show only the events with a failed or a pending delivery (closes #390) The event log had no way to list only the events whose delivery failed, and once it showed only the 50 newest, an older failure could not be found at all. It now has All, Failed (N) and Pending (N) links, carried in a `show` query parameter, so they work without the page's script library. Each filtered list keeps the 50-row limit and newest-first order, and lists an event once. It finds matching deliveries through `idx_deliveries_status` and looks their events up by ID, so its cost follows the matches, not the webhook's size. Replay returns to the list it was pressed in. The heading line says what a filter counts. Model: opus-5-5 --- README.md | 9 +- internal/database/event_tier_indexes_test.go | 56 ++++++ internal/handlers/delivery_replay.go | 18 +- internal/handlers/delivery_replay_test.go | 61 ++++++ internal/handlers/export_test.go | 2 +- internal/handlers/source_logs_test.go | 147 ++++++++++++++ internal/handlers/source_management.go | 196 +++++++++++++++++-- internal/handlers/target_paused_test.go | 3 +- internal/middleware/middleware.go | 5 +- internal/server/alpine_browser_test.go | 12 +- internal/server/sentry.go | 4 +- static/css/tailwind.css | 2 +- templates/source_logs.html | 13 +- 13 files changed, 484 insertions(+), 44 deletions(-) diff --git a/README.md b/README.md index fbbb1db..8b0197c 100644 --- a/README.md +++ b/README.md @@ -1989,7 +1989,7 @@ tags, so `AutoMigrate` creates them on a fresh database: | 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 and target list and the webhook list, 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, the webhook page's statistics and target list and the webhook list, which count each target's deliveries by status and when they finished, and the event log, which lists and counts the events with a failed delivery or one pending or retrying | | `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 | | `events` | `deleted_at`, `created_at` | The webhook page's statistics, which count recent events | @@ -2592,8 +2592,9 @@ The query string is never logged; it is replaced by the fixed marker 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 -`next`, the page to return to, and `notice`, which names the line a page -shows after an action. +`next`, the page to return to, `notice`, which names the line a page +shows after an action, and the event log's `show`, which picks the events +it lists. Client-supplied request content does not leave the host by the other route either. The Sentry SDK attaches the request to every event it @@ -3106,7 +3107,7 @@ returns to the page that was asked for. | `GET` | `/hook/{id}/edit` | Edit webhook form | | `POST` | `/hook/{id}/edit` | Edit webhook submission | | `POST` | `/hook/{id}/delete` | Delete webhook | -| `GET` | `/hook/{id}/events` | Full Event Log | +| `GET` | `/hook/{id}/events` | Full Event Log. `?show=failed` lists only the events with a failed delivery, and `?show=pending` only those with a delivery pending or retrying | | `GET` | `/hook/{id}/events/{eventID}` | One event's own page: its details, the entrypoint it arrived at (for a resubmitted copy, the one the request it copies arrived at), its request headers, 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`) | diff --git a/internal/database/event_tier_indexes_test.go b/internal/database/event_tier_indexes_test.go index ea84c7b..09f97a5 100644 --- a/internal/database/event_tier_indexes_test.go +++ b/internal/database/event_tier_indexes_test.go @@ -149,6 +149,62 @@ func TestEventTierQueriesUseTheirIndexes(t *testing.T) { Delete(&database.Event{}), "sqlite_autoindex_events_1 (id=?)") } +// TestEventLogFiltersUseTheStatusIndex does the same for the event log's +// Failed and Pending lists, of the newest events with a delivery in +// given statuses, and for their counts (eventsWithStatus and +// countEventsWithStatus in the handlers). The lists must also reach +// the events table only by ID: from the matching deliveries, then from +// the newest of those events. +func TestEventLogFiltersUseTheStatusIndex(t *testing.T) { + t.Parallel() + + mgr, lc := setupTestWebhookDBManager(t) + ctx := context.Background() + require.NoError(t, lc.Start(ctx)) + + defer func() { require.NoError(t, lc.Stop(ctx)) }() + + webhookID := uuid.New().String() + + db, err := mgr.GetDB(webhookID) + require.NoError(t, err) + + dry := db.Session(&gorm.Session{DryRun: true}) + byStatus := "idx_deliveries_status (status=? AND deleted_at=?)" + pending := []database.DeliveryStatus{ + database.DeliveryStatusPending, + database.DeliveryStatusRetrying, + } + + var ( + rows []struct{ ID string } + count int64 + ) + + matching := dry.Model(&database.Delivery{}). + Distinct("event_id").Where("status IN ?", pending) + newest := dry.Table("(?) AS matching", matching). + Joins("CROSS JOIN events ON events.id = matching.event_id"). + Where( + "events.webhook_id = ? AND events.deleted_at IS NULL", + webhookID, + ). + Order("events.created_at DESC").Limit(50). + Select("events.id AS event_id") + + // Each step of the plan is printed in braces, so these name the + // lookup that follows each scan. + byID := "{SEARCH events USING INDEX sqlite_autoindex_events_1 (id=?)}" + + assertPlanUses(t, db, dry.Table("(?) AS newest", newest). + Joins("CROSS JOIN events ON events.id = newest.event_id"). + Select("id").Order("created_at DESC").Limit(50).Find(&rows), + byStatus, "{SCAN matching} "+byID, "{SCAN newest} "+byID) + assertPlanUses(t, db, dry.Model(&database.Delivery{}). + Distinct("event_id").Where("status IN ?", pending).Count(&count), + byStatus) +} + // TestStatisticsQueriesUseTheirIndexes does the same for the webhook // page's statistics (readEventStats in the handlers): deliveries in // progress, each target's deliveries finished since a time, which must diff --git a/internal/handlers/delivery_replay.go b/internal/handlers/delivery_replay.go index 48f7f5b..8516742 100644 --- a/internal/handlers/delivery_replay.go +++ b/internal/handlers/delivery_replay.go @@ -329,15 +329,25 @@ func replayBody(body string) *string { } // redirectToEventLog redirects a replay or resubmit back to the event -// log it was triggered from, carrying the outcome as its notice. +// log it was triggered from, carrying the outcome as its notice. A +// Replay form carries the list it was pressed in as show, so a replay +// returns to the Failed or Pending list; a Resubmit form carries none, +// so a resubmit returns to the full log, where its new event is the +// newest. func redirectToEventLog( w http.ResponseWriter, r *http.Request, webhook database.Webhook, code noticeCode, ) { - http.Redirect( - w, r, withNotice("/hook/"+webhook.ID+"/events", code), - http.StatusSeeOther, + location := withNotice("/hook/"+webhook.ID+"/events", code) + + show := r.PostFormValue(showParam) + if eventLogStatuses(show) != nil { + location += "&" + showParam + "=" + show + } + + http.Redirect( //nolint:gosec // show is checked by eventLogStatuses + w, r, location, http.StatusSeeOther, ) } diff --git a/internal/handlers/delivery_replay_test.go b/internal/handlers/delivery_replay_test.go index 2c4865a..58c6e03 100644 --- a/internal/handlers/delivery_replay_test.go +++ b/internal/handlers/delivery_replay_test.go @@ -1,6 +1,7 @@ package handlers_test import ( + "io" "net/http" "net/http/httptest" "strings" @@ -471,6 +472,66 @@ func TestHandleDeliveryReplay_RefusesWhileEarlierReplayInFlight( ) } +// TestHandleDeliveryReplay_ReturnsToTheListItWasPressedIn proves a +// Replay pressed in the Failed list carries that list in its form and +// returns to it, and that a show value the event log does not know +// returns to the full log. +func TestHandleDeliveryReplay_ReturnsToTheListItWasPressedIn( + 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) + + wh := seedWebhook(t, db) + tgt := seedConfiguredTarget( + t, db, wh.ID, database.TargetTypeHTTP, + `{"url":"`+replayTargetURL+`"}`, + ) + + _, original := seedFailedDelivery(t, dbMgr, wh.ID, tgt.ID) + + assert.Contains(t, renderSourceLogsPageWithQuery( + t, h, sess, wh.ID, "?show=failed", + ), `name="show" value="failed"`) + + // The second replay is refused, as the first is still queued. + for _, tc := range []struct{ show, location string }{ + {"failed", "/hook/" + wh.ID + + "/events?notice=replay-queued&show=failed"}, + {"made-up", "/hook/" + wh.ID + "/events?notice=replay-in-flight"}, + } { + req := postRequest( + "/hook/"+wh.ID+"/deliveries/"+original.ID+"/replay", + authenticatedCookies( + t, sess, deleteTestUserID, deleteTestUsername, + ), + map[string]string{ + paramSourceID: wh.ID, + paramDeliveryID: original.ID, + }, + ) + req.Body = io.NopCloser(strings.NewReader("show=" + tc.show)) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + w := httptest.NewRecorder() + h.HandleDeliveryReplay().ServeHTTP(w, req) + + require.Equal(t, http.StatusSeeOther, w.Code, tc.show) + assert.Equal(t, tc.location, w.Header().Get("Location"), tc.show) + } +} + // TestHandleSourceLogs_RendersReplayControlAndBanner proves the action // reaches the page it belongs on: a finished delivery renders a POST // form carrying a CSRF token, and the outcome code a refusal redirects diff --git a/internal/handlers/export_test.go b/internal/handlers/export_test.go index 076a71a..852f79a 100644 --- a/internal/handlers/export_test.go +++ b/internal/handlers/export_test.go @@ -75,7 +75,7 @@ func (s *Handlers) LoadEventLogViewsForTest( webhook database.Webhook, ) []EventLogView { views, _, _ := s.loadEventsWithDeliveries( - w, newRequestForTest(), webhook, nil, + w, newRequestForTest(), webhook, nil, nil, ) return views diff --git a/internal/handlers/source_logs_test.go b/internal/handlers/source_logs_test.go index 79cdaff..0cbe5fd 100644 --- a/internal/handlers/source_logs_test.go +++ b/internal/handlers/source_logs_test.go @@ -5,6 +5,7 @@ import ( "fmt" "net/http" "net/http/httptest" + "slices" "strings" "testing" "time" @@ -186,6 +187,152 @@ func TestHandleSourceLogs_ShowsFiftyNewestEvents(t *testing.T) { assert.Contains(t, body, "50 most recent of 51 events") } +// TestHandleSourceLogs_ShowsEventsByDeliveryStatus proves that the +// Failed list holds exactly the events with a failed delivery, the +// Pending list exactly those with a delivery pending or retrying, each +// once, and any other show value every event; that each link, and the +// line beside the heading, counts the events its list holds; and that +// the shown link is marked. +func TestHandleSourceLogs_ShowsEventsByDeliveryStatus(t *testing.T) { + t.Parallel() + + f := newRecentEventsFixture(t) + target := seedTarget(t, f.db, f.webhook.ID, database.TargetTypeLog) + now := time.Now() + + const ( + failed = database.DeliveryStatusFailed + delivered = database.DeliveryStatusDelivered + pending = database.DeliveryStatusPending + retrying = database.DeliveryStatusRetrying + ) + + // Each event is named by its content type. The first failed and + // was then replayed and delivered. The second failed, and so did + // its replay, and the fifth has one delivery pending and another + // retrying: each must still be listed and counted once. + events := []struct { + contentType string + deliveries []database.DeliveryStatus + }{ + {"application/x-failed", []database.DeliveryStatus{failed, delivered}}, + {"application/x-failed-twice", []database.DeliveryStatus{failed, failed}}, + {"application/x-pending", []database.DeliveryStatus{pending}}, + {"application/x-retrying", []database.DeliveryStatus{retrying}}, + {"application/x-pending-retrying", []database.DeliveryStatus{pending, retrying}}, + {"application/x-delivered", []database.DeliveryStatus{delivered}}, + {"application/x-no-delivery", nil}, + } + + all := make([]string, len(events)) + + for i, e := range events { + event := f.event( + t, e.contentType, "{}", now.Add(time.Duration(i)*time.Second), + ) + + for _, status := range e.deliveries { + f.delivery(t, event, target.ID, status) + } + + all[i] = e.contentType + } + + for _, tc := range []struct { + query string + current string + heading string + listed []string + }{ + {"", "All", "7 total events", all}, + {"?show=failed", "Failed (2)", "2 events with a failed delivery", + []string{"application/x-failed", "application/x-failed-twice"}}, + {"?show=pending", "Pending (3)", + "3 events with a delivery pending or retrying", []string{ + "application/x-pending", "application/x-retrying", + "application/x-pending-retrying", + }}, + {"?show=unknown", "All", "7 total events", all}, + } { + body := renderSourceLogsPageWithQuery( + t, f.h, f.sess, f.webhook.ID, tc.query, + ) + + // One row per listed event, so with each listed event shown + // no event is listed twice. + assert.Equal(t, len(tc.listed), + strings.Count(body, `role="button"`), tc.query) + + for _, contentType := range all { + assert.Equal(t, + slices.Contains(tc.listed, contentType), + strings.Contains(body, ">"+contentType+"<"), + tc.query+" "+contentType) + } + + assert.Contains(t, body, ">"+tc.heading+"<", tc.query) + assert.Contains(t, body, "Failed (2)", tc.query) + assert.Contains(t, body, "Pending (3)", tc.query) + assert.Equal(t, 1, strings.Count(body, "aria-current"), tc.query) + assert.Contains(t, body, + `aria-current="page">`+tc.current+"", tc.query) + } +} + +// TestHandleSourceLogs_FilteredListShowsFiftyNewest proves a filtered +// list holds the 50 newest matching events, as the full log does, +// while its link and heading count every matching event. +func TestHandleSourceLogs_FilteredListShowsFiftyNewest(t *testing.T) { + t.Parallel() + + f := newRecentEventsFixture(t) + target := seedTarget(t, f.db, f.webhook.ID, database.TargetTypeLog) + base := time.Now().Add(-time.Hour) + + for i := range 51 { + event := f.event( + t, fmt.Sprintf("application/x-failed-%02d", i), "{}", + base.Add(time.Duration(i)*time.Second), + ) + f.delivery(t, event, target.ID, database.DeliveryStatusFailed) + } + + // The newest event has no failed delivery. + f.event(t, "application/x-no-delivery", "{}", time.Now()) + + body := renderSourceLogsPageWithQuery( + t, f.h, f.sess, f.webhook.ID, "?show=failed", + ) + + assert.Equal(t, 50, strings.Count(body, `role="button"`)) + assert.NotContains(t, body, "application/x-failed-00") + assert.NotContains(t, body, "application/x-no-delivery") + assert.Contains(t, body, "Failed (51)") + assert.Contains(t, body, + "50 most recent of 51 events with a failed delivery") +} + +// TestHandleSourceLogs_EmptyFilteredList proves an empty filtered list +// says that no event matches rather than that none was recorded. +func TestHandleSourceLogs_EmptyFilteredList(t *testing.T) { + t.Parallel() + + f := newRecentEventsFixture(t) + target := seedTarget(t, f.db, f.webhook.ID, database.TargetTypeLog) + + f.delivery( + t, f.event(t, contentTypeJSON, "{}", time.Now()), + target.ID, database.DeliveryStatusDelivered, + ) + + assert.Contains(t, renderSourceLogsPageWithQuery( + t, f.h, f.sess, f.webhook.ID, "?show=failed", + ), "No event has a failed delivery.") + assert.Contains(t, renderSourceLogsPageWithQuery( + t, f.h, f.sess, f.webhook.ID, "?show=pending", + ), "No event has a delivery pending or retrying.") +} + // TestHandleSourceLogs_OnlyNewestStartsExpanded proves that of the // events in the log only the newest starts expanded. func TestHandleSourceLogs_OnlyNewestStartsExpanded(t *testing.T) { diff --git a/internal/handlers/source_management.go b/internal/handlers/source_management.go index 845580a..0f5531f 100644 --- a/internal/handlers/source_management.go +++ b/internal/handlers/source_management.go @@ -1110,6 +1110,15 @@ func (h *Handlers) ownedWebhook( return webhook, true } +// The event log's show query parameter and its two values: the events +// with a failed delivery, and those with a delivery still pending or +// retrying. +const ( + showParam = "show" + showFailed = "failed" + showPending = "pending" +) + // HandleSourceLogs shows the request/response logs for a // webhook. func (h *Handlers) HandleSourceLogs() http.HandlerFunc { @@ -1129,17 +1138,36 @@ func (h *Handlers) HandleSourceLogs() http.HandlerFunc { return } + // Any other value of show lists every event, as no value + // does. + show := r.URL.Query().Get(showParam) + + statuses := eventLogStatuses(show) + if statuses == nil { + show = "" + } + evts, total, ok := h.loadEventsWithDeliveries( - w, r, webhook, targets, + w, r, webhook, targets, statuses, ) if !ok { return } + failed, pending, err := h.countFailedAndPendingEvents(webhook.ID) + if err != nil { + h.serverError(w, r, "failed to count events", err) + + return + } + data := map[string]any{ - tmplKeyWebhook: &webhook, - "Events": evts, - "TotalEvents": total, + tmplKeyWebhook: &webhook, + "Events": evts, + "TotalEvents": total, + "Show": show, + "FailedEvents": failed, + "PendingEvents": pending, } h.renderTemplate(w, r, "source_logs.html", data) @@ -1201,9 +1229,10 @@ func (h *Handlers) loadTargetMap( // loadEventsWithDeliveries loads the recentEventLimit newest events // and their deliveries from the per-webhook database, and the total -// number of events stored. Events come back as capped projections -// rather than database.Event rows: see eventLogColumns for why the -// cut happens in SQL. +// number of events stored. Given delivery statuses, both cover only +// the events with a delivery in one of them. Events come back as +// capped projections rather than database.Event rows: see +// eventLogColumns for why the cut happens in SQL. // // The bool reports whether the load succeeded. It is false // once this has answered the request with an error, and the @@ -1213,6 +1242,7 @@ func (h *Handlers) loadEventsWithDeliveries( r *http.Request, webhook database.Webhook, targetMap map[string]eventLogTarget, + statuses []database.DeliveryStatus, ) ([]EventLogView, int64, bool) { if !h.dbMgr.DBExists(webhook.ID) { return nil, 0, true @@ -1227,7 +1257,14 @@ func (h *Handlers) loadEventsWithDeliveries( return nil, 0, false } - rows, totalEvents := loadEventLogRows(webhookDB, webhook.ID) + rows, totalEvents, err := loadEventLogRows( + webhookDB, webhook.ID, statuses, + ) + if err != nil { + h.serverError(w, r, "failed to load events", err) + + return nil, 0, false + } result, ok := h.eventLogViews( w, r, webhookDB, webhook.ID, rows, targetMap, @@ -1321,25 +1358,142 @@ func (h *Handlers) eventLogViews( // loadEventLogRows reads the event log projection of the // recentEventLimit newest events, newest first, and the total number -// of events stored. +// of events stored, both narrowed by statuses as eventsWithStatus +// narrows them. func loadEventLogRows( - webhookDB *gorm.DB, webhookID string, -) ([]eventLogRow, int64) { - var totalEvents int64 - - webhookDB.Model(&database.Event{}).Where( - "webhook_id = ?", webhookID, - ).Count(&totalEvents) + webhookDB *gorm.DB, + webhookID string, + statuses []database.DeliveryStatus, +) ([]eventLogRow, int64, error) { + totalEvents, err := countEventsWithStatus( + webhookDB, webhookID, statuses, + ) + if err != nil { + return nil, 0, err + } var rows []eventLogRow - webhookDB.Model(&database.Event{}).Select( + err = eventsWithStatus(webhookDB, webhookID, statuses).Select( eventLogColumns, maxRenderedBodyBytes, maxRenderedBodyBytes, - ).Where( - "webhook_id = ?", webhookID, - ).Order("created_at DESC").Limit(recentEventLimit).Find(&rows) + ).Order("created_at DESC").Limit(recentEventLimit).Find(&rows).Error - return rows, totalEvents + return rows, totalEvents, err +} + +// eventLogStatuses returns the delivery statuses the event log's show +// value lists events by, or nil for one that lists every event. +func eventLogStatuses(show string) []database.DeliveryStatus { + switch show { + case showFailed: + return []database.DeliveryStatus{database.DeliveryStatusFailed} + case showPending: + return []database.DeliveryStatus{ + database.DeliveryStatusPending, + database.DeliveryStatusRetrying, + } + default: + return nil + } +} + +// eventsWithStatus selects the webhook's events, or, given statuses, +// the recentEventLimit newest of those with at least one delivery in +// one of them. +// +// Given statuses, its cost follows the matching deliveries. SQLite +// never reorders a CROSS JOIN, so it reads each join's left side +// first: the distinct event IDs of the matching deliveries, through +// idx_deliveries_status; then each of those events by ID, sorted to +// keep the newest; then the rows of only the events kept, so no other +// event's body is read. With a plain "id IN (matching deliveries)" +// condition instead, SQLite, which keeps no statistics on these +// tables, walks every event newest first. +func eventsWithStatus( + webhookDB *gorm.DB, + webhookID string, + statuses []database.DeliveryStatus, +) *gorm.DB { + if statuses == nil { + return webhookDB.Model(&database.Event{}).Where( + "webhook_id = ?", webhookID, + ) + } + + matching := webhookDB.Model(&database.Delivery{}). + Distinct("event_id").Where("status IN ?", statuses) + + newest := webhookDB.Table("(?) AS matching", matching). + Joins("CROSS JOIN events ON events.id = matching.event_id"). + Where( + "events.webhook_id = ? AND events.deleted_at IS NULL", + webhookID, + ). + Order("events.created_at DESC").Limit(recentEventLimit). + Select("events.id AS event_id") + + return webhookDB.Table("(?) AS newest", newest). + Joins("CROSS JOIN events ON events.id = newest.event_id") +} + +// countEventsWithStatus counts the webhook's events with at least one +// delivery in one of the statuses, or every event when statuses is +// nil. Given statuses, it counts the distinct events of the matching +// deliveries and reads nothing but those deliveries, through +// idx_deliveries_status, where counting the events would read every +// event row. That is the same number, because retention deletes an +// event's deliveries with it. +func countEventsWithStatus( + webhookDB *gorm.DB, + webhookID string, + statuses []database.DeliveryStatus, +) (int64, error) { + var count int64 + + if statuses == nil { + err := webhookDB.Model(&database.Event{}).Where( + "webhook_id = ?", webhookID, + ).Count(&count).Error + + return count, err + } + + err := webhookDB.Model(&database.Delivery{}).Distinct("event_id"). + Where("status IN ?", statuses).Count(&count).Error + + return count, err +} + +// countFailedAndPendingEvents returns how many of the webhook's events +// the event log lists when it shows only those with a failed delivery, +// and when it shows only those with a delivery pending or retrying. +func (h *Handlers) countFailedAndPendingEvents( + webhookID string, +) (int64, int64, error) { + if !h.dbMgr.DBExists(webhookID) { + return 0, 0, nil + } + + webhookDB, err := h.dbMgr.GetDB(webhookID) + if err != nil { + return 0, 0, err + } + + failed, err := countEventsWithStatus( + webhookDB, webhookID, eventLogStatuses(showFailed), + ) + if err != nil { + return 0, 0, err + } + + pending, err := countEventsWithStatus( + webhookDB, webhookID, eventLogStatuses(showPending), + ) + if err != nil { + return 0, 0, err + } + + return failed, pending, nil } // resubmitCounts reports, for each of the page's events, how many diff --git a/internal/handlers/target_paused_test.go b/internal/handlers/target_paused_test.go index 37d57f2..6b5cbbc 100644 --- a/internal/handlers/target_paused_test.go +++ b/internal/handlers/target_paused_test.go @@ -91,7 +91,8 @@ func TestPausedTarget_ShownUntilBreakerCloses(t *testing.T) { assert.Contains(t, log, "t-http: delivered") assert.Regexp(t, waiting+cooldownEnds, log) assert.Contains(t, log, waiting+backoffEnds) - assert.NotContains(t, log, "retrying") + // No delivery shows as retrying; the Pending link's title says it. + assert.NotRegexp(t, `t-http: retrying|>retrying<`, log) page := eventPage(t, h, sess, wh.ID, retrying.ID) assert.Regexp(t, waiting+cooldownEnds, page) diff --git a/internal/middleware/middleware.go b/internal/middleware/middleware.go index 3c2e3ff..7c452ae 100644 --- a/internal/middleware/middleware.go +++ b/internal/middleware/middleware.go @@ -279,8 +279,9 @@ func (lrw *loggingResponseWriter) Unwrap() http.ResponseWriter { // this branch as bounded as the pattern branches below. // // Nothing debuggable is lost. The only query parameters the 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. The +// reads are the login page's `next`, the page to return to, +// `notice`, which names the line a page shows after an action, and +// the event log's `show`, which picks the events it lists. The // alternatives that would preserve more (a key count, a key // allowlist) all require parsing an attacker-sized query on every // request, which is work an unauthenticated client would then be diff --git a/internal/server/alpine_browser_test.go b/internal/server/alpine_browser_test.go index 6677cef..1d6d892 100644 --- a/internal/server/alpine_browser_test.go +++ b/internal/server/alpine_browser_test.go @@ -947,15 +947,15 @@ func checkEventSelection( } // checkEventKeyboard loads the event log and checks that Tab from the -// page's Back link reaches the row of the newest event, the first after -// it, and that Enter then collapses that event, which starts expanded, -// and Space expands it again. +// page's Pending link, the last link above the list, reaches the row of +// the newest event, the first after it, and that Enter then collapses +// that event, which starts expanded, and Space expands it again. func checkEventKeyboard( ctx context.Context, t *testing.T, url, eventID string, ) { t.Helper() - back := `//a[contains(text(), "Back to")]` + pending := `//a[starts-with(text(), "Pending")]` expanded := `form[action$="/` + eventID + `/resubmit"]` var focused string @@ -963,12 +963,12 @@ func checkEventKeyboard( require.NoError(t, chromedp.Run( ctx, loadPage(url), - chromedp.Focus(back, chromedp.BySearch), + chromedp.Focus(pending, chromedp.BySearch), chromedp.KeyEvent(kb.Tab), chromedp.Evaluate(`document.activeElement.textContent`, &focused), )) require.Contains(t, focused, eventID, - "Tab from the Back link does not reach the event's row") + "Tab from the Pending link does not reach the event's row") require.NoError(t, chromedp.Run(ctx, chromedp.KeyEvent(kb.Enter))) assert.True(t, hidden(ctx, expanded), "Enter does not collapse the event") diff --git a/internal/server/sentry.go b/internal/server/sentry.go index b02dc68..086c3a6 100644 --- a/internal/server/sentry.go +++ b/internal/server/sentry.go @@ -219,8 +219,8 @@ func keptSentryHeaders(headers map[string]string) map[string]string { // sentryKeepsHeader reports whether a request header is routing or // content metadata rather than client-chosen payload. Referer is kept // on the reasoning that it is browser-set, that the only query -// parameters in this service's own URLs are the login page's `next` -// and `notice`, and that Referrer-Policy is set to +// parameters in this service's own URLs are the login page's `next`, +// `notice` and the event log's `show`, and that Referrer-Policy is set to // strict-origin-when-cross-origin. X-Request-Id ties the event to the // local access log line, which holds the rest of the detail. func sentryKeepsHeader(name string) bool { diff --git a/static/css/tailwind.css b/static/css/tailwind.css index 0f2a07f..23eb6dd 100644 --- a/static/css/tailwind.css +++ b/static/css/tailwind.css @@ -1,2 +1,2 @@ /*! tailwindcss v4.2.1 | MIT License | https://tailwindcss.com */ -@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-space-y-reverse:0;--tw-divide-y-reverse:0;--tw-border-style:solid;--tw-font-weight:initial;--tw-tracking:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-duration:initial}}}@layer theme{:root,:host{--font-sans:ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--color-red-500:oklch(63.7% .237 25.331);--color-red-600:oklch(57.7% .245 27.325);--color-red-700:oklch(50.5% .213 27.518);--color-red-800:oklch(44.4% .177 26.899);--color-yellow-600:oklch(68.1% .162 75.834);--color-green-600:oklch(62.7% .194 149.214);--color-gray-50:oklch(98.5% .002 247.839);--color-gray-100:oklch(96.7% .003 264.542);--color-gray-200:oklch(92.8% .006 264.531);--color-gray-300:oklch(87.2% .01 258.338);--color-gray-400:oklch(70.7% .022 261.325);--color-gray-500:oklch(55.1% .027 264.364);--color-gray-600:oklch(44.6% .03 256.802);--color-gray-700:oklch(37.3% .034 259.733);--color-gray-900:oklch(21% .034 264.665);--color-white:#fff;--spacing:.25rem;--container-md:28rem;--container-2xl:42rem;--container-4xl:56rem;--container-6xl:72rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--text-xl:1.25rem;--text-xl--line-height:calc(1.75 / 1.25);--text-2xl:1.5rem;--text-2xl--line-height:calc(2 / 1.5);--text-3xl:1.875rem;--text-3xl--line-height:calc(2.25 / 1.875);--font-weight-light:300;--font-weight-medium:500;--tracking-wide:.025em;--radius-md:.375rem;--radius-lg:.5rem;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono);--color-primary-50:#e3f2fd;--color-primary-500:#2196f3;--color-primary-600:#1e88e5;--color-primary-700:#1976d2;--color-primary-800:#1565c0;--color-error-50:#ffebee;--color-error-500:#f44336;--color-error-700:#d32f2f;--color-success-50:#e8f5e9;--color-success-500:#4caf50;--color-success-700:#388e3c}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab, currentcolor 50%, transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}}@layer components{.btn-primary{border-radius:var(--radius-md);background-color:var(--color-primary-600);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 2);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);color:var(--color-white);--tw-shadow:0 1px 3px var(--tw-shadow-color,#0000001f), 0 1px 2px var(--tw-shadow-color,#0000003d);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));--tw-duration:.2s;justify-content:center;align-items:center;transition-duration:.2s;display:inline-flex}@media (hover:hover){.btn-primary:hover{background-color:var(--color-primary-700);--tw-shadow:0 3px 6px var(--tw-shadow-color,#00000029), 0 3px 6px var(--tw-shadow-color,#0000003b);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}}.btn-primary:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);--tw-ring-color:var(--color-primary-500);--tw-ring-offset-width:2px;--tw-ring-offset-shadow:var(--tw-ring-inset,) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-outline-style:none;outline-style:none}.btn-primary:active{background-color:var(--color-primary-800)}.btn-primary:disabled{cursor:not-allowed;opacity:.5}.btn-secondary{border-radius:var(--radius-md);border-style:var(--tw-border-style);border-width:1px;border-color:var(--color-gray-300);background-color:var(--color-white);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 2);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);color:var(--color-gray-700);transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));--tw-duration:.2s;justify-content:center;align-items:center;transition-duration:.2s;display:inline-flex}@media (hover:hover){.btn-secondary:hover{background-color:var(--color-gray-50)}}.btn-secondary:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);--tw-ring-color:var(--color-primary-500);--tw-ring-offset-width:2px;--tw-ring-offset-shadow:var(--tw-ring-inset,) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-outline-style:none;outline-style:none}.btn-secondary:active{background-color:var(--color-gray-100)}.btn-secondary:disabled{cursor:not-allowed;opacity:.5}.btn-danger{border-radius:var(--radius-md);background-color:var(--color-error-500);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 2);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);color:var(--color-white);--tw-shadow:0 1px 3px var(--tw-shadow-color,#0000001f), 0 1px 2px var(--tw-shadow-color,#0000003d);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));--tw-duration:.2s;justify-content:center;align-items:center;transition-duration:.2s;display:inline-flex}@media (hover:hover){.btn-danger:hover{background-color:var(--color-error-700);--tw-shadow:0 3px 6px var(--tw-shadow-color,#00000029), 0 3px 6px var(--tw-shadow-color,#0000003b);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}}.btn-danger:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);--tw-ring-color:var(--color-red-500);--tw-ring-offset-width:2px;--tw-ring-offset-shadow:var(--tw-ring-inset,) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-outline-style:none;outline-style:none}.btn-danger:active{background-color:var(--color-red-800)}.btn-danger:disabled{cursor:not-allowed;opacity:.5}.card{border-radius:var(--radius-lg);background-color:var(--color-white);--tw-shadow:0 1px 3px var(--tw-shadow-color,#0000001f), 0 1px 2px var(--tw-shadow-color,#0000003d);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);overflow:hidden}.card-elevated{border-radius:var(--radius-lg);background-color:var(--color-white);--tw-shadow:0 1px 3px var(--tw-shadow-color,#0000001f), 0 1px 2px var(--tw-shadow-color,#0000003d);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);transition-property:box-shadow;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));overflow:hidden}@media (hover:hover){.card-elevated:hover{--tw-shadow:0 3px 6px var(--tw-shadow-color,#00000029), 0 3px 6px var(--tw-shadow-color,#0000003b);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}}.input{border-radius:var(--radius-md);border-style:var(--tw-border-style);border-width:1px;border-color:var(--color-gray-300);width:100%;padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 3);color:var(--color-gray-900)}.input::placeholder{color:var(--color-gray-500)}.input{transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.input:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);--tw-ring-color:var(--color-primary-500);--tw-outline-style:none;border-color:#0000;outline-style:none}.label{margin-bottom:calc(var(--spacing) * 1);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);color:var(--color-gray-700);display:block}.form-group{margin-bottom:calc(var(--spacing) * 4)}.badge-success{background-color:var(--color-success-50);padding-inline:calc(var(--spacing) * 2.5);padding-block:calc(var(--spacing) * .5);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);color:var(--color-success-700);border-radius:3.40282e38px;align-items:center;display:inline-flex}.badge-error{background-color:var(--color-error-50);padding-inline:calc(var(--spacing) * 2.5);padding-block:calc(var(--spacing) * .5);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);color:var(--color-error-700);border-radius:3.40282e38px;align-items:center;display:inline-flex}.badge-info{background-color:var(--color-primary-50);padding-inline:calc(var(--spacing) * 2.5);padding-block:calc(var(--spacing) * .5);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);color:var(--color-primary-700);border-radius:3.40282e38px;align-items:center;display:inline-flex}.app-bar{background-color:var(--color-white);padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 4);--tw-shadow:0 1px 3px var(--tw-shadow-color,#0000001f), 0 1px 2px var(--tw-shadow-color,#0000003d);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.alert-error{margin-bottom:calc(var(--spacing) * 4);border-radius:var(--radius-md);border-style:var(--tw-border-style);border-width:1px;border-color:#f4433633}@supports (color:color-mix(in lab, red, red)){.alert-error{border-color:color-mix(in oklab, var(--color-error-500) 20%, transparent)}}.alert-error{background-color:var(--color-error-50);padding:calc(var(--spacing) * 4);color:var(--color-error-700)}.alert-success{margin-bottom:calc(var(--spacing) * 4);border-radius:var(--radius-md);border-style:var(--tw-border-style);border-width:1px;border-color:#4caf5033}@supports (color:color-mix(in lab, red, red)){.alert-success{border-color:color-mix(in oklab, var(--color-success-500) 20%, transparent)}}.alert-success{background-color:var(--color-success-50);padding:calc(var(--spacing) * 4);color:var(--color-success-700)}}@layer utilities{.start{inset-inline-start:var(--spacing)}.mx-1{margin-inline:calc(var(--spacing) * 1)}.mx-3{margin-inline:calc(var(--spacing) * 3)}.mx-auto{margin-inline:auto}.mt-1{margin-top:calc(var(--spacing) * 1)}.mt-2{margin-top:calc(var(--spacing) * 2)}.mt-3{margin-top:calc(var(--spacing) * 3)}.mt-4{margin-top:calc(var(--spacing) * 4)}.mt-6{margin-top:calc(var(--spacing) * 6)}.mt-8{margin-top:calc(var(--spacing) * 8)}.mr-1{margin-right:calc(var(--spacing) * 1)}.mr-2{margin-right:calc(var(--spacing) * 2)}.mr-4{margin-right:calc(var(--spacing) * 4)}.mb-1{margin-bottom:calc(var(--spacing) * 1)}.mb-2{margin-bottom:calc(var(--spacing) * 2)}.mb-3{margin-bottom:calc(var(--spacing) * 3)}.mb-4{margin-bottom:calc(var(--spacing) * 4)}.mb-6{margin-bottom:calc(var(--spacing) * 6)}.mb-8{margin-bottom:calc(var(--spacing) * 8)}.block{display:block}.flex{display:flex}.grid{display:grid}.hidden{display:none}.inline{display:inline}.h-3{height:calc(var(--spacing) * 3)}.h-4{height:calc(var(--spacing) * 4)}.h-5{height:calc(var(--spacing) * 5)}.h-6{height:calc(var(--spacing) * 6)}.h-16{height:calc(var(--spacing) * 16)}.min-h-screen{min-height:100vh}.w-3{width:calc(var(--spacing) * 3)}.w-4{width:calc(var(--spacing) * 4)}.w-5{width:calc(var(--spacing) * 5)}.w-6{width:calc(var(--spacing) * 6)}.w-16{width:calc(var(--spacing) * 16)}.w-24{width:calc(var(--spacing) * 24)}.w-28{width:calc(var(--spacing) * 28)}.w-32{width:calc(var(--spacing) * 32)}.w-full{width:100%}.max-w-2xl{max-width:var(--container-2xl)}.max-w-4xl{max-width:var(--container-4xl)}.max-w-6xl{max-width:var(--container-6xl)}.max-w-md{max-width:var(--container-md)}.flex-1{flex:1}.flex-shrink-0{flex-shrink:0}.flex-grow{flex-grow:1}.rotate-180{rotate:180deg}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-center{align-items:center}.items-start{align-items:flex-start}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.gap-2{gap:calc(var(--spacing) * 2)}.gap-3{gap:calc(var(--spacing) * 3)}.gap-4{gap:calc(var(--spacing) * 4)}.gap-6{gap:calc(var(--spacing) * 6)}:where(.space-y-2>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 2) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-3>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 3) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 3) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-6>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 6) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 6) * calc(1 - var(--tw-space-y-reverse)))}:where(.divide-y>:not(:last-child)){--tw-divide-y-reverse:0;border-bottom-style:var(--tw-border-style);border-top-style:var(--tw-border-style);border-top-width:calc(1px * var(--tw-divide-y-reverse));border-bottom-width:calc(1px * calc(1 - var(--tw-divide-y-reverse)))}:where(.divide-gray-100>:not(:last-child)){border-color:var(--color-gray-100)}:where(.divide-gray-200>:not(:last-child)){border-color:var(--color-gray-200)}.overflow-x-auto{overflow-x:auto}.rounded-md{border-radius:var(--radius-md)}.border{border-style:var(--tw-border-style);border-width:1px}.border-t{border-top-style:var(--tw-border-style);border-top-width:1px}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-gray-200{border-color:var(--color-gray-200)}.bg-gray-50{background-color:var(--color-gray-50)}.bg-gray-100{background-color:var(--color-gray-100)}.bg-white{background-color:var(--color-white)}.p-2{padding:calc(var(--spacing) * 2)}.p-3{padding:calc(var(--spacing) * 3)}.p-4{padding:calc(var(--spacing) * 4)}.p-6{padding:calc(var(--spacing) * 6)}.p-8{padding:calc(var(--spacing) * 8)}.p-12{padding:calc(var(--spacing) * 12)}.px-4{padding-inline:calc(var(--spacing) * 4)}.px-6{padding-inline:calc(var(--spacing) * 6)}.px-8{padding-inline:calc(var(--spacing) * 8)}.py-2{padding-block:calc(var(--spacing) * 2)}.py-3{padding-block:calc(var(--spacing) * 3)}.py-6{padding-block:calc(var(--spacing) * 6)}.py-8{padding-block:calc(var(--spacing) * 8)}.py-12{padding-block:calc(var(--spacing) * 12)}.pt-3{padding-top:calc(var(--spacing) * 3)}.pt-4{padding-top:calc(var(--spacing) * 4)}.text-center{text-align:center}.text-left{text-align:left}.font-mono{font-family:var(--font-mono)}.text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}.text-3xl{font-size:var(--text-3xl);line-height:var(--tw-leading,var(--text-3xl--line-height))}.text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xl{font-size:var(--text-xl);line-height:var(--tw-leading,var(--text-xl--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.font-light{--tw-font-weight:var(--font-weight-light);font-weight:var(--font-weight-light)}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.tracking-wide{--tw-tracking:var(--tracking-wide);letter-spacing:var(--tracking-wide)}.break-all{word-break:break-all}.whitespace-pre-wrap{white-space:pre-wrap}.text-gray-300{color:var(--color-gray-300)}.text-gray-400{color:var(--color-gray-400)}.text-gray-500{color:var(--color-gray-500)}.text-gray-600{color:var(--color-gray-600)}.text-gray-700{color:var(--color-gray-700)}.text-gray-900{color:var(--color-gray-900)}.text-green-600{color:var(--color-green-600)}.text-primary-500{color:var(--color-primary-500)}.text-red-600{color:var(--color-red-600)}.text-red-700{color:var(--color-red-700)}.text-yellow-600{color:var(--color-yellow-600)}.uppercase{text-transform:uppercase}.shadow-\[0_-4px_6px_-1px_rgba\(0\,0\,0\,0\.1\)\]{--tw-shadow:0 -4px 6px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.transition-transform{transition-property:transform,translate,scale,rotate;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}@media (min-width:48rem){.md\:flex{display:flex}.md\:hidden{display:none}}@media (min-width:64rem){.lg\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}}}@property --tw-space-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-divide-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-duration{syntax:"*";inherits:false} \ No newline at end of file +@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-space-y-reverse:0;--tw-divide-y-reverse:0;--tw-border-style:solid;--tw-font-weight:initial;--tw-tracking:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-duration:initial}}}@layer theme{:root,:host{--font-sans:ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--color-red-500:oklch(63.7% .237 25.331);--color-red-600:oklch(57.7% .245 27.325);--color-red-700:oklch(50.5% .213 27.518);--color-red-800:oklch(44.4% .177 26.899);--color-yellow-600:oklch(68.1% .162 75.834);--color-green-600:oklch(62.7% .194 149.214);--color-gray-50:oklch(98.5% .002 247.839);--color-gray-100:oklch(96.7% .003 264.542);--color-gray-200:oklch(92.8% .006 264.531);--color-gray-300:oklch(87.2% .01 258.338);--color-gray-400:oklch(70.7% .022 261.325);--color-gray-500:oklch(55.1% .027 264.364);--color-gray-600:oklch(44.6% .03 256.802);--color-gray-700:oklch(37.3% .034 259.733);--color-gray-900:oklch(21% .034 264.665);--color-white:#fff;--spacing:.25rem;--container-md:28rem;--container-2xl:42rem;--container-4xl:56rem;--container-6xl:72rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--text-xl:1.25rem;--text-xl--line-height:calc(1.75 / 1.25);--text-2xl:1.5rem;--text-2xl--line-height:calc(2 / 1.5);--text-3xl:1.875rem;--text-3xl--line-height:calc(2.25 / 1.875);--font-weight-light:300;--font-weight-medium:500;--tracking-wide:.025em;--radius-md:.375rem;--radius-lg:.5rem;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono);--color-primary-50:#e3f2fd;--color-primary-500:#2196f3;--color-primary-600:#1e88e5;--color-primary-700:#1976d2;--color-primary-800:#1565c0;--color-error-50:#ffebee;--color-error-500:#f44336;--color-error-700:#d32f2f;--color-success-50:#e8f5e9;--color-success-500:#4caf50;--color-success-700:#388e3c}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab, currentcolor 50%, transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}}@layer components{.btn-primary{border-radius:var(--radius-md);background-color:var(--color-primary-600);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 2);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);color:var(--color-white);--tw-shadow:0 1px 3px var(--tw-shadow-color,#0000001f), 0 1px 2px var(--tw-shadow-color,#0000003d);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));--tw-duration:.2s;justify-content:center;align-items:center;transition-duration:.2s;display:inline-flex}@media (hover:hover){.btn-primary:hover{background-color:var(--color-primary-700);--tw-shadow:0 3px 6px var(--tw-shadow-color,#00000029), 0 3px 6px var(--tw-shadow-color,#0000003b);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}}.btn-primary:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);--tw-ring-color:var(--color-primary-500);--tw-ring-offset-width:2px;--tw-ring-offset-shadow:var(--tw-ring-inset,) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-outline-style:none;outline-style:none}.btn-primary:active{background-color:var(--color-primary-800)}.btn-primary:disabled{cursor:not-allowed;opacity:.5}.btn-secondary{border-radius:var(--radius-md);border-style:var(--tw-border-style);border-width:1px;border-color:var(--color-gray-300);background-color:var(--color-white);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 2);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);color:var(--color-gray-700);transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));--tw-duration:.2s;justify-content:center;align-items:center;transition-duration:.2s;display:inline-flex}@media (hover:hover){.btn-secondary:hover{background-color:var(--color-gray-50)}}.btn-secondary:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);--tw-ring-color:var(--color-primary-500);--tw-ring-offset-width:2px;--tw-ring-offset-shadow:var(--tw-ring-inset,) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-outline-style:none;outline-style:none}.btn-secondary:active{background-color:var(--color-gray-100)}.btn-secondary:disabled{cursor:not-allowed;opacity:.5}.btn-danger{border-radius:var(--radius-md);background-color:var(--color-error-500);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 2);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);color:var(--color-white);--tw-shadow:0 1px 3px var(--tw-shadow-color,#0000001f), 0 1px 2px var(--tw-shadow-color,#0000003d);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));--tw-duration:.2s;justify-content:center;align-items:center;transition-duration:.2s;display:inline-flex}@media (hover:hover){.btn-danger:hover{background-color:var(--color-error-700);--tw-shadow:0 3px 6px var(--tw-shadow-color,#00000029), 0 3px 6px var(--tw-shadow-color,#0000003b);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}}.btn-danger:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);--tw-ring-color:var(--color-red-500);--tw-ring-offset-width:2px;--tw-ring-offset-shadow:var(--tw-ring-inset,) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-outline-style:none;outline-style:none}.btn-danger:active{background-color:var(--color-red-800)}.btn-danger:disabled{cursor:not-allowed;opacity:.5}.card{border-radius:var(--radius-lg);background-color:var(--color-white);--tw-shadow:0 1px 3px var(--tw-shadow-color,#0000001f), 0 1px 2px var(--tw-shadow-color,#0000003d);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);overflow:hidden}.card-elevated{border-radius:var(--radius-lg);background-color:var(--color-white);--tw-shadow:0 1px 3px var(--tw-shadow-color,#0000001f), 0 1px 2px var(--tw-shadow-color,#0000003d);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);transition-property:box-shadow;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));overflow:hidden}@media (hover:hover){.card-elevated:hover{--tw-shadow:0 3px 6px var(--tw-shadow-color,#00000029), 0 3px 6px var(--tw-shadow-color,#0000003b);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}}.input{border-radius:var(--radius-md);border-style:var(--tw-border-style);border-width:1px;border-color:var(--color-gray-300);width:100%;padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 3);color:var(--color-gray-900)}.input::placeholder{color:var(--color-gray-500)}.input{transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.input:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);--tw-ring-color:var(--color-primary-500);--tw-outline-style:none;border-color:#0000;outline-style:none}.label{margin-bottom:calc(var(--spacing) * 1);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);color:var(--color-gray-700);display:block}.form-group{margin-bottom:calc(var(--spacing) * 4)}.badge-success{background-color:var(--color-success-50);padding-inline:calc(var(--spacing) * 2.5);padding-block:calc(var(--spacing) * .5);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);color:var(--color-success-700);border-radius:3.40282e38px;align-items:center;display:inline-flex}.badge-error{background-color:var(--color-error-50);padding-inline:calc(var(--spacing) * 2.5);padding-block:calc(var(--spacing) * .5);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);color:var(--color-error-700);border-radius:3.40282e38px;align-items:center;display:inline-flex}.badge-info{background-color:var(--color-primary-50);padding-inline:calc(var(--spacing) * 2.5);padding-block:calc(var(--spacing) * .5);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);color:var(--color-primary-700);border-radius:3.40282e38px;align-items:center;display:inline-flex}.app-bar{background-color:var(--color-white);padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 4);--tw-shadow:0 1px 3px var(--tw-shadow-color,#0000001f), 0 1px 2px var(--tw-shadow-color,#0000003d);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.alert-error{margin-bottom:calc(var(--spacing) * 4);border-radius:var(--radius-md);border-style:var(--tw-border-style);border-width:1px;border-color:#f4433633}@supports (color:color-mix(in lab, red, red)){.alert-error{border-color:color-mix(in oklab, var(--color-error-500) 20%, transparent)}}.alert-error{background-color:var(--color-error-50);padding:calc(var(--spacing) * 4);color:var(--color-error-700)}.alert-success{margin-bottom:calc(var(--spacing) * 4);border-radius:var(--radius-md);border-style:var(--tw-border-style);border-width:1px;border-color:#4caf5033}@supports (color:color-mix(in lab, red, red)){.alert-success{border-color:color-mix(in oklab, var(--color-success-500) 20%, transparent)}}.alert-success{background-color:var(--color-success-50);padding:calc(var(--spacing) * 4);color:var(--color-success-700)}}@layer utilities{.start{inset-inline-start:var(--spacing)}.mx-1{margin-inline:calc(var(--spacing) * 1)}.mx-3{margin-inline:calc(var(--spacing) * 3)}.mx-auto{margin-inline:auto}.mt-1{margin-top:calc(var(--spacing) * 1)}.mt-2{margin-top:calc(var(--spacing) * 2)}.mt-3{margin-top:calc(var(--spacing) * 3)}.mt-4{margin-top:calc(var(--spacing) * 4)}.mt-6{margin-top:calc(var(--spacing) * 6)}.mt-8{margin-top:calc(var(--spacing) * 8)}.mr-1{margin-right:calc(var(--spacing) * 1)}.mr-2{margin-right:calc(var(--spacing) * 2)}.mr-4{margin-right:calc(var(--spacing) * 4)}.mb-1{margin-bottom:calc(var(--spacing) * 1)}.mb-2{margin-bottom:calc(var(--spacing) * 2)}.mb-3{margin-bottom:calc(var(--spacing) * 3)}.mb-4{margin-bottom:calc(var(--spacing) * 4)}.mb-6{margin-bottom:calc(var(--spacing) * 6)}.mb-8{margin-bottom:calc(var(--spacing) * 8)}.block{display:block}.flex{display:flex}.grid{display:grid}.hidden{display:none}.inline{display:inline}.h-3{height:calc(var(--spacing) * 3)}.h-4{height:calc(var(--spacing) * 4)}.h-5{height:calc(var(--spacing) * 5)}.h-6{height:calc(var(--spacing) * 6)}.h-16{height:calc(var(--spacing) * 16)}.min-h-screen{min-height:100vh}.w-3{width:calc(var(--spacing) * 3)}.w-4{width:calc(var(--spacing) * 4)}.w-5{width:calc(var(--spacing) * 5)}.w-6{width:calc(var(--spacing) * 6)}.w-16{width:calc(var(--spacing) * 16)}.w-24{width:calc(var(--spacing) * 24)}.w-28{width:calc(var(--spacing) * 28)}.w-32{width:calc(var(--spacing) * 32)}.w-full{width:100%}.max-w-2xl{max-width:var(--container-2xl)}.max-w-4xl{max-width:var(--container-4xl)}.max-w-6xl{max-width:var(--container-6xl)}.max-w-md{max-width:var(--container-md)}.flex-1{flex:1}.flex-shrink-0{flex-shrink:0}.flex-grow{flex-grow:1}.rotate-180{rotate:180deg}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-center{align-items:center}.items-start{align-items:flex-start}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.gap-2{gap:calc(var(--spacing) * 2)}.gap-3{gap:calc(var(--spacing) * 3)}.gap-4{gap:calc(var(--spacing) * 4)}.gap-6{gap:calc(var(--spacing) * 6)}:where(.space-y-2>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 2) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-3>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 3) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 3) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-6>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 6) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 6) * calc(1 - var(--tw-space-y-reverse)))}:where(.divide-y>:not(:last-child)){--tw-divide-y-reverse:0;border-bottom-style:var(--tw-border-style);border-top-style:var(--tw-border-style);border-top-width:calc(1px * var(--tw-divide-y-reverse));border-bottom-width:calc(1px * calc(1 - var(--tw-divide-y-reverse)))}:where(.divide-gray-100>:not(:last-child)){border-color:var(--color-gray-100)}:where(.divide-gray-200>:not(:last-child)){border-color:var(--color-gray-200)}.overflow-x-auto{overflow-x:auto}.rounded-md{border-radius:var(--radius-md)}.border{border-style:var(--tw-border-style);border-width:1px}.border-t{border-top-style:var(--tw-border-style);border-top-width:1px}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-gray-200{border-color:var(--color-gray-200)}.border-primary-500{border-color:var(--color-primary-500)}.bg-gray-50{background-color:var(--color-gray-50)}.bg-gray-100{background-color:var(--color-gray-100)}.bg-primary-50{background-color:var(--color-primary-50)}.bg-white{background-color:var(--color-white)}.p-2{padding:calc(var(--spacing) * 2)}.p-3{padding:calc(var(--spacing) * 3)}.p-4{padding:calc(var(--spacing) * 4)}.p-6{padding:calc(var(--spacing) * 6)}.p-8{padding:calc(var(--spacing) * 8)}.p-12{padding:calc(var(--spacing) * 12)}.px-4{padding-inline:calc(var(--spacing) * 4)}.px-6{padding-inline:calc(var(--spacing) * 6)}.px-8{padding-inline:calc(var(--spacing) * 8)}.py-2{padding-block:calc(var(--spacing) * 2)}.py-3{padding-block:calc(var(--spacing) * 3)}.py-6{padding-block:calc(var(--spacing) * 6)}.py-8{padding-block:calc(var(--spacing) * 8)}.py-12{padding-block:calc(var(--spacing) * 12)}.pt-3{padding-top:calc(var(--spacing) * 3)}.pt-4{padding-top:calc(var(--spacing) * 4)}.text-center{text-align:center}.text-left{text-align:left}.font-mono{font-family:var(--font-mono)}.text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}.text-3xl{font-size:var(--text-3xl);line-height:var(--tw-leading,var(--text-3xl--line-height))}.text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xl{font-size:var(--text-xl);line-height:var(--tw-leading,var(--text-xl--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.font-light{--tw-font-weight:var(--font-weight-light);font-weight:var(--font-weight-light)}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.tracking-wide{--tw-tracking:var(--tracking-wide);letter-spacing:var(--tracking-wide)}.break-all{word-break:break-all}.whitespace-pre-wrap{white-space:pre-wrap}.text-gray-300{color:var(--color-gray-300)}.text-gray-400{color:var(--color-gray-400)}.text-gray-500{color:var(--color-gray-500)}.text-gray-600{color:var(--color-gray-600)}.text-gray-700{color:var(--color-gray-700)}.text-gray-900{color:var(--color-gray-900)}.text-green-600{color:var(--color-green-600)}.text-primary-500{color:var(--color-primary-500)}.text-red-600{color:var(--color-red-600)}.text-red-700{color:var(--color-red-700)}.text-yellow-600{color:var(--color-yellow-600)}.uppercase{text-transform:uppercase}.shadow-\[0_-4px_6px_-1px_rgba\(0\,0\,0\,0\.1\)\]{--tw-shadow:0 -4px 6px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.filter{filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.transition-transform{transition-property:transform,translate,scale,rotate;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}@media (min-width:48rem){.md\:flex{display:flex}.md\:hidden{display:none}}@media (min-width:64rem){.lg\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}}}@property --tw-space-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-divide-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-blur{syntax:"*";inherits:false}@property --tw-brightness{syntax:"*";inherits:false}@property --tw-contrast{syntax:"*";inherits:false}@property --tw-grayscale{syntax:"*";inherits:false}@property --tw-hue-rotate{syntax:"*";inherits:false}@property --tw-invert{syntax:"*";inherits:false}@property --tw-opacity{syntax:"*";inherits:false}@property --tw-saturate{syntax:"*";inherits:false}@property --tw-sepia{syntax:"*";inherits:false}@property --tw-drop-shadow{syntax:"*";inherits:false}@property --tw-drop-shadow-color{syntax:"*";inherits:false}@property --tw-drop-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false}@property --tw-duration{syntax:"*";inherits:false} \ No newline at end of file diff --git a/templates/source_logs.html b/templates/source_logs.html index 07a88b1..d5719e4 100644 --- a/templates/source_logs.html +++ b/templates/source_logs.html @@ -8,7 +8,14 @@ ← Back to {{.Webhook.Name}}

Full Event Log

- {{if gt .TotalEvents (len .Events)}}{{len .Events}} most recent of {{.TotalEvents}} events{{else}}{{.TotalEvents}} total event{{if ne .TotalEvents 1}}s{{end}}{{end}} + + {{if gt .TotalEvents (len .Events)}}{{len .Events}} most recent of {{.TotalEvents}} events{{else}}{{.TotalEvents}}{{if not .Show}} total{{end}} event{{if ne .TotalEvents 1}}s{{end}}{{end}}{{if eq .Show "failed"}} with a failed delivery{{else if eq .Show "pending"}} with a delivery pending or retrying{{end}} +
+ +
@@ -76,6 +83,8 @@ {{if and .Status.Terminal (not .Target.Deleted)}}
+ +
{{end}} @@ -92,7 +101,7 @@
{{else}} -
No events recorded yet.
+
{{if eq $.Show "failed"}}No event has a failed delivery.{{else if eq $.Show "pending"}}No event has a delivery pending or retrying.{{else}}No events recorded yet.{{end}}
{{end}}
-- 2.54.0 From af91d8a72339985c97a354a9c794a8ebb0a1bdf5 Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Sat, 3 Oct 2026 06:19:46 +0200 Subject: [PATCH 091/126] Split source_management.go along its CRUD seams (closes #274) `internal/handlers/source_management.go` had grown to about 2,370 lines holding the webhook, event log, entrypoint and target handlers, so unrelated units had to wait on each other to touch it. It is split, as pure code movement, into files named for what they hold: `webhook_list.go`, `webhook_create.go`, `webhook_detail.go`, `webhook_edit.go`, `webhook_delete.go`, `event_log.go`, `entrypoint.go`, `target_create.go`, `target_delete.go`, `target_toggle.go` and `shared.go`. No function body, signature, comment or behaviour changed. The README's file tree and log-line caveat, and one middleware comment, name the new files. Model: opus-5-5 --- README.md | 17 +- internal/handlers/entrypoint.go | 169 ++ internal/handlers/event_log.go | 620 +++++++ internal/handlers/shared.go | 230 +++ internal/handlers/source_management.go | 2371 ------------------------ internal/handlers/target_create.go | 384 ++++ internal/handlers/target_delete.go | 31 + internal/handlers/target_toggle.go | 35 + internal/handlers/webhook_create.go | 263 +++ internal/handlers/webhook_delete.go | 170 ++ internal/handlers/webhook_detail.go | 133 ++ internal/handlers/webhook_edit.go | 245 +++ internal/handlers/webhook_list.go | 178 ++ internal/middleware/middleware.go | 5 +- 14 files changed, 2475 insertions(+), 2376 deletions(-) create mode 100644 internal/handlers/entrypoint.go create mode 100644 internal/handlers/event_log.go create mode 100644 internal/handlers/shared.go delete mode 100644 internal/handlers/source_management.go create mode 100644 internal/handlers/target_create.go create mode 100644 internal/handlers/target_delete.go create mode 100644 internal/handlers/target_toggle.go create mode 100644 internal/handlers/webhook_create.go create mode 100644 internal/handlers/webhook_delete.go create mode 100644 internal/handlers/webhook_detail.go create mode 100644 internal/handlers/webhook_edit.go create mode 100644 internal/handlers/webhook_list.go diff --git a/README.md b/README.md index 8b0197c..d1e32ca 100644 --- a/README.md +++ b/README.md @@ -2840,8 +2840,9 @@ read as more than it is: - **Lines carrying an authenticated operator's own input**, which are not truncated at all. `webhook created` logs the submitted `name` - verbatim and `target URL blocked by SSRF protection` logs the target - host (both `internal/handlers/source_management.go`), as do the + verbatim (`internal/handlers/webhook_create.go`) and + `target URL blocked by SSRF protection` logs the target host + (`internal/handlers/target_create.go`), as do the `target_name` lines in `internal/delivery/engine.go` and `internal/delivery/target_http.go`. The only bound on any of them is the 1 MB form body cap, so a 100 KB `name` writes a single line of @@ -3220,7 +3221,17 @@ webhooker/ │ │ ├── index.go # Index page handler │ │ ├── profile.go # User profile handler │ │ ├── settings.go # Read-only Settings page handler -│ │ ├── source_management.go # Webhook CRUD handlers +│ │ ├── webhook_list.go # Webhook list page +│ │ ├── webhook_create.go # Webhook create +│ │ ├── webhook_detail.go # Webhook detail page +│ │ ├── webhook_edit.go # Webhook edit, archive renaming +│ │ ├── webhook_delete.go # Webhook delete, event database and archive writer removal +│ │ ├── event_log.go # Event log page: loaders, filters, delivery views +│ │ ├── entrypoint.go # Entrypoint create, edit, delete and toggle +│ │ ├── target_create.go # Target create, per-type config builders +│ │ ├── target_delete.go # Target delete +│ │ ├── target_toggle.go # Target toggle +│ │ ├── shared.go # Helpers shared by several handlers │ │ └── webhook.go # Webhook receiver handler │ ├── healthcheck/ │ │ └── healthcheck.go # Health check service (uptime, version) diff --git a/internal/handlers/entrypoint.go b/internal/handlers/entrypoint.go new file mode 100644 index 0000000..6faac72 --- /dev/null +++ b/internal/handlers/entrypoint.go @@ -0,0 +1,169 @@ +package handlers + +import ( + "net/http" + + "github.com/go-chi/chi" + "github.com/google/uuid" + "sneak.berlin/go/webhooker/internal/database" +) + +// HandleEntrypointCreate handles adding a new entrypoint. +func (h *Handlers) HandleEntrypointCreate() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + userID, ok := h.getUserID(r) + if !ok { + http.Redirect( + w, r, "/pages/login", http.StatusSeeOther, + ) + + return + } + + sourceID := chi.URLParam(r, "sourceID") + + var webhook database.Webhook + + err := h.db.DB().Where( + "id = ? AND user_id = ?", sourceID, userID, + ).First(&webhook).Error + if err != nil { + h.renderError(w, r, http.StatusNotFound) + + return + } + + // The body size cap is enforced by the MaxBodySize + // middleware, which runs before CSRF parses the form. + err = r.ParseForm() + if err != nil { + h.renderError(w, r, http.StatusBadRequest) + + return + } + + description := r.PostFormValue("description") + + entrypoint := &database.Entrypoint{ + WebhookID: webhook.ID, + Path: uuid.New().String(), + Description: description, + Active: true, + } + + err = h.db.DB().Create(entrypoint).Error + if err != nil { + h.serverError(w, r, "failed to create entrypoint", err) + + return + } + + http.Redirect( + w, r, withNotice("/hook/"+webhook.ID, entrypointAdded), + http.StatusSeeOther, + ) + } +} + +// HandleEntrypointEdit handles changing an entrypoint's description. +// It writes only the description column, so the entrypoint keeps its +// URL, and an activate or deactivate saved since the page was shown +// is not undone. +func (h *Handlers) HandleEntrypointEdit() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + userID, ok := h.getUserID(r) + if !ok { + http.Redirect( + w, r, "/pages/login", http.StatusSeeOther, + ) + + return + } + + sourceID := chi.URLParam(r, "sourceID") + entrypointID := chi.URLParam(r, "entrypointID") + + var webhook database.Webhook + + err := h.db.DB().Where( + "id = ? AND user_id = ?", sourceID, userID, + ).First(&webhook).Error + if err != nil { + h.renderError(w, r, http.StatusNotFound) + + return + } + + // The body size cap is enforced by the MaxBodySize + // middleware, which runs before CSRF parses the form. + err = r.ParseForm() + if err != nil { + h.renderError(w, r, http.StatusBadRequest) + + return + } + + result := h.db.DB().Model(&database.Entrypoint{}).Where( + "id = ? AND webhook_id = ?", entrypointID, webhook.ID, + ).Update("description", r.PostFormValue("description")) + if result.Error != nil { + h.serverError( + w, r, "failed to edit entrypoint", result.Error, + ) + + return + } + + // The id came from the URL and may name another webhook's + // entrypoint, which this webhook does not have. + if result.RowsAffected == 0 { + h.renderError(w, r, http.StatusNotFound) + + return + } + + http.Redirect( + w, r, withNotice("/hook/"+webhook.ID, entrypointSaved), + http.StatusSeeOther, + ) + } +} + +// HandleEntrypointDelete handles deleting an entrypoint. +func (h *Handlers) HandleEntrypointDelete() http.HandlerFunc { + return h.deleteChildResource( + "entrypointID", &database.Entrypoint{}, + "failed to delete entrypoint", + nil, + entrypointDeleted, + ) +} + +// HandleEntrypointToggle handles toggling an entrypoint's +// active state. +func (h *Handlers) HandleEntrypointToggle() http.HandlerFunc { + return h.toggleChildResource( + "entrypointID", + func(webhookID, childID string) (bool, error) { + var ep database.Entrypoint + + err := h.db.DB().Where( + "id = ? AND webhook_id = ?", + childID, webhookID, + ).First(&ep).Error + if err != nil { + return false, err + } + + // Only the active column: saving the whole row would + // write back the description read above over an edit + // saved since. + active := !ep.Active + + return active, h.db.DB().Model(&ep). + Update("active", active).Error + }, + "failed to toggle entrypoint", + entrypointActivated, entrypointDeactivated, + ) +} diff --git a/internal/handlers/event_log.go b/internal/handlers/event_log.go new file mode 100644 index 0000000..2cad0dc --- /dev/null +++ b/internal/handlers/event_log.go @@ -0,0 +1,620 @@ +package handlers + +import ( + "net/http" + "slices" + "time" + + "github.com/dustin/go-humanize" + "gorm.io/gorm" + "sneak.berlin/go/webhooker/internal/database" + "sneak.berlin/go/webhooker/internal/delivery" +) + +// DeliveryView is the display-safe projection of a delivery +// for the event log page. Its target is a TargetView, so the +// stored configuration blob — which holds the target's +// credential — has no path to the template. +type DeliveryView struct { + ID string + Status database.DeliveryStatus + Target delivery.TargetView + + // Replay is set on a delivery the Replay action created. + Replay bool + + // Created is how long ago the delivery was created, and + // CreatedUTC the full timestamp the page shows on hover. + Created string + CreatedUTC string + + // Results is this delivery's attempts in attempt order, + // bounded by maxRenderedAttempts. Without them a failure + // renders as the status word alone and says nothing about + // why. + Results []DeliveryResultView + + // AttemptCount is how many attempts were recorded, which + // is more than len(Results) once the middle was dropped. + AttemptCount int + + // AttemptsOmitted is how many attempts were dropped from + // the middle of Results. The page must show it, or the + // bound would hide history rather than fold it. + AttemptsOmitted int + + // Paused is set while the delivery is retrying and its + // target's circuit breaker is open, and nil otherwise. + Paused *PausedView +} + +// eventLogTarget is what the event log needs to know about +// one target: the display-safe view its template renders, and +// the redactor that keeps that target's own credential out of +// the text its remote peer chose. The two are kept together +// so a caller cannot pick up one without the other, and apart +// from TargetView so the secrets never reach a template. +type eventLogTarget struct { + View delivery.TargetView + Redactor delivery.Redactor +} + +// The event log's show query parameter and its two values: the events +// with a failed delivery, and those with a delivery still pending or +// retrying. +const ( + showParam = "show" + showFailed = "failed" + showPending = "pending" +) + +// HandleSourceLogs shows the request/response logs for a +// webhook. +func (h *Handlers) HandleSourceLogs() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + webhook, ok := h.ownedWebhook(w, r) + if !ok { + return + } + + targets, err := h.loadTargetMap(webhook.ID) + if err != nil { + // Without the map every delivery renders through a + // zero redactor, so failing the page is the only + // safe answer. + h.serverError(w, r, "failed to load targets", err) + + return + } + + // Any other value of show lists every event, as no value + // does. + show := r.URL.Query().Get(showParam) + + statuses := eventLogStatuses(show) + if statuses == nil { + show = "" + } + + evts, total, ok := h.loadEventsWithDeliveries( + w, r, webhook, targets, statuses, + ) + if !ok { + return + } + + failed, pending, err := h.countFailedAndPendingEvents(webhook.ID) + if err != nil { + h.serverError(w, r, "failed to count events", err) + + return + } + + data := map[string]any{ + tmplKeyWebhook: &webhook, + "Events": evts, + "TotalEvents": total, + "Show": show, + "FailedEvents": failed, + "PendingEvents": pending, + } + + h.renderTemplate(w, r, "source_logs.html", data) + } +} + +// loadTargetMap loads targets into a map of display-safe +// views keyed by target ID, each paired with its redactor. +// The projection happens here so that no caller can hand a +// raw target, configuration blob and all, to a template: the +// raw rows do not leave this function. +// +// The load is Unscoped because deleting a target only soft +// deletes the row while its deliveries survive in the +// per-webhook database. Both halves of the map need those rows: +// a scoped load leaves an old delivery with a zero redactor, +// which renders its response bodies unredacted, and with a zero +// view, which renders its target as a blank name. +// +// This map is historical display only. It is built for the event +// log and an event's own page, and reaches nothing but +// DeliveryView.Target: the target list on the source detail page, +// the edit form and the replay path each resolve targets +// themselves, and a deleted row is refused there as before. +func (h *Handlers) loadTargetMap( + webhookID string, +) (map[string]eventLogTarget, error) { + var targets []database.Target + + err := h.db.DB().Unscoped().Where( + "webhook_id = ?", webhookID, + ).Find(&targets).Error + if err != nil { + return nil, err + } + + targetMap := make( + map[string]eventLogTarget, len(targets), + ) + + for i := range targets { + targetMap[targets[i].ID] = eventLogTarget{ + Redactor: delivery.NewRedactor(&targets[i]), + } + } + + // The views come from NewTargetViews rather than being + // rebuilt here, so the masking rules stay in one place and a + // deleted target's configuration is masked by the same code + // that masks a live one's. + for _, v := range delivery.NewTargetViews(targets) { + entry := targetMap[v.ID] + entry.View = v + targetMap[v.ID] = entry + } + + return targetMap, nil +} + +// loadEventsWithDeliveries loads the recentEventLimit newest events +// and their deliveries from the per-webhook database, and the total +// number of events stored. Given delivery statuses, both cover only +// the events with a delivery in one of them. Events come back as +// capped projections rather than database.Event rows: see +// eventLogColumns for why the cut happens in SQL. +// +// The bool reports whether the load succeeded. It is false +// once this has answered the request with an error, and the +// caller must then render nothing further. +func (h *Handlers) loadEventsWithDeliveries( + w http.ResponseWriter, + r *http.Request, + webhook database.Webhook, + targetMap map[string]eventLogTarget, + statuses []database.DeliveryStatus, +) ([]EventLogView, int64, bool) { + if !h.dbMgr.DBExists(webhook.ID) { + return nil, 0, true + } + + webhookDB, err := h.dbMgr.GetDB(webhook.ID) + if err != nil { + h.serverError( + w, r, "failed to get webhook database", err, + ) + + return nil, 0, false + } + + rows, totalEvents, err := loadEventLogRows( + webhookDB, webhook.ID, statuses, + ) + if err != nil { + h.serverError(w, r, "failed to load events", err) + + return nil, 0, false + } + + result, ok := h.eventLogViews( + w, r, webhookDB, webhook.ID, rows, targetMap, + maxRenderedBodyBytes, + ) + + return result, totalEvents, ok +} + +// eventLogViews projects loaded events for rendering, each with +// its deliveries, how many times it has been resubmitted and the +// entrypoint it arrived at (for a resubmitted copy, the one the +// request it copies arrived at), and with its request headers only +// when their text holds at most maxHeaderBytes. 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, + maxHeaderBytes int, +) ([]EventLogView, bool) { + result := make([]EventLogView, len(rows)) + eventDeliveries := make([][]database.Delivery, len(rows)) + + var deliveryIDs []string + + eventIDs := make([]string, len(rows)) + + for i := range rows { + result[i] = rows[i].view(webhookID, maxHeaderBytes) + eventIDs[i] = rows[i].ID + + webhookDB.Where( + "event_id = ?", rows[i].ID, + ).Find(&eventDeliveries[i]) + + for j := range eventDeliveries[i] { + deliveryIDs = append( + deliveryIDs, eventDeliveries[i][j].ID, + ) + } + } + + attempts, err := h.loadDeliveryResults( + webhookDB, deliveryIDs, + ) + if err != nil { + h.serverError( + w, r, "failed to load delivery attempts", err, + ) + + return nil, false + } + + resubmits, err := resubmitCounts(webhookDB, eventIDs) + if err != nil { + h.serverError( + w, r, "failed to count event resubmissions", err, + ) + + return nil, false + } + + entrypoints, err := h.entrypointNames(webhookID) + if err != nil { + h.serverError(w, r, "failed to load entrypoints", err) + + return nil, false + } + + for i := range rows { + result[i].Deliveries = h.newDeliveryViews( + eventDeliveries[i], targetMap, attempts, + ) + result[i].ResubmitCount = resubmits[rows[i].ID] + + name, ok := entrypoints[rows[i].EntrypointID] + if !ok { + name = "deleted entrypoint" + } + + result[i].Entrypoint = name + } + + return result, true +} + +// loadEventLogRows reads the event log projection of the +// recentEventLimit newest events, newest first, and the total number +// of events stored, both narrowed by statuses as eventsWithStatus +// narrows them. +func loadEventLogRows( + webhookDB *gorm.DB, + webhookID string, + statuses []database.DeliveryStatus, +) ([]eventLogRow, int64, error) { + totalEvents, err := countEventsWithStatus( + webhookDB, webhookID, statuses, + ) + if err != nil { + return nil, 0, err + } + + var rows []eventLogRow + + err = eventsWithStatus(webhookDB, webhookID, statuses).Select( + eventLogColumns, maxRenderedBodyBytes, maxRenderedBodyBytes, + ).Order("created_at DESC").Limit(recentEventLimit).Find(&rows).Error + + return rows, totalEvents, err +} + +// eventLogStatuses returns the delivery statuses the event log's show +// value lists events by, or nil for one that lists every event. +func eventLogStatuses(show string) []database.DeliveryStatus { + switch show { + case showFailed: + return []database.DeliveryStatus{database.DeliveryStatusFailed} + case showPending: + return []database.DeliveryStatus{ + database.DeliveryStatusPending, + database.DeliveryStatusRetrying, + } + default: + return nil + } +} + +// eventsWithStatus selects the webhook's events, or, given statuses, +// the recentEventLimit newest of those with at least one delivery in +// one of them. +// +// Given statuses, its cost follows the matching deliveries. SQLite +// never reorders a CROSS JOIN, so it reads each join's left side +// first: the distinct event IDs of the matching deliveries, through +// idx_deliveries_status; then each of those events by ID, sorted to +// keep the newest; then the rows of only the events kept, so no other +// event's body is read. With a plain "id IN (matching deliveries)" +// condition instead, SQLite, which keeps no statistics on these +// tables, walks every event newest first. +func eventsWithStatus( + webhookDB *gorm.DB, + webhookID string, + statuses []database.DeliveryStatus, +) *gorm.DB { + if statuses == nil { + return webhookDB.Model(&database.Event{}).Where( + "webhook_id = ?", webhookID, + ) + } + + matching := webhookDB.Model(&database.Delivery{}). + Distinct("event_id").Where("status IN ?", statuses) + + newest := webhookDB.Table("(?) AS matching", matching). + Joins("CROSS JOIN events ON events.id = matching.event_id"). + Where( + "events.webhook_id = ? AND events.deleted_at IS NULL", + webhookID, + ). + Order("events.created_at DESC").Limit(recentEventLimit). + Select("events.id AS event_id") + + return webhookDB.Table("(?) AS newest", newest). + Joins("CROSS JOIN events ON events.id = newest.event_id") +} + +// countEventsWithStatus counts the webhook's events with at least one +// delivery in one of the statuses, or every event when statuses is +// nil. Given statuses, it counts the distinct events of the matching +// deliveries and reads nothing but those deliveries, through +// idx_deliveries_status, where counting the events would read every +// event row. That is the same number, because retention deletes an +// event's deliveries with it. +func countEventsWithStatus( + webhookDB *gorm.DB, + webhookID string, + statuses []database.DeliveryStatus, +) (int64, error) { + var count int64 + + if statuses == nil { + err := webhookDB.Model(&database.Event{}).Where( + "webhook_id = ?", webhookID, + ).Count(&count).Error + + return count, err + } + + err := webhookDB.Model(&database.Delivery{}).Distinct("event_id"). + Where("status IN ?", statuses).Count(&count).Error + + return count, err +} + +// countFailedAndPendingEvents returns how many of the webhook's events +// the event log lists when it shows only those with a failed delivery, +// and when it shows only those with a delivery pending or retrying. +func (h *Handlers) countFailedAndPendingEvents( + webhookID string, +) (int64, int64, error) { + if !h.dbMgr.DBExists(webhookID) { + return 0, 0, nil + } + + webhookDB, err := h.dbMgr.GetDB(webhookID) + if err != nil { + return 0, 0, err + } + + failed, err := countEventsWithStatus( + webhookDB, webhookID, eventLogStatuses(showFailed), + ) + if err != nil { + return 0, 0, err + } + + pending, err := countEventsWithStatus( + webhookDB, webhookID, eventLogStatuses(showPending), + ) + if err != nil { + return 0, 0, err + } + + return failed, pending, nil +} + +// resubmitCounts reports, for each of the page's events, how many +// events have been resubmitted from it. +// +// One grouped query covers the page rather than one query per event. +// The page shows at most recentEventLimit events, far below SQLite's +// bound parameter ceiling, so it needs no chunking as the delivery +// result load does. +func resubmitCounts( + webhookDB *gorm.DB, eventIDs []string, +) (map[string]int, error) { + counts := make(map[string]int, len(eventIDs)) + + if len(eventIDs) == 0 { + return counts, nil + } + + var rows []struct { + ResubmittedFromID string + Total int + } + + err := webhookDB.Model(&database.Event{}). + Select("resubmitted_from_id, count(*) AS total"). + Where("resubmitted_from_id IN ?", eventIDs). + Group("resubmitted_from_id"). + Find(&rows).Error + if err != nil { + return nil, err + } + + for _, row := range rows { + counts[row.ResubmittedFromID] = row.Total + } + + return counts, nil +} + +// deliveryIDChunkSize bounds how many delivery IDs go into one +// IN clause. SQLite refuses a statement carrying more than +// SQLITE_MAX_VARIABLE_NUMBER (32766) bound parameters, and a +// page holds one delivery per target per event, so a webhook +// with enough targets would turn the whole query into an error +// and the page into zero attempts. +const deliveryIDChunkSize = 500 + +// loadDeliveryResults loads the recorded attempts for the +// page's deliveries, keyed by delivery ID. +// +// Each response body is cut by SQLite rather than in Go, for +// the reason deliveryResultColumns gives. How many attempts a +// delivery has is the target's MaxRetries, which the +// authenticated operator sets; how many of them reach the page +// is bounded again by maxRenderedAttempts. +func (h *Handlers) loadDeliveryResults( + webhookDB *gorm.DB, + deliveryIDs []string, +) (map[string][]deliveryResultRow, error) { + byDelivery := make(map[string][]deliveryResultRow) + + for chunk := range slices.Chunk( + deliveryIDs, deliveryIDChunkSize, + ) { + var rows []deliveryResultRow + + err := webhookDB.Model( + &database.DeliveryResult{}, + ).Select( + deliveryResultColumns, maxRenderedResponseBytes, + ).Where( + "delivery_id IN ?", chunk, + ).Order("attempt_num ASC").Find(&rows).Error + if err != nil { + // Returning what was loaded so far renders the + // deliveries in the failed chunk as never having run, + // which is indistinguishable from ones that really + // never ran. The page fails instead. + return nil, err + } + + for i := range rows { + byDelivery[rows[i].DeliveryID] = append( + byDelivery[rows[i].DeliveryID], rows[i], + ) + } + } + + return byDelivery, nil +} + +// newDeliveryViews projects deliveries for rendering, +// resolving each one's target to its display-safe view and +// each one's attempts through that target's redactor. A +// retrying delivery also reads its target's circuit breaker. +func (h *Handlers) newDeliveryViews( + deliveries []database.Delivery, + targetMap map[string]eventLogTarget, + attempts map[string][]deliveryResultRow, +) []DeliveryView { + views := make([]DeliveryView, len(deliveries)) + + for i := range deliveries { + target := targetMap[deliveries[i].TargetID] + rows := attempts[deliveries[i].ID] + created := deliveries[i].CreatedAt + + results, omitted := renderedAttempts( + rows, target.Redactor, + ) + + views[i] = DeliveryView{ + ID: deliveries[i].ID, + Status: deliveries[i].Status, + Target: target.View, + Replay: deliveries[i].Replay, + Created: humanize.Time(created), + CreatedUTC: created.UTC().Format(time.DateTime) + " UTC", + Results: results, + AttemptCount: len(rows), + AttemptsOmitted: omitted, + } + + if deliveries[i].Status == database.DeliveryStatusRetrying { + views[i].Paused = h.deliveryPausedView( + deliveries[i].TargetID, rows, + ) + } + } + + return views +} + +// maxRenderedAttempts bounds how many of one delivery's +// attempts the page renders. Past it the middle is dropped and +// counted, keeping the first attempts and the last ones: how +// the delivery started failing and how it ended are what a +// reader needs, and the count says plainly that the rest was +// dropped rather than never recorded. +const ( + renderedAttemptsHead = 10 + renderedAttemptsTail = 10 + maxRenderedAttempts = renderedAttemptsHead + + renderedAttemptsTail +) + +// renderedAttempts projects a delivery's attempts through the +// target's redactor, at most maxRenderedAttempts of them, and +// reports how many it dropped. +func renderedAttempts( + rows []deliveryResultRow, + redactor delivery.Redactor, +) ([]DeliveryResultView, int) { + omitted := 0 + + if len(rows) > maxRenderedAttempts { + omitted = len(rows) - maxRenderedAttempts + + kept := make( + []deliveryResultRow, 0, maxRenderedAttempts, + ) + kept = append(kept, rows[:renderedAttemptsHead]...) + kept = append( + kept, rows[len(rows)-renderedAttemptsTail:]..., + ) + rows = kept + } + + views := make([]DeliveryResultView, len(rows)) + for i := range rows { + views[i] = rows[i].view(redactor) + } + + return views, omitted +} diff --git a/internal/handlers/shared.go b/internal/handlers/shared.go new file mode 100644 index 0000000..43e13fc --- /dev/null +++ b/internal/handlers/shared.go @@ -0,0 +1,230 @@ +package handlers + +import ( + "net/http" + "strconv" + "strings" + + "github.com/go-chi/chi" + "sneak.berlin/go/webhooker/internal/database" +) + +// parseRetentionDays interprets a retention_days form value. It +// 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 +// is accepted. +// +// An empty value yields fallback, which lets the create path apply the +// default and the edit path leave the stored value unchanged. A value +// of 0 is returned as 0 and is rewritten to the retain-forever +// sentinel by database.Webhook's BeforeSave hook. Anything unparseable +// or negative is refused rather than silently given a default. +// +// The upper bound is not cosmetic. The reaper computes its cutoff as a +// time.Duration, an int64 nanosecond count, so a day count above +// database.MaxFiniteRetentionDays overflows, puts the cutoff in the +// future, and deletes every event the webhook has. A finite value +// above that ceiling is therefore refused, and the message names the +// ceiling rather than implying the input was not a number. +// +// A value at or above the retain-forever sentinel is not out of range: +// it is what the edit form pre-fills for a retain-forever webhook, so +// submitting the form back unchanged has to keep meaning "forever" +// rather than being rejected. +func parseRetentionDays(raw string, fallback int) (int, string) { + raw = strings.TrimSpace(raw) + if raw == "" { + return fallback, "" + } + + v, err := strconv.Atoi(raw) + if err != nil || v < 0 { + return 0, "Retention must be a whole number of days, or 0 to " + + "retain events forever." + } + + if v >= database.RetentionForeverDays { + return database.RetentionForeverDays, "" + } + + if v > database.MaxFiniteRetentionDays { + return 0, "Retention must be at most " + + strconv.Itoa(database.MaxFiniteRetentionDays) + + " days, or 0 to retain events forever." + } + + return v, "" +} + +// ownedWebhook resolves the request's sourceID parameter to a +// webhook the session's user owns. +// +// Ownership and existence are decided by one query, so a +// webhook belonging to another user is indistinguishable from +// one that does not exist: both are a 404, and neither confirms +// the id. Callers that reach further into a webhook's data — +// the event log page and the event body download — share this +// one check rather than restating it, so the download cannot +// come to authorize differently from the page that links to it. +// +// It reports false once it has written the response, which is a +// redirect to the login page for an unauthenticated request and +// a 404 otherwise. The caller returns without writing more. +func (h *Handlers) ownedWebhook( + w http.ResponseWriter, + r *http.Request, +) (database.Webhook, bool) { + var webhook database.Webhook + + userID, ok := h.getUserID(r) + if !ok { + http.Redirect( + w, r, "/pages/login", http.StatusSeeOther, + ) + + return database.Webhook{}, false + } + + sourceID := chi.URLParam(r, "sourceID") + + err := h.db.DB().Where( + "id = ? AND user_id = ?", sourceID, userID, + ).First(&webhook).Error + if err != nil { + h.renderError(w, r, http.StatusNotFound) + + return database.Webhook{}, false + } + + return webhook, true +} + +// deleteChildResource returns a handler that deletes a child +// resource (entrypoint or target) belonging to a webhook. The +// optional afterDelete hook runs with the child's id once the +// delete has removed it, before the redirect, which carries done as +// its notice. +func (h *Handlers) deleteChildResource( + idParam string, + model any, + errMsg string, + afterDelete func(childID string), + done noticeCode, +) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + userID, ok := h.getUserID(r) + if !ok { + http.Redirect( + w, r, "/pages/login", http.StatusSeeOther, + ) + + return + } + + sourceID := chi.URLParam(r, "sourceID") + childID := chi.URLParam(r, idParam) + + var webhook database.Webhook + + err := h.db.DB().Where( + "id = ? AND user_id = ?", sourceID, userID, + ).First(&webhook).Error + if err != nil { + h.renderError(w, r, http.StatusNotFound) + + return + } + + result := h.db.DB().Where( + "id = ? AND webhook_id = ?", + childID, webhook.ID, + ).Delete(model) + if result.Error != nil { + h.serverError(w, r, errMsg, result.Error) + + return + } + + // Only for a row this webhook really had: the id came from + // the URL and may name another webhook's child. + if afterDelete != nil && result.RowsAffected > 0 { + afterDelete(childID) + } + + http.Redirect( + w, r, + withNotice("/hook/"+webhook.ID, done), + http.StatusSeeOther, + ) + } +} + +// toggleChildResource returns a handler that toggles the active +// state of a child resource belonging to a webhook. toggleFn returns +// the new state, and the redirect carries activated or deactivated as +// its notice to match. +func (h *Handlers) toggleChildResource( + idParam string, + toggleFn func(webhookID, childID string) (bool, error), + errMsg string, + activated, deactivated noticeCode, +) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + userID, ok := h.getUserID(r) + if !ok { + http.Redirect( + w, r, "/pages/login", http.StatusSeeOther, + ) + + return + } + + sourceID := chi.URLParam(r, "sourceID") + childID := chi.URLParam(r, idParam) + + var webhook database.Webhook + + err := h.db.DB().Where( + "id = ? AND user_id = ?", sourceID, userID, + ).First(&webhook).Error + if err != nil { + h.renderError(w, r, http.StatusNotFound) + + return + } + + active, err := toggleFn(webhook.ID, childID) + if err != nil { + h.serverError(w, r, errMsg, err) + + return + } + + done := deactivated + if active { + done = activated + } + + http.Redirect( + w, r, + withNotice("/hook/"+webhook.ID, done), + http.StatusSeeOther, + ) + } +} + +// getUserID extracts the user ID from the session. +func (h *Handlers) getUserID( + r *http.Request, +) (string, bool) { + sess, err := h.session.Get(r) + if err != nil { + return "", false + } + + if !h.session.IsAuthenticated(sess) { + return "", false + } + + return h.session.GetUserID(sess) +} diff --git a/internal/handlers/source_management.go b/internal/handlers/source_management.go deleted file mode 100644 index 0f5531f..0000000 --- a/internal/handlers/source_management.go +++ /dev/null @@ -1,2371 +0,0 @@ -package handlers - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "net/http" - "slices" - "strconv" - "strings" - "time" - - "github.com/dustin/go-humanize" - "github.com/go-chi/chi" - "github.com/google/uuid" - "gorm.io/gorm" - "sneak.berlin/go/webhooker/internal/database" - "sneak.berlin/go/webhooker/internal/delivery" - "sneak.berlin/go/webhooker/internal/reqtls" -) - -// WebhookListItem holds data for the webhook list view. -type WebhookListItem struct { - database.Webhook - - EntrypointCount int - InactiveEntrypointCount int - 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 -} - -// parseRetentionDays interprets a retention_days form value. It -// 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 -// is accepted. -// -// An empty value yields fallback, which lets the create path apply the -// default and the edit path leave the stored value unchanged. A value -// of 0 is returned as 0 and is rewritten to the retain-forever -// sentinel by database.Webhook's BeforeSave hook. Anything unparseable -// or negative is refused rather than silently given a default. -// -// The upper bound is not cosmetic. The reaper computes its cutoff as a -// time.Duration, an int64 nanosecond count, so a day count above -// database.MaxFiniteRetentionDays overflows, puts the cutoff in the -// future, and deletes every event the webhook has. A finite value -// above that ceiling is therefore refused, and the message names the -// ceiling rather than implying the input was not a number. -// -// A value at or above the retain-forever sentinel is not out of range: -// it is what the edit form pre-fills for a retain-forever webhook, so -// submitting the form back unchanged has to keep meaning "forever" -// rather than being rejected. -func parseRetentionDays(raw string, fallback int) (int, string) { - raw = strings.TrimSpace(raw) - if raw == "" { - return fallback, "" - } - - v, err := strconv.Atoi(raw) - if err != nil || v < 0 { - return 0, "Retention must be a whole number of days, or 0 to " + - "retain events forever." - } - - if v >= database.RetentionForeverDays { - return database.RetentionForeverDays, "" - } - - if v > database.MaxFiniteRetentionDays { - return 0, "Retention must be at most " + - strconv.Itoa(database.MaxFiniteRetentionDays) + - " days, or 0 to retain events forever." - } - - return v, "" -} - -// DeliveryView is the display-safe projection of a delivery -// for the event log page. Its target is a TargetView, so the -// stored configuration blob — which holds the target's -// credential — has no path to the template. -type DeliveryView struct { - ID string - Status database.DeliveryStatus - Target delivery.TargetView - - // Replay is set on a delivery the Replay action created. - Replay bool - - // Created is how long ago the delivery was created, and - // CreatedUTC the full timestamp the page shows on hover. - Created string - CreatedUTC string - - // Results is this delivery's attempts in attempt order, - // bounded by maxRenderedAttempts. Without them a failure - // renders as the status word alone and says nothing about - // why. - Results []DeliveryResultView - - // AttemptCount is how many attempts were recorded, which - // is more than len(Results) once the middle was dropped. - AttemptCount int - - // AttemptsOmitted is how many attempts were dropped from - // the middle of Results. The page must show it, or the - // bound would hide history rather than fold it. - AttemptsOmitted int - - // Paused is set while the delivery is retrying and its - // target's circuit breaker is open, and nil otherwise. - Paused *PausedView -} - -// eventLogTarget is what the event log needs to know about -// one target: the display-safe view its template renders, and -// the redactor that keeps that target's own credential out of -// the text its remote peer chose. The two are kept together -// so a caller cannot pick up one without the other, and apart -// from TargetView so the secrets never reach a template. -type eventLogTarget struct { - View delivery.TargetView - Redactor delivery.Redactor -} - -// HandleSourceList shows a list of user's webhooks. -func (h *Handlers) HandleSourceList() http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - userID, ok := h.getUserID(r) - if !ok { - http.Redirect( - w, r, "/pages/login", http.StatusSeeOther, - ) - - return - } - - var webhooks []database.Webhook - - err := h.db.DB().Where( - "user_id = ?", userID, - ).Order("created_at DESC").Find(&webhooks).Error - if err != nil { - h.serverError(w, r, "failed to list webhooks", err) - - return - } - - items, err := h.buildWebhookListItems(webhooks) - if err != nil { - h.serverError(w, r, "failed to list webhooks", err) - - return - } - - data := map[string]any{ - "Webhooks": items, - } - - h.renderTemplate(w, r, "sources_list.html", data) - } -} - -// 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( - webhooks []database.Webhook, -) ([]WebhookListItem, error) { - items := make([]WebhookListItem, len(webhooks)) - since := time.Now().Add(-longWindow) - - for i := range webhooks { - item := &items[i] - item.Webhook = webhooks[i] - - var err error - - item.EntrypointCount, item.InactiveEntrypointCount, err = - h.countWithInactive(&database.Entrypoint{}, item.ID) - if err != nil { - return nil, err - } - - item.TargetCount, item.InactiveTargetCount, err = - h.countWithInactive(&database.Target{}, item.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, - ) - - item.EventsUnreadable = true - } - } - - 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. -func (h *Handlers) HandleSourceCreate() http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - h.renderTemplate( - w, r, "sources_new.html", - newSourceFormData("", sourceFormInput{ - RetentionDays: strconv.Itoa( - database.DefaultRetentionDays, - ), - }), - ) - } -} - -// sourceFormInput carries the raw values of the new webhook form. A -// refused submission is shown again from it, so every value entered -// comes back, retention included. -type sourceFormInput struct { - Name string - Description string - RetentionDays string - // HTTPURL, when not empty, asks for an HTTP target with this - // destination. - HTTPURL string - // Archive asks for a database (archive) target, whose rows expire - // after ArchiveExpiry and whose files rotate by ArchiveRotation. - Archive bool - ArchiveExpiry string - ArchiveRotation string -} - -// newSourceFormData builds the template data for the webhook creation -// form. It carries the retention default, which the form's help text -// names, from database.DefaultRetentionDays rather than a hardcoded -// copy of the same policy. -func newSourceFormData( - errMsg string, in sourceFormInput, -) map[string]any { - return map[string]any{ - tmplKeyError: errMsg, - "Form": in, - "DefaultRetentionDays": database.DefaultRetentionDays, - tmplKeyArchiveExpiryChoices: archiveExpiryOptions( - in.ArchiveExpiry, - ), - tmplKeyArchiveRotationChoices: archiveRotationOptions( - in.ArchiveRotation, - ), - } -} - -// HandleSourceCreateSubmit handles the webhook creation form -// submission. -func (h *Handlers) HandleSourceCreateSubmit() http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - userID, ok := h.getUserID(r) - if !ok { - http.Redirect( - w, r, "/pages/login", http.StatusSeeOther, - ) - - return - } - - // The body size cap is enforced by the MaxBodySize - // middleware, which runs before CSRF parses the form. - err := r.ParseForm() - if err != nil { - h.renderError(w, r, http.StatusBadRequest) - - return - } - - in := sourceFormInput{ - Name: r.PostFormValue("name"), - Description: r.PostFormValue("description"), - RetentionDays: r.PostFormValue("retention_days"), - HTTPURL: r.PostFormValue("http_url"), - Archive: r.PostFormValue("archive") != "", - ArchiveExpiry: r.PostFormValue("archive_expiry"), - ArchiveRotation: r.PostFormValue("archive_rotation"), - } - - refuse := func(errMsg string) { - h.renderTemplateStatus( - w, r, "sources_new.html", - newSourceFormData(errMsg, in), - http.StatusBadRequest, - ) - } - - if in.Name == "" { - refuse("Name is required") - - return - } - - retentionDays, errMsg := parseRetentionDays( - in.RetentionDays, database.DefaultRetentionDays, - ) - if errMsg != "" { - refuse(errMsg) - - return - } - - targets, errMsg, err := h.newWebhookTargets(r.Context(), in) - if err != nil { - h.serverError(w, r, "failed to encode target config", err) - - return - } - - if errMsg != "" { - refuse(errMsg) - - return - } - - h.createWebhookWithEntrypoint(w, r, &database.Webhook{ - UserID: userID, - Name: in.Name, - Description: in.Description, - RetentionDays: retentionDays, - }, targets) - } -} - -// newWebhookTargets validates the targets the new webhook form asks -// for and returns the rows to create with the webhook, or the message -// the form shows for the first one it refuses. A filled-in HTTP URL -// asks for an HTTP target named "HTTP", and the archive checkbox for a -// database target named "Archive". Each goes through newTarget, as on -// the webhook page's add target form. The rows have no WebhookID yet: -// the webhook has no ID until it is created. -func (h *Handlers) newWebhookTargets( - ctx context.Context, - in sourceFormInput, -) ([]*database.Target, string, error) { - var requested []targetFormInput - - if in.HTTPURL != "" { - requested = append(requested, targetFormInput{ - Name: "HTTP", - Type: database.TargetTypeHTTP, - URL: in.HTTPURL, - }) - } - - if in.Archive { - requested = append(requested, targetFormInput{ - Name: "Archive", - Type: database.TargetTypeDatabase, - Expiry: in.ArchiveExpiry, - Rotation: in.ArchiveRotation, - }) - } - - targets := make([]*database.Target, 0, len(requested)) - - for _, form := range requested { - target, errMsg, err := h.newTarget(ctx, "", form) - if err != nil || errMsg != "" { - return nil, errMsg, err - } - - targets = append(targets, target) - } - - return targets, "", nil -} - -// createWebhookWithEntrypoint creates a webhook, its default -// entrypoint and the given targets in a transaction. -func (h *Handlers) createWebhookWithEntrypoint( - w http.ResponseWriter, - r *http.Request, - webhook *database.Webhook, - targets []*database.Target, -) { - err := h.commitWebhook(webhook, targets) - if err != nil { - h.serverError(w, r, "failed to create webhook", err) - - return - } - - err = h.dbMgr.CreateDB(webhook.ID) - if err != nil { - h.log.Error( - "failed to create webhook event database", - "webhook_id", webhook.ID, "error", err, - ) - } - - h.log.Info("webhook created", - "webhook_id", webhook.ID, - "name", webhook.Name, "user_id", webhook.UserID, - ) - - http.Redirect( - w, r, withNotice("/hook/"+webhook.ID, webhookCreated), - http.StatusSeeOther, - ) -} - -// commitWebhook creates a webhook, its default entrypoint and the -// given targets in a transaction. Returns an error on failure (rolls -// back). -func (h *Handlers) commitWebhook( - webhook *database.Webhook, - targets []*database.Target, -) error { - tx := h.db.DB().Begin() - if tx.Error != nil { - return tx.Error - } - - err := tx.Create(webhook).Error - if err != nil { - tx.Rollback() - - return err - } - - entrypoint := &database.Entrypoint{ - WebhookID: webhook.ID, - Path: uuid.New().String(), - Description: "Default entrypoint", - Active: true, - } - - err = tx.Create(entrypoint).Error - if err != nil { - tx.Rollback() - - return err - } - - for _, target := range targets { - target.WebhookID = webhook.ID - - err = tx.Create(target).Error - if err != nil { - tx.Rollback() - - return err - } - } - - return tx.Commit().Error -} - -// HandleSourceDetail shows details for a specific webhook. -func (h *Handlers) HandleSourceDetail() http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - userID, ok := h.getUserID(r) - if !ok { - http.Redirect( - w, r, "/pages/login", http.StatusSeeOther, - ) - - return - } - - sourceID := chi.URLParam(r, "sourceID") - - var webhook database.Webhook - - err := h.db.DB().Where( - "id = ? AND user_id = ?", sourceID, userID, - ).First(&webhook).Error - if err != nil { - h.renderError(w, r, http.StatusNotFound) - - return - } - - h.renderSourceDetail(w, r, webhook, targetFormInput{}, "") - } -} - -// 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( - w http.ResponseWriter, - r *http.Request, - webhook database.Webhook, - targetForm targetFormInput, - targetErr string, -) { - var entrypoints []database.Entrypoint - - h.db.DB().Where( - "webhook_id = ?", webhook.ID, - ).Find(&entrypoints) - - var targets []database.Target - - h.db.DB().Where( - "webhook_id = ?", webhook.ID, - ).Find(&targets) - - entrypointViews := NewEntrypointViews(entrypoints) - - var events []RecentEventView - - if h.dbMgr.DBExists(webhook.ID) { - webhookDB, err := h.dbMgr.GetDB(webhook.ID) - if err != nil { - h.serverError(w, r, "failed to get webhook database", err) - - return - } - - events, err = loadRecentEvents( - webhookDB, webhook.ID, singleHTTPTargetID(targets), - ) - if err != nil { - h.serverError(w, r, "failed to load recent events", err) - - return - } - - err = addEntrypointEvents( - webhookDB, &webhook, entrypointViews, time.Now(), - ) - if err != nil { - h.serverError(w, r, "failed to count entrypoint events", err) - - return - } - } - - scheme := "http" - if reqtls.IsTLS(r) { - scheme = "https" - } - - // The host is the client's Host header, unvalidated. It is - // inert only because source_detail.html renders BaseURL as - // text, inside a element and in an entrypoint's delete - // prompt; putting it in an href or any other URL context - // needs it constrained first. - baseURL := scheme + "://" + r.Host - - // The template calls Webhook methods, which take pointer - // receivers; html/template cannot address a value stored in a map. - data := map[string]any{ - tmplKeyWebhook: &webhook, - // Targets are projected to a display-safe view: a - // target's stored config blob holds a credential, and it - // must never reach a template. - "Entrypoints": entrypointViews, - "Targets": h.targetRows(&webhook, targets), - "Events": events, - "BaseURL": baseURL, - "Stats": h.loadWebhookStats(webhook.ID, entrypoints, targets), - tmplKeyTargetForm: targetForm, - "TargetError": targetErr, - // The add target form's selects start on its expiry and - // rotation through Alpine, so no choice is selected here. - tmplKeyArchiveExpiryChoices: archiveExpiryChoices(), - tmplKeyArchiveRotationChoices: archiveRotationChoices(), - } - - 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. -func (h *Handlers) HandleSourceEdit() http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - userID, ok := h.getUserID(r) - if !ok { - http.Redirect( - w, r, "/pages/login", http.StatusSeeOther, - ) - - return - } - - sourceID := chi.URLParam(r, "sourceID") - - var webhook database.Webhook - - err := h.db.DB().Where( - "id = ? AND user_id = ?", sourceID, userID, - ).First(&webhook).Error - if err != nil { - h.renderError(w, r, http.StatusNotFound) - - return - } - - h.renderWebhookEdit( - w, r, &webhook, - webhook.Name, webhook.Description, - strconv.Itoa(webhook.RetentionDays), - "", http.StatusOK, - ) - } -} - -// HandleSourceEditSubmit handles the webhook edit form -// submission. -func (h *Handlers) HandleSourceEditSubmit() http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - userID, ok := h.getUserID(r) - if !ok { - http.Redirect( - w, r, "/pages/login", http.StatusSeeOther, - ) - - return - } - - sourceID := chi.URLParam(r, "sourceID") - - h.renameMu.Lock() - defer h.renameMu.Unlock() - - var webhook database.Webhook - - err := h.db.DB().Where( - "id = ? AND user_id = ?", sourceID, userID, - ).First(&webhook).Error - if err != nil { - h.renderError(w, r, http.StatusNotFound) - - return - } - - // The body size cap is enforced by the MaxBodySize - // middleware, which runs before CSRF parses the form. - err = r.ParseForm() - if err != nil { - h.renderError(w, r, http.StatusBadRequest) - - return - } - - h.applyWebhookEdit(w, r, &webhook) - } -} - -// applyWebhookEdit validates and saves webhook edits. A refused save -// shows the edit form again with the values submitted and the reason. -func (h *Handlers) applyWebhookEdit( - w http.ResponseWriter, - r *http.Request, - webhook *database.Webhook, -) { - // The body size cap is enforced by the MaxBodySize middleware, - // which runs before CSRF parses the form. - name := r.PostFormValue("name") - description := r.PostFormValue("description") - retention := r.PostFormValue("retention_days") - - if name == "" { - h.renderWebhookEdit( - w, r, webhook, name, description, retention, - "Name is required", http.StatusBadRequest, - ) - - return - } - - // An empty field falls back to the stored value, so submitting the - // form without touching retention leaves the policy alone. - retentionDays, errMsg := parseRetentionDays( - retention, webhook.RetentionDays, - ) - if errMsg != "" { - h.renderWebhookEdit( - w, r, webhook, name, description, retention, - errMsg, http.StatusBadRequest, - ) - - return - } - - // edited is the webhook as the submission leaves it; webhook stays - // as stored, for the page shown again when the save is refused. - edited := *webhook - edited.Name = name - edited.Description = description - edited.RetentionDays = retentionDays - - // A new name renames the archive files before it is saved (see - // delivery.Engine.Rename). If either step fails, the same targets' - // archives go back to the name that is still stored, without - // reading the main database again. - targets, err := h.renameWebhookArchives( - webhook.ID, webhook.Name, edited.Name, - ) - if err == nil { - err = h.db.DB().Save(&edited).Error - } - - if err != nil { - restoreErr := h.renameArchives(targets, webhook.Name) - if restoreErr != nil { - h.log.Error( - "failed to rename archives back", - "webhook_id", webhook.ID, - "error", restoreErr, - ) - } - - if errors.Is(err, delivery.ErrArchiveNameTaken) { - h.renderWebhookEdit( - w, r, webhook, name, description, retention, - "Not saved: "+err.Error()+ - ". Move that archive out of the data directory, "+ - "its .db together with any -wal and -shm beside "+ - "it, then save again.", - http.StatusConflict, - ) - - return - } - - h.serverError(w, r, "failed to update webhook", err) - - return - } - - http.Redirect( - w, r, withNotice("/hook/"+webhook.ID, webhookSaved), - http.StatusSeeOther, - ) -} - -// renderWebhookEdit renders the webhook edit page for the webhook as -// stored, its form showing name, description and retentionDays, with -// an optional error message above it. -func (h *Handlers) renderWebhookEdit( - w http.ResponseWriter, - r *http.Request, - webhook *database.Webhook, - name, description, retentionDays, errMsg string, - status int, -) { - data := map[string]any{ - tmplKeyWebhook: webhook, - tmplKeyError: errMsg, - "Name": name, - "Description": description, - "RetentionDays": retentionDays, - } - - h.renderTemplateStatus(w, r, "source_edit.html", data, status) -} - -// HandleSourceDelete handles webhook deletion. -func (h *Handlers) HandleSourceDelete() http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - userID, ok := h.getUserID(r) - if !ok { - http.Redirect( - w, r, "/pages/login", http.StatusSeeOther, - ) - - return - } - - sourceID := chi.URLParam(r, "sourceID") - - var webhook database.Webhook - - err := h.db.DB().Where( - "id = ? AND user_id = ?", sourceID, userID, - ).First(&webhook).Error - if err != nil { - h.renderError(w, r, http.StatusNotFound) - - return - } - - h.deleteWebhookResources(w, r, webhook, userID) - } -} - -// The messages deleteWebhookResources logs when a file of the event -// database cannot be removed: the database file itself, or only a -// sidecar once the database file is gone. -const ( - eventDBLeftMsg = "webhook deleted, but its event database file is " + - "still on disk; remove it by hand" - sidecarLeftMsg = "webhook deleted and its events are gone, but a " + - "-wal or -shm sidecar of its event database is " + - "still on disk; remove it by hand" -) - -// deleteWebhookResources soft-deletes config and hard-deletes -// the per-webhook event database. -func (h *Handlers) deleteWebhookResources( - w http.ResponseWriter, - r *http.Request, - webhook database.Webhook, - userID string, -) { - // The configuration delete commits before the event database - // is touched. No transaction spans the main database and the - // filesystem, so one side has to go first: committing the - // configuration first means a later failure leaves an unused - // event database file on disk, while removing the event - // database first would mean a failed commit destroys the - // history of a webhook that still exists. A leftover file can - // be removed by hand; deleted history cannot be recovered. - err := h.commitWebhookDeletion(&webhook) - if err != nil { - h.serverError(w, r, "failed to delete webhook", err) - - return - } - - h.log.Info( - "webhook deleted", - "webhook_id", webhook.ID, - "user_id", userID, - ) - - // Release the delivery engine's per-webhook archiving state - // so a deleted webhook's archive writer (and any handle open - // within its debounce window) does not linger for the - // process lifetime. The archive file itself is deliberately - // left on disk; see evictArchiveWriter. - h.evictArchiveWriter(webhook.ID) - - err = h.dbMgr.DeleteDB(webhook.ID) - if err != nil { - // The configuration is committed, so the webhook is gone, - // but a file of its event database is still on disk with - // nothing referencing it. Report the failure rather than - // redirecting as though everything succeeded: the file - // needs removing by hand, and the logged error names it. - // When only a sidecar is left, the events are already - // gone, and the message must not suggest they survive. - msg := eventDBLeftMsg - if errors.Is(err, database.ErrSidecarNotRemoved) { - msg = sidecarLeftMsg - } - - h.serverError(w, r, msg, err) - - return - } - - http.Redirect( - w, r, withNotice("/hooks", webhookDeleted), http.StatusSeeOther, - ) -} - -// commitWebhookDeletion soft-deletes a webhook's entrypoints, -// targets and the webhook row in one transaction. Every -// statement is checked and any failure rolls the whole -// transaction back, so a caller that gets an error knows the -// configuration is untouched and the event database must be -// left alone. -func (h *Handlers) commitWebhookDeletion( - webhook *database.Webhook, -) error { - tx := h.db.DB().Begin() - if tx.Error != nil { - return tx.Error - } - - err := tx.Where( - "webhook_id = ?", webhook.ID, - ).Delete(&database.Entrypoint{}).Error - if err != nil { - tx.Rollback() - - return err - } - - err = tx.Where( - "webhook_id = ?", webhook.ID, - ).Delete(&database.Target{}).Error - if err != nil { - tx.Rollback() - - return err - } - - err = tx.Delete(webhook).Error - if err != nil { - tx.Rollback() - - return err - } - - return tx.Commit().Error -} - -// evictArchiveWriter asks the delivery engine to drop the cached -// archive writers of a webhook's database targets, closing their -// archive file handles. -// -// The archive database files are NOT deleted. Unlike the event -// database — which is per-webhook working storage and is -// hard-deleted with the webhook — an archive is explicitly -// long-term storage that an operator may want to keep or move -// away for offline retention. Destroying it as a side effect of -// deleting a webhook would be a surprising and unrecoverable -// data loss, so the file is left for the operator to handle. -func (h *Handlers) evictArchiveWriter(webhookID string) { - if h.archives == nil { - return - } - - h.archives.EvictWebhook(webhookID) -} - -// evictTargetArchiveWriter is evictArchiveWriter for one deleted -// target, and leaves its archive file on disk for the same reason. -// A target that is not a database target has no writer, and -// evicting it does nothing. -func (h *Handlers) evictTargetArchiveWriter(targetID string) { - if h.archives == nil { - return - } - - h.archives.EvictTarget(targetID) -} - -// renameWebhookArchives renames the archive file of every database -// target of a webhook from the webhook name oldName to newName, -// keeping each target's own name. It does nothing when the name is -// unchanged. It returns the targets it read, so that a failed edit can -// move those same archives back with renameArchives. -func (h *Handlers) renameWebhookArchives( - webhookID, oldName, newName string, -) ([]database.Target, error) { - if h.archives == nil || oldName == newName { - return nil, nil - } - - var targets []database.Target - - err := h.db.DB(). - Where( - "webhook_id = ? AND type = ?", - webhookID, database.TargetTypeDatabase, - ). - Find(&targets).Error - if err != nil { - return nil, err - } - - return targets, h.renameArchives(targets, newName) -} - -// renameArchives renames the archive file of each of the given -// database targets to the webhook name webhookName, keeping each -// target's own name. It tries every target even after one fails, so -// that moving the archives back after a failed edit leaves none under -// the new name, and returns every failure joined. -func (h *Handlers) renameArchives( - targets []database.Target, webhookName string, -) error { - var errs []error - - for i := range targets { - err := h.archives.Rename( - targets[i].ID, webhookName, targets[i].Name, - ) - if err != nil { - errs = append(errs, err) - } - } - - return errors.Join(errs...) -} - -// ownedWebhook resolves the request's sourceID parameter to a -// webhook the session's user owns. -// -// Ownership and existence are decided by one query, so a -// webhook belonging to another user is indistinguishable from -// one that does not exist: both are a 404, and neither confirms -// the id. Callers that reach further into a webhook's data — -// the event log page and the event body download — share this -// one check rather than restating it, so the download cannot -// come to authorize differently from the page that links to it. -// -// It reports false once it has written the response, which is a -// redirect to the login page for an unauthenticated request and -// a 404 otherwise. The caller returns without writing more. -func (h *Handlers) ownedWebhook( - w http.ResponseWriter, - r *http.Request, -) (database.Webhook, bool) { - var webhook database.Webhook - - userID, ok := h.getUserID(r) - if !ok { - http.Redirect( - w, r, "/pages/login", http.StatusSeeOther, - ) - - return database.Webhook{}, false - } - - sourceID := chi.URLParam(r, "sourceID") - - err := h.db.DB().Where( - "id = ? AND user_id = ?", sourceID, userID, - ).First(&webhook).Error - if err != nil { - h.renderError(w, r, http.StatusNotFound) - - return database.Webhook{}, false - } - - return webhook, true -} - -// The event log's show query parameter and its two values: the events -// with a failed delivery, and those with a delivery still pending or -// retrying. -const ( - showParam = "show" - showFailed = "failed" - showPending = "pending" -) - -// HandleSourceLogs shows the request/response logs for a -// webhook. -func (h *Handlers) HandleSourceLogs() http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - webhook, ok := h.ownedWebhook(w, r) - if !ok { - return - } - - targets, err := h.loadTargetMap(webhook.ID) - if err != nil { - // Without the map every delivery renders through a - // zero redactor, so failing the page is the only - // safe answer. - h.serverError(w, r, "failed to load targets", err) - - return - } - - // Any other value of show lists every event, as no value - // does. - show := r.URL.Query().Get(showParam) - - statuses := eventLogStatuses(show) - if statuses == nil { - show = "" - } - - evts, total, ok := h.loadEventsWithDeliveries( - w, r, webhook, targets, statuses, - ) - if !ok { - return - } - - failed, pending, err := h.countFailedAndPendingEvents(webhook.ID) - if err != nil { - h.serverError(w, r, "failed to count events", err) - - return - } - - data := map[string]any{ - tmplKeyWebhook: &webhook, - "Events": evts, - "TotalEvents": total, - "Show": show, - "FailedEvents": failed, - "PendingEvents": pending, - } - - h.renderTemplate(w, r, "source_logs.html", data) - } -} - -// loadTargetMap loads targets into a map of display-safe -// views keyed by target ID, each paired with its redactor. -// The projection happens here so that no caller can hand a -// raw target, configuration blob and all, to a template: the -// raw rows do not leave this function. -// -// The load is Unscoped because deleting a target only soft -// deletes the row while its deliveries survive in the -// per-webhook database. Both halves of the map need those rows: -// a scoped load leaves an old delivery with a zero redactor, -// which renders its response bodies unredacted, and with a zero -// view, which renders its target as a blank name. -// -// This map is historical display only. It is built for the event -// log and an event's own page, and reaches nothing but -// DeliveryView.Target: the target list on the source detail page, -// the edit form and the replay path each resolve targets -// themselves, and a deleted row is refused there as before. -func (h *Handlers) loadTargetMap( - webhookID string, -) (map[string]eventLogTarget, error) { - var targets []database.Target - - err := h.db.DB().Unscoped().Where( - "webhook_id = ?", webhookID, - ).Find(&targets).Error - if err != nil { - return nil, err - } - - targetMap := make( - map[string]eventLogTarget, len(targets), - ) - - for i := range targets { - targetMap[targets[i].ID] = eventLogTarget{ - Redactor: delivery.NewRedactor(&targets[i]), - } - } - - // The views come from NewTargetViews rather than being - // rebuilt here, so the masking rules stay in one place and a - // deleted target's configuration is masked by the same code - // that masks a live one's. - for _, v := range delivery.NewTargetViews(targets) { - entry := targetMap[v.ID] - entry.View = v - targetMap[v.ID] = entry - } - - return targetMap, nil -} - -// loadEventsWithDeliveries loads the recentEventLimit newest events -// and their deliveries from the per-webhook database, and the total -// number of events stored. Given delivery statuses, both cover only -// the events with a delivery in one of them. Events come back as -// capped projections rather than database.Event rows: see -// eventLogColumns for why the cut happens in SQL. -// -// The bool reports whether the load succeeded. It is false -// once this has answered the request with an error, and the -// caller must then render nothing further. -func (h *Handlers) loadEventsWithDeliveries( - w http.ResponseWriter, - r *http.Request, - webhook database.Webhook, - targetMap map[string]eventLogTarget, - statuses []database.DeliveryStatus, -) ([]EventLogView, int64, bool) { - if !h.dbMgr.DBExists(webhook.ID) { - return nil, 0, true - } - - webhookDB, err := h.dbMgr.GetDB(webhook.ID) - if err != nil { - h.serverError( - w, r, "failed to get webhook database", err, - ) - - return nil, 0, false - } - - rows, totalEvents, err := loadEventLogRows( - webhookDB, webhook.ID, statuses, - ) - if err != nil { - h.serverError(w, r, "failed to load events", err) - - return nil, 0, false - } - - result, ok := h.eventLogViews( - w, r, webhookDB, webhook.ID, rows, targetMap, - maxRenderedBodyBytes, - ) - - return result, totalEvents, ok -} - -// eventLogViews projects loaded events for rendering, each with -// its deliveries, how many times it has been resubmitted and the -// entrypoint it arrived at (for a resubmitted copy, the one the -// request it copies arrived at), and with its request headers only -// when their text holds at most maxHeaderBytes. 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, - maxHeaderBytes int, -) ([]EventLogView, bool) { - result := make([]EventLogView, len(rows)) - eventDeliveries := make([][]database.Delivery, len(rows)) - - var deliveryIDs []string - - eventIDs := make([]string, len(rows)) - - for i := range rows { - result[i] = rows[i].view(webhookID, maxHeaderBytes) - eventIDs[i] = rows[i].ID - - webhookDB.Where( - "event_id = ?", rows[i].ID, - ).Find(&eventDeliveries[i]) - - for j := range eventDeliveries[i] { - deliveryIDs = append( - deliveryIDs, eventDeliveries[i][j].ID, - ) - } - } - - attempts, err := h.loadDeliveryResults( - webhookDB, deliveryIDs, - ) - if err != nil { - h.serverError( - w, r, "failed to load delivery attempts", err, - ) - - return nil, false - } - - resubmits, err := resubmitCounts(webhookDB, eventIDs) - if err != nil { - h.serverError( - w, r, "failed to count event resubmissions", err, - ) - - return nil, false - } - - entrypoints, err := h.entrypointNames(webhookID) - if err != nil { - h.serverError(w, r, "failed to load entrypoints", err) - - return nil, false - } - - for i := range rows { - result[i].Deliveries = h.newDeliveryViews( - eventDeliveries[i], targetMap, attempts, - ) - result[i].ResubmitCount = resubmits[rows[i].ID] - - name, ok := entrypoints[rows[i].EntrypointID] - if !ok { - name = "deleted entrypoint" - } - - result[i].Entrypoint = name - } - - return result, true -} - -// loadEventLogRows reads the event log projection of the -// recentEventLimit newest events, newest first, and the total number -// of events stored, both narrowed by statuses as eventsWithStatus -// narrows them. -func loadEventLogRows( - webhookDB *gorm.DB, - webhookID string, - statuses []database.DeliveryStatus, -) ([]eventLogRow, int64, error) { - totalEvents, err := countEventsWithStatus( - webhookDB, webhookID, statuses, - ) - if err != nil { - return nil, 0, err - } - - var rows []eventLogRow - - err = eventsWithStatus(webhookDB, webhookID, statuses).Select( - eventLogColumns, maxRenderedBodyBytes, maxRenderedBodyBytes, - ).Order("created_at DESC").Limit(recentEventLimit).Find(&rows).Error - - return rows, totalEvents, err -} - -// eventLogStatuses returns the delivery statuses the event log's show -// value lists events by, or nil for one that lists every event. -func eventLogStatuses(show string) []database.DeliveryStatus { - switch show { - case showFailed: - return []database.DeliveryStatus{database.DeliveryStatusFailed} - case showPending: - return []database.DeliveryStatus{ - database.DeliveryStatusPending, - database.DeliveryStatusRetrying, - } - default: - return nil - } -} - -// eventsWithStatus selects the webhook's events, or, given statuses, -// the recentEventLimit newest of those with at least one delivery in -// one of them. -// -// Given statuses, its cost follows the matching deliveries. SQLite -// never reorders a CROSS JOIN, so it reads each join's left side -// first: the distinct event IDs of the matching deliveries, through -// idx_deliveries_status; then each of those events by ID, sorted to -// keep the newest; then the rows of only the events kept, so no other -// event's body is read. With a plain "id IN (matching deliveries)" -// condition instead, SQLite, which keeps no statistics on these -// tables, walks every event newest first. -func eventsWithStatus( - webhookDB *gorm.DB, - webhookID string, - statuses []database.DeliveryStatus, -) *gorm.DB { - if statuses == nil { - return webhookDB.Model(&database.Event{}).Where( - "webhook_id = ?", webhookID, - ) - } - - matching := webhookDB.Model(&database.Delivery{}). - Distinct("event_id").Where("status IN ?", statuses) - - newest := webhookDB.Table("(?) AS matching", matching). - Joins("CROSS JOIN events ON events.id = matching.event_id"). - Where( - "events.webhook_id = ? AND events.deleted_at IS NULL", - webhookID, - ). - Order("events.created_at DESC").Limit(recentEventLimit). - Select("events.id AS event_id") - - return webhookDB.Table("(?) AS newest", newest). - Joins("CROSS JOIN events ON events.id = newest.event_id") -} - -// countEventsWithStatus counts the webhook's events with at least one -// delivery in one of the statuses, or every event when statuses is -// nil. Given statuses, it counts the distinct events of the matching -// deliveries and reads nothing but those deliveries, through -// idx_deliveries_status, where counting the events would read every -// event row. That is the same number, because retention deletes an -// event's deliveries with it. -func countEventsWithStatus( - webhookDB *gorm.DB, - webhookID string, - statuses []database.DeliveryStatus, -) (int64, error) { - var count int64 - - if statuses == nil { - err := webhookDB.Model(&database.Event{}).Where( - "webhook_id = ?", webhookID, - ).Count(&count).Error - - return count, err - } - - err := webhookDB.Model(&database.Delivery{}).Distinct("event_id"). - Where("status IN ?", statuses).Count(&count).Error - - return count, err -} - -// countFailedAndPendingEvents returns how many of the webhook's events -// the event log lists when it shows only those with a failed delivery, -// and when it shows only those with a delivery pending or retrying. -func (h *Handlers) countFailedAndPendingEvents( - webhookID string, -) (int64, int64, error) { - if !h.dbMgr.DBExists(webhookID) { - return 0, 0, nil - } - - webhookDB, err := h.dbMgr.GetDB(webhookID) - if err != nil { - return 0, 0, err - } - - failed, err := countEventsWithStatus( - webhookDB, webhookID, eventLogStatuses(showFailed), - ) - if err != nil { - return 0, 0, err - } - - pending, err := countEventsWithStatus( - webhookDB, webhookID, eventLogStatuses(showPending), - ) - if err != nil { - return 0, 0, err - } - - return failed, pending, nil -} - -// resubmitCounts reports, for each of the page's events, how many -// events have been resubmitted from it. -// -// One grouped query covers the page rather than one query per event. -// The page shows at most recentEventLimit events, far below SQLite's -// bound parameter ceiling, so it needs no chunking as the delivery -// result load does. -func resubmitCounts( - webhookDB *gorm.DB, eventIDs []string, -) (map[string]int, error) { - counts := make(map[string]int, len(eventIDs)) - - if len(eventIDs) == 0 { - return counts, nil - } - - var rows []struct { - ResubmittedFromID string - Total int - } - - err := webhookDB.Model(&database.Event{}). - Select("resubmitted_from_id, count(*) AS total"). - Where("resubmitted_from_id IN ?", eventIDs). - Group("resubmitted_from_id"). - Find(&rows).Error - if err != nil { - return nil, err - } - - for _, row := range rows { - counts[row.ResubmittedFromID] = row.Total - } - - return counts, nil -} - -// deliveryIDChunkSize bounds how many delivery IDs go into one -// IN clause. SQLite refuses a statement carrying more than -// SQLITE_MAX_VARIABLE_NUMBER (32766) bound parameters, and a -// page holds one delivery per target per event, so a webhook -// with enough targets would turn the whole query into an error -// and the page into zero attempts. -const deliveryIDChunkSize = 500 - -// loadDeliveryResults loads the recorded attempts for the -// page's deliveries, keyed by delivery ID. -// -// Each response body is cut by SQLite rather than in Go, for -// the reason deliveryResultColumns gives. How many attempts a -// delivery has is the target's MaxRetries, which the -// authenticated operator sets; how many of them reach the page -// is bounded again by maxRenderedAttempts. -func (h *Handlers) loadDeliveryResults( - webhookDB *gorm.DB, - deliveryIDs []string, -) (map[string][]deliveryResultRow, error) { - byDelivery := make(map[string][]deliveryResultRow) - - for chunk := range slices.Chunk( - deliveryIDs, deliveryIDChunkSize, - ) { - var rows []deliveryResultRow - - err := webhookDB.Model( - &database.DeliveryResult{}, - ).Select( - deliveryResultColumns, maxRenderedResponseBytes, - ).Where( - "delivery_id IN ?", chunk, - ).Order("attempt_num ASC").Find(&rows).Error - if err != nil { - // Returning what was loaded so far renders the - // deliveries in the failed chunk as never having run, - // which is indistinguishable from ones that really - // never ran. The page fails instead. - return nil, err - } - - for i := range rows { - byDelivery[rows[i].DeliveryID] = append( - byDelivery[rows[i].DeliveryID], rows[i], - ) - } - } - - return byDelivery, nil -} - -// newDeliveryViews projects deliveries for rendering, -// resolving each one's target to its display-safe view and -// each one's attempts through that target's redactor. A -// retrying delivery also reads its target's circuit breaker. -func (h *Handlers) newDeliveryViews( - deliveries []database.Delivery, - targetMap map[string]eventLogTarget, - attempts map[string][]deliveryResultRow, -) []DeliveryView { - views := make([]DeliveryView, len(deliveries)) - - for i := range deliveries { - target := targetMap[deliveries[i].TargetID] - rows := attempts[deliveries[i].ID] - created := deliveries[i].CreatedAt - - results, omitted := renderedAttempts( - rows, target.Redactor, - ) - - views[i] = DeliveryView{ - ID: deliveries[i].ID, - Status: deliveries[i].Status, - Target: target.View, - Replay: deliveries[i].Replay, - Created: humanize.Time(created), - CreatedUTC: created.UTC().Format(time.DateTime) + " UTC", - Results: results, - AttemptCount: len(rows), - AttemptsOmitted: omitted, - } - - if deliveries[i].Status == database.DeliveryStatusRetrying { - views[i].Paused = h.deliveryPausedView( - deliveries[i].TargetID, rows, - ) - } - } - - return views -} - -// maxRenderedAttempts bounds how many of one delivery's -// attempts the page renders. Past it the middle is dropped and -// counted, keeping the first attempts and the last ones: how -// the delivery started failing and how it ended are what a -// reader needs, and the count says plainly that the rest was -// dropped rather than never recorded. -const ( - renderedAttemptsHead = 10 - renderedAttemptsTail = 10 - maxRenderedAttempts = renderedAttemptsHead + - renderedAttemptsTail -) - -// renderedAttempts projects a delivery's attempts through the -// target's redactor, at most maxRenderedAttempts of them, and -// reports how many it dropped. -func renderedAttempts( - rows []deliveryResultRow, - redactor delivery.Redactor, -) ([]DeliveryResultView, int) { - omitted := 0 - - if len(rows) > maxRenderedAttempts { - omitted = len(rows) - maxRenderedAttempts - - kept := make( - []deliveryResultRow, 0, maxRenderedAttempts, - ) - kept = append(kept, rows[:renderedAttemptsHead]...) - kept = append( - kept, rows[len(rows)-renderedAttemptsTail:]..., - ) - rows = kept - } - - views := make([]DeliveryResultView, len(rows)) - for i := range rows { - views[i] = rows[i].view(redactor) - } - - return views, omitted -} - -// HandleEntrypointCreate handles adding a new entrypoint. -func (h *Handlers) HandleEntrypointCreate() http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - userID, ok := h.getUserID(r) - if !ok { - http.Redirect( - w, r, "/pages/login", http.StatusSeeOther, - ) - - return - } - - sourceID := chi.URLParam(r, "sourceID") - - var webhook database.Webhook - - err := h.db.DB().Where( - "id = ? AND user_id = ?", sourceID, userID, - ).First(&webhook).Error - if err != nil { - h.renderError(w, r, http.StatusNotFound) - - return - } - - // The body size cap is enforced by the MaxBodySize - // middleware, which runs before CSRF parses the form. - err = r.ParseForm() - if err != nil { - h.renderError(w, r, http.StatusBadRequest) - - return - } - - description := r.PostFormValue("description") - - entrypoint := &database.Entrypoint{ - WebhookID: webhook.ID, - Path: uuid.New().String(), - Description: description, - Active: true, - } - - err = h.db.DB().Create(entrypoint).Error - if err != nil { - h.serverError(w, r, "failed to create entrypoint", err) - - return - } - - http.Redirect( - w, r, withNotice("/hook/"+webhook.ID, entrypointAdded), - http.StatusSeeOther, - ) - } -} - -// HandleEntrypointEdit handles changing an entrypoint's description. -// It writes only the description column, so the entrypoint keeps its -// URL, and an activate or deactivate saved since the page was shown -// is not undone. -func (h *Handlers) HandleEntrypointEdit() http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - userID, ok := h.getUserID(r) - if !ok { - http.Redirect( - w, r, "/pages/login", http.StatusSeeOther, - ) - - return - } - - sourceID := chi.URLParam(r, "sourceID") - entrypointID := chi.URLParam(r, "entrypointID") - - var webhook database.Webhook - - err := h.db.DB().Where( - "id = ? AND user_id = ?", sourceID, userID, - ).First(&webhook).Error - if err != nil { - h.renderError(w, r, http.StatusNotFound) - - return - } - - // The body size cap is enforced by the MaxBodySize - // middleware, which runs before CSRF parses the form. - err = r.ParseForm() - if err != nil { - h.renderError(w, r, http.StatusBadRequest) - - return - } - - result := h.db.DB().Model(&database.Entrypoint{}).Where( - "id = ? AND webhook_id = ?", entrypointID, webhook.ID, - ).Update("description", r.PostFormValue("description")) - if result.Error != nil { - h.serverError( - w, r, "failed to edit entrypoint", result.Error, - ) - - return - } - - // The id came from the URL and may name another webhook's - // entrypoint, which this webhook does not have. - if result.RowsAffected == 0 { - h.renderError(w, r, http.StatusNotFound) - - return - } - - http.Redirect( - w, r, withNotice("/hook/"+webhook.ID, entrypointSaved), - http.StatusSeeOther, - ) - } -} - -// HandleTargetCreate handles adding a new target to a webhook. -func (h *Handlers) HandleTargetCreate() http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - userID, ok := h.getUserID(r) - if !ok { - http.Redirect( - w, r, "/pages/login", http.StatusSeeOther, - ) - - return - } - - sourceID := chi.URLParam(r, "sourceID") - - h.renameMu.Lock() - defer h.renameMu.Unlock() - - var webhook database.Webhook - - err := h.db.DB().Where( - "id = ? AND user_id = ?", sourceID, userID, - ).First(&webhook).Error - if err != nil { - h.renderError(w, r, http.StatusNotFound) - - return - } - - // The body size cap is enforced by the MaxBodySize - // middleware, which runs before CSRF parses the form. - err = r.ParseForm() - if err != nil { - h.renderError(w, r, http.StatusBadRequest) - - return - } - - h.processTargetCreate(w, r, webhook) - } -} - -// 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( - w http.ResponseWriter, - r *http.Request, - webhook database.Webhook, -) { - in := targetFormInputFrom(r) - - target, errMsg, err := h.newTarget(r.Context(), webhook.ID, in) - if err != nil { - h.serverError(w, r, "failed to encode target config", err) - - return - } - - if errMsg != "" { - h.renderSourceDetail(w, r, webhook, in, errMsg) - - return - } - - err = h.db.DB().Create(target).Error - if err != nil { - h.serverError(w, r, "failed to create target", err) - - return - } - - http.Redirect( - w, r, withNotice("/hook/"+webhook.ID, targetAdded), - http.StatusSeeOther, - ) -} - -// 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) { - target := &database.Target{ - WebhookID: webhookID, - Type: in.Type, - Active: true, - } - - errMsg, err := h.setTargetFromForm(ctx, target, in) - if err != nil || errMsg != "" { - return nil, errMsg, err - } - - return target, "", nil -} - -// setTargetFromForm validates a target form against the target's type -// and, when it accepts it, sets the target's name, configuration and -// retry count from it. It returns the message the form shows for -// anything it refuses, an unknown type among them, and then leaves the -// target unchanged; an error is the server's fault, as for newTarget. -// The add target form and the target edit form both go through here, -// so the two cannot come to disagree about what a target may be. -func (h *Handlers) setTargetFromForm( - ctx context.Context, - target *database.Target, - in targetFormInput, -) (string, error) { - if in.Name == "" { - return "Name is required", nil - } - - configJSON, errMsg, err := h.buildTargetConfig(ctx, target.Type, in) - if err != nil || errMsg != "" { - return errMsg, err - } - - // An empty max_retries keeps the target's count: the - // fire-and-forget default of 0 for a new target, and the stored - // count for an edited one, since the forms for target types that - // do not retry have no such field. A value that is filled in but - // invalid is refused rather than becoming that count, so a typo - // cannot destroy the count a target is delivering with. - maxRetries, err := parseMaxRetries(in.MaxRetries, target.MaxRetries) - if err != nil { - return "Invalid delivery attempts: " + retriesErrorMessage(err), nil - } - - target.Name = in.Name - target.Config = configJSON - target.MaxRetries = maxRetries - - return "", nil -} - -// targetFormInput carries the raw values of a target form. Both the -// create and the edit path fill one and hand it to setTargetFromForm, -// so neither can come to validate a target differently from the -// other. Both forms are filled from one: the edit form with the -// stored values, and a refused form with the values submitted. -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 - // for a Slack target. - URL string - // Headers is an HTTP target's headers, one "Name: value" per - // line. - Headers string - // Timeout is an HTTP target's per-request timeout in seconds. - Timeout string - // MaxRetries is an HTTP or Slack target's max_retries. - MaxRetries string - // Expiry is a database (archive) target's row expiry. - Expiry string - // Rotation is a database (archive) target's rotation. - Rotation string -} - -// targetFormInputFrom reads a target form from a request body. The -// body size cap is enforced by the MaxBodySize middleware, which runs -// before CSRF parses the form. -// -// Every field 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. The headers field is under the -// same rule and for the same reason: its values are authorization -// tokens. -func targetFormInputFrom(r *http.Request) targetFormInput { - return targetFormInput{ - Name: r.PostFormValue("name"), - Type: database.TargetType(r.PostFormValue("type")), - URL: r.PostFormValue("url"), - Headers: r.PostFormValue("headers"), - Timeout: r.PostFormValue("timeout"), - MaxRetries: r.PostFormValue("max_retries"), - Expiry: r.PostFormValue("expiry"), - Rotation: r.PostFormValue("rotation"), - } -} - -// buildTargetConfig builds the JSON config string for a target from -// the submitted form values, or returns the message the form shows -// 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( - ctx context.Context, - targetType database.TargetType, - in targetFormInput, -) (string, string, error) { - switch targetType { - case database.TargetTypeHTTP: - return h.buildHTTPTargetConfig(ctx, in) - case database.TargetTypeSlack: - return h.buildSlackTargetConfig(ctx, in.URL) - case database.TargetTypeDatabase: - return buildDatabaseTargetConfig(in.Expiry, in.Rotation) - case database.TargetTypeLog: - return "", "", nil - default: - return "", "Invalid target type", nil - } -} - -// buildHTTPTargetConfig builds config JSON for an HTTP target: an -// SSRF-validated destination plus the optional headers and timeout -// the delivery path honours. -func (h *Handlers) buildHTTPTargetConfig( - ctx context.Context, - in targetFormInput, -) (string, string, error) { - errMsg := h.validateTargetURL( - ctx, in.URL, "URL is required for HTTP targets", - ) - if errMsg != "" { - return "", errMsg, nil - } - - headers, err := delivery.ParseTargetHeaders(in.Headers) - if err != nil { - return "", fmt.Sprintf("Invalid headers: %v", err), nil - } - - timeout, err := delivery.ParseTargetTimeout(in.Timeout) - if err != nil { - return "", fmt.Sprintf("Invalid timeout: %v", err), nil - } - - configJSON, err := marshalTargetConfig(delivery.HTTPTargetConfig{ - URL: in.URL, - Headers: headers, - Timeout: timeout, - }) - - return configJSON, "", err -} - -// buildSlackTargetConfig builds config JSON for a Slack target, -// whose whole configuration is one SSRF-validated webhook URL. -func (h *Handlers) buildSlackTargetConfig( - ctx context.Context, - targetURL string, -) (string, string, error) { - errMsg := h.validateTargetURL( - ctx, targetURL, - "Webhook URL is required for Slack targets", - ) - if errMsg != "" { - return "", errMsg, nil - } - - configJSON, err := marshalTargetConfig(delivery.SlackTargetConfig{ - WebhookURL: targetURL, - }) - - return configJSON, "", err -} - -// validateTargetURL refuses an empty or SSRF-blocked destination, -// returning the message the form shows, or "" when the destination -// is accepted. missingMsg is the message for no URL at all. -// -// 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 -// reached storage without passing through here would reopen the hole -// the guard closes. -func (h *Handlers) validateTargetURL( - ctx context.Context, - targetURL, missingMsg string, -) string { - if targetURL == "" { - return missingMsg - } - - err := h.ssrf.ValidateTargetURL(ctx, targetURL) - if err != nil { - // The submitted URL can be a credential (a Slack - // incoming webhook URL is a bearer token), so the log - // records only its scheme and host. - h.log.Warn( - "target URL blocked by SSRF protection", - "url", delivery.MaskURL(targetURL), - "error", err, - ) - - msg := "Invalid target URL: " + err.Error() - - // Only a private or reserved address's refusal says how - // to allow it. Other refusals never do: link-local, the - // unspecified addresses and the unconditional metadata - // addresses cannot be opened, and the default - // blocklist's public addresses, which listing does open, - // hand out credentials. - if errors.Is(err, delivery.ErrBlockedPrivateOrReservedIP) { - msg += ". Private and reserved addresses are refused " + - "by default; the server's ALLOWED_EGRESS_CIDRS " + - "setting allows named networks (see \"Allowing " + - "egress to your own network\" in the README)." - } - - return msg - } - - return "" -} - -// marshalTargetConfig serialises a target configuration for storage. -func marshalTargetConfig(cfg any) (string, error) { - configBytes, err := json.Marshal(cfg) - if err != nil { - return "", err - } - - return string(configBytes), nil -} - -// buildDatabaseTargetConfig builds config JSON for a database -// (archive) target. The optional expiry and rotation are validated -// here, at creation time, so a bad value is refused instead of -// failing every subsequent delivery. Each is stored only when set, -// and with neither the config is empty (the keep-forever, one-file -// default). -func buildDatabaseTargetConfig( - expiry, rotation string, -) (string, string, error) { - expiry = strings.TrimSpace(expiry) - - err := delivery.ValidateArchiveExpiry(expiry) - if err != nil { - return "", fmt.Sprintf("Invalid archive expiry: %v", err), nil - } - - err = delivery.ValidateArchiveRotation(rotation) - if err != nil { - return "", fmt.Sprintf("Invalid archive rotation: %v", err), nil - } - - cfg := map[string]any{} - - if expiry != "" { - cfg["expiry"] = expiry - } - - if rotation != "" { - cfg["rotation"] = rotation - } - - if len(cfg) == 0 { - return "", "", nil - } - - configJSON, err := marshalTargetConfig(cfg) - - return configJSON, "", err -} - -// HandleEntrypointDelete handles deleting an entrypoint. -func (h *Handlers) HandleEntrypointDelete() http.HandlerFunc { - return h.deleteChildResource( - "entrypointID", &database.Entrypoint{}, - "failed to delete entrypoint", - nil, - entrypointDeleted, - ) -} - -// HandleTargetDelete handles deleting a target. A deleted -// database target's archive writer is evicted and its handle -// closed; the archive file is left on disk. -func (h *Handlers) HandleTargetDelete() http.HandlerFunc { - return h.deleteChildResource( - "targetID", &database.Target{}, - "failed to delete target", - h.evictTargetArchiveWriter, - targetDeleted, - ) -} - -// deleteChildResource returns a handler that deletes a child -// resource (entrypoint or target) belonging to a webhook. The -// optional afterDelete hook runs with the child's id once the -// delete has removed it, before the redirect, which carries done as -// its notice. -func (h *Handlers) deleteChildResource( - idParam string, - model any, - errMsg string, - afterDelete func(childID string), - done noticeCode, -) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - userID, ok := h.getUserID(r) - if !ok { - http.Redirect( - w, r, "/pages/login", http.StatusSeeOther, - ) - - return - } - - sourceID := chi.URLParam(r, "sourceID") - childID := chi.URLParam(r, idParam) - - var webhook database.Webhook - - err := h.db.DB().Where( - "id = ? AND user_id = ?", sourceID, userID, - ).First(&webhook).Error - if err != nil { - h.renderError(w, r, http.StatusNotFound) - - return - } - - result := h.db.DB().Where( - "id = ? AND webhook_id = ?", - childID, webhook.ID, - ).Delete(model) - if result.Error != nil { - h.serverError(w, r, errMsg, result.Error) - - return - } - - // Only for a row this webhook really had: the id came from - // the URL and may name another webhook's child. - if afterDelete != nil && result.RowsAffected > 0 { - afterDelete(childID) - } - - http.Redirect( - w, r, - withNotice("/hook/"+webhook.ID, done), - http.StatusSeeOther, - ) - } -} - -// HandleEntrypointToggle handles toggling an entrypoint's -// active state. -func (h *Handlers) HandleEntrypointToggle() http.HandlerFunc { - return h.toggleChildResource( - "entrypointID", - func(webhookID, childID string) (bool, error) { - var ep database.Entrypoint - - err := h.db.DB().Where( - "id = ? AND webhook_id = ?", - childID, webhookID, - ).First(&ep).Error - if err != nil { - return false, err - } - - // Only the active column: saving the whole row would - // write back the description read above over an edit - // saved since. - active := !ep.Active - - return active, h.db.DB().Model(&ep). - Update("active", active).Error - }, - "failed to toggle entrypoint", - entrypointActivated, entrypointDeactivated, - ) -} - -// HandleTargetToggle handles toggling a target's active state. -func (h *Handlers) HandleTargetToggle() http.HandlerFunc { - return h.toggleChildResource( - "targetID", - func(webhookID, childID string) (bool, error) { - var tgt database.Target - - err := h.db.DB().Where( - "id = ? AND webhook_id = ?", - childID, webhookID, - ).First(&tgt).Error - if err != nil { - return false, err - } - - // Only the active column: saving the whole row would - // write back the name and settings read above over an - // edit saved since. - active := !tgt.Active - - return active, h.db.DB().Model(&tgt). - Update("active", active).Error - }, - "failed to toggle target", - targetActivated, targetDeactivated, - ) -} - -// toggleChildResource returns a handler that toggles the active -// state of a child resource belonging to a webhook. toggleFn returns -// the new state, and the redirect carries activated or deactivated as -// its notice to match. -func (h *Handlers) toggleChildResource( - idParam string, - toggleFn func(webhookID, childID string) (bool, error), - errMsg string, - activated, deactivated noticeCode, -) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - userID, ok := h.getUserID(r) - if !ok { - http.Redirect( - w, r, "/pages/login", http.StatusSeeOther, - ) - - return - } - - sourceID := chi.URLParam(r, "sourceID") - childID := chi.URLParam(r, idParam) - - var webhook database.Webhook - - err := h.db.DB().Where( - "id = ? AND user_id = ?", sourceID, userID, - ).First(&webhook).Error - if err != nil { - h.renderError(w, r, http.StatusNotFound) - - return - } - - active, err := toggleFn(webhook.ID, childID) - if err != nil { - h.serverError(w, r, errMsg, err) - - return - } - - done := deactivated - if active { - done = activated - } - - http.Redirect( - w, r, - withNotice("/hook/"+webhook.ID, done), - http.StatusSeeOther, - ) - } -} - -// getUserID extracts the user ID from the session. -func (h *Handlers) getUserID( - r *http.Request, -) (string, bool) { - sess, err := h.session.Get(r) - if err != nil { - return "", false - } - - if !h.session.IsAuthenticated(sess) { - return "", false - } - - return h.session.GetUserID(sess) -} diff --git a/internal/handlers/target_create.go b/internal/handlers/target_create.go new file mode 100644 index 0000000..c8cd048 --- /dev/null +++ b/internal/handlers/target_create.go @@ -0,0 +1,384 @@ +package handlers + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "strings" + + "github.com/go-chi/chi" + "sneak.berlin/go/webhooker/internal/database" + "sneak.berlin/go/webhooker/internal/delivery" +) + +// HandleTargetCreate handles adding a new target to a webhook. +func (h *Handlers) HandleTargetCreate() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + userID, ok := h.getUserID(r) + if !ok { + http.Redirect( + w, r, "/pages/login", http.StatusSeeOther, + ) + + return + } + + sourceID := chi.URLParam(r, "sourceID") + + h.renameMu.Lock() + defer h.renameMu.Unlock() + + var webhook database.Webhook + + err := h.db.DB().Where( + "id = ? AND user_id = ?", sourceID, userID, + ).First(&webhook).Error + if err != nil { + h.renderError(w, r, http.StatusNotFound) + + return + } + + // The body size cap is enforced by the MaxBodySize + // middleware, which runs before CSRF parses the form. + err = r.ParseForm() + if err != nil { + h.renderError(w, r, http.StatusBadRequest) + + return + } + + h.processTargetCreate(w, r, webhook) + } +} + +// 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( + w http.ResponseWriter, + r *http.Request, + webhook database.Webhook, +) { + in := targetFormInputFrom(r) + + target, errMsg, err := h.newTarget(r.Context(), webhook.ID, in) + if err != nil { + h.serverError(w, r, "failed to encode target config", err) + + return + } + + if errMsg != "" { + h.renderSourceDetail(w, r, webhook, in, errMsg) + + return + } + + err = h.db.DB().Create(target).Error + if err != nil { + h.serverError(w, r, "failed to create target", err) + + return + } + + http.Redirect( + w, r, withNotice("/hook/"+webhook.ID, targetAdded), + http.StatusSeeOther, + ) +} + +// 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) { + target := &database.Target{ + WebhookID: webhookID, + Type: in.Type, + Active: true, + } + + errMsg, err := h.setTargetFromForm(ctx, target, in) + if err != nil || errMsg != "" { + return nil, errMsg, err + } + + return target, "", nil +} + +// setTargetFromForm validates a target form against the target's type +// and, when it accepts it, sets the target's name, configuration and +// retry count from it. It returns the message the form shows for +// anything it refuses, an unknown type among them, and then leaves the +// target unchanged; an error is the server's fault, as for newTarget. +// The add target form and the target edit form both go through here, +// so the two cannot come to disagree about what a target may be. +func (h *Handlers) setTargetFromForm( + ctx context.Context, + target *database.Target, + in targetFormInput, +) (string, error) { + if in.Name == "" { + return "Name is required", nil + } + + configJSON, errMsg, err := h.buildTargetConfig(ctx, target.Type, in) + if err != nil || errMsg != "" { + return errMsg, err + } + + // An empty max_retries keeps the target's count: the + // fire-and-forget default of 0 for a new target, and the stored + // count for an edited one, since the forms for target types that + // do not retry have no such field. A value that is filled in but + // invalid is refused rather than becoming that count, so a typo + // cannot destroy the count a target is delivering with. + maxRetries, err := parseMaxRetries(in.MaxRetries, target.MaxRetries) + if err != nil { + return "Invalid delivery attempts: " + retriesErrorMessage(err), nil + } + + target.Name = in.Name + target.Config = configJSON + target.MaxRetries = maxRetries + + return "", nil +} + +// targetFormInput carries the raw values of a target form. Both the +// create and the edit path fill one and hand it to setTargetFromForm, +// so neither can come to validate a target differently from the +// other. Both forms are filled from one: the edit form with the +// stored values, and a refused form with the values submitted. +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 + // for a Slack target. + URL string + // Headers is an HTTP target's headers, one "Name: value" per + // line. + Headers string + // Timeout is an HTTP target's per-request timeout in seconds. + Timeout string + // MaxRetries is an HTTP or Slack target's max_retries. + MaxRetries string + // Expiry is a database (archive) target's row expiry. + Expiry string + // Rotation is a database (archive) target's rotation. + Rotation string +} + +// targetFormInputFrom reads a target form from a request body. The +// body size cap is enforced by the MaxBodySize middleware, which runs +// before CSRF parses the form. +// +// Every field 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. The headers field is under the +// same rule and for the same reason: its values are authorization +// tokens. +func targetFormInputFrom(r *http.Request) targetFormInput { + return targetFormInput{ + Name: r.PostFormValue("name"), + Type: database.TargetType(r.PostFormValue("type")), + URL: r.PostFormValue("url"), + Headers: r.PostFormValue("headers"), + Timeout: r.PostFormValue("timeout"), + MaxRetries: r.PostFormValue("max_retries"), + Expiry: r.PostFormValue("expiry"), + Rotation: r.PostFormValue("rotation"), + } +} + +// buildTargetConfig builds the JSON config string for a target from +// the submitted form values, or returns the message the form shows +// 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( + ctx context.Context, + targetType database.TargetType, + in targetFormInput, +) (string, string, error) { + switch targetType { + case database.TargetTypeHTTP: + return h.buildHTTPTargetConfig(ctx, in) + case database.TargetTypeSlack: + return h.buildSlackTargetConfig(ctx, in.URL) + case database.TargetTypeDatabase: + return buildDatabaseTargetConfig(in.Expiry, in.Rotation) + case database.TargetTypeLog: + return "", "", nil + default: + return "", "Invalid target type", nil + } +} + +// buildHTTPTargetConfig builds config JSON for an HTTP target: an +// SSRF-validated destination plus the optional headers and timeout +// the delivery path honours. +func (h *Handlers) buildHTTPTargetConfig( + ctx context.Context, + in targetFormInput, +) (string, string, error) { + errMsg := h.validateTargetURL( + ctx, in.URL, "URL is required for HTTP targets", + ) + if errMsg != "" { + return "", errMsg, nil + } + + headers, err := delivery.ParseTargetHeaders(in.Headers) + if err != nil { + return "", fmt.Sprintf("Invalid headers: %v", err), nil + } + + timeout, err := delivery.ParseTargetTimeout(in.Timeout) + if err != nil { + return "", fmt.Sprintf("Invalid timeout: %v", err), nil + } + + configJSON, err := marshalTargetConfig(delivery.HTTPTargetConfig{ + URL: in.URL, + Headers: headers, + Timeout: timeout, + }) + + return configJSON, "", err +} + +// buildSlackTargetConfig builds config JSON for a Slack target, +// whose whole configuration is one SSRF-validated webhook URL. +func (h *Handlers) buildSlackTargetConfig( + ctx context.Context, + targetURL string, +) (string, string, error) { + errMsg := h.validateTargetURL( + ctx, targetURL, + "Webhook URL is required for Slack targets", + ) + if errMsg != "" { + return "", errMsg, nil + } + + configJSON, err := marshalTargetConfig(delivery.SlackTargetConfig{ + WebhookURL: targetURL, + }) + + return configJSON, "", err +} + +// validateTargetURL refuses an empty or SSRF-blocked destination, +// returning the message the form shows, or "" when the destination +// is accepted. missingMsg is the message for no URL at all. +// +// 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 +// reached storage without passing through here would reopen the hole +// the guard closes. +func (h *Handlers) validateTargetURL( + ctx context.Context, + targetURL, missingMsg string, +) string { + if targetURL == "" { + return missingMsg + } + + err := h.ssrf.ValidateTargetURL(ctx, targetURL) + if err != nil { + // The submitted URL can be a credential (a Slack + // incoming webhook URL is a bearer token), so the log + // records only its scheme and host. + h.log.Warn( + "target URL blocked by SSRF protection", + "url", delivery.MaskURL(targetURL), + "error", err, + ) + + msg := "Invalid target URL: " + err.Error() + + // Only a private or reserved address's refusal says how + // to allow it. Other refusals never do: link-local, the + // unspecified addresses and the unconditional metadata + // addresses cannot be opened, and the default + // blocklist's public addresses, which listing does open, + // hand out credentials. + if errors.Is(err, delivery.ErrBlockedPrivateOrReservedIP) { + msg += ". Private and reserved addresses are refused " + + "by default; the server's ALLOWED_EGRESS_CIDRS " + + "setting allows named networks (see \"Allowing " + + "egress to your own network\" in the README)." + } + + return msg + } + + return "" +} + +// marshalTargetConfig serialises a target configuration for storage. +func marshalTargetConfig(cfg any) (string, error) { + configBytes, err := json.Marshal(cfg) + if err != nil { + return "", err + } + + return string(configBytes), nil +} + +// buildDatabaseTargetConfig builds config JSON for a database +// (archive) target. The optional expiry and rotation are validated +// here, at creation time, so a bad value is refused instead of +// failing every subsequent delivery. Each is stored only when set, +// and with neither the config is empty (the keep-forever, one-file +// default). +func buildDatabaseTargetConfig( + expiry, rotation string, +) (string, string, error) { + expiry = strings.TrimSpace(expiry) + + err := delivery.ValidateArchiveExpiry(expiry) + if err != nil { + return "", fmt.Sprintf("Invalid archive expiry: %v", err), nil + } + + err = delivery.ValidateArchiveRotation(rotation) + if err != nil { + return "", fmt.Sprintf("Invalid archive rotation: %v", err), nil + } + + cfg := map[string]any{} + + if expiry != "" { + cfg["expiry"] = expiry + } + + if rotation != "" { + cfg["rotation"] = rotation + } + + if len(cfg) == 0 { + return "", "", nil + } + + configJSON, err := marshalTargetConfig(cfg) + + return configJSON, "", err +} diff --git a/internal/handlers/target_delete.go b/internal/handlers/target_delete.go new file mode 100644 index 0000000..4402b3a --- /dev/null +++ b/internal/handlers/target_delete.go @@ -0,0 +1,31 @@ +package handlers + +import ( + "net/http" + + "sneak.berlin/go/webhooker/internal/database" +) + +// HandleTargetDelete handles deleting a target. A deleted +// database target's archive writer is evicted and its handle +// closed; the archive file is left on disk. +func (h *Handlers) HandleTargetDelete() http.HandlerFunc { + return h.deleteChildResource( + "targetID", &database.Target{}, + "failed to delete target", + h.evictTargetArchiveWriter, + targetDeleted, + ) +} + +// evictTargetArchiveWriter is evictArchiveWriter for one deleted +// target, and leaves its archive file on disk for the same reason. +// A target that is not a database target has no writer, and +// evicting it does nothing. +func (h *Handlers) evictTargetArchiveWriter(targetID string) { + if h.archives == nil { + return + } + + h.archives.EvictTarget(targetID) +} diff --git a/internal/handlers/target_toggle.go b/internal/handlers/target_toggle.go new file mode 100644 index 0000000..d9780f4 --- /dev/null +++ b/internal/handlers/target_toggle.go @@ -0,0 +1,35 @@ +package handlers + +import ( + "net/http" + + "sneak.berlin/go/webhooker/internal/database" +) + +// HandleTargetToggle handles toggling a target's active state. +func (h *Handlers) HandleTargetToggle() http.HandlerFunc { + return h.toggleChildResource( + "targetID", + func(webhookID, childID string) (bool, error) { + var tgt database.Target + + err := h.db.DB().Where( + "id = ? AND webhook_id = ?", + childID, webhookID, + ).First(&tgt).Error + if err != nil { + return false, err + } + + // Only the active column: saving the whole row would + // write back the name and settings read above over an + // edit saved since. + active := !tgt.Active + + return active, h.db.DB().Model(&tgt). + Update("active", active).Error + }, + "failed to toggle target", + targetActivated, targetDeactivated, + ) +} diff --git a/internal/handlers/webhook_create.go b/internal/handlers/webhook_create.go new file mode 100644 index 0000000..bfa1fb9 --- /dev/null +++ b/internal/handlers/webhook_create.go @@ -0,0 +1,263 @@ +package handlers + +import ( + "context" + "net/http" + "strconv" + + "github.com/google/uuid" + "sneak.berlin/go/webhooker/internal/database" +) + +// HandleSourceCreate shows the form to create a new webhook. +func (h *Handlers) HandleSourceCreate() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + h.renderTemplate( + w, r, "sources_new.html", + newSourceFormData("", sourceFormInput{ + RetentionDays: strconv.Itoa( + database.DefaultRetentionDays, + ), + }), + ) + } +} + +// sourceFormInput carries the raw values of the new webhook form. A +// refused submission is shown again from it, so every value entered +// comes back, retention included. +type sourceFormInput struct { + Name string + Description string + RetentionDays string + // HTTPURL, when not empty, asks for an HTTP target with this + // destination. + HTTPURL string + // Archive asks for a database (archive) target, whose rows expire + // after ArchiveExpiry and whose files rotate by ArchiveRotation. + Archive bool + ArchiveExpiry string + ArchiveRotation string +} + +// newSourceFormData builds the template data for the webhook creation +// form. It carries the retention default, which the form's help text +// names, from database.DefaultRetentionDays rather than a hardcoded +// copy of the same policy. +func newSourceFormData( + errMsg string, in sourceFormInput, +) map[string]any { + return map[string]any{ + tmplKeyError: errMsg, + "Form": in, + "DefaultRetentionDays": database.DefaultRetentionDays, + tmplKeyArchiveExpiryChoices: archiveExpiryOptions( + in.ArchiveExpiry, + ), + tmplKeyArchiveRotationChoices: archiveRotationOptions( + in.ArchiveRotation, + ), + } +} + +// HandleSourceCreateSubmit handles the webhook creation form +// submission. +func (h *Handlers) HandleSourceCreateSubmit() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + userID, ok := h.getUserID(r) + if !ok { + http.Redirect( + w, r, "/pages/login", http.StatusSeeOther, + ) + + return + } + + // The body size cap is enforced by the MaxBodySize + // middleware, which runs before CSRF parses the form. + err := r.ParseForm() + if err != nil { + h.renderError(w, r, http.StatusBadRequest) + + return + } + + in := sourceFormInput{ + Name: r.PostFormValue("name"), + Description: r.PostFormValue("description"), + RetentionDays: r.PostFormValue("retention_days"), + HTTPURL: r.PostFormValue("http_url"), + Archive: r.PostFormValue("archive") != "", + ArchiveExpiry: r.PostFormValue("archive_expiry"), + ArchiveRotation: r.PostFormValue("archive_rotation"), + } + + refuse := func(errMsg string) { + h.renderTemplateStatus( + w, r, "sources_new.html", + newSourceFormData(errMsg, in), + http.StatusBadRequest, + ) + } + + if in.Name == "" { + refuse("Name is required") + + return + } + + retentionDays, errMsg := parseRetentionDays( + in.RetentionDays, database.DefaultRetentionDays, + ) + if errMsg != "" { + refuse(errMsg) + + return + } + + targets, errMsg, err := h.newWebhookTargets(r.Context(), in) + if err != nil { + h.serverError(w, r, "failed to encode target config", err) + + return + } + + if errMsg != "" { + refuse(errMsg) + + return + } + + h.createWebhookWithEntrypoint(w, r, &database.Webhook{ + UserID: userID, + Name: in.Name, + Description: in.Description, + RetentionDays: retentionDays, + }, targets) + } +} + +// newWebhookTargets validates the targets the new webhook form asks +// for and returns the rows to create with the webhook, or the message +// the form shows for the first one it refuses. A filled-in HTTP URL +// asks for an HTTP target named "HTTP", and the archive checkbox for a +// database target named "Archive". Each goes through newTarget, as on +// the webhook page's add target form. The rows have no WebhookID yet: +// the webhook has no ID until it is created. +func (h *Handlers) newWebhookTargets( + ctx context.Context, + in sourceFormInput, +) ([]*database.Target, string, error) { + var requested []targetFormInput + + if in.HTTPURL != "" { + requested = append(requested, targetFormInput{ + Name: "HTTP", + Type: database.TargetTypeHTTP, + URL: in.HTTPURL, + }) + } + + if in.Archive { + requested = append(requested, targetFormInput{ + Name: "Archive", + Type: database.TargetTypeDatabase, + Expiry: in.ArchiveExpiry, + Rotation: in.ArchiveRotation, + }) + } + + targets := make([]*database.Target, 0, len(requested)) + + for _, form := range requested { + target, errMsg, err := h.newTarget(ctx, "", form) + if err != nil || errMsg != "" { + return nil, errMsg, err + } + + targets = append(targets, target) + } + + return targets, "", nil +} + +// createWebhookWithEntrypoint creates a webhook, its default +// entrypoint and the given targets in a transaction. +func (h *Handlers) createWebhookWithEntrypoint( + w http.ResponseWriter, + r *http.Request, + webhook *database.Webhook, + targets []*database.Target, +) { + err := h.commitWebhook(webhook, targets) + if err != nil { + h.serverError(w, r, "failed to create webhook", err) + + return + } + + err = h.dbMgr.CreateDB(webhook.ID) + if err != nil { + h.log.Error( + "failed to create webhook event database", + "webhook_id", webhook.ID, "error", err, + ) + } + + h.log.Info("webhook created", + "webhook_id", webhook.ID, + "name", webhook.Name, "user_id", webhook.UserID, + ) + + http.Redirect( + w, r, withNotice("/hook/"+webhook.ID, webhookCreated), + http.StatusSeeOther, + ) +} + +// commitWebhook creates a webhook, its default entrypoint and the +// given targets in a transaction. Returns an error on failure (rolls +// back). +func (h *Handlers) commitWebhook( + webhook *database.Webhook, + targets []*database.Target, +) error { + tx := h.db.DB().Begin() + if tx.Error != nil { + return tx.Error + } + + err := tx.Create(webhook).Error + if err != nil { + tx.Rollback() + + return err + } + + entrypoint := &database.Entrypoint{ + WebhookID: webhook.ID, + Path: uuid.New().String(), + Description: "Default entrypoint", + Active: true, + } + + err = tx.Create(entrypoint).Error + if err != nil { + tx.Rollback() + + return err + } + + for _, target := range targets { + target.WebhookID = webhook.ID + + err = tx.Create(target).Error + if err != nil { + tx.Rollback() + + return err + } + } + + return tx.Commit().Error +} diff --git a/internal/handlers/webhook_delete.go b/internal/handlers/webhook_delete.go new file mode 100644 index 0000000..209081d --- /dev/null +++ b/internal/handlers/webhook_delete.go @@ -0,0 +1,170 @@ +package handlers + +import ( + "errors" + "net/http" + + "github.com/go-chi/chi" + "sneak.berlin/go/webhooker/internal/database" +) + +// HandleSourceDelete handles webhook deletion. +func (h *Handlers) HandleSourceDelete() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + userID, ok := h.getUserID(r) + if !ok { + http.Redirect( + w, r, "/pages/login", http.StatusSeeOther, + ) + + return + } + + sourceID := chi.URLParam(r, "sourceID") + + var webhook database.Webhook + + err := h.db.DB().Where( + "id = ? AND user_id = ?", sourceID, userID, + ).First(&webhook).Error + if err != nil { + h.renderError(w, r, http.StatusNotFound) + + return + } + + h.deleteWebhookResources(w, r, webhook, userID) + } +} + +// The messages deleteWebhookResources logs when a file of the event +// database cannot be removed: the database file itself, or only a +// sidecar once the database file is gone. +const ( + eventDBLeftMsg = "webhook deleted, but its event database file is " + + "still on disk; remove it by hand" + sidecarLeftMsg = "webhook deleted and its events are gone, but a " + + "-wal or -shm sidecar of its event database is " + + "still on disk; remove it by hand" +) + +// deleteWebhookResources soft-deletes config and hard-deletes +// the per-webhook event database. +func (h *Handlers) deleteWebhookResources( + w http.ResponseWriter, + r *http.Request, + webhook database.Webhook, + userID string, +) { + // The configuration delete commits before the event database + // is touched. No transaction spans the main database and the + // filesystem, so one side has to go first: committing the + // configuration first means a later failure leaves an unused + // event database file on disk, while removing the event + // database first would mean a failed commit destroys the + // history of a webhook that still exists. A leftover file can + // be removed by hand; deleted history cannot be recovered. + err := h.commitWebhookDeletion(&webhook) + if err != nil { + h.serverError(w, r, "failed to delete webhook", err) + + return + } + + h.log.Info( + "webhook deleted", + "webhook_id", webhook.ID, + "user_id", userID, + ) + + // Release the delivery engine's per-webhook archiving state + // so a deleted webhook's archive writer (and any handle open + // within its debounce window) does not linger for the + // process lifetime. The archive file itself is deliberately + // left on disk; see evictArchiveWriter. + h.evictArchiveWriter(webhook.ID) + + err = h.dbMgr.DeleteDB(webhook.ID) + if err != nil { + // The configuration is committed, so the webhook is gone, + // but a file of its event database is still on disk with + // nothing referencing it. Report the failure rather than + // redirecting as though everything succeeded: the file + // needs removing by hand, and the logged error names it. + // When only a sidecar is left, the events are already + // gone, and the message must not suggest they survive. + msg := eventDBLeftMsg + if errors.Is(err, database.ErrSidecarNotRemoved) { + msg = sidecarLeftMsg + } + + h.serverError(w, r, msg, err) + + return + } + + http.Redirect( + w, r, withNotice("/hooks", webhookDeleted), http.StatusSeeOther, + ) +} + +// commitWebhookDeletion soft-deletes a webhook's entrypoints, +// targets and the webhook row in one transaction. Every +// statement is checked and any failure rolls the whole +// transaction back, so a caller that gets an error knows the +// configuration is untouched and the event database must be +// left alone. +func (h *Handlers) commitWebhookDeletion( + webhook *database.Webhook, +) error { + tx := h.db.DB().Begin() + if tx.Error != nil { + return tx.Error + } + + err := tx.Where( + "webhook_id = ?", webhook.ID, + ).Delete(&database.Entrypoint{}).Error + if err != nil { + tx.Rollback() + + return err + } + + err = tx.Where( + "webhook_id = ?", webhook.ID, + ).Delete(&database.Target{}).Error + if err != nil { + tx.Rollback() + + return err + } + + err = tx.Delete(webhook).Error + if err != nil { + tx.Rollback() + + return err + } + + return tx.Commit().Error +} + +// evictArchiveWriter asks the delivery engine to drop the cached +// archive writers of a webhook's database targets, closing their +// archive file handles. +// +// The archive database files are NOT deleted. Unlike the event +// database — which is per-webhook working storage and is +// hard-deleted with the webhook — an archive is explicitly +// long-term storage that an operator may want to keep or move +// away for offline retention. Destroying it as a side effect of +// deleting a webhook would be a surprising and unrecoverable +// data loss, so the file is left for the operator to handle. +func (h *Handlers) evictArchiveWriter(webhookID string) { + if h.archives == nil { + return + } + + h.archives.EvictWebhook(webhookID) +} diff --git a/internal/handlers/webhook_detail.go b/internal/handlers/webhook_detail.go new file mode 100644 index 0000000..ed3e783 --- /dev/null +++ b/internal/handlers/webhook_detail.go @@ -0,0 +1,133 @@ +package handlers + +import ( + "net/http" + "time" + + "github.com/go-chi/chi" + "sneak.berlin/go/webhooker/internal/database" + "sneak.berlin/go/webhooker/internal/reqtls" +) + +// HandleSourceDetail shows details for a specific webhook. +func (h *Handlers) HandleSourceDetail() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + userID, ok := h.getUserID(r) + if !ok { + http.Redirect( + w, r, "/pages/login", http.StatusSeeOther, + ) + + return + } + + sourceID := chi.URLParam(r, "sourceID") + + var webhook database.Webhook + + err := h.db.DB().Where( + "id = ? AND user_id = ?", sourceID, userID, + ).First(&webhook).Error + if err != nil { + h.renderError(w, r, http.StatusNotFound) + + return + } + + h.renderSourceDetail(w, r, webhook, targetFormInput{}, "") + } +} + +// 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( + w http.ResponseWriter, + r *http.Request, + webhook database.Webhook, + targetForm targetFormInput, + targetErr string, +) { + var entrypoints []database.Entrypoint + + h.db.DB().Where( + "webhook_id = ?", webhook.ID, + ).Find(&entrypoints) + + var targets []database.Target + + h.db.DB().Where( + "webhook_id = ?", webhook.ID, + ).Find(&targets) + + entrypointViews := NewEntrypointViews(entrypoints) + + var events []RecentEventView + + if h.dbMgr.DBExists(webhook.ID) { + webhookDB, err := h.dbMgr.GetDB(webhook.ID) + if err != nil { + h.serverError(w, r, "failed to get webhook database", err) + + return + } + + events, err = loadRecentEvents( + webhookDB, webhook.ID, singleHTTPTargetID(targets), + ) + if err != nil { + h.serverError(w, r, "failed to load recent events", err) + + return + } + + err = addEntrypointEvents( + webhookDB, &webhook, entrypointViews, time.Now(), + ) + if err != nil { + h.serverError(w, r, "failed to count entrypoint events", err) + + return + } + } + + scheme := "http" + if reqtls.IsTLS(r) { + scheme = "https" + } + + // The host is the client's Host header, unvalidated. It is + // inert only because source_detail.html renders BaseURL as + // text, inside a element and in an entrypoint's delete + // prompt; putting it in an href or any other URL context + // needs it constrained first. + baseURL := scheme + "://" + r.Host + + // The template calls Webhook methods, which take pointer + // receivers; html/template cannot address a value stored in a map. + data := map[string]any{ + tmplKeyWebhook: &webhook, + // Targets are projected to a display-safe view: a + // target's stored config blob holds a credential, and it + // must never reach a template. + "Entrypoints": entrypointViews, + "Targets": h.targetRows(&webhook, targets), + "Events": events, + "BaseURL": baseURL, + "Stats": h.loadWebhookStats(webhook.ID, entrypoints, targets), + tmplKeyTargetForm: targetForm, + "TargetError": targetErr, + // The add target form's selects start on its expiry and + // rotation through Alpine, so no choice is selected here. + tmplKeyArchiveExpiryChoices: archiveExpiryChoices(), + tmplKeyArchiveRotationChoices: archiveRotationChoices(), + } + + status := http.StatusOK + if targetErr != "" { + status = http.StatusBadRequest + } + + h.renderTemplateStatus(w, r, "source_detail.html", data, status) +} diff --git a/internal/handlers/webhook_edit.go b/internal/handlers/webhook_edit.go new file mode 100644 index 0000000..f3ca94b --- /dev/null +++ b/internal/handlers/webhook_edit.go @@ -0,0 +1,245 @@ +package handlers + +import ( + "errors" + "net/http" + "strconv" + + "github.com/go-chi/chi" + "sneak.berlin/go/webhooker/internal/database" + "sneak.berlin/go/webhooker/internal/delivery" +) + +// HandleSourceEdit shows the form to edit a webhook. +func (h *Handlers) HandleSourceEdit() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + userID, ok := h.getUserID(r) + if !ok { + http.Redirect( + w, r, "/pages/login", http.StatusSeeOther, + ) + + return + } + + sourceID := chi.URLParam(r, "sourceID") + + var webhook database.Webhook + + err := h.db.DB().Where( + "id = ? AND user_id = ?", sourceID, userID, + ).First(&webhook).Error + if err != nil { + h.renderError(w, r, http.StatusNotFound) + + return + } + + h.renderWebhookEdit( + w, r, &webhook, + webhook.Name, webhook.Description, + strconv.Itoa(webhook.RetentionDays), + "", http.StatusOK, + ) + } +} + +// HandleSourceEditSubmit handles the webhook edit form +// submission. +func (h *Handlers) HandleSourceEditSubmit() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + userID, ok := h.getUserID(r) + if !ok { + http.Redirect( + w, r, "/pages/login", http.StatusSeeOther, + ) + + return + } + + sourceID := chi.URLParam(r, "sourceID") + + h.renameMu.Lock() + defer h.renameMu.Unlock() + + var webhook database.Webhook + + err := h.db.DB().Where( + "id = ? AND user_id = ?", sourceID, userID, + ).First(&webhook).Error + if err != nil { + h.renderError(w, r, http.StatusNotFound) + + return + } + + // The body size cap is enforced by the MaxBodySize + // middleware, which runs before CSRF parses the form. + err = r.ParseForm() + if err != nil { + h.renderError(w, r, http.StatusBadRequest) + + return + } + + h.applyWebhookEdit(w, r, &webhook) + } +} + +// applyWebhookEdit validates and saves webhook edits. A refused save +// shows the edit form again with the values submitted and the reason. +func (h *Handlers) applyWebhookEdit( + w http.ResponseWriter, + r *http.Request, + webhook *database.Webhook, +) { + // The body size cap is enforced by the MaxBodySize middleware, + // which runs before CSRF parses the form. + name := r.PostFormValue("name") + description := r.PostFormValue("description") + retention := r.PostFormValue("retention_days") + + if name == "" { + h.renderWebhookEdit( + w, r, webhook, name, description, retention, + "Name is required", http.StatusBadRequest, + ) + + return + } + + // An empty field falls back to the stored value, so submitting the + // form without touching retention leaves the policy alone. + retentionDays, errMsg := parseRetentionDays( + retention, webhook.RetentionDays, + ) + if errMsg != "" { + h.renderWebhookEdit( + w, r, webhook, name, description, retention, + errMsg, http.StatusBadRequest, + ) + + return + } + + // edited is the webhook as the submission leaves it; webhook stays + // as stored, for the page shown again when the save is refused. + edited := *webhook + edited.Name = name + edited.Description = description + edited.RetentionDays = retentionDays + + // A new name renames the archive files before it is saved (see + // delivery.Engine.Rename). If either step fails, the same targets' + // archives go back to the name that is still stored, without + // reading the main database again. + targets, err := h.renameWebhookArchives( + webhook.ID, webhook.Name, edited.Name, + ) + if err == nil { + err = h.db.DB().Save(&edited).Error + } + + if err != nil { + restoreErr := h.renameArchives(targets, webhook.Name) + if restoreErr != nil { + h.log.Error( + "failed to rename archives back", + "webhook_id", webhook.ID, + "error", restoreErr, + ) + } + + if errors.Is(err, delivery.ErrArchiveNameTaken) { + h.renderWebhookEdit( + w, r, webhook, name, description, retention, + "Not saved: "+err.Error()+ + ". Move that archive out of the data directory, "+ + "its .db together with any -wal and -shm beside "+ + "it, then save again.", + http.StatusConflict, + ) + + return + } + + h.serverError(w, r, "failed to update webhook", err) + + return + } + + http.Redirect( + w, r, withNotice("/hook/"+webhook.ID, webhookSaved), + http.StatusSeeOther, + ) +} + +// renderWebhookEdit renders the webhook edit page for the webhook as +// stored, its form showing name, description and retentionDays, with +// an optional error message above it. +func (h *Handlers) renderWebhookEdit( + w http.ResponseWriter, + r *http.Request, + webhook *database.Webhook, + name, description, retentionDays, errMsg string, + status int, +) { + data := map[string]any{ + tmplKeyWebhook: webhook, + tmplKeyError: errMsg, + "Name": name, + "Description": description, + "RetentionDays": retentionDays, + } + + h.renderTemplateStatus(w, r, "source_edit.html", data, status) +} + +// renameWebhookArchives renames the archive file of every database +// target of a webhook from the webhook name oldName to newName, +// keeping each target's own name. It does nothing when the name is +// unchanged. It returns the targets it read, so that a failed edit can +// move those same archives back with renameArchives. +func (h *Handlers) renameWebhookArchives( + webhookID, oldName, newName string, +) ([]database.Target, error) { + if h.archives == nil || oldName == newName { + return nil, nil + } + + var targets []database.Target + + err := h.db.DB(). + Where( + "webhook_id = ? AND type = ?", + webhookID, database.TargetTypeDatabase, + ). + Find(&targets).Error + if err != nil { + return nil, err + } + + return targets, h.renameArchives(targets, newName) +} + +// renameArchives renames the archive file of each of the given +// database targets to the webhook name webhookName, keeping each +// target's own name. It tries every target even after one fails, so +// that moving the archives back after a failed edit leaves none under +// the new name, and returns every failure joined. +func (h *Handlers) renameArchives( + targets []database.Target, webhookName string, +) error { + var errs []error + + for i := range targets { + err := h.archives.Rename( + targets[i].ID, webhookName, targets[i].Name, + ) + if err != nil { + errs = append(errs, err) + } + } + + return errors.Join(errs...) +} diff --git a/internal/handlers/webhook_list.go b/internal/handlers/webhook_list.go new file mode 100644 index 0000000..ee151dc --- /dev/null +++ b/internal/handlers/webhook_list.go @@ -0,0 +1,178 @@ +package handlers + +import ( + "fmt" + "net/http" + "time" + + "sneak.berlin/go/webhooker/internal/database" +) + +// WebhookListItem holds data for the webhook list view. +type WebhookListItem struct { + database.Webhook + + EntrypointCount int + InactiveEntrypointCount int + 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 +} + +// HandleSourceList shows a list of user's webhooks. +func (h *Handlers) HandleSourceList() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + userID, ok := h.getUserID(r) + if !ok { + http.Redirect( + w, r, "/pages/login", http.StatusSeeOther, + ) + + return + } + + var webhooks []database.Webhook + + err := h.db.DB().Where( + "user_id = ?", userID, + ).Order("created_at DESC").Find(&webhooks).Error + if err != nil { + h.serverError(w, r, "failed to list webhooks", err) + + return + } + + items, err := h.buildWebhookListItems(webhooks) + if err != nil { + h.serverError(w, r, "failed to list webhooks", err) + + return + } + + data := map[string]any{ + "Webhooks": items, + } + + h.renderTemplate(w, r, "sources_list.html", data) + } +} + +// 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( + webhooks []database.Webhook, +) ([]WebhookListItem, error) { + items := make([]WebhookListItem, len(webhooks)) + since := time.Now().Add(-longWindow) + + for i := range webhooks { + item := &items[i] + item.Webhook = webhooks[i] + + var err error + + item.EntrypointCount, item.InactiveEntrypointCount, err = + h.countWithInactive(&database.Entrypoint{}, item.ID) + if err != nil { + return nil, err + } + + item.TargetCount, item.InactiveTargetCount, err = + h.countWithInactive(&database.Target{}, item.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, + ) + + item.EventsUnreadable = true + } + } + + 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 +} diff --git a/internal/middleware/middleware.go b/internal/middleware/middleware.go index 7c452ae..930abbf 100644 --- a/internal/middleware/middleware.go +++ b/internal/middleware/middleware.go @@ -131,8 +131,9 @@ const ( // // - Lines carrying an AUTHENTICATED operator's own input, which // are not truncated at all: the webhook name on "webhook - // created" and the target host on "target URL blocked by SSRF - // protection" (both internal/handlers/source_management.go), + // created" (internal/handlers/webhook_create.go) and the target + // host on "target URL blocked by SSRF protection" + // (internal/handlers/target_create.go), // and target_name in internal/delivery/engine.go and // target_http.go. Each is bounded only by the 1 MB form body // cap, so a 100 KB name writes one line of roughly 600 KB. -- 2.54.0 From 935e18c6f12db4c6451a6ec8b42b6d782e932c33 Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Sat, 3 Oct 2026 06:32:27 +0200 Subject: [PATCH 092/126] Format the Markdown with prettier in make fmt and make fmt-check (closes #215) `make fmt` formatted only Go, so the org's Markdown settings were unenforced and Markdown was wrapped by hand. prettier, pinned in `package.json` and `yarn.lock` beside ESLint, now formats the Markdown with `.prettierrc` (4-space tabs, `proseWrap: always`). It runs only in Docker: `make fmt` writes the formatted files back without a bind mount, and `make fmt-check`, `make check` and the image build fail on unformatted Markdown. The image build's lint stage now runs the Go format check directly and no longer installs `make`. `README.md` and `TODO.md` are reformatted with no word changed: the README reflows from 72 to 80 columns. Model: opus-5-5 --- .dockerignore | 4 +- .gitea/workflows/check.yml | 2 +- .gitignore | 2 +- .prettierrc | 4 + Dockerfile | 47 +- README.md | 4719 +++++++++++++++++------------------- TODO.md | 662 +++-- package.json | 3 +- script/bootstrap | 9 +- script/cibuild | 8 +- script/fmt | 5 +- script/fmt-check | 1 + yarn.lock | 5 + 13 files changed, 2616 insertions(+), 2855 deletions(-) create mode 100644 .prettierrc diff --git a/.dockerignore b/.dockerignore index 1a22075..fe1d815 100644 --- a/.dockerignore +++ b/.dockerignore @@ -15,8 +15,8 @@ bin/ # Extracted from 3p/ by `make assets` inside the build; a host copy is not # needed. The tarball in 3p/ must stay in the context. static/js/alpine.min.js -# The js-deps stage installs ESLint; a host copy would overwrite it at the -# js-lint stage's `COPY . .`. +# The js-deps stage installs ESLint and prettier; a host copy would overwrite +# them at the `COPY . .` of the stages built on it. node_modules/ .env .env.* diff --git a/.gitea/workflows/check.yml b/.gitea/workflows/check.yml index 74baddd..8143c1a 100644 --- a/.gitea/workflows/check.yml +++ b/.gitea/workflows/check.yml @@ -33,5 +33,5 @@ jobs: # and built cannot report success from cache. run: git rev-parse HEAD > .ci-fingerprint - - name: Build Docker image (runs make fmt-check, golangci-lint, the stylesheet check, ESLint, make test, make build) + - name: Build Docker image (runs the gofmt check, golangci-lint, the stylesheet check, ESLint, the Markdown check, make test, make build) run: script/cibuild diff --git a/.gitignore b/.gitignore index 6f0b07f..d11c6f5 100644 --- a/.gitignore +++ b/.gitignore @@ -15,7 +15,7 @@ bin/ # Go vendor directory vendor/ -# ESLint and its dependencies, installed from yarn.lock +# ESLint, prettier and their dependencies, installed from yarn.lock node_modules/ # IDE specific files diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..8af31cd --- /dev/null +++ b/.prettierrc @@ -0,0 +1,4 @@ +{ + "tabWidth": 4, + "proseWrap": "always" +} diff --git a/Dockerfile b/Dockerfile index 604a6b2..e050d4b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,8 +4,6 @@ # compile on Alpine musl (off64_t is a glibc type). FROM golangci/golangci-lint:v2.12.2@sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240 AS lint -RUN apt-get update && apt-get install -y --no-install-recommends make && rm -rf /var/lib/apt/lists/* - WORKDIR /src # Copy go mod files first for better layer caching @@ -19,12 +17,14 @@ RUN go mod download # .dockerignore. COPY . . -# Run formatting check and linter. golangci-lint is invoked directly rather -# than through `make lint`: this stage is already the pinned linter image, and -# script/lint is a wrapper that builds Dockerfile.lint, so calling it here -# would need a docker daemon inside the build. Keep these steps in step with -# Dockerfile.lint, including --network=none (see its header for why). -RUN make fmt-check +# Run the Go formatting check and the linter. gofmt and golangci-lint are +# invoked directly rather than through `make fmt-check` and `make lint`: this +# stage is already the pinned linter image, and both scripts build docker +# stages, so calling them here would need a docker daemon inside the build. +# The Markdown half of `make fmt-check` is the markdown-check stage below. +# Keep the golangci-lint steps in step with Dockerfile.lint, including +# --network=none (see its header for why). +RUN if [ -n "$(gofmt -s -l .)" ]; then echo "gofmt needed on:"; gofmt -s -l .; exit 1; fi RUN script/assets RUN --network=none golangci-lint config verify --config .golangci.yml RUN --network=none golangci-lint run --config .golangci.yml --build-tags browser ./... @@ -66,10 +66,11 @@ RUN sed 's/}/}\n/g' static/css/tailwind.css > /tmp/committed.css \ } # JavaScript lint stages: ESLint, at the version package.json and yarn.lock -# pin, checks static/js/ against eslint.config.mjs. js-deps installs it and -# stays cached until those two files change. script/lint forces only js-lint -# to re-run, and the build stage below runs it too. COPY . . brings in the CI -# cache barrier described in the lint stage above. +# pin, checks static/js/ against eslint.config.mjs. js-deps installs it, and +# prettier for the Markdown stages below, and stays cached until those two +# files change. script/lint forces only js-lint to re-run, and the build stage +# below runs it too. COPY . . brings in the CI cache barrier described in the +# lint stage above. # node:24.21.0-alpine (LTS, with yarn 1.22.22), 2026-09-18 FROM node:24.21.0-alpine@sha256:ebfe2f90462722a7a4de65e91990e97fe0d401c70e0e762c5b53302f905ec1c1 AS js-deps WORKDIR /src @@ -80,16 +81,36 @@ FROM js-deps AS js-lint COPY . . RUN --network=none node_modules/.bin/eslint static/js +# Markdown stages: prettier, at the version package.json and yarn.lock pin, +# formats every Markdown file in the tree with the settings in .prettierrc. +# `make fmt` (script/fmt) writes the formatted files out from markdown-output. +# markdown-check fails on any file prettier would change; `make fmt-check` +# runs it, and so does the build stage below. +FROM js-deps AS markdown +COPY . . +RUN --network=none node_modules/.bin/prettier --write '**/*.md' \ + && mkdir /out \ + && find . -name '*.md' ! -path './node_modules/*' -exec cp -p --parents {} /out \; + +FROM scratch AS markdown-output +COPY --from=markdown /out / + +FROM js-deps AS markdown-check +COPY . . +RUN --network=none node_modules/.bin/prettier --check '**/*.md' + # Build stage # golang:1.26.1-bookworm (Debian-based), 2026-03-17 # Using Debian-based image because gorm.io/driver/sqlite pulls in # mattn/go-sqlite3 (CGO), which does not compile on Alpine musl. FROM golang:1.26.1-bookworm@sha256:4465644228bc2857a954b092167e12aa59c006a3492282a6c820bf4755fd64a4 AS builder -# Depend on the lint, stylesheet check and JavaScript lint stages passing +# Depend on the lint, stylesheet check, JavaScript lint and Markdown check +# stages passing COPY --from=lint /src/go.sum /dev/null COPY --from=css-check /out/tailwind.css /dev/null COPY --from=js-lint /src/yarn.lock /dev/null +COPY --from=markdown-check /src/yarn.lock /dev/null # jq is a runtime dependency of script/ci-mark-superseded, which the test # suite executes. git is what script/version derives the version with. diff --git a/README.md b/README.md index d1e32ca..16d6005 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,14 @@ # webhooker -webhooker is a self-hosted webhook proxy and store-and-forward service -written in [Go](https://golang.org) by -[@sneak](https://sneak.berlin). It receives webhooks from external -services, durably stores them, and delivers them to configured targets -with retry support, logging, and observability. Category: infrastructure -/ web service. License: MIT. +webhooker is a self-hosted webhook proxy and store-and-forward service written +in [Go](https://golang.org) by [@sneak](https://sneak.berlin). It receives +webhooks from external services, durably stores them, and delivers them to +configured targets with retry support, logging, and observability. Category: +infrastructure / web service. License: MIT. -Each entrypoint is a version 4 UUID served at `/h/{uuid}`, and -that UUID is the entrypoint's only credential. webhooker does not use -shared secrets, HMAC signatures or token headers on the receiver, and -will not add them — read +Each entrypoint is a version 4 UUID served at `/h/{uuid}`, and that UUID is the +entrypoint's only credential. webhooker does not use shared secrets, HMAC +signatures or token headers on the receiver, and will not add them — read [The entrypoint URL is the authentication secret](#the-entrypoint-url-is-the-authentication-secret) before deploying one. @@ -19,16 +17,16 @@ before deploying one. ### Prerequisites - Go 1.26.1+ (the version in `go.mod`) -- Docker (for `make lint` and `make css`, and so for `make check`, for the - browser test in `make test-browser`, for the CI gate, and for +- Docker (for `make lint`, `make fmt` and `make css`, and so for `make check`, + for the browser test in `make test-browser`, for the CI gate, and for containerized deployment) -golangci-lint is not a prerequisite and must not be installed on the -host: `script/bootstrap` does not install it, and `make lint` runs the -digest-pinned linter image via `Dockerfile.lint`. The same holds for -tailwindcss (see [Stylesheet](#stylesheet)). ESLint, node and yarn are -not prerequisites either, and `make lint` never uses a host copy of them -(see [Linting](#linting)). +golangci-lint is not a prerequisite and must not be installed on the host: +`script/bootstrap` does not install it, and `make lint` runs the digest-pinned +linter image via `Dockerfile.lint`. The same holds for tailwindcss (see +[Stylesheet](#stylesheet)). ESLint, prettier, node and yarn are not +prerequisites either, and `make lint` and `make fmt` never use a host copy of +them (see [Linting](#linting)). ### Quick Start @@ -58,8 +56,8 @@ make docker make bootstrap # Install all dependencies (idempotent) make setup # Bootstrap + install git pre-commit hook make assets # Extract Alpine.js from 3p/ (test, check, build, dev run it) -make fmt # Format code (gofmt + goimports) -make fmt-check # Fail if gofmt would change anything (writes nothing) +make fmt # Format Go (gofmt + goimports) and Markdown (prettier, in Docker) +make fmt-check # Fail if gofmt or prettier would change anything (writes nothing) make lint # Run golangci-lint and ESLint in Docker make test # Run tests with race detection make test-browser # Run the browser test in Docker (Dockerfile.browser) @@ -78,477 +76,438 @@ make clean # Remove bin/ ### Configuration -All configuration is via environment variables. For local development, -you can place variables in a `.env` file in the process working -directory, read once at startup before anything else looks at the -environment. +All configuration is via environment variables. For local development, you can +place variables in a `.env` file in the process working directory, read once at +startup before anything else looks at the environment. -The file is optional and having none is the normal case for a -deployment. An empty file is the same as none: it has nothing in it to -apply. A file that is there but cannot be parsed aborts startup -with a message naming it, because a single malformed line makes none -of the file apply: every variable in it silently reverts to its -default, which is exactly the failure [Invalid values abort -startup](#invalid-values-abort-startup) exists to prevent, for all of -them at once. A variable already present in the real environment wins +The file is optional and having none is the normal case for a deployment. An +empty file is the same as none: it has nothing in it to apply. A file that is +there but cannot be parsed aborts startup with a message naming it, because a +single malformed line makes none of the file apply: every variable in it +silently reverts to its default, which is exactly the failure +[Invalid values abort startup](#invalid-values-abort-startup) exists to prevent, +for all of them at once. A variable already present in the real environment wins over the file's value for the same name. -The environment is selected by setting `WEBHOOKER_ENVIRONMENT` to `dev` -or `prod` (default: `prod`; `dev` must be set explicitly). The setting -controls exactly one behavior: +The environment is selected by setting `WEBHOOKER_ENVIRONMENT` to `dev` or +`prod` (default: `prod`; `dev` must be set explicitly). The setting controls +exactly one behavior: | Behavior | `dev` | `prod` | | -------- | ----------------------- | ---------------- | | CORS | Allows any origin (`*`) | Disabled (no-op) | -The environment setting does **not** control cookie security. Both the -session cookie and the CSRF cookie get their `Secure` flag, and the -CSRF middleware its Origin/Referer validation mode, from the transport -of each individual request, decided by one predicate — -`internal/reqtls.IsTLS`. It reports TLS for a direct TLS connection -(`r.TLS`) or for a TLS-terminating reverse proxy that reports one in -`X-Forwarded-Proto`: +The environment setting does **not** control cookie security. Both the session +cookie and the CSRF cookie get their `Secure` flag, and the CSRF middleware its +Origin/Referer validation mode, from the transport of each individual request, +decided by one predicate — `internal/reqtls.IsTLS`. It reports TLS for a direct +TLS connection (`r.TLS`) or for a TLS-terminating reverse proxy that reports one +in `X-Forwarded-Proto`: - **Direct TLS or `X-Forwarded-Proto: https`**: Secure cookies, strict Origin/Referer validation. -- **Plaintext HTTP**: Non-Secure cookies, relaxed Origin/Referer - checks (token validation still enforced). +- **Plaintext HTTP**: Non-Secure cookies, relaxed Origin/Referer checks (token + validation still enforced). -The `X-Forwarded-Proto` value is matched case-insensitively on its -first comma-separated element, trimmed, so `HTTPS` and the appended -chains a proxy behind another proxy emits (`https, http`) are all read -as TLS. +The `X-Forwarded-Proto` value is matched case-insensitively on its first +comma-separated element, trimmed, so `HTTPS` and the appended chains a proxy +behind another proxy emits (`https, http`) are all read as TLS. -This means both cookie security and CSRF protection work correctly in -all deployment scenarios: behind a TLS-terminating reverse proxy, with -direct TLS, or over plain HTTP during development — a plain-HTTP local -run gets non-`Secure` cookies and remains usable, and a proxied -deployment gets `Secure` ones without the operator setting anything. -When running behind a reverse proxy, ensure it sets the -`X-Forwarded-Proto: https` header. Unlike `X-Forwarded-For`, this -header is read from any peer and is **not** gated by -`TRUSTED_PROXIES`; a correctly configured proxy overwrites whatever a -client sent. On a listener exposed directly to clients, any client can -assert it, so do not run one without a proxy in front. +This means both cookie security and CSRF protection work correctly in all +deployment scenarios: behind a TLS-terminating reverse proxy, with direct TLS, +or over plain HTTP during development — a plain-HTTP local run gets non-`Secure` +cookies and remains usable, and a proxied deployment gets `Secure` ones without +the operator setting anything. When running behind a reverse proxy, ensure it +sets the `X-Forwarded-Proto: https` header. Unlike `X-Forwarded-For`, this +header is read from any peer and is **not** gated by `TRUSTED_PROXIES`; a +correctly configured proxy overwrites whatever a client sent. On a listener +exposed directly to clients, any client can assert it, so do not run one without +a proxy in front. -All other differences (log format, security headers, etc.) are -independent of the environment setting — log format is determined by -TTY detection, and security headers are always applied. +All other differences (log format, security headers, etc.) are independent of +the environment setting — log format is determined by TTY detection, and +security headers are always applied. -| Variable | Description | Default | -| ----------------------- | ----------------------------------- | -------- | -| `WEBHOOKER_ENVIRONMENT` | `dev` or `prod` | `prod` | -| `PORT` | HTTP listen port | `8080` | -| `BIND_ADDRESS` | IP address the HTTP listener binds. Loopback by default, so the cleartext listener is not published on every interface. The Docker image ships `0.0.0.0` instead. See [Bind address](#bind-address) | `127.0.0.1` (image: `0.0.0.0`) | -| `DATA_DIR` | Directory for all SQLite databases | `/var/lib/webhooker` | -| `DEBUG` | Enable debug logging | `false` | -| `METRICS_USERNAME` | Basic auth username for `/metrics`. Must be set together with `METRICS_PASSWORD`; one without the other fails startup | `""` | -| `METRICS_PASSWORD` | Basic auth password for `/metrics`. Must be set together with `METRICS_USERNAME`; one without the other fails startup | `""` | -| `SENTRY_DSN` | Sentry error reporting DSN. Unset leaves error reporting off; a value the Sentry SDK cannot parse fails startup rather than serving with reporting silently off | `""` | -| `RETENTION_SWEEP_INTERVAL` | How often the retention reaper and archive sweeper run (Go duration, must be positive). A value that does not parse, or is zero or negative, fails startup | `1h` | -| `SESSION_IDLE_TIMEOUT` | Idle session timeout (Go duration) | `24h` | -| `RECEIVER_RATE_LIMIT` | Receiver requests/minute per IP per entrypoint (10x that per IP across the route) | `120` | -| `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) | +| Variable | Description | Default | +| -------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------- | +| `WEBHOOKER_ENVIRONMENT` | `dev` or `prod` | `prod` | +| `PORT` | HTTP listen port | `8080` | +| `BIND_ADDRESS` | IP address the HTTP listener binds. Loopback by default, so the cleartext listener is not published on every interface. The Docker image ships `0.0.0.0` instead. See [Bind address](#bind-address) | `127.0.0.1` (image: `0.0.0.0`) | +| `DATA_DIR` | Directory for all SQLite databases | `/var/lib/webhooker` | +| `DEBUG` | Enable debug logging | `false` | +| `METRICS_USERNAME` | Basic auth username for `/metrics`. Must be set together with `METRICS_PASSWORD`; one without the other fails startup | `""` | +| `METRICS_PASSWORD` | Basic auth password for `/metrics`. Must be set together with `METRICS_USERNAME`; one without the other fails startup | `""` | +| `SENTRY_DSN` | Sentry error reporting DSN. Unset leaves error reporting off; a value the Sentry SDK cannot parse fails startup rather than serving with reporting silently off | `""` | +| `RETENTION_SWEEP_INTERVAL` | How often the retention reaper and archive sweeper run (Go duration, must be positive). A value that does not parse, or is zero or negative, fails startup | `1h` | +| `SESSION_IDLE_TIMEOUT` | Idle session timeout (Go duration) | `24h` | +| `RECEIVER_RATE_LIMIT` | Receiver requests/minute per IP per entrypoint (10x that per IP across the route) | `120` | +| `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) | -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. +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 -By default every delivery target must resolve to a public address. The -private and reserved ranges — RFC 1918, loopback, CGNAT, link-local and -the rest — are refused, which stops a target from being used to make -webhooker probe the network it sits in. +By default every delivery target must resolve to a public address. The private +and reserved ranges — RFC 1918, loopback, CGNAT, link-local and the rest — are +refused, which stops a target from being used to make webhooker probe the +network it sits in. -Besides the private and reserved ranges, the default blocklist refuses -public cloud metadata addresses: currently only `168.63.129.16`, Azure's -WireServer, which serves an Azure VM its credentials. Because it is a -public address, listing it in `ALLOWED_EGRESS_CIDRS` reopens it. +Besides the private and reserved ranges, the default blocklist refuses public +cloud metadata addresses: currently only `168.63.129.16`, Azure's WireServer, +which serves an Azure VM its credentials. Because it is a public address, +listing it in `ALLOWED_EGRESS_CIDRS` reopens it. -That is all the default blocklist covers: the IPv4 private and reserved -ranges; of IPv6, only loopback (`::1`), the unspecified address (`::`), -unique local addresses (`fc00::/7`), link-local addresses (`fe80::/10`), -multicast (`ff00::/8`) and documentation space (`2001:db8::/32`); and -certain public addresses. A public address belongs on the default -blocklist only if it hands credentials, user data or bootstrap material -to whatever can reach it, without the caller presenting anything. A -provider's other public addresses are not refused. IBM Cloud, for -example, serves its package mirrors, time servers and object storage on -`161.26.0.0/16`, and the private endpoints of its own cloud services on -`166.8.0.0/14`. Neither range hands out credentials that way: the token -service among those endpoints issues a token only in exchange for -something the caller presents, such as an API key. Reaching these -services can be a legitimate delivery, and every cloud has some, so a -partial list would promise coverage it does not give. +That is all the default blocklist covers: the IPv4 private and reserved ranges; +of IPv6, only loopback (`::1`), the unspecified address (`::`), unique local +addresses (`fc00::/7`), link-local addresses (`fe80::/10`), multicast +(`ff00::/8`) and documentation space (`2001:db8::/32`); and certain public +addresses. A public address belongs on the default blocklist only if it hands +credentials, user data or bootstrap material to whatever can reach it, without +the caller presenting anything. A provider's other public addresses are not +refused. IBM Cloud, for example, serves its package mirrors, time servers and +object storage on `161.26.0.0/16`, and the private endpoints of its own cloud +services on `166.8.0.0/14`. Neither range hands out credentials that way: the +token service among those endpoints issues a token only in exchange for +something the caller presents, such as an API key. Reaching these services can +be a legitimate delivery, and every cloud has some, so a partial list would +promise coverage it does not give. -That default is also inconvenient for the thing webhooker is mostly -for: taking a public webhook and forwarding it to something on your own -network. A container on the same Docker network, a box on `10.x`, a -service on `127.0.0.1` — all refused, until you name them. +That default is also inconvenient for the thing webhooker is mostly for: taking +a public webhook and forwarding it to something on your own network. A container +on the same Docker network, a box on `10.x`, a service on `127.0.0.1` — all +refused, until you name them. -`ALLOWED_EGRESS_CIDRS` is a comma-separated list of CIDR blocks (a bare -address such as `10.0.0.7` is accepted and treated as a single host), -for example `10.0.0.0/8, 172.17.0.0/16`. Addresses inside those blocks -become valid delivery destinations. Everything outside them keeps the -default answer, so this only ever adds destinations — it never removes -any, and it cannot narrow what was already reachable. +`ALLOWED_EGRESS_CIDRS` is a comma-separated list of CIDR blocks (a bare address +such as `10.0.0.7` is accepted and treated as a single host), for example +`10.0.0.0/8, 172.17.0.0/16`. Addresses inside those blocks become valid delivery +destinations. Everything outside them keeps the default answer, so this only +ever adds destinations — it never removes any, and it cannot narrow what was +already reachable. -**The risk, plainly.** Each block you list is a network that anyone who -can create a delivery target can now make this process issue requests -into, and read the response body back out of via the delivery log. That -is server-side request forgery, deliberately enabled and scoped by you. -A webhooker admin account is therefore as trusted as the narrowest -thing on those networks: an unauthenticated admin panel, a database -listening without a password, or an internal API that trusts its -network position is reachable through it. List the smallest blocks that -cover the destinations you actually deliver to — prefer -`10.1.2.3/32` over `10.0.0.0/8` — and never list a block wider than the -network you are willing to expose. +**The risk, plainly.** Each block you list is a network that anyone who can +create a delivery target can now make this process issue requests into, and read +the response body back out of via the delivery log. That is server-side request +forgery, deliberately enabled and scoped by you. A webhooker admin account is +therefore as trusted as the narrowest thing on those networks: an +unauthenticated admin panel, a database listening without a password, or an +internal API that trusts its network position is reachable through it. List the +smallest blocks that cover the destinations you actually deliver to — prefer +`10.1.2.3/32` over `10.0.0.0/8` — and never list a block wider than the network +you are willing to expose. -Listing `0.0.0.0/0` or `::/0` opens **every** other private and -reserved range at once — loopback, RFC 1918, CGNAT, ULA, the lot. It is -a functional off switch for everything except the addresses listed as -unconditionally blocked below, and it makes any delivery target a probe -into your entire network and this host's own loopback services. Do not -list it. +Listing `0.0.0.0/0` or `::/0` opens **every** other private and reserved range +at once — loopback, RFC 1918, CGNAT, ULA, the lot. It is a functional off switch +for everything except the addresses listed as unconditionally blocked below, and +it makes any delivery target a probe into your entire network and this host's +own loopback services. Do not list it. Two things this setting cannot do: -- **It cannot turn the guard off.** There is no boolean, and no value - that disables SSRF protection wholesale. The guard is always on and - the list is always an allowlist; an empty list (the default) means - every private and reserved range stays refused. Note that - `0.0.0.0/0` gets you most of the way there anyway, per above. -- **It cannot open link-local, the unspecified addresses, or a cloud - metadata endpoint at a non-public address that discloses credentials - or user data.** A metadata address is on the list below when it is not - a public address and both of these hold: the provider fixes it, so it - cannot collide with anything you run; and reaching it hands out - credentials, user data or bootstrap material. Those stay blocked no - matter what you list, including when you list them outright or list a - supernet such as `0.0.0.0/0`, `::/0`, `fd00::/8` or `100.64.0.0/10`. - Treat this as best effort rather than a guarantee — it is a +- **It cannot turn the guard off.** There is no boolean, and no value that + disables SSRF protection wholesale. The guard is always on and the list is + always an allowlist; an empty list (the default) means every private and + reserved range stays refused. Note that `0.0.0.0/0` gets you most of the way + there anyway, per above. +- **It cannot open link-local, the unspecified addresses, or a cloud metadata + endpoint at a non-public address that discloses credentials or user data.** A + metadata address is on the list below when it is not a public address and both + of these hold: the provider fixes it, so it cannot collide with anything you + run; and reaching it hands out credentials, user data or bootstrap material. + Those stay blocked no matter what you list, including when you list them + outright or list a supernet such as `0.0.0.0/0`, `::/0`, `fd00::/8` or + `100.64.0.0/10`. Treat this as best effort rather than a guarantee — it is a hand-maintained list and the caveat below the table applies: - | Blocked unconditionally | What it is | - | ----------------------- | ---------- | - | `169.254.0.0/16` | IPv4 link-local, carrying `169.254.169.254` (AWS, Azure, DigitalOcean, Hetzner, OpenStack and others — not Alibaba, which uses `100.100.100.200` below) | - | `fe80::/10` | IPv6 link-local | - | `fd00:ec2::254/128` | AWS IPv6 IMDS | - | `fd00:ec2::23/128` | AWS EKS Pod Identity Agent | - | `fd20:ce::254/128` | GCP metadata for IPv6-only instances | - | `fd00:c1::a9fe:a9fe/128` | Oracle OCI IMDS over IPv6 | - | `fd00:42::42/128` | Scaleway metadata over IPv6 | - | `fd00:a9fe:a9fe::1/128` | Linode/Akamai metadata over IPv6 | - | `100.100.100.200/32` | Alibaba Cloud metadata, inside CGNAT | - | `192.0.0.192/32` | Oracle Cloud Classic metadata | - | `0.0.0.0/32` | IPv4 unspecified address, which reaches this host's loopback on Linux | - | `::/128` | IPv6 unspecified address, which reaches this host's loopback on Linux | - | `::a9fe:a9fe/128` | `169.254.169.254` as an IPv4-compatible IPv6 address | - | `64:ff9b::a9fe:a9fe/128` | `169.254.169.254` behind the NAT64 well-known prefix | + | Blocked unconditionally | What it is | + | ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------- | + | `169.254.0.0/16` | IPv4 link-local, carrying `169.254.169.254` (AWS, Azure, DigitalOcean, Hetzner, OpenStack and others — not Alibaba, which uses `100.100.100.200` below) | + | `fe80::/10` | IPv6 link-local | + | `fd00:ec2::254/128` | AWS IPv6 IMDS | + | `fd00:ec2::23/128` | AWS EKS Pod Identity Agent | + | `fd20:ce::254/128` | GCP metadata for IPv6-only instances | + | `fd00:c1::a9fe:a9fe/128` | Oracle OCI IMDS over IPv6 | + | `fd00:42::42/128` | Scaleway metadata over IPv6 | + | `fd00:a9fe:a9fe::1/128` | Linode/Akamai metadata over IPv6 | + | `100.100.100.200/32` | Alibaba Cloud metadata, inside CGNAT | + | `192.0.0.192/32` | Oracle Cloud Classic metadata | + | `0.0.0.0/32` | IPv4 unspecified address, which reaches this host's loopback on Linux | + | `::/128` | IPv6 unspecified address, which reaches this host's loopback on Linux | + | `::a9fe:a9fe/128` | `169.254.169.254` as an IPv4-compatible IPv6 address | + | `64:ff9b::a9fe:a9fe/128` | `169.254.169.254` behind the NAT64 well-known prefix | - The IPv4-mapped form `::ffff:169.254.169.254` is covered by the - `169.254.0.0/16` entry. Reaching any of these but the two unspecified - addresses is credential or user-data theft rather than delivery to an - internal service. Every entry outside the two link-local blocks is a - single address, so blocking it costs you nothing else on the network - around it. + The IPv4-mapped form `::ffff:169.254.169.254` is covered by the + `169.254.0.0/16` entry. Reaching any of these but the two unspecified + addresses is credential or user-data theft rather than delivery to an + internal service. Every entry outside the two link-local blocks is a single + address, so blocking it costs you nothing else on the network around it. - The unspecified addresses `0.0.0.0` and `::` hand out nothing - themselves, but no host can have either, and on Linux a connection to - one reaches this host's own loopback. They are listed so that an - allowlist reaches loopback only through an entry that covers a loopback - address, such as `127.0.0.0/8`, `::1` or `0.0.0.0/0`, never through one - that covers only `0.0.0.0` or `::`; `0.0.0.0/8`, for example, does not - open loopback. + The unspecified addresses `0.0.0.0` and `::` hand out nothing themselves, + but no host can have either, and on Linux a connection to one reaches this + host's own loopback. They are listed so that an allowlist reaches loopback + only through an entry that covers a loopback address, such as `127.0.0.0/8`, + `::1` or `0.0.0.0/0`, never through one that covers only `0.0.0.0` or `::`; + `0.0.0.0/8`, for example, does not open loopback. - The six ULA entries, all inside `fd00::/8`, are why this matters in - practice: `fd00::/8` is an ordinary block to allowlist for your own - IPv6 network, and without those host routes that one line would hand - out cloud credentials on five providers at once. There is only one - `/8` involved — `fd20:ce::254` masks into `fd00::/8` as well — and - the six endpoints are five providers because AWS appears twice, IMDS - and EKS Pod Identity. Several of them are described as "link-local" — - or even "localhost" — in their own vendor's documentation, but they - are ULAs and `fe80::/10` does not cover them. + The six ULA entries, all inside `fd00::/8`, are why this matters in + practice: `fd00::/8` is an ordinary block to allowlist for your own IPv6 + network, and without those host routes that one line would hand out cloud + credentials on five providers at once. There is only one `/8` involved — + `fd20:ce::254` masks into `fd00::/8` as well — and the six endpoints are + five providers because AWS appears twice, IMDS and EKS Pod Identity. Several + of them are described as "link-local" — or even "localhost" — in their own + vendor's documentation, but they are ULAs and `fe80::/10` does not cover + them. - Every entry above is reserved space. All but the last two are already - refused with no allowlist set, and listing them here is only what - stops an allowlist from reopening them; the last two are the alternate - encodings, which the default blocklist does not match. A publicly - routable metadata address is not listed here, because nothing on this - list can be reopened and blocking one that way would leave you no - escape hatch at all; Azure's `168.63.129.16` is refused by the default - blocklist instead, as described above. + Every entry above is reserved space. All but the last two are already + refused with no allowlist set, and listing them here is only what stops an + allowlist from reopening them; the last two are the alternate encodings, + which the default blocklist does not match. A publicly routable metadata + address is not listed here, because nothing on this list can be reopened and + blocking one that way would leave you no escape hatch at all; Azure's + `168.63.129.16` is refused by the default blocklist instead, as described + above. - This list is not exhaustive of every cloud's metadata address — if - yours is not here, do not allowlist the block that contains it. + This list is not exhaustive of every cloud's metadata address — if yours is + not here, do not allowlist the block that contains it. -The list is applied at one place in the code, which both target -creation and delivery consult, so a URL that the target form accepts is -one that delivery will actually attempt — the two cannot disagree. -Delivery re-resolves and re-checks the destination at dial time, so a -hostname that resolves to an allowed address during validation and a -different one later (DNS rebinding) is still refused unless the new -address is also allowed. +The list is applied at one place in the code, which both target creation and +delivery consult, so a URL that the target form accepts is one that delivery +will actually attempt — the two cannot disagree. Delivery re-resolves and +re-checks the destination at dial time, so a hostname that resolves to an +allowed address during validation and a different one later (DNS rebinding) is +still refused unless the new address is also allowed. -A set but unparseable value aborts startup. When the list is non-empty -webhooker logs it at startup, blocks and all, so the hole is visible in -the log of any deployment that has one. +A set but unparseable value aborts startup. When the list is non-empty webhooker +logs it at startup, blocks and all, so the hole is visible in the log of any +deployment that has one. #### Bind address -`BIND_ADDRESS` is the IP address the HTTP listener binds. The binary -defaults to `127.0.0.1`, so a bare webhooker is reachable only from the -host it runs on. The Docker image ships `ENV BIND_ADDRESS=0.0.0.0` -instead — see below for why the two differ. +`BIND_ADDRESS` is the IP address the HTTP listener binds. The binary defaults to +`127.0.0.1`, so a bare webhooker is reachable only from the host it runs on. The +Docker image ships `ENV BIND_ADDRESS=0.0.0.0` instead — see below for why the +two differ. -That listener speaks **cleartext**, and it serves both the admin UI and -the unauthenticated webhook receiver. webhooker terminates no TLS -itself; a production deployment puts a reverse proxy in front of it -(see -[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 -clear, on a port nobody chose to publish. Reaching webhooker from -another host is therefore something you configure, not something you -get by default. +That listener speaks **cleartext**, and it serves both the admin UI and the +unauthenticated webhook receiver. webhooker terminates no TLS itself; a +production deployment puts a reverse proxy in front of it (see +[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 clear, on a port nobody chose to publish. +Reaching webhooker from another host is therefore something you configure, not +something you get by default. -**In a container the answer is `0.0.0.0`, which is why the image ships -that.** A container's network namespace is already the boundary the -loopback default is reaching for: nothing outside the container gets to -`0.0.0.0:8080` because of the namespace, whatever the process bound. -Exposure is decided at the publish flag instead — `-p -127.0.0.1:8080:8080` rather than `-p 8080:8080` — which is the -operator's to choose and is what -[Running with Docker](#running-with-docker) shows. A loopback bind -inside a container buys nothing and makes the process unreachable -through its own published port. +**In a container the answer is `0.0.0.0`, which is why the image ships that.** A +container's network namespace is already the boundary the loopback default is +reaching for: nothing outside the container gets to `0.0.0.0:8080` because of +the namespace, whatever the process bound. Exposure is decided at the publish +flag instead — `-p 127.0.0.1:8080:8080` rather than `-p 8080:8080` — which is +the operator's to choose and is what [Running with Docker](#running-with-docker) +shows. A loopback bind inside a container buys nothing and makes the process +unreachable through its own published port. The value must be an IP address literal: -- `127.0.0.1` — loopback only (the binary's default). Use this with a - reverse proxy on the same host. -- `0.0.0.0` — every IPv4 address. The image's default; on a bare host, - only behind a firewall on the port. +- `127.0.0.1` — loopback only (the binary's default). Use this with a reverse + proxy on the same host. +- `0.0.0.0` — every IPv4 address. The image's default; on a bare host, only + behind a firewall on the port. - `::` — every address, IPv6 and (on Linux, with the default `net.ipv6.bindv6only=0`) IPv4 as well. - A specific address such as `10.0.0.5` — that interface only. -An **empty** value is treated as unset, as everywhere else here, and -takes the default. In a container that matters: `BIND_ADDRESS=` throws -away the image's `0.0.0.0` and falls back to the binary's -`127.0.0.1`, which is the one quiet failure this setting has — see -[Running with Docker](#running-with-docker). +An **empty** value is treated as unset, as everywhere else here, and takes the +default. In a container that matters: `BIND_ADDRESS=` throws away the image's +`0.0.0.0` and falls back to the binary's `127.0.0.1`, which is the one quiet +failure this setting has — see [Running with Docker](#running-with-docker). -Hostnames are **not** accepted. `localhost` aborts startup rather than -being resolved: which of `127.0.0.1` and `::1` it means differs by -host, a name can resolve to several addresses of which only one could -be bound, and the answer can change under a running process. A value -carrying a port (`127.0.0.1:8080`) is likewise rejected — the port is -`PORT`'s business. Any unparseable value aborts startup; see +Hostnames are **not** accepted. `localhost` aborts startup rather than being +resolved: which of `127.0.0.1` and `::1` it means differs by host, a name can +resolve to several addresses of which only one could be bound, and the answer +can change under a running process. A value carrying a port (`127.0.0.1:8080`) +is likewise rejected — the port is `PORT`'s business. Any unparseable value +aborts startup; see [Invalid values abort startup](#invalid-values-abort-startup). -An address that parses but is not assigned to this host — say -`10.0.0.5` on a machine that has no such interface — is a valid -literal, so it reaches the listener and fails there. The process logs -the bind error and exits non-zero rather than staying up with nothing -listening. The effective value is in the `bindAddress` field of the -startup log line, which is the way to check what a running deployment -actually bound. +An address that parses but is not assigned to this host — say `10.0.0.5` on a +machine that has no such interface — is a valid literal, so it reaches the +listener and fails there. The process logs the bind error and exits non-zero +rather than staying up with nothing listening. The effective value is in the +`bindAddress` field of the startup log line, which is the way to check what a +running deployment actually bound. #### Metrics credentials -`METRICS_USERNAME` and `METRICS_PASSWORD` are set together or not at -all. With both set, `/metrics` is served behind basic auth. With -neither set, the route is not registered and returns 404. With one set -and the other empty or unset, the process refuses to start and exits -non-zero with an error naming both variables — mounting the endpoint -on the username alone would publish it behind a password that is the -empty string, and quietly withholding it would deny an endpoint that -was asked for. The `hasMetricsAuth` field in the startup log and the -existence of the route are the same value, so they cannot disagree. +`METRICS_USERNAME` and `METRICS_PASSWORD` are set together or not at all. With +both set, `/metrics` is served behind basic auth. With neither set, the route is +not registered and returns 404. With one set and the other empty or unset, the +process refuses to start and exits non-zero with an error naming both variables +— mounting the endpoint on the username alone would publish it behind a password +that is the empty string, and quietly withholding it would deny an endpoint that +was asked for. The `hasMetricsAuth` field in the startup log and the existence +of the route are the same value, so they cannot disagree. #### Single-instance lock -Exactly one webhooker process may use a `DATA_DIR` at a time. Two -processes sharing one open the same databases and each run delivery -recovery over the same rows, so every pending delivery goes out twice — -duplicate delivery to your endpoints, from nothing worse than an -overlapping deploy or a double start. +Exactly one webhooker process may use a `DATA_DIR` at a time. Two processes +sharing one open the same databases and each run delivery recovery over the same +rows, so every pending delivery goes out twice — duplicate delivery to your +endpoints, from nothing worse than an overlapping deploy or a double start. -At startup, before anything opens a database, the process takes an -exclusive advisory lock (`flock(2)`) on `{DATA_DIR}/webhooker.lock` and -holds it for its lifetime. A second process pointed at the same -directory prints a message naming it and exits non-zero: +At startup, before anything opens a database, the process takes an exclusive +advisory lock (`flock(2)`) on `{DATA_DIR}/webhooker.lock` and holds it for its +lifetime. A second process pointed at the same directory prints a message naming +it and exits non-zero: ``` webhooker: data directory is already in use by another instance: /var/lib/webhooker (/var/lib/webhooker/webhooker.lock). Only one webhooker may use a data directory: two both run delivery recovery over the same rows and both deliver ``` -The lock is the kernel's, not the file's: it is released when the -process exits, including `kill -9`, so a leftover `webhooker.lock` -never blocks a restart and must not be deleted by hand. The file is -also left in place on a clean shutdown, deliberately — unlinking it -would let the next process lock a fresh inode while a third still held -the old one. +The lock is the kernel's, not the file's: it is released when the process exits, +including `kill -9`, so a leftover `webhooker.lock` never blocks a restart and +must not be deleted by hand. The file is also left in place on a clean shutdown, +deliberately — unlinking it would let the next process lock a fresh inode while +a third still held the old one. To run two webhookers on one host, give each its own `DATA_DIR`. -`flock(2)` is host-local and per-inode: it arbitrates between processes -and containers sharing a volume or bind mount on one machine, but not -between hosts on a network filesystem, and a `DATA_DIR` inside a -container's own writable layer is not shared with anything. On a -filesystem that refuses `flock` outright, startup fails closed — the -process reports the error and refuses to start rather than running -unlocked. +`flock(2)` is host-local and per-inode: it arbitrates between processes and +containers sharing a volume or bind mount on one machine, but not between hosts +on a network filesystem, and a `DATA_DIR` inside a container's own writable +layer is not shared with anything. On a filesystem that refuses `flock` +outright, startup fails closed — the process reports the error and refuses to +start rather than running unlocked. #### Trusted proxies -`TRUSTED_PROXIES` is a comma-separated list of CIDR blocks (a bare -address such as `192.168.1.7` is accepted and treated as a single -host), for example `192.168.1.7, 2001:db8::5`. It decides whose -`X-Forwarded-For` header the rate limiters believe, so it should cover -the addresses of your reverse proxies. +`TRUSTED_PROXIES` is a comma-separated list of CIDR blocks (a bare address such +as `192.168.1.7` is accepted and treated as a single host), for example +`192.168.1.7, 2001:db8::5`. It decides whose `X-Forwarded-For` header the rate +limiters believe, so it should cover the addresses of your reverse proxies. -`X-Forwarded-For` is honoured **only** when the connecting peer is -inside one of these blocks; for every other peer the client identity is -the connection's own address and the header is ignored. Unset (or -empty), the list is the RFC 1918 private ranges: `10.0.0.0/8`, -`172.16.0.0/12` and `192.168.0.0/16`. A set value replaces the default -entirely. A set but unparseable value aborts startup. +`X-Forwarded-For` is honoured **only** when the connecting peer is inside one of +these blocks; for every other peer the client identity is the connection's own +address and the header is ignored. Unset (or empty), the list is the RFC 1918 +private ranges: `10.0.0.0/8`, `172.16.0.0/12` and `192.168.0.0/16`. A set value +replaces the default entirely. A set but unparseable value aborts startup. -If any client can reach webhooker, or the proxy in front of it, from an -RFC 1918 source address (directly, or through anything that can -rewrite source addresses, such as NAT or a published container port), -set `TRUSTED_PROXIES` to the proxy's address alone, or every rate -limit, the webhook receiver's included, can be bypassed by those -clients. The address to set is the `remoteIP` field of the +If any client can reach webhooker, or the proxy in front of it, from an RFC 1918 +source address (directly, or through anything that can rewrite source addresses, +such as NAT or a published container port), set `TRUSTED_PROXIES` to the proxy's +address alone, or every rate limit, the webhook receiver's included, can be +bypassed by those clients. The address to set is the `remoteIP` field of the `http request` log line for a request that came through the proxy. -Behind a proxy the list does not cover, every request keys on the -proxy's own address and all clients share a single bucket per limit. -The receiver limits become service-wide ceilings, and the login -endpoint's failure counting collapses onto one key, so a stranger's -wrong passwords throttle every other client's wrong passwords. Set -`TRUSTED_PROXIES` to that proxy's address to restore per-client -buckets. +Behind a proxy the list does not cover, every request keys on the proxy's own +address and all clients share a single bucket per limit. The receiver limits +become service-wide ceilings, and the login endpoint's failure counting +collapses onto one key, so a stranger's wrong passwords throttle every other +client's wrong passwords. Set `TRUSTED_PROXIES` to that proxy's address to +restore per-client buckets. -What it cannot do is lock the operator out. The login endpoint -verifies credentials **before** it consults any limit and charges only -failures, so a correct password is never throttled no matter how full -the bucket is. See [Rate Limiting](#rate-limiting). +What it cannot do is lock the operator out. The login endpoint verifies +credentials **before** it consults any limit and charges only failures, so a +correct password is never throttled no matter how full the bucket is. See +[Rate Limiting](#rate-limiting). -`X-Real-IP` and `True-Client-IP` are **never** read, from any peer. -Reverse proxies append to `X-Forwarded-For` but forward other client -headers verbatim, so a single-valued header is client-controlled even -behind a trusted proxy. +`X-Real-IP` and `True-Client-IP` are **never** read, from any peer. Reverse +proxies append to `X-Forwarded-For` but forward other client headers verbatim, +so a single-valued header is client-controlled even behind a trusted proxy. -Within a trusted request, `X-Forwarded-For` is read right to left, -because the rightmost entry is the one the nearest proxy appended and -everything left of it may have been written by the client. The first -hop that is not itself a trusted proxy is taken as the client. A hop -that is not a bare IP address — `ip:port`, a bracketed IPv6 literal, -the token `unknown` — ends the walk and the peer address is used -instead, since past such an entry the chain is not the shape assumed -here. The peer address is likewise used when the header is absent or -every hop in it is a trusted proxy. +Within a trusted request, `X-Forwarded-For` is read right to left, because the +rightmost entry is the one the nearest proxy appended and everything left of it +may have been written by the client. The first hop that is not itself a trusted +proxy is taken as the client. A hop that is not a bare IP address — `ip:port`, a +bracketed IPv6 literal, the token `unknown` — ends the walk and the peer address +is used instead, since past such an entry the chain is not the shape assumed +here. The peer address is likewise used when the header is absent or every hop +in it is a trusted proxy. -Your proxy must therefore **append** the peer address to -`X-Forwarded-For` (nginx `$proxy_add_x_forwarded_for`, HAProxy -`option forwardfor`, Caddy and AWS ALB by default), and must append a -bare address with no port. +Your proxy must therefore **append** the peer address to `X-Forwarded-For` +(nginx `$proxy_add_x_forwarded_for`, HAProxy `option forwardfor`, Caddy and AWS +ALB by default), and must append a bare address with no port. -Every log line that names a client carries two addresses: `remoteIP`, -the connecting peer, which behind a proxy is the proxy; and `clientIP`, -the client the rate limiters identify by the rules above, which is the -field to read when tracing who sent what. Those lines are the -`http request` access log line, the rate-limit rejection lines -(`login failure limit exceeded` among them), the +Every log line that names a client carries two addresses: `remoteIP`, the +connecting peer, which behind a proxy is the proxy; and `clientIP`, the client +the rate limiters identify by the rules above, which is the field to read when +tracing who sent what. Those lines are the `http request` access log line, the +rate-limit rejection lines (`login failure limit exceeded` among them), the `csrf: token validation failed` warning and the receiver's `webhook request received` line. `clientIP` is only as trustworthy as -`TRUSTED_PROXIES`: for a request from a peer inside the list, it is -read out of the `X-Forwarded-For` that peer sent, so a peer that does -not belong in the list can make it name any address it likes. For a -request from any other peer, both fields name the peer. +`TRUSTED_PROXIES`: for a request from a peer inside the list, it is read out of +the `X-Forwarded-For` that peer sent, so a peer that does not belong in the list +can make it name any address it likes. For a request from any other peer, both +fields name the peer. #### Sessions -Sessions are bounded by two independent clocks, and end at whichever -one runs out first: +Sessions are bounded by two independent clocks, and end at whichever one runs +out first: -- **Idle expiry** (`SESSION_IDLE_TIMEOUT`, default `24h`) is a sliding - window. Every authenticated request pushes it forward, so a session - in continuous use never hits it, while an abandoned one expires a day - after its last use. Any non-positive value (`0`, or a negative - duration such as `-1s`) disables idle expiry entirely; the absolute - cap below still applies. A set-but-unparseable value aborts startup - rather than silently falling back to the default. -- **Absolute expiry** is a fixed 7 days from login. Activity does - **not** extend it: after a week, every session ends and the user - authenticates again. +- **Idle expiry** (`SESSION_IDLE_TIMEOUT`, default `24h`) is a sliding window. + Every authenticated request pushes it forward, so a session in continuous use + never hits it, while an abandoned one expires a day after its last use. Any + non-positive value (`0`, or a negative duration such as `-1s`) disables idle + expiry entirely; the absolute cap below still applies. A set-but-unparseable + value aborts startup rather than silently falling back to the default. +- **Absolute expiry** is a fixed 7 days from login. Activity does **not** extend + it: after a week, every session ends and the user authenticates again. -Only requests that authenticate with the session count as activity, so -an unauthenticated request carrying the cookie cannot keep a session -alive. The idle timestamp is rewritten at most once per tenth of the -idle window rather than on every request, which means a session may -expire up to 10% early relative to the user's true last request, but -never late. +Only requests that authenticate with the session count as activity, so an +unauthenticated request carrying the cookie cannot keep a session alive. The +idle timestamp is rewritten at most once per tenth of the idle window rather +than on every request, which means a session may expire up to 10% early relative +to the user's true last request, but never late. -Both clocks are anchored by timestamps stored in the session cookie. -Sessions issued before this feature existed carry neither, so they are -treated as expired: upgrading to a build that has it logs every -existing session out once, and those users sign in again. +Both clocks are anchored by timestamps stored in the session cookie. Sessions +issued before this feature existed carry neither, so they are treated as +expired: upgrading to a build that has it logs every existing session out once, +and those users sign in again. #### Invalid values abort startup -The defaults above apply **only** to variables that are unset (or set -to an empty string). A variable that is set but cannot be parsed is a -fatal configuration error: webhooker logs the offending variable and -its value and refuses to start, rather than silently running with a -substituted default. `PORT=eighty`, `DEBUG=ture`, and -`RETENTION_SWEEP_INTERVAL=1 hour` all abort startup. `PORT` must -additionally be a number in the range 1–65535, -`RECEIVER_RATE_LIMIT` must be at least 1, -`RETENTION_SWEEP_INTERVAL` must be greater than zero (it is a ticker -period, so `0s` or a negative value would crash the reaper after -startup), every entry in `TRUSTED_PROXIES` and +The defaults above apply **only** to variables that are unset (or set to an +empty string). A variable that is set but cannot be parsed is a fatal +configuration error: webhooker logs the offending variable and its value and +refuses to start, rather than silently running with a substituted default. +`PORT=eighty`, `DEBUG=ture`, and `RETENTION_SWEEP_INTERVAL=1 hour` all abort +startup. `PORT` must additionally be a number in the range 1–65535, +`RECEIVER_RATE_LIMIT` must be at least 1, `RETENTION_SWEEP_INTERVAL` must be +greater than zero (it is a ticker period, so `0s` or a negative value would +crash the reaper after startup), every entry in `TRUSTED_PROXIES` and `ALLOWED_EGRESS_CIDRS` must be a CIDR block or a bare IP address, and -`BIND_ADDRESS` must be an IP address literal — `localhost`, -`127.0.0.1:8080` and `10.0.0.0/8` are each rejected rather than -resolved, split, or narrowed to something they do not say — and -`SENTRY_DSN` must parse as a Sentry DSN. -`SESSION_IDLE_TIMEOUT` is the exception: a -non-positive value there means idle expiry is disabled, not invalid. +`BIND_ADDRESS` must be an IP address literal — `localhost`, `127.0.0.1:8080` and +`10.0.0.0/8` are each rejected rather than resolved, split, or narrowed to +something they do not say — and `SENTRY_DSN` must parse as a Sentry DSN. +`SESSION_IDLE_TIMEOUT` is the exception: a non-positive value there means idle +expiry is disabled, not invalid. -`SENTRY_DSN` is checked with the Sentry SDK's own parser, the same call -the SDK makes on the DSN it is later handed, so what configuration -accepts is exactly what will initialise. A typo in it is the one -configuration mistake nothing downstream can ever notice — the variable -is still set, so every later signal reports error reporting as on while -no report is being sent — which is why it aborts rather than starting -with reporting off. Leaving it unset is not a mistake and not affected: -error reporting is simply off and startup is normal. +`SENTRY_DSN` is checked with the Sentry SDK's own parser, the same call the SDK +makes on the DSN it is later handed, so what configuration accepts is exactly +what will initialise. A typo in it is the one configuration mistake nothing +downstream can ever notice — the variable is still set, so every later signal +reports error reporting as on while no report is being sent — which is why it +aborts rather than starting with reporting off. Leaving it unset is not a +mistake and not affected: error reporting is simply off and startup is normal. The boolean variable `DEBUG` accepts exactly the spellings Go's -`strconv.ParseBool` accepts — `1`, `t`, `T`, `TRUE`, -`true`, `True`, `0`, `f`, `F`, `FALSE`, `false`, `False` — and nothing -else. `yes`, `on`, and `off` are rejected rather than quietly treated -as false. +`strconv.ParseBool` accepts — `1`, `t`, `T`, `TRUE`, `true`, `True`, `0`, `f`, +`F`, `FALSE`, `false`, `False` — and nothing else. `yes`, `on`, and `off` are +rejected rather than quietly treated as false. -On first startup, webhooker automatically generates a cryptographically -secure session encryption key and stores it in the database. This key -persists across restarts — no manual key management is needed. +On first startup, webhooker automatically generates a cryptographically secure +session encryption key and stores it in the database. This key persists across +restarts — no manual key management is needed. #### The admin account -On first startup — a `DATA_DIR` with no accounts in it — webhooker -creates an `admin` user with a randomly generated password and prints -it to standard output as a ruled banner: +On first startup — a `DATA_DIR` with no accounts in it — webhooker creates an +`admin` user with a randomly generated password and prints it to standard output +as a ruled banner: ``` ======================================================================== @@ -562,26 +521,23 @@ If it is lost, run `webhooker resetpw admin` on a stopped deployment. ======================================================================== ``` -It is a banner rather than a log line because that is the only time it -is ever shown: as one `INFO` record it would sit among the records fx -writes as each start hook runs, and under `docker run -d` -it is one line in a log subject to rotation. The database stores only -its Argon2id hash. There is no second account and no forgot-password -flow, so the banner and the reset command below are the only two ways -in. +It is a banner rather than a log line because that is the only time it is ever +shown: as one `INFO` record it would sit among the records fx writes as each +start hook runs, and under `docker run -d` it is one line in a log subject to +rotation. The database stores only its Argon2id hash. There is no second account +and no forgot-password flow, so the banner and the reset command below are the +only two ways in. -A start that finds no `webhooker.db` in `DATA_DIR`, or a zero-length -one (which SQLite opens as an empty database), also logs -`created a new, empty database` at `WARN`, with the file's path, -shortly before the banner. On a deployment that has run before, that -line means `webhooker.db` was lost: either the file was missing, most -often because the volume holding `DATA_DIR` is not mounted, or it was -zero-length, as a truncated copy leaves it. +A start that finds no `webhooker.db` in `DATA_DIR`, or a zero-length one (which +SQLite opens as an empty database), also logs `created a new, empty database` at +`WARN`, with the file's path, shortly before the banner. On a deployment that +has run before, that line means `webhooker.db` was lost: either the file was +missing, most often because the volume holding `DATA_DIR` is not mounted, or it +was zero-length, as a truncated copy leaves it. #### Recovering a lost admin password -`webhooker resetpw` sets an existing account's password from the -command line: +`webhooker resetpw` sets an existing account's password from the command line: ```bash # Generate a new password and print it. @@ -592,95 +548,88 @@ printf '%s' "$NEW_PASSWORD" | \ DATA_DIR=/var/lib/webhooker webhooker resetpw admin ``` -In a container it is the same binary. The image's `CMD` is -`/app/webhooker`, and a command given to `docker run` replaces all of -it, so the whole command has to be given: +In a container it is the same binary. The image's `CMD` is `/app/webhooker`, and +a command given to `docker run` replaces all of it, so the whole command has to +be given: ```bash docker run --rm -v webhooker-data:/var/lib/webhooker \ webhooker /app/webhooker resetpw -generate admin ``` -Stop the service first — with the volume still attached to a running -container, the command refuses. +Stop the service first — with the volume still attached to a running container, +the command refuses. -The password is never taken as a command-line argument: on Linux argv -is readable through `/proc` by every account on the host for as long as -the process lives. Standard input is echoed when it is a terminal — the -prompt says so — so `-generate` or a pipe is preferable on a shared -machine. +The password is never taken as a command-line argument: on Linux argv is +readable through `/proc` by every account on the host for as long as the process +lives. Standard input is echoed when it is a terminal — the prompt says so — so +`-generate` or a pipe is preferable on a shared machine. What it will not do: -- **Run against a live deployment.** It takes the same exclusive - `DATA_DIR` lock the server does (see - [Single-instance lock](#single-instance-lock)) and refuses while a - running instance holds it, naming the directory and exiting non-zero. - A running process keeps serving every session that authenticated with - the old password, so a reset underneath it would report a change the +- **Run against a live deployment.** It takes the same exclusive `DATA_DIR` lock + the server does (see [Single-instance lock](#single-instance-lock)) and + refuses while a running instance holds it, naming the directory and exiting + non-zero. A running process keeps serving every session that authenticated + with the old password, so a reset underneath it would report a change the service does not honour. -- **Create anything.** A `DATA_DIR` that does not exist, or that holds - no `webhooker.db` or a zero-length one, is an error naming the path - rather than a new empty deployment — - a mistyped path must not be built out and then reported as a success. -- **Create an account.** A username that does not exist is an error. - `resetpw` changes an existing account's password and nothing else. +- **Create anything.** A `DATA_DIR` that does not exist, or that holds no + `webhooker.db` or a zero-length one, is an error naming the path rather than a + new empty deployment — a mistyped path must not be built out and then reported + as a success. +- **Create an account.** A username that does not exist is an error. `resetpw` + changes an existing account's password and nothing else. -`DATA_DIR` selects the deployment exactly as it does for the server. A -password that changes on disk takes effect at the next login; sessions -that are already authenticated are unaffected either way. +`DATA_DIR` selects the deployment exactly as it does for the server. A password +that changes on disk takes effect at the next login; sessions that are already +authenticated are unaffected either way. Changing a password you still know needs none of this — use `POST /user/{username}/password` in the web UI. #### What `DEBUG=true` exposes -`DEBUG=true` lowers the log level to `DEBUG`, which turns on every -statement GORM runs, the two by-design lookup misses on the -unauthenticated routes, the rate limiter's own rejections, and fx's -records of building the dependency graph at startup. It is -meant to be safe to turn on while diagnosing a live service and safe to -paste the output of into a bug report. +`DEBUG=true` lowers the log level to `DEBUG`, which turns on every statement +GORM runs, the two by-design lookup misses on the unauthenticated routes, the +rate limiter's own rejections, and fx's records of building the dependency graph +at startup. It is meant to be safe to turn on while diagnosing a live service +and safe to paste the output of into a bug report. What it does **not** put in the log: - **Values bound to a SQL statement.** Statements are logged with their - placeholders, never with the values substituted into them, at every - level. That is what keeps the session encryption key out of the first - boot's `INSERT INTO settings` and the `admin` account's Argon2id - password hash out of its `INSERT INTO users` — the two statements - that made a debug log worth stealing. It applies to every table and - every statement rather than to a list of tables known to hold a - secret, so a table added later is covered without anyone remembering - to add it. The cost is that a failing statement can no longer be - replayed from the log alone: the statement, the table, the driver - error and the row count are all still there, but its values have to - come from the database. - `internal/gormlog/firstboot_test.go` boots the real graph with - `DEBUG=true` against an empty `DATA_DIR` and asserts that neither - secret appears in what that boot wrote to stdout. - The one exception is `(*gorm.DB).Scan`, which GORM logs through its - own trace recorder rather than through this filter. No production - code path calls it, and `internal/gormlog/scan_guard_test.go` fails - if a non-test file adds one. -- **Session cookies, API keys or target credentials.** None of these is - logged at any level. + placeholders, never with the values substituted into them, at every level. + That is what keeps the session encryption key out of the first boot's + `INSERT INTO settings` and the `admin` account's Argon2id password hash out of + its `INSERT INTO users` — the two statements that made a debug log worth + stealing. It applies to every table and every statement rather than to a list + of tables known to hold a secret, so a table added later is covered without + anyone remembering to add it. The cost is that a failing statement can no + longer be replayed from the log alone: the statement, the table, the driver + error and the row count are all still there, but its values have to come from + the database. `internal/gormlog/firstboot_test.go` boots the real graph with + `DEBUG=true` against an empty `DATA_DIR` and asserts that neither secret + appears in what that boot wrote to stdout. The one exception is + `(*gorm.DB).Scan`, which GORM logs through its own trace recorder rather than + through this filter. No production code path calls it, and + `internal/gormlog/scan_guard_test.go` fails if a non-test file adds one. +- **Session cookies, API keys or target credentials.** None of these is logged + at any level. -What is in the log regardless of `DEBUG`, and is not a debug-logging -decision: +What is in the log regardless of `DEBUG`, and is not a debug-logging decision: -- **The initial `admin` password**, in the clear, once, on the first - boot that creates the account — as the banner described under - [The admin account](#the-admin-account), written straight to standard - output rather than through the logger. That banner is the only place - it is ever shown; the database stores the hash. A first boot's output - is not safe to paste anywhere until that account's password has been - changed. The same applies to `webhooker resetpw -generate`, which - prints the password it generated in the same form. -- **An authenticated operator's own configuration**, echoed back - untruncated — webhook names, target hostnames. See the logging - section under Security for the full list and for the per-line size - bound that covers unauthenticated traffic. +- **The initial `admin` password**, in the clear, once, on the first boot that + creates the account — as the banner described under + [The admin account](#the-admin-account), written straight to standard output + rather than through the logger. That banner is the only place it is ever + shown; the database stores the hash. A first boot's output is not safe to + paste anywhere until that account's password has been changed. The same + applies to `webhooker resetpw -generate`, which prints the password it + generated in the same form. +- **An authenticated operator's own configuration**, echoed back untruncated — + webhook names, target hostnames. See the logging section under Security for + the full list and for the per-line size bound that covers unauthenticated + traffic. ### Running with Docker @@ -697,117 +646,106 @@ purpose.** The binary defaults to `127.0.0.1`; the image ships `ENV BIND_ADDRESS=0.0.0.0`, so the `-e BIND_ADDRESS=0.0.0.0` above is belt-and-braces and the command works without it. -The two cases are not the same question. On a bare host, `0.0.0.0` -puts the cleartext admin UI and the unauthenticated receiver on every -interface of the machine, which is what the loopback default exists to -prevent. In a container, the network namespace is already that -boundary: nothing outside reaches `0.0.0.0:8080` because of the -namespace, not because of the bind. What decides exposure there is the -**publish flag**, and that is the line to get right. +The two cases are not the same question. On a bare host, `0.0.0.0` puts the +cleartext admin UI and the unauthenticated receiver on every interface of the +machine, which is what the loopback default exists to prevent. In a container, +the network namespace is already that boundary: nothing outside reaches +`0.0.0.0:8080` because of the namespace, not because of the bind. What decides +exposure there is the **publish flag**, and that is the line to get right. -So publish to `127.0.0.1:8080` rather than `8080`. A bare -`-p 8080:8080` opens the port on every interface of the host — through -firewall rules too, since Docker's forwarding rules are inserted ahead -of most host firewalls. Publish to the host address your reverse proxy -connects from, and nothing wider. +So publish to `127.0.0.1:8080` rather than `8080`. A bare `-p 8080:8080` opens +the port on every interface of the host — through firewall rules too, since +Docker's forwarding rules are inserted ahead of most host firewalls. Publish to +the host address your reverse proxy connects from, and nothing wider. -**An empty `BIND_ADDRESS` is treated as unset**, like every other -variable here, so `-e BIND_ADDRESS=` does not mean "keep the image -default" — it discards the image's `0.0.0.0` and falls back to the -_binary's_ `127.0.0.1`. In a container that is the failure below, and -nothing in the logs names the variable. A templated Compose file or a -`.env` line with an empty value is the usual way in. Either set a -literal or leave the variable out entirely. +**An empty `BIND_ADDRESS` is treated as unset**, like every other variable here, +so `-e BIND_ADDRESS=` does not mean "keep the image default" — it discards the +image's `0.0.0.0` and falls back to the _binary's_ `127.0.0.1`. In a container +that is the failure below, and nothing in the logs names the variable. A +templated Compose file or a `.env` line with an empty value is the usual way in. +Either set a literal or leave the variable out entirely. -Overriding `BIND_ADDRESS` to a loopback address in a container — by -that route or deliberately — makes the container unreachable from -outside its namespace even with `-p`. The published port answers -nothing, and the health check fails too: it requests -`http://localhost:8080`, `localhost` resolves to `::1` first, and a -`127.0.0.1` bind is not listening there. The container then goes -`unhealthy` about **65 seconds** after start — from `HEALTHCHECK ---start-period=5s --interval=30s --retries=3`, so failing probes at -5s, 35s and 65s, and `unhealthy` on the third. (Docker's probe cadence -during the start period has changed between versions; re-derive from -those three values rather than trusting the figure. Measured at 65s on -Docker 29.7.2.) A container `unhealthy` with `connection refused` in -its health log, or a published port that resets connections, is this. +Overriding `BIND_ADDRESS` to a loopback address in a container — by that route +or deliberately — makes the container unreachable from outside its namespace +even with `-p`. The published port answers nothing, and the health check fails +too: it requests `http://localhost:8080`, `localhost` resolves to `::1` first, +and a `127.0.0.1` bind is not listening there. The container then goes +`unhealthy` about **65 seconds** after start — from +`HEALTHCHECK --start-period=5s --interval=30s --retries=3`, so failing probes at +5s, 35s and 65s, and `unhealthy` on the third. (Docker's probe cadence during +the start period has changed between versions; re-derive from those three values +rather than trusting the figure. Measured at 65s on Docker 29.7.2.) A container +`unhealthy` with `connection refused` in its health log, or a published port +that resets connections, is this. -The app runs as a non-root user (`webhooker`, UID 1000), exposes port -8080, and includes a health check against `/.well-known/healthcheck`. -The `/var/lib/webhooker` volume holds all SQLite databases: the main -application database (`webhooker.db`), the per-webhook event databases -(`events-{uuid}.db`), and any archive databases written by `database` -targets (`archive-{webhook_name}-{target_name}-{target_uuid}.db`, with -`-{period}` before `.db` for a target that rotates). Mount -this as a persistent volume to +The app runs as a non-root user (`webhooker`, UID 1000), exposes port 8080, and +includes a health check against `/.well-known/healthcheck`. The +`/var/lib/webhooker` volume holds all SQLite databases: the main application +database (`webhooker.db`), the per-webhook event databases (`events-{uuid}.db`), +and any archive databases written by `database` targets +(`archive-{webhook_name}-{target_name}-{target_uuid}.db`, with `-{period}` +before `.db` for a target that rotates). Mount this as a persistent volume to preserve data across container restarts. -**The container sets its data directory's owner and mode itself -before the app starts**, so a host directory can be mounted as it is, -whoever owns it. The image's `ENTRYPOINT`, -`deploy/docker-entrypoint.sh`, starts as root, creates `DATA_DIR` if -it is missing, gives the directory and anything in it that belongs to -another user to `webhooker`, sets the directory to `0750`, and only -then runs the app as `webhooker`. Started with `--user`, it changes -nothing and runs the app as that user. +**The container sets its data directory's owner and mode itself before the app +starts**, so a host directory can be mounted as it is, whoever owns it. The +image's `ENTRYPOINT`, `deploy/docker-entrypoint.sh`, starts as root, creates +`DATA_DIR` if it is missing, gives the directory and anything in it that belongs +to another user to `webhooker`, sets the directory to `0750`, and only then runs +the app as `webhooker`. Started with `--user`, it changes nothing and runs the +app as that user. -**The file modes are not yours to set, and do not depend on the -directory.** `webhooker.db` holds target configuration in plaintext — -bearer tokens, API keys, Slack webhook URLs — along with the session -encryption key, so webhooker creates every SQLite file it owns `0600`: -each database and both of its `-wal` and `-shm` sidecars, across all -three tiers. Files an earlier build left `0644` are tightened when -they are opened. The directory's `0750` is defence in depth — it stops -other local users listing the directory and learning your webhook -UUIDs from the `events-{uuid}.db` filenames — not the barrier -protecting the credentials. +**The file modes are not yours to set, and do not depend on the directory.** +`webhooker.db` holds target configuration in plaintext — bearer tokens, API +keys, Slack webhook URLs — along with the session encryption key, so webhooker +creates every SQLite file it owns `0600`: each database and both of its `-wal` +and `-shm` sidecars, across all three tiers. Files an earlier build left `0644` +are tightened when they are opened. The directory's `0750` is defence in depth — +it stops other local users listing the directory and learning your webhook UUIDs +from the `events-{uuid}.db` filenames — not the barrier protecting the +credentials. ### Running under upaas -[upaas](https://git.eeqj.de/sneak/upaas) builds the image from this -repository's `Dockerfile` and runs it. The app needs: +[upaas](https://git.eeqj.de/sneak/upaas) builds the image from this repository's +`Dockerfile` and runs it. The app needs: -- **Network and port:** add no port mapping in upaas. upaas publishes - every mapped port on all interfaces of the host - ([upaas issue 113](https://git.eeqj.de/sneak/upaas/issues/113)), - which would put the plain-HTTP admin UI and receiver there. Instead, - set the app's Docker Network in upaas to your reverse proxy's Docker - network; the proxy then reaches the app at `upaas-` followed by the - app name, port `8080`. Leave `PORT` unset: the image's health check - probes `8080`. +- **Network and port:** add no port mapping in upaas. upaas publishes every + mapped port on all interfaces of the host + ([upaas issue 113](https://git.eeqj.de/sneak/upaas/issues/113)), which would + put the plain-HTTP admin UI and receiver there. Instead, set the app's Docker + Network in upaas to your reverse proxy's Docker network; the proxy then + reaches the app at `upaas-` followed by the app name, port `8080`. Leave + `PORT` unset: the image's health check probes `8080`. - **Volume:** one host directory mounted at `/var/lib/webhooker`. - **Environment variables:** - `WEBHOOKER_ENVIRONMENT=prod` - - `TRUSTED_PROXIES`: unset, it is the RFC 1918 ranges. Set it to - your reverse proxy's address alone if that address is outside - those ranges, or if any client can reach webhooker, or the proxy, - from an RFC 1918 source address (directly, or through anything - that can rewrite source addresses, such as NAT or a published - container port). The `remoteIP` field of the `http request` log - line for a request that came through the proxy shows that - address; the health check's own lines show `::1`. See + - `TRUSTED_PROXIES`: unset, it is the RFC 1918 ranges. Set it to your + reverse proxy's address alone if that address is outside those ranges, or + if any client can reach webhooker, or the proxy, from an RFC 1918 source + address (directly, or through anything that can rewrite source addresses, + such as NAT or a published container port). The `remoteIP` field of the + `http request` log line for a request that came through the proxy shows + that address; the health check's own lines show `::1`. See [Trusted proxies](#trusted-proxies). - - Leave `BIND_ADDRESS` and `DATA_DIR` unset: the image sets - `BIND_ADDRESS` to `0.0.0.0`, and `DATA_DIR` defaults to - `/var/lib/webhooker`. + - Leave `BIND_ADDRESS` and `DATA_DIR` unset: the image sets `BIND_ADDRESS` + to `0.0.0.0`, and `DATA_DIR` defaults to `/var/lib/webhooker`. - Everything else is optional; see [Configuration](#configuration). -- **Health check:** the image's own, which requests - `/.well-known/healthcheck`. upaas reads the container's health 60 - seconds after a deploy and marks the deploy failed unless it is - `healthy`. -- **First run:** the first start prints the `admin` password once, in - the banner described under [The admin account](#the-admin-account), - to the container's log. upaas names the container `upaas-` followed - by the app name, so for an app named `webhooker`: +- **Health check:** the image's own, which requests `/.well-known/healthcheck`. + upaas reads the container's health 60 seconds after a deploy and marks the + deploy failed unless it is `healthy`. +- **First run:** the first start prints the `admin` password once, in the banner + described under [The admin account](#the-admin-account), to the container's + log. upaas names the container `upaas-` followed by the app name, so for an + app named `webhooker`: ```bash docker logs upaas-webhooker ``` - If the password is lost, stop the container, set a new password with - the app's own image and volume, and start it again (see - [Recovering a lost admin password](#recovering-a-lost-admin-password)): + If the password is lost, stop the container, set a new password with the + app's own image and volume, and start it again (see + [Recovering a lost admin password](#recovering-a-lost-admin-password)): ```bash docker stop upaas-webhooker @@ -819,63 +757,56 @@ repository's `Dockerfile` and runs it. The app needs: ## Deployment behind a reverse proxy -webhooker terminates no TLS of its own. It serves plaintext HTTP and -expects a reverse proxy in front of it, which is the deployment it is -built for: the proxy holds the certificate, and webhooker binds -loopback where only the proxy can reach it. +webhooker terminates no TLS of its own. It serves plaintext HTTP and expects a +reverse proxy in front of it, which is the deployment it is built for: the proxy +holds the certificate, and webhooker binds loopback where only the proxy can +reach it. -Five things have to be right. Each one is silent when it is wrong — -the service comes up, serves pages, and is broken in a way nothing -reports. +Five things have to be right. Each one is silent when it is wrong — the service +comes up, serves pages, and is broken in a way nothing reports. -1. **Bind or firewall the app port.** The binary binds `127.0.0.1` by - default, so the cleartext listener is not published beside the - proxy. The image binds `0.0.0.0` inside its own network namespace - and relies on the publish address instead — - `-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 - no TLS at all, and the proxy in front of it changes nothing about - that. -2. **Make sure the environment is not `dev` (leave - `WEBHOOKER_ENVIRONMENT` unset or set it to `prod`), and make sure - the proxy sends `X-Forwarded-Proto`.** These are two requirements, - not one. The environment setting decides CORS and nothing else: - `dev` answers every origin with `Access-Control-Allow-Origin: *` - (without credentials), which a server-rendered production - deployment has no use for, and `prod` — the default — disables it. - Cookie `Secure` and the strict Origin/Referer mode are **not** tied - to it — they are decided per request from the transport, which - behind a proxy means the `X-Forwarded-Proto` header. The block below - sets it; without it every request is read as plaintext and cookies - ship without `Secure`. See [Configuration](#configuration). -3. **Make sure `TRUSTED_PROXIES` covers the proxy's address.** For a - proxy it does not cover, every rate limiter keys on the proxy, so - all clients share one bucket per limit. Unset, the list is the RFC - 1918 ranges, which do not cover a proxy that reaches the binary - itself over loopback (the binary bound to `127.0.0.1`). With the - image, the address to check is the `remoteIP` field of the - `http request` log line for a request that came through the proxy. - If any client can reach webhooker, or the proxy, from an RFC 1918 - source address, set the list to the proxy's address alone. See +1. **Bind or firewall the app port.** The binary binds `127.0.0.1` by default, + so the cleartext listener is not published beside the proxy. The image binds + `0.0.0.0` inside its own network namespace and relies on the publish address + instead — `-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 no TLS at all, and the proxy in front + of it changes nothing about that. +2. **Make sure the environment is not `dev` (leave `WEBHOOKER_ENVIRONMENT` unset + or set it to `prod`), and make sure the proxy sends `X-Forwarded-Proto`.** + These are two requirements, not one. The environment setting decides CORS and + nothing else: `dev` answers every origin with + `Access-Control-Allow-Origin: *` (without credentials), which a + server-rendered production deployment has no use for, and `prod` — the + default — disables it. Cookie `Secure` and the strict Origin/Referer mode are + **not** tied to it — they are decided per request from the transport, which + behind a proxy means the `X-Forwarded-Proto` header. The block below sets it; + without it every request is read as plaintext and cookies ship without + `Secure`. See [Configuration](#configuration). +3. **Make sure `TRUSTED_PROXIES` covers the proxy's address.** For a proxy it + does not cover, every rate limiter keys on the proxy, so all clients share + one bucket per limit. Unset, the list is the RFC 1918 ranges, which do not + cover a proxy that reaches the binary itself over loopback (the binary bound + to `127.0.0.1`). With the image, the address to check is the `remoteIP` field + of the `http request` log line for a request that came through the proxy. If + any client can reach webhooker, or the proxy, from an RFC 1918 source + address, set the list to the proxy's address alone. See [Trusted proxies](#trusted-proxies). -4. **Send `Host` as `$http_host`, not `$host`.** `$host` strips the - port. webhooker's Origin/Referer check compares against the host it - was given, so on any port other than 443 `$host` makes every form - POST — including login — fail with `403 origin invalid`, with - nothing in the error naming the cause. -5. **Keep the proxy's access log.** webhooker's own access log names - the client in its `clientIP` field only while `TRUSTED_PROXIES` - covers the proxy; the proxy's log names it regardless. nginx's - default `combined` format already logs `$remote_addr`; do not - replace it with one that drops the client address, and retain those - logs as long as you would want to answer a question about traffic. +4. **Send `Host` as `$http_host`, not `$host`.** `$host` strips the port. + webhooker's Origin/Referer check compares against the host it was given, so + on any port other than 443 `$host` makes every form POST — including login — + fail with `403 origin invalid`, with nothing in the error naming the cause. +5. **Keep the proxy's access log.** webhooker's own access log names the client + in its `clientIP` field only while `TRUSTED_PROXIES` covers the proxy; the + proxy's log names it regardless. nginx's default `combined` format already + logs `$remote_addr`; do not replace it with one that drops the client + address, and retain those logs as long as you would want to answer a question + about traffic. ### nginx -Complete server block. Replace the `server_name` and the two -certificate paths. +Complete server block. Replace the `server_name` and the two certificate paths. ```nginx server { @@ -927,19 +858,17 @@ server { } ``` -`X-Forwarded-For` must be **appended** to, which -`$proxy_add_x_forwarded_for` does. webhooker reads no other forwarded -client header: `X-Real-IP` and `True-Client-IP` are ignored from every -peer, so setting them has no effect. See +`X-Forwarded-For` must be **appended** to, which `$proxy_add_x_forwarded_for` +does. webhooker reads no other forwarded client header: `X-Real-IP` and +`True-Client-IP` are ignored from every peer, so setting them has no effect. See [Trusted proxies](#trusted-proxies) for how the chain is walked. -`X-Forwarded-Proto: https` is what tells webhooker the request arrived -over TLS, which decides the `Secure` flag on both the session and CSRF -cookies and the strict Origin/Referer mode. Without it, requests are -treated as plaintext and the cookies ship without `Secure`. Unlike -`X-Forwarded-For`, this header is read from any peer and is not gated -by `TRUSTED_PROXIES`, so the proxy must overwrite whatever a client -sent — `$scheme` above does. +`X-Forwarded-Proto: https` is what tells webhooker the request arrived over TLS, +which decides the `Secure` flag on both the session and CSRF cookies and the +strict Origin/Referer mode. Without it, requests are treated as plaintext and +the cookies ship without `Secure`. Unlike `X-Forwarded-For`, this header is read +from any peer and is not gated by `TRUSTED_PROXIES`, so the proxy must overwrite +whatever a client sent — `$scheme` above does. With that block, webhooker's environment is: @@ -948,95 +877,90 @@ BIND_ADDRESS=127.0.0.1 # the default; stated here to be explicit TRUSTED_PROXIES=127.0.0.1 ``` -If nginx runs on another host, `BIND_ADDRESS` becomes the address it -connects to, `TRUSTED_PROXIES` becomes nginx's address, and the port -must be firewalled to that address — the traffic between them is -cleartext. +If nginx runs on another host, `BIND_ADDRESS` becomes the address it connects +to, `TRUSTED_PROXIES` becomes nginx's address, and the port must be firewalled +to that address — the traffic between them is cleartext. ### HSTS is always sent, and is not configurable Every response carries -`Strict-Transport-Security: max-age=63072000; includeSubDomains; preload`. -Two years, every subdomain, and a `preload` token. There is no setting -that changes or suppresses it. +`Strict-Transport-Security: max-age=63072000; includeSubDomains; preload`. Two +years, every subdomain, and a `preload` token. There is no setting that changes +or suppresses it. -This is worth knowing before the first request reaches a browser: a -client that sees it once will refuse plaintext HTTP to that hostname — -and to every subdomain of it — for two years, whatever else is served -there. Terminate TLS on a hostname you are prepared to keep on HTTPS. +This is worth knowing before the first request reaches a browser: a client that +sees it once will refuse plaintext HTTP to that hostname — and to every +subdomain of it — for two years, whatever else is served there. Terminate TLS on +a hostname you are prepared to keep on HTTPS. ## Backup, Restore, and Upgrades ### What to back up -All persistent state lives in `DATA_DIR` (`/var/lib/webhooker` by -default). Back up that directory in full — the state is spread across -several files whose names depend on your data, so copying the directory -is both the simplest and the only complete rule: +All persistent state lives in `DATA_DIR` (`/var/lib/webhooker` by default). Back +up that directory in full — the state is spread across several files whose names +depend on your data, so copying the directory is both the simplest and the only +complete rule: -- `webhooker.db` — one per install. Settings (including the session - encryption key), users, API keys, webhooks, entrypoints, targets. -- `events-{webhook_uuid}.db` — **one per webhook**. Events, deliveries, - delivery results. -- `archive-{webhook_name}-{target_name}-{target_uuid}.db` — **one per - `database` target**, or one per month, day or hour for a target that - rotates, with `-{period}` before `.db`. Archived events. The two names - are made safe for a file name, and the files are renamed when the - webhook or the target is (see +- `webhooker.db` — one per install. Settings (including the session encryption + key), users, API keys, webhooks, entrypoints, targets. +- `events-{webhook_uuid}.db` — **one per webhook**. Events, deliveries, delivery + results. +- `archive-{webhook_name}-{target_name}-{target_uuid}.db` — **one per `database` + target**, or one per month, day or hour for a target that rotates, with + `-{period}` before `.db`. Archived events. The two names are made safe for a + file name, and the files are renamed when the webhook or the target is (see [Database Architecture](#database-architecture)). -`{webhook_uuid}` and `{target_uuid}` are UUID primary keys in their -canonical 36-character hyphenated form, so a real filename looks like +`{webhook_uuid}` and `{target_uuid}` are UUID primary keys in their canonical +36-character hyphenated form, so a real filename looks like `events-3f2a1c9e-....db`. The only other file is `webhooker.lock`, the -always-empty [single-instance lock](#single-instance-lock); it holds no -state and is not part of the backup set — a copied one is stale and -blocks nothing. +always-empty [single-instance lock](#single-instance-lock); it holds no state +and is not part of the backup set — a copied one is stale and blocks nothing. -**`-wal` and `-shm` sidecars.** Every database runs in WAL journal mode, -so while the service is running each `{name}.db` has a `{name}.db-wal` -and a `{name}.db-shm` beside it. **`-wal` is part of the database, not a -scratch file**: it holds committed transactions that are not yet in the -`.db`, so a copy of the `.db` without its `-wal` is missing data and may -have no readable schema at all. `-shm` is regenerable, but there is no -reason to separate the two — copy the directory and you have them. +**`-wal` and `-shm` sidecars.** Every database runs in WAL journal mode, so +while the service is running each `{name}.db` has a `{name}.db-wal` and a +`{name}.db-shm` beside it. **`-wal` is part of the database, not a scratch +file**: it holds committed transactions that are not yet in the `.db`, so a copy +of the `.db` without its `-wal` is missing data and may have no readable schema +at all. `-shm` is regenerable, but there is no reason to separate the two — copy +the directory and you have them. -A clean shutdown closes every database, which checkpoints and removes -its sidecars; a killed or crashed instance leaves them, and they must be -carried with the `.db`. An archive the service has not opened since a -crash keeps that crash's sidecars, even across a later clean stop. +A clean shutdown closes every database, which checkpoints and removes its +sidecars; a killed or crashed instance leaves them, and they must be carried +with the `.db`. An archive the service has not opened since a crash keeps that +crash's sidecars, even across a later clean stop. -A missing sidecar is therefore normal, and SQLite makes new ones, so a -`-wal` lost from a copy cannot be reported: the transactions it held -are simply gone. SQLite reads a `-wal` up to its first damaged frame, -as after a crash, and rebuilds a damaged `-shm`. A sidecar with the -wrong mode is set back to `0600` when its database is opened. A -directory in place of either is refused then, with an error naming it: -for `webhooker.db` the server and `webhooker resetpw` stop, and an event -or archive database fails as a damaged one does (see +A missing sidecar is therefore normal, and SQLite makes new ones, so a `-wal` +lost from a copy cannot be reported: the transactions it held are simply gone. +SQLite reads a `-wal` up to its first damaged frame, as after a crash, and +rebuilds a damaged `-shm`. A sidecar with the wrong mode is set back to `0600` +when its database is opened. A directory in place of either is refused then, +with an error naming it: for `webhooker.db` the server and `webhooker resetpw` +stop, and an event or archive database fails as a damaged one does (see [Database Architecture](#database-architecture)). -Configuration is **not** in `DATA_DIR` — it comes from the environment -and from a `.env` file read out of the process working directory. Back -that up with your deployment config, separately. +Configuration is **not** in `DATA_DIR` — it comes from the environment and from +a `.env` file read out of the process working directory. Back that up with your +deployment config, separately. ### A hot copy is not safe -Every database webhooker opens runs in WAL journal mode. The main and -event databases are also held open for the entire process lifetime — -`WebhookDBManager` caches event database handles and closes them only on -webhook deletion or shutdown — so "it looked idle" is not a guarantee -that nothing was mid-transaction. +Every database webhooker opens runs in WAL journal mode. The main and event +databases are also held open for the entire process lifetime — +`WebhookDBManager` caches event database handles and closes them only on webhook +deletion or shutdown — so "it looked idle" is not a guarantee that nothing was +mid-transaction. -That means `cp`, `rsync`, `tar` or a filesystem snapshot taken against a -running instance can capture a database and its `-wal` at two different -instants and yield a file that is corrupt or missing state. Copying a -`.db` on its own is worse and fails loudly: recently written pages, -including the schema itself on a young database, live in the `-wal`, so -the copy reads back as an empty or table-less database. Use one of the -two procedures below instead. +That means `cp`, `rsync`, `tar` or a filesystem snapshot taken against a running +instance can capture a database and its `-wal` at two different instants and +yield a file that is corrupt or missing state. Copying a `.db` on its own is +worse and fails loudly: recently written pages, including the schema itself on a +young database, live in the `-wal`, so the copy reads back as an empty or +table-less database. Use one of the two procedures below instead. -**Stop, copy, start.** The simplest, needs no extra tooling, and the -only one that gives a single point in time across every file: +**Stop, copy, start.** The simplest, needs no extra tooling, and the only one +that gives a single point in time across every file: ```bash docker stop webhooker @@ -1052,80 +976,71 @@ for db in /path/to/data/*.db; do done ``` -`.backup` reads through the WAL and writes a single consistent file with -no sidecars of its own, so the destination is complete as it stands. -Two caveats. First, the runtime image is `alpine:3.21` with only -`ca-certificates` and `su-exec` added — the `sqlite3` CLI is **not** in -it, so run this on the host against the volume path, or from a -throwaway container that mounts the volume. Second, each file is -captured at its own instant, so a webhook created or an event delivered -between two files being copied lands in one and not the other. If you -need the whole set coherent as of a single moment, stop the service. +`.backup` reads through the WAL and writes a single consistent file with no +sidecars of its own, so the destination is complete as it stands. Two caveats. +First, the runtime image is `alpine:3.21` with only `ca-certificates` and +`su-exec` added — the `sqlite3` CLI is **not** in it, so run this on the host +against the volume path, or from a throwaway container that mounts the volume. +Second, each file is captured at its own instant, so a webhook created or an +event delivered between two files being copied lands in one and not the other. +If you need the whole set coherent as of a single moment, stop the service. -Note that `sqlite3 .dump` is **not** one of these procedures: it is -an export, it holds a read transaction open for as long as it runs, and -it pins the WAL against checkpointing for that whole time. It is safe to -run — it does not block ingestion — but back up with `.backup` or a -stopped copy. +Note that `sqlite3 .dump` is **not** one of these procedures: it is an +export, it holds a read transaction open for as long as it runs, and it pins the +WAL against checkpointing for that whole time. It is safe to run — it does not +block ingestion — but back up with `.backup` or a stopped copy. -Archive databases are the one exception the service is built for: the -archive writer closes and reopens its handle around writes (debounced -to at most one reopen per second), so an operator can move an -`archive-….db` away for offline retention while the service runs, -and it is recreated on the next write. The webhook page names each -`database` target's archive file. See -[Database Architecture](#database-architecture). That is a -move-the-file-away workflow, not a substitute for the backup procedures -above. +Archive databases are the one exception the service is built for: the archive +writer closes and reopens its handle around writes (debounced to at most one +reopen per second), so an operator can move an `archive-….db` away for offline +retention while the service runs, and it is recreated on the next write. The +webhook page names each `database` target's archive file. See +[Database Architecture](#database-architecture). That is a move-the-file-away +workflow, not a substitute for the backup procedures above. -**Move the sidecars with it.** Under WAL that workflow is no longer a -single file, and the common case is the dangerous one. The reopen -happens on the *next* write after the debounce window elapses, so after -the last write of a burst nothing checkpoints: measured, 20 s after ten -events the `archive-….db` was 4096 bytes — a header, no table — with -all ten rows sitting in a 189 KB `-wal`. Copying the `.db` alone at that -moment yields a file that opens with `no such table: archived_events`. -The file becomes self-contained again when the handle closes, which -happens on the next write past the debounce window, when the connection -pool retires the idle connection (about a minute after the last write), -or at the idle archive sweep — measured, the same file was a complete -20 KB `.db` with no sidecars about a minute after its last write. A -clean stop closes it too. So either move the `archive-….db` together -with any `-wal`/`-shm` beside it, or wait until there are none. +**Move the sidecars with it.** Under WAL that workflow is no longer a single +file, and the common case is the dangerous one. The reopen happens on the _next_ +write after the debounce window elapses, so after the last write of a burst +nothing checkpoints: measured, 20 s after ten events the `archive-….db` was 4096 +bytes — a header, no table — with all ten rows sitting in a 189 KB `-wal`. +Copying the `.db` alone at that moment yields a file that opens with +`no such table: archived_events`. The file becomes self-contained again when the +handle closes, which happens on the next write past the debounce window, when +the connection pool retires the idle connection (about a minute after the last +write), or at the idle archive sweep — measured, the same file was a complete 20 +KB `.db` with no sidecars about a minute after its last write. A clean stop +closes it too. So either move the `archive-….db` together with any `-wal`/`-shm` +beside it, or wait until there are none. ### Restore 1. Stop the service. -2. Restore the **whole set together**: `webhooker.db` *and* every - `events-*.db` *and* every `archive-*.db`. A restore that leaves out - `webhooker.db` or an `events-*.db` is reported, not refused; one - that leaves out an `archive-*.db` or a `-wal` (step 3) is not - reported at all. Every database is opened `mode=rwc`, so a - missing `events-{uuid}.db` is **created empty**: the webhook comes - back with its configuration intact and its entire event history - gone. The first start after the restore logs - `created a new, empty database` at `WARN` for each such file, with - its path, as it does for a missing `webhooker.db`. A missing - `archive-*.db` is recreated at its target's next delivery without a - warning, since moving one away is a supported workflow. Event - databases restored without `webhooker.db` are simply orphaned; - nothing references their UUIDs. +2. Restore the **whole set together**: `webhooker.db` _and_ every `events-*.db` + _and_ every `archive-*.db`. A restore that leaves out `webhooker.db` or an + `events-*.db` is reported, not refused; one that leaves out an `archive-*.db` + or a `-wal` (step 3) is not reported at all. Every database is opened + `mode=rwc`, so a missing `events-{uuid}.db` is **created empty**: the webhook + comes back with its configuration intact and its entire event history gone. + The first start after the restore logs `created a new, empty database` at + `WARN` for each such file, with its path, as it does for a missing + `webhooker.db`. A missing `archive-*.db` is recreated at its target's next + delivery without a warning, since moving one away is a supported workflow. + Event databases restored without `webhooker.db` are simply orphaned; nothing + references their UUIDs. -3. Carry any `*.db-wal` and `*.db-shm` files that are in the backup. - They are part of the database, and dropping a `-wal` silently - discards every transaction it still holds. An `.backup` set will not - contain any: it writes a single consolidated file per database. A - stop-and-copy set normally has none, because a clean stop closes - every database and checkpoints its sidecars away; the exception is an - archive not opened since a crash. A copy salvaged from a crashed - instance has them for everything, and needs all of them. +3. Carry any `*.db-wal` and `*.db-shm` files that are in the backup. They are + part of the database, and dropping a `-wal` silently discards every + transaction it still holds. An `.backup` set will not contain any: it writes + a single consolidated file per database. A stop-and-copy set normally has + none, because a clean stop closes every database and checkpoints its sidecars + away; the exception is an archive not opened since a crash. A copy salvaged + from a crashed instance has them for everything, and needs all of them. -4. Start the service. The container gives the directory and the - restored files to the `webhooker` user before the app starts, - whoever restored them (see - [Running with Docker](#running-with-docker)). `AutoMigrate` runs - against each restored database as it is opened. +4. Start the service. The container gives the directory and the restored files + to the `webhooker` user before the app starts, whoever restored them (see + [Running with Docker](#running-with-docker)). `AutoMigrate` runs against each + restored database as it is opened. ### Upgrades @@ -1138,213 +1053,203 @@ unconditionally against whatever files it finds: `DeliveryResult`, `EventTotals`, `TargetTotals`, `EntrypointTotals` - each archive database on every open and reopen -There is no schema version table, no migration ledger, and no down -migrations. Nothing in the files records which version wrote them, and -no code path undoes a migration. +There is no schema version table, no migration ledger, and no down migrations. +Nothing in the files records which version wrote them, and no code path undoes a +migration. Upgrade procedure: 1. Stop the service. 2. Back up `DATA_DIR` using one of the procedures above. 3. Pull the new image and start it. -4. Confirm `database migrations completed` in the logs before putting - traffic back on it. +4. Confirm `database migrations completed` in the logs before putting traffic + back on it. 5. Confirm the new build is the one running: - `curl -s http://host:8080/.well-known/healthcheck` reports the - version it was stamped with (see [Version stamping](#version-stamping)). + `curl -s http://host:8080/.well-known/healthcheck` reports the version it was + stamped with (see [Version stamping](#version-stamping)). -**Upgrading past the introduction of `BIND_ADDRESS`:** earlier versions -always bound every interface. **Container deployments are unaffected** -— the image ships `ENV BIND_ADDRESS=0.0.0.0`, so a `docker run` or -Compose service that worked before still works with nothing changed. +**Upgrading past the introduction of `BIND_ADDRESS`:** earlier versions always +bound every interface. **Container deployments are unaffected** — the image +ships `ENV BIND_ADDRESS=0.0.0.0`, so a `docker run` or Compose service that +worked before still works with nothing changed. -A **bare binary** is the case that changes: the listener now binds -`127.0.0.1` unless `BIND_ADDRESS` says otherwise, so a deployment that -relied on reaching it from another host becomes unreachable until it -sets the address the proxy connects to. Check the `bindAddress` field -of the startup log to see what a running process bound. See -[Bind address](#bind-address). +A **bare binary** is the case that changes: the listener now binds `127.0.0.1` +unless `BIND_ADDRESS` says otherwise, so a deployment that relied on reaching it +from another host becomes unreachable until it sets the address the proxy +connects to. Check the `bindAddress` field of the startup log to see what a +running process bound. See [Bind address](#bind-address). -**Downgrade is unsupported.** Once a newer binary has migrated the files -there is no way to move them back. `AutoMigrate` is additive — it adds -tables, columns and indexes and never drops or rewrites them — so an -older binary will generally open migrated files and appear to work while -writing against a schema it does not know about. The failure mode is -silent divergence, not a startup error. The only supported way back to -an older version is restoring the pre-upgrade backup, which discards -everything received since that backup was taken. +**Downgrade is unsupported.** Once a newer binary has migrated the files there +is no way to move them back. `AutoMigrate` is additive — it adds tables, columns +and indexes and never drops or rewrites them — so an older binary will generally +open migrated files and appear to work while writing against a schema it does +not know about. The failure mode is silent divergence, not a startup error. The +only supported way back to an older version is restoring the pre-upgrade backup, +which discards everything received since that backup was taken. ### Version stamping -The binary reports its version at `/.well-known/healthcheck` (the -`version` field), in the UI footer, and in the startup log line -(`msg=starting`, `version=...`). It is also the Sentry release name, -as `webhooker-{version}`. The value is stamped in at build time by the -linker; it is not read from a file at runtime, so it identifies the -build itself. +The binary reports its version at `/.well-known/healthcheck` (the `version` +field), in the UI footer, and in the startup log line (`msg=starting`, +`version=...`). It is also the Sentry release name, as `webhooker-{version}`. +The value is stamped in at build time by the linker; it is not read from a file +at runtime, so it identifies the build itself. `script/version` produces the value and both build paths use it: -| Build | What it reports | -| --- | --- | -| Clean checkout at a tag | exactly that tag, e.g. `v1.0.0` | -| Commits past a tag | `v1.0.0-3-g1a2b3c4` — tag, commits since, short SHA | -| No tag reachable | the short SHA, e.g. `1a2b3c4` | -| Uncommitted changes | the above with a `-dirty` suffix | -| No git metadata | `unknown` | +| Build | What it reports | +| ----------------------- | --------------------------------------------------- | +| Clean checkout at a tag | exactly that tag, e.g. `v1.0.0` | +| Commits past a tag | `v1.0.0-3-g1a2b3c4` — tag, commits since, short SHA | +| No tag reachable | the short SHA, e.g. `1a2b3c4` | +| Uncommitted changes | the above with a `-dirty` suffix | +| No git metadata | `unknown` | -The image derives it the same way, from the `.git` that the build -context carries, so any `docker build .` of a clone, with no build -arguments, stamps the commit it was built from; a shallow clone of one -branch has no tags and stamps the short SHA. `.dockerignore` must -therefore leave out neither `.git` nor any tracked file, which git in -the build would see as deleted, marking the version `-dirty`. It does -leave `.git/config`, which can hold a remote URL carrying a credential -and which `git describe` does not need, out of a directory context. A +The image derives it the same way, from the `.git` that the build context +carries, so any `docker build .` of a clone, with no build arguments, stamps the +commit it was built from; a shallow clone of one branch has no tags and stamps +the short SHA. `.dockerignore` must therefore leave out neither `.git` nor any +tracked file, which git in the build would see as deleted, marking the version +`-dirty`. It does leave `.git/config`, which can hold a remote URL carrying a +credential and which `git describe` does not need, out of a directory context. A context sent as a tar is not filtered by `.dockerignore`, so it carries `.git/config` unless its sender leaves it out; for upaas, that is -https://git.eeqj.de/sneak/upaas/issues/274. git in the build -reads the checkout whoever owns its files, since a context sent as a tar -archive keeps the sender's owners and git otherwise refuses a checkout -owned by another user. A `VERSION` build arg (`--build-arg VERSION=...`) -takes precedence; `script/docker` (and so `make docker`) passes the one -`script/version` resolves on the host. The image build fails if its -context carries `.git` and the version still comes out `unknown`, which -means git is missing from the build or could not read the checkout. +https://git.eeqj.de/sneak/upaas/issues/274. git in the build reads the checkout +whoever owns its files, since a context sent as a tar archive keeps the sender's +owners and git otherwise refuses a checkout owned by another user. A `VERSION` +build arg (`--build-arg VERSION=...`) takes precedence; `script/docker` (and so +`make docker`) passes the one `script/version` resolves on the host. The image +build fails if its context carries `.git` and the version still comes out +`unknown`, which means git is missing from the build or could not read the +checkout. -`unknown` is what a source tarball, or a `docker build` with no `.git` -in its context and no `VERSION` build arg, reports. A build that reports -`unknown` is a build nobody told what it was; it is not a failure, but -it cannot be traced back to a commit. +`unknown` is what a source tarball, or a `docker build` with no `.git` in its +context and no `VERSION` build arg, reports. A build that reports `unknown` is a +build nobody told what it was; it is not a failure, but it cannot be traced back +to a commit. `make version` prints what the current checkout would stamp, and `make build VERSION=v1.2.3` overrides it. An empty override — from -`make build VERSION=` or from `--build-arg VERSION=` — means unset -rather than `""`, and resolves the way an absent one does. +`make build VERSION=` or from `--build-arg VERSION=` — means unset rather than +`""`, and resolves the way an absent one does. -Nothing that varies between two builds of the same commit is stamped — -no timestamp, no hostname, no builder identity — so two builds of one -commit still produce a byte-identical binary. +Nothing that varies between two builds of the same commit is stamped — no +timestamp, no hostname, no builder identity — so two builds of one commit still +produce a byte-identical binary. ### Backups contain secrets -Treat a backup with the same care as the credentials inside it. Encrypt -backups at rest and restrict who can read them. +Treat a backup with the same care as the credentials inside it. Encrypt backups +at rest and restrict who can read them. -- `events-{uuid}.db` and `archive-….db` hold the **full payload - body and headers** of every event as received, including whatever the - sending service put in them — tokens, signatures, personal data. +- `events-{uuid}.db` and `archive-….db` hold the **full payload body and + headers** of every event as received, including whatever the sending service + put in them — tokens, signatures, personal data. - Event databases written before - [issue #206](https://git.eeqj.de/sneak/webhooker/issues/206) was fixed - **also contain target credentials**: a GORM association upsert on the - delivery and retry write path copied `targets` rows, `config` - included, into the per-webhook database. For a Slack target the - `webhookUrl` *is* the bearer credential, and an `http` target's URL - can embed userinfo. This version never writes those rows; the first - time it opens such a file it deletes them and vacuums the file, which - removes the credential bytes rather than only unlinking the rows. - Deleting alone would not: the bytes stay readable in the file's free - pages until it is rewritten. The sweep is recorded in the file's - `user_version` only once the vacuum returns, so a sweep that fails or - is interrupted fails the open and is retried on the next one, and a - file this version has opened without error holds no leaked rows and - no recoverable bytes from them. On upgrade this rewrites each - existing `events-{uuid}.db` once, on its first open. Two cases still - hand over live delivery destinations: a backup taken from an older - build, and a backup of a file this version has not yet opened - successfully. Copies already made stay affected — the sweep only - rewrites the file it opens, and freed blocks may persist in - filesystem snapshots and on the underlying storage. Rotate any target - credential that was in a backup you cannot account for. + [issue #206](https://git.eeqj.de/sneak/webhooker/issues/206) was fixed **also + contain target credentials**: a GORM association upsert on the delivery and + retry write path copied `targets` rows, `config` included, into the + per-webhook database. For a Slack target the `webhookUrl` _is_ the bearer + credential, and an `http` target's URL can embed userinfo. This version never + writes those rows; the first time it opens such a file it deletes them and + vacuums the file, which removes the credential bytes rather than only + unlinking the rows. Deleting alone would not: the bytes stay readable in the + file's free pages until it is rewritten. The sweep is recorded in the file's + `user_version` only once the vacuum returns, so a sweep that fails or is + interrupted fails the open and is retried on the next one, and a file this + version has opened without error holds no leaked rows and no recoverable bytes + from them. On upgrade this rewrites each existing `events-{uuid}.db` once, on + its first open. Two cases still hand over live delivery destinations: a backup + taken from an older build, and a backup of a file this version has not yet + opened successfully. Copies already made stay affected — the sweep only + rewrites the file it opens, and freed blocks may persist in filesystem + snapshots and on the underlying storage. Rotate any target credential that was + in a backup you cannot account for. - `webhooker.db` stores target config **unencrypted**, tracked at - [issue #212](https://git.eeqj.de/sneak/webhooker/issues/212), next to - the session encryption key and the Argon2id password hashes. + [issue #212](https://git.eeqj.de/sneak/webhooker/issues/212), next to the + session encryption key and the Argon2id password hashes. ## The entrypoint URL is the authentication secret -**The entrypoint UUID is the credential, and it is the only one.** -webhooker mints a version 4 UUID per entrypoint and serves it at -`/h/{uuid}`. Possession of that URL is the authentication: -anyone who holds it can submit events to the entrypoint, and the -receiver verifies nothing else about the sender. +**The entrypoint UUID is the credential, and it is the only one.** webhooker +mints a version 4 UUID per entrypoint and serves it at `/h/{uuid}`. Possession +of that URL is the authentication: anyone who holds it can submit events to the +entrypoint, and the receiver verifies nothing else about the sender. -There is no shared secret, no HMAC signature, no bearer token and no -second factor on the receiver, and none will be added. This was -considered and rejected; the implementation that existed was removed -in [PR #279](https://git.eeqj.de/sneak/webhooker/pulls/279), closing +There is no shared secret, no HMAC signature, no bearer token and no second +factor on the receiver, and none will be added. This was considered and +rejected; the implementation that existed was removed in +[PR #279](https://git.eeqj.de/sneak/webhooker/pulls/279), closing [issue #67](https://git.eeqj.de/sneak/webhooker/issues/67) and -[issue #241](https://git.eeqj.de/sneak/webhooker/issues/241). A -proposal to reintroduce any of them — including as "defence in depth" -alongside the UUID — is answered by this section. Inbound signature -headers a sender sends anyway (`X-Hub-Signature` and its -per-provider equivalents) are stored and forwarded as ordinary -headers; nothing checks them. +[issue #241](https://git.eeqj.de/sneak/webhooker/issues/241). A proposal to +reintroduce any of them — including as "defence in depth" alongside the UUID — +is answered by this section. Inbound signature headers a sender sends anyway +(`X-Hub-Signature` and its per-provider equivalents) are stored and forwarded as +ordinary headers; nothing checks them. What that means for an operator: -- **The URL is a capability, so treat it as a secret.** Keep it out of - logs, ticket bodies, chat messages and screenshots. Anyone who reads - it anywhere can post events as that sender. -- **Rotating means minting a new entrypoint, not changing a key.** - There is no way to rotate the UUID in place. To retire one, delete - the entrypoint (or deactivate it, which answers `410`) and create a - new one, then point the sender at the new URL. +- **The URL is a capability, so treat it as a secret.** Keep it out of logs, + ticket bodies, chat messages and screenshots. Anyone who reads it anywhere can + post events as that sender. +- **Rotating means minting a new entrypoint, not changing a key.** There is no + way to rotate the UUID in place. To retire one, delete the entrypoint (or + deactivate it, which answers `410`) and create a new one, then point the + sender at the new URL. - **A sender that cannot be given a secret URL is a constraint on that - integration, not a reason to change this.** If a service only - supports signed payloads to a well-known URL, raise it as its own - problem — pick a different integration path, or accept that it - cannot be used. It is not grounds to reintroduce shared secrets. + integration, not a reason to change this.** If a service only supports signed + payloads to a well-known URL, raise it as its own problem — pick a different + integration path, or accept that it cannot be used. It is not grounds to + reintroduce shared secrets. ## Entrypoints This repository adheres to the [Scripts to Rule Them All](https://github.com/github/scripts-to-rule-them-all) standard: normalized scripts in `script/` are the entrypoints for the -development workflow. Thirteen of the Makefile's nineteen targets are thin -shims that call them; `build`, `run`, `dev`, `deps`, `clean` and `version` -are inline commands with no script behind them, though `build`, `run` and -`dev` first run `script/assets`, and `build` and `version` both take their -value from `script/version`. +development workflow. Thirteen of the Makefile's nineteen targets are thin shims +that call them; `build`, `run`, `dev`, `deps`, `clean` and `version` are inline +commands with no script behind them, though `build`, `run` and `dev` first run +`script/assets`, and `build` and `version` both take their value from +`script/version`. -`script/test`, `make build` and `make dev` each run `script/assets` -first, which writes the ignored `static/js/alpine.min.js` (see -[Third-party browser assets](#third-party-browser-assets)), so -`make test`, `make check` and the pre-commit hook work on a fresh clone -without a separate step. +`script/test`, `make build` and `make dev` each run `script/assets` first, which +writes the ignored `static/js/alpine.min.js` (see +[Third-party browser assets](#third-party-browser-assets)), so `make test`, +`make check` and the pre-commit hook work on a fresh clone without a separate +step. We provide: - `script/bootstrap` — install all dependencies (idempotent) -- `script/setup` — make a fresh clone ready for development - (bootstrap, then install-precommit) +- `script/setup` — make a fresh clone ready for development (bootstrap, then + install-precommit) - `script/projectname` — output the project name ("webhooker") - `script/assets` — extract Alpine.js from its tarball in `3p/` (see [Third-party browser assets](#third-party-browser-assets)) - `script/test` — run the test suite - `script/test-browser` — run the browser test in Docker (see [Third-party browser assets](#third-party-browser-assets)) -- `script/lint` — run golangci-lint and ESLint in Docker (see Linting - below) -- `script/fmt` — format all code (writes) +- `script/lint` — run golangci-lint and ESLint in Docker (see Linting below) +- `script/fmt` — format the Go code and, in Docker, the Markdown (writes) - `script/fmt-check` — check formatting (read-only) -- `script/css` — regenerate `static/css/tailwind.css` in Docker (writes; - see [Stylesheet](#stylesheet)) +- `script/css` — regenerate `static/css/tailwind.css` in Docker (writes; see + [Stylesheet](#stylesheet)) - `script/css-check` — fail if `static/css/tailwind.css` differs from what `script/css` would generate (read-only) - `script/check` — run test, lint, fmt-check, and css-check - `script/version` — output the version to stamp into the binary (see [Version stamping](#version-stamping)) -- `script/docker` — build the Docker image tagged via - `script/projectname`, passing `script/version`'s output in as the - `VERSION` build arg -- `script/cibuild` — CI entrypoint: `docker build .` (the Dockerfile - runs the checks, so a green build implies a green repo) -- `script/ci-mark-superseded` — CI helper: mark the commits whose run a - newer push cancelled (see [CI gate honesty](#ci-gate-honesty)) +- `script/docker` — build the Docker image tagged via `script/projectname`, + passing `script/version`'s output in as the `VERSION` build arg +- `script/cibuild` — CI entrypoint: `docker build .` (the Dockerfile runs the + checks, so a green build implies a green repo) +- `script/ci-mark-superseded` — CI helper: mark the commits whose run a newer + push cancelled (see [CI gate honesty](#ci-gate-honesty)) - `script/precommit` — pre-commit checks (`go mod tidy` guard, then `script/check`) -- `script/install-precommit` — install the git pre-commit hook that - runs `script/precommit` +- `script/install-precommit` — install the git pre-commit hook that runs + `script/precommit` ## Third-party browser assets @@ -1364,12 +1269,12 @@ 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 +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 +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 @@ -1390,13 +1295,13 @@ apply. The directory is `3p/` rather than `vendor/` because Go treats a root `vendor/` directory as its module vendor directory. `script/assets` (`make assets`) extracts the browser build, -`package/dist/cdn.min.js`, from the tarball to `static/js/alpine.min.js`, -where `go:embed` picks it up. `script/test`, `make build` and `make dev` run -it first, and the Dockerfile builds through `make test` and `make build`, so -nothing downloads Alpine.js. The extracted file is not committed, and -`.dockerignore` keeps any host copy out of the build context. `static/static.go` -names every file it embeds, so a build that skips the extraction, such as a -bare `go build`, fails with an error naming `js/alpine.min.js`. +`package/dist/cdn.min.js`, from the tarball to `static/js/alpine.min.js`, where +`go:embed` picks it up. `script/test`, `make build` and `make dev` run it first, +and the Dockerfile builds through `make test` and `make build`, so nothing +downloads Alpine.js. The extracted file is not committed, and `.dockerignore` +keeps any host copy out of the build context. `static/static.go` names every +file it embeds, so a build that skips the extraction, such as a bare `go build`, +fails with an error naming `js/alpine.min.js`. To move to a new version: download `https://registry.npmjs.org/@alpinejs/csp/-/csp-.tgz`, check it against @@ -1424,63 +1329,56 @@ commit the result. ## Rationale -Webhook integrations between services are inherently fragile. The -receiving service must be online when the webhook fires, most webhook -senders provide no built-in retry mechanism, and there is no standard -way to inspect what was sent, when it was sent, or whether delivery -succeeded. +Webhook integrations between services are inherently fragile. The receiving +service must be online when the webhook fires, most webhook senders provide no +built-in retry mechanism, and there is no standard way to inspect what was sent, +when it was sent, or whether delivery succeeded. webhooker solves this by acting as a durable intermediary: 1. **Reliable ingestion** — webhooker is always ready to accept incoming - webhooks. It stores every received event before attempting any - delivery, so nothing is lost if downstream targets are unavailable. + webhooks. It stores every received event before attempting any delivery, so + nothing is lost if downstream targets are unavailable. -2. **Guaranteed delivery** — Events are queued for delivery to each - configured target. Failed deliveries are retried with configurable - backoff. Every delivery attempt is logged with status codes, response - bodies, and timing. +2. **Guaranteed delivery** — Events are queued for delivery to each configured + target. Failed deliveries are retried with configurable backoff. Every + delivery attempt is logged with status codes, response bodies, and timing. - **That guarantee is at-least-once, not exactly-once.** When a send - reaches its target but the write recording that outcome fails, the - delivery is deliberately left in a recoverable state rather than - marked done — losing a delivery is the worse failure — so the - pending sweep picks it up about fifteen minutes later, or the next - restart does, and the target receives a payload it already got. - webhooker adds no delivery identifier of its own to an outbound - request, so **make your receiver idempotent** against whatever the - payload itself carries. + **That guarantee is at-least-once, not exactly-once.** When a send reaches + its target but the write recording that outcome fails, the delivery is + deliberately left in a recoverable state rather than marked done — losing a + delivery is the worse failure — so the pending sweep picks it up about + fifteen minutes later, or the next restart does, and the target receives a + payload it already got. webhooker adds no delivery identifier of its own to + an outbound request, so **make your receiver idempotent** against whatever + the payload itself carries. -3. **Observability** — Full request/response logging for every webhook - received and every delivery attempted. Prometheus metrics expose - volume, latency, and error rates. The web UI provides real-time - visibility into event flow. +3. **Observability** — Full request/response logging for every webhook received + and every delivery attempted. Prometheus metrics expose volume, latency, and + error rates. The web UI provides real-time visibility into event flow. -4. **Fan-out** — A single incoming webhook can be delivered to multiple - targets simultaneously. This enables patterns like forwarding a - GitHub webhook to both a deployment service and a Slack channel. +4. **Fan-out** — A single incoming webhook can be delivered to multiple targets + simultaneously. This enables patterns like forwarding a GitHub webhook to + both a deployment service and a Slack channel. -5. **Replay and resubmit** — Every received event is stored in full, - and the event log offers two redelivery actions built on that. - **Replay** re-sends one finished delivery to its own target, for - recovering a delivery that failed. **Resubmit** re-injects the stored - event as a new undelivered event and fans it out to every currently - active target, for firing captured traffic at a backend under - development. Both are web UI actions; there is no API for either. +5. **Replay and resubmit** — Every received event is stored in full, and the + event log offers two redelivery actions built on that. **Replay** re-sends + one finished delivery to its own target, for recovering a delivery that + failed. **Resubmit** re-injects the stored event as a new undelivered event + and fans it out to every currently active target, for firing captured traffic + at a backend under development. Both are web UI actions; there is no API for + either. ### Use Cases -- **Store-and-forward** with configurable retries for unreliable - receivers -- **Observability** via Prometheus metrics on webhook frequency, payload - size, and delivery performance +- **Store-and-forward** with configurable retries for unreliable receivers +- **Observability** via Prometheus metrics on webhook frequency, payload size, + and delivery performance - **Debugging** and introspection of webhook payloads in the web UI - **Resubmit** of captured webhook events for application testing and development, and **replay** of a single failed delivery for recovery -- **Fan-out** delivery of a single webhook to multiple downstream - targets -- **High-availability ingestion** for delivery to less reliable backend - systems +- **Fan-out** delivery of a single webhook to multiple downstream targets +- **High-availability ingestion** for delivery to less reliable backend systems ## Design @@ -1490,30 +1388,29 @@ webhooker is structured as a standard Go HTTP server following the [sneak/prompts GO_HTTP_SERVER_CONVENTIONS](https://git.eeqj.de/sneak/prompts/src/branch/main/prompts/GO_HTTP_SERVER_CONVENTIONS.md). It uses: -- **[Uber fx](https://go.uber.org/fx)** for dependency injection and - lifecycle management +- **[Uber fx](https://go.uber.org/fx)** for dependency injection and lifecycle + management - **[go-chi](https://github.com/go-chi/chi)** for HTTP routing - **[GORM](https://gorm.io)** for database access with - **[modernc.org/sqlite](https://pkg.go.dev/modernc.org/sqlite)** as - the runtime SQLite driver. Note: `gorm.io/driver/sqlite` transitively - depends on `mattn/go-sqlite3`, which requires CGO at build time (see - [Docker](#docker) section) -- **[slog](https://pkg.go.dev/log/slog)** (stdlib) for structured - logging with TTY detection (text for dev, JSON for prod) -- **[gorilla/sessions](https://github.com/gorilla/sessions)** for - encrypted cookie-based session management -- **[gorilla/csrf](https://github.com/gorilla/csrf)** for CSRF - protection (cookie-based double-submit tokens) -- **[go-chi/httprate](https://github.com/go-chi/httprate)** for - sliding-window rate limiting of the password-change and webhook - receiver endpoints. The bucket is per client IP only when - `TRUSTED_PROXIES` covers the reverse proxy (by default it covers the - RFC 1918 private ranges); otherwise every client behind that proxy - shares one bucket per limit. The login endpoint counts failed - attempts itself instead, so that a correct password is never - throttled (see [Rate Limiting](#rate-limiting)) -- **[Prometheus](https://prometheus.io)** for metrics, served at - `/metrics` behind basic auth + **[modernc.org/sqlite](https://pkg.go.dev/modernc.org/sqlite)** as the runtime + SQLite driver. Note: `gorm.io/driver/sqlite` transitively depends on + `mattn/go-sqlite3`, which requires CGO at build time (see [Docker](#docker) + section) +- **[slog](https://pkg.go.dev/log/slog)** (stdlib) for structured logging with + TTY detection (text for dev, JSON for prod) +- **[gorilla/sessions](https://github.com/gorilla/sessions)** for encrypted + cookie-based session management +- **[gorilla/csrf](https://github.com/gorilla/csrf)** for CSRF protection + (cookie-based double-submit tokens) +- **[go-chi/httprate](https://github.com/go-chi/httprate)** for sliding-window + rate limiting of the password-change and webhook receiver endpoints. The + bucket is per client IP only when `TRUSTED_PROXIES` covers the reverse proxy + (by default it covers the RFC 1918 private ranges); otherwise every client + behind that proxy shares one bucket per limit. The login endpoint counts + failed attempts itself instead, so that a correct password is never throttled + (see [Rate Limiting](#rate-limiting)) +- **[Prometheus](https://prometheus.io)** for metrics, served at `/metrics` + behind basic auth - **[Sentry](https://sentry.io)** for optional error reporting ### Naming Conventions @@ -1521,17 +1418,17 @@ It uses: The codebase uses consistent naming throughout (rename completed in [issue #12](https://git.eeqj.de/sneak/webhooker/issues/12)): -| Entity | Description | -| ---------------- | ----------- | -| **Webhook** | Top-level configuration entity grouping entrypoints and targets | -| **Entrypoint** | A receiver URL where external services POST events | -| **Target** | A delivery destination for events | +| Entity | Description | +| -------------- | --------------------------------------------------------------- | +| **Webhook** | Top-level configuration entity grouping entrypoints and targets | +| **Entrypoint** | A receiver URL where external services POST events | +| **Target** | A delivery destination for events | ### Data Model webhooker's data model has eleven entities organized into two tiers: the -**application tier** (user and webhook configuration) and the **event -tier** (event ingestion, delivery, and logging). +**application tier** (user and webhook configuration) and the **event tier** +(event ingestion, delivery, and logging). ``` ┌─────────────────────────────────────────────────────────────┐ @@ -1573,14 +1470,14 @@ tier** (event ingestion, delivery, and logging). #### Setting -A key-value pair for application-level configuration that is -auto-managed rather than user-provided. Used to store the session -encryption key and any future auto-generated settings. +A key-value pair for application-level configuration that is auto-managed rather +than user-provided. Used to store the session encryption key and any future +auto-generated settings. -| Field | Type | Description | -| ------- | ------ | ----------- | +| Field | Type | Description | +| ------- | ------ | -------------------------- | | `key` | string | Primary key (setting name) | -| `value` | text | Setting value | +| `value` | text | Setting value | Currently stored settings: @@ -1591,368 +1488,348 @@ Currently stored settings: A registered user of the webhooker service. -| Field | Type | Description | -| ---------- | -------- | ----------- | -| `id` | UUID | Primary key | -| `username` | string | Unique login name, at most 1024 bytes so that it fits in the session cookie | -| `password` | string | Argon2id hash (never exposed via API) | +| Field | Type | Description | +| ---------- | ------ | --------------------------------------------------------------------------- | +| `id` | UUID | Primary key | +| `username` | string | Unique login name, at most 1024 bytes so that it fits in the session cookie | +| `password` | string | Argon2id hash (never exposed via API) | **Relations:** Has many Webhooks. Has many APIKeys. -Passwords are hashed with Argon2id using secure defaults (64 MB memory, -1 iteration, 4 threads, 32-byte key, 16-byte salt). On first startup, -an `admin` user is created with a randomly generated 16-character -password printed once to stdout; `webhooker resetpw` sets it again if -it is lost (see [The admin account](#the-admin-account)). Every one of -those paths hashes through the same `internal/database` code, so the -parameters cannot drift between them. +Passwords are hashed with Argon2id using secure defaults (64 MB memory, 1 +iteration, 4 threads, 32-byte key, 16-byte salt). On first startup, an `admin` +user is created with a randomly generated 16-character password printed once to +stdout; `webhooker resetpw` sets it again if it is lost (see +[The admin account](#the-admin-account)). Every one of those paths hashes +through the same `internal/database` code, so the parameters cannot drift +between them. #### Webhook -The top-level configuration entity. A webhook groups together one or -more entrypoints (receiver URLs) and one or more targets (delivery -destinations) into a logical unit. A user creates a webhook to set up -event routing. +The top-level configuration entity. A webhook groups together one or more +entrypoints (receiver URLs) and one or more targets (delivery destinations) into +a logical unit. A user creates a webhook to set up event routing. -The new webhook form can also give the webhook its first targets: an -optional HTTP target URL creates an `http` target named `HTTP`, and the -archive checkbox creates a `database` target named `Archive` whose -`expiry` is the archive expiry chosen beside it (never, 1h, 12h, 24h, -30d, 90d or 365d) and whose `rotation` is the rotation chosen below that -(none, monthly, daily or hourly). Both are validated as on the add -target form, and the webhook and its targets are created together or -not at all. +The new webhook form can also give the webhook its first targets: an optional +HTTP target URL creates an `http` target named `HTTP`, and the archive checkbox +creates a `database` target named `Archive` whose `expiry` is the archive expiry +chosen beside it (never, 1h, 12h, 24h, 30d, 90d or 365d) and whose `rotation` is +the rotation chosen below that (none, monthly, daily or hourly). Both are +validated as on the add target form, and the webhook and its targets are created +together or not at all. -| Field | Type | Description | -| ---------------- | ------- | ----------- | -| `id` | UUID | Primary key | -| `user_id` | UUID | Foreign key → User | -| `name` | string | Human-readable name | -| `description` | string | Optional description | +| Field | Type | Description | +| ---------------- | ------- | ----------------------------------------------------------- | +| `id` | UUID | Primary key | +| `user_id` | UUID | Foreign key → User | +| `name` | string | Human-readable name | +| `description` | string | Optional description | | `retention_days` | integer | Days to retain events (default: 30; 0 means retain forever) | **Relations:** Belongs to User. Has many Entrypoints. Has many Targets. -The `retention_days` field controls how long event data is kept in the -webhook's dedicated database before automatic cleanup. +The `retention_days` field controls how long event data is kept in the webhook's +dedicated database before automatic cleanup. -Setting `retention_days` to `0` means "retain events forever". Because -the column carries a default of 30, a literal zero cannot survive an -insert, so a zero is rewritten on save to a sentinel of `365 * 1000` -days (`database.RetentionForeverDays`). The retention reaper recognises -that sentinel and skips the webhook entirely, and the web UI displays -such a webhook's retention as "forever" rather than as a day count. +Setting `retention_days` to `0` means "retain events forever". Because the +column carries a default of 30, a literal zero cannot survive an insert, so a +zero is rewritten on save to a sentinel of `365 * 1000` days +(`database.RetentionForeverDays`). The retention reaper recognises that sentinel +and skips the webhook entirely, and the web UI displays such a webhook's +retention as "forever" rather than as a day count. -Submitted `retention_days` values therefore fall into three bands, not -two: +Submitted `retention_days` values therefore fall into three bands, not two: -- `1` up to `database.MaxFiniteRetentionDays` (106751 days, about 292 - years) is accepted as a finite retention. +- `1` up to `database.MaxFiniteRetentionDays` (106751 days, about 292 years) is + accepted as a finite retention. - Above that ceiling but below the retain-forever sentinel of 365000 - (`database.RetentionForeverDays`) is rejected with a 400. This is the - band the cap exists for. -- `0`, and `365000` or above, are accepted and mean retain forever, - collapsing to the sentinel — `0` in `Webhook.BeforeSave`, the large - values in `parseRetentionDays`. The large values are not out of - range: the edit form pre-fills the sentinel for a retain-forever - webhook, so submitting that form back unchanged has to keep meaning - "forever". + (`database.RetentionForeverDays`) is rejected with a 400. This is the band the + cap exists for. +- `0`, and `365000` or above, are accepted and mean retain forever, collapsing + to the sentinel — `0` in `Webhook.BeforeSave`, the large values in + `parseRetentionDays`. The large values are not out of range: the edit form + pre-fills the sentinel for a retain-forever webhook, so submitting that form + back unchanged has to keep meaning "forever". -A negative value is in none of the three: `parseRetentionDays` rejects -it with a 400 before `BeforeSave` ever sees it. +A negative value is in none of the three: `parseRetentionDays` rejects it with a +400 before `BeforeSave` ever sees it. -The cap is not arbitrary: the reaper computes its cutoff as a -`time.Duration`, an int64 nanosecond count, and a longer period -overflows it. An overflowed cutoff lands in the future, where it -matches every row, so the sweep would delete every event the webhook -has instead of none. The reaper also clamps the value it is given, so a -row written by an older version cannot trigger that either. +The cap is not arbitrary: the reaper computes its cutoff as a `time.Duration`, +an int64 nanosecond count, and a longer period overflows it. An overflowed +cutoff lands in the future, where it matches every row, so the sweep would +delete every event the webhook has instead of none. The reaper also clamps the +value it is given, so a row written by an older version cannot trigger that +either. #### Entrypoint -A receiver URL where external services POST webhook events. Each -entrypoint has a unique UUID-based path. -When an HTTP request arrives at an entrypoint's path, webhooker captures -the full request and creates an Event. +A receiver URL where external services POST webhook events. Each entrypoint has +a unique UUID-based path. When an HTTP request arrives at an entrypoint's path, +webhooker captures the full request and creates an Event. -| Field | Type | Description | -| -------------- | ------- | ----------- | -| `id` | UUID | Primary key | -| `webhook_id` | UUID | Foreign key → Webhook | -| `path` | string | Unique bare UUID, generated at creation. The `/h/` prefix is route only and is not stored: the receiver matches this column against the raw `{uuid}` path segment. It is also the entrypoint's credential; see [The entrypoint URL is the authentication secret](#the-entrypoint-url-is-the-authentication-secret) | -| `description` | string | Optional description | -| `active` | boolean | Whether this entrypoint accepts events (default: true) | +| Field | Type | Description | +| ------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `id` | UUID | Primary key | +| `webhook_id` | UUID | Foreign key → Webhook | +| `path` | string | Unique bare UUID, generated at creation. The `/h/` prefix is route only and is not stored: the receiver matches this column against the raw `{uuid}` path segment. It is also the entrypoint's credential; see [The entrypoint URL is the authentication secret](#the-entrypoint-url-is-the-authentication-secret) | +| `description` | string | Optional description | +| `active` | boolean | Whether this entrypoint accepts events (default: true) | **Relations:** Belongs to Webhook. -A webhook can have multiple entrypoints. This allows separate URLs for -different event sources that all feed into the same processing pipeline -(e.g., one entrypoint for GitHub, another for Stripe, both routing to -the same targets). +A webhook can have multiple entrypoints. This allows separate URLs for different +event sources that all feed into the same processing pipeline (e.g., one +entrypoint for GitHub, another for Stripe, both routing to the same targets). -The webhook page shows, for each entrypoint, when the last event arrived -on its URL, which retention leaves in place, or "never" if none ever has, -and how many events arrived on it within the webhook's retention period. -A resubmitted event did not arrive on the URL and counts in neither. +The webhook page shows, for each entrypoint, when the last event arrived on its +URL, which retention leaves in place, or "never" if none ever has, and how many +events arrived on it within the webhook's retention period. A resubmitted event +did not arrive on the URL and counts in neither. #### Target -A delivery destination for events. Each target defines where and how -events should be forwarded. +A delivery destination for events. Each target defines where and how events +should be forwarded. -| Field | Type | Description | -| ---------------- | ---------- | ----------- | -| `id` | UUID | Primary key | -| `webhook_id` | UUID | Foreign key → Webhook | -| `name` | string | Human-readable name | -| `type` | TargetType | One of: `http`, `slack`, `database`, `log` | -| `active` | boolean | Whether deliveries are enabled (default: true) | -| `config` | JSON text | Type-specific configuration | -| `max_retries` | integer | Total delivery attempts for `http` and `slack` targets, not retries on top of the first: 0 is a single fire-and-forget attempt with no retries and no circuit breaker, and a value of N makes N attempts in all, with exponential backoff and a per-target circuit breaker. Ignored by `database` and `log` targets. The web UI labels it Delivery attempts | +| Field | Type | Description | +| ------------- | ---------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `id` | UUID | Primary key | +| `webhook_id` | UUID | Foreign key → Webhook | +| `name` | string | Human-readable name | +| `type` | TargetType | One of: `http`, `slack`, `database`, `log` | +| `active` | boolean | Whether deliveries are enabled (default: true) | +| `config` | JSON text | Type-specific configuration | +| `max_retries` | integer | Total delivery attempts for `http` and `slack` targets, not retries on top of the first: 0 is a single fire-and-forget attempt with no retries and no circuit breaker, and a value of N makes N attempts in all, with exponential backoff and a per-target circuit breaker. Ignored by `database` and `log` targets. The web UI labels it Delivery attempts | **Relations:** Belongs to Webhook. Has many Deliveries. **Target types:** - **`http`** — Forward the event as an HTTP POST to a configured URL. - `max_retries` is the total number of delivery attempts, not retries on - top of the first: when `max_retries` is 0 (the default), the target - operates in fire-and-forget mode, a single attempt with no retries and - no circuit breaker; a value of N makes up to N attempts in all, - retrying failed deliveries with exponential backoff and protecting them - with a per-target circuit breaker. -- **`slack`** — Post the event as a formatted message to a - Slack-compatible incoming webhook URL (`webhookUrl` in `config`). It - is built on the same HTTP core as `http` and honours `max_retries` - identically, circuit breaker included. See the Slack target section - under "Per-Webhook Event Databases" for the message format. -- **`database`** — Archive the full event as a row into the target's - own archive database - (`archive-{webhook_name}-{target_name}-{target_uuid}.db`) for long-term - retention, with an optional creation-validated expiry (default: keep - forever) and rotation (default: none, one file). The new webhook form, - the add target form and the target edit form all offer the same - expiries: never, 1h, 12h, 24h, 30d, 90d or 365d, and the same - rotations: none, monthly, daily or hourly. The target list shows the - expiry in plain units, such as "30 days", and the rotation. No external - delivery and no retries; an archive write failure fails the delivery. - See the database target section under "Per-Webhook Event Databases" - for the full semantics. The web UI calls this type an archive: its - badge, the add target form's type list and the target edit page say - so, and its settings are labelled Archive expiry and Archive rotation. -- **`log`** — Write the event to the application log (stdout). Useful - for debugging. + `max_retries` is the total number of delivery attempts, not retries on top of + the first: when `max_retries` is 0 (the default), the target operates in + fire-and-forget mode, a single attempt with no retries and no circuit breaker; + a value of N makes up to N attempts in all, retrying failed deliveries with + exponential backoff and protecting them with a per-target circuit breaker. +- **`slack`** — Post the event as a formatted message to a Slack-compatible + incoming webhook URL (`webhookUrl` in `config`). It is built on the same HTTP + core as `http` and honours `max_retries` identically, circuit breaker + included. See the Slack target section under "Per-Webhook Event Databases" for + the message format. +- **`database`** — Archive the full event as a row into the target's own archive + database (`archive-{webhook_name}-{target_name}-{target_uuid}.db`) for + long-term retention, with an optional creation-validated expiry (default: keep + forever) and rotation (default: none, one file). The new webhook form, the add + target form and the target edit form all offer the same expiries: never, 1h, + 12h, 24h, 30d, 90d or 365d, and the same rotations: none, monthly, daily or + hourly. The target list shows the expiry in plain units, such as "30 days", + and the rotation. No external delivery and no retries; an archive write + failure fails the delivery. See the database target section under "Per-Webhook + Event Databases" for the full semantics. The web UI calls this type an + archive: its badge, the add target form's type list and the target edit page + say so, and its settings are labelled Archive expiry and Archive rotation. +- **`log`** — Write the event to the application log (stdout). Useful for + debugging. -The `config` field stores type-specific configuration as JSON (e.g., -destination URL, custom headers, timeout settings). +The `config` field stores type-specific configuration as JSON (e.g., destination +URL, custom headers, timeout settings). **`http` target configuration:** -| Key | Type | Description | -| --------- | ------------- | ----------- | -| `url` | string | Destination the event is POSTed to | +| Key | Type | Description | +| --------- | ------------- | -------------------------------------------------------------------------------------- | +| `url` | string | Destination the event is POSTed to | | `headers` | object | Extra request headers, applied last so they win over the event's own forwarded headers | -| `timeout` | integer (sec) | Per-target request timeout; unset (or 0) uses the shared 30-second client timeout | +| `timeout` | integer (sec) | Per-target request timeout; unset (or 0) uses the shared 30-second client timeout | -`timeout` is capped at **300 seconds**, and the form rejects anything -above it rather than substituting the cap. A delivery attempt holds one -of the bounded pool's workers for its whole duration, so an unbounded -timeout would let a single unresponsive destination stall the queue. +`timeout` is capped at **300 seconds**, and the form rejects anything above it +rather than substituting the cap. A delivery attempt holds one of the bounded +pool's workers for its whole duration, so an unbounded timeout would let a +single unresponsive destination stall the queue. -`headers` rejects the names the delivery path or `net/http` writes -regardless of what is configured: `Host`, `Content-Length`, -`Transfer-Encoding`, `Connection`, `Trailer` and `User-Agent`. These are -refused at the form rather than accepted and ignored, because a stored -header that provably never reaches the wire tells the operator their -configuration took effect when it did not. `Content-Type` is _not_ -reserved: a configured one deliberately overrides the event's. +`headers` rejects the names the delivery path or `net/http` writes regardless of +what is configured: `Host`, `Content-Length`, `Transfer-Encoding`, `Connection`, +`Trailer` and `User-Agent`. These are refused at the form rather than accepted +and ignored, because a stored header that provably never reaches the wire tells +the operator their configuration took effect when it did not. `Content-Type` is +_not_ reserved: a configured one deliberately overrides the event's. -**Redirects.** A redirect from an `http` target's destination is -followed, up to ten hops, and the delivery's recorded status and body -come from the final hop. One rule governs every header the delivery -carries for someone else — the configured `headers` and the inbound -event headers forwarded from the sender alike: **a hop that leaves the -origin the target names carries none of them.** Leaving the origin -means a different host, a different port, or a step down from `https` -to `http`. Both classes routinely carry a secret — a configured -`X-Api-Key` or `PRIVATE-TOKEN`, an inbound `X-Hub-Signature` — and an -open redirect at the destination would otherwise hand it to a host the -operator never chose. `net/http` already does this for `Authorization` -and `Cookie`. The delivery path's own headers (`Content-Type`, -`User-Agent`) are not origin-scoped and always travel, so a body -preserved across a `307` is still typed. A `301`, `302` or `303` is a -different matter, and this is `net/http`'s behaviour rather than -webhooker's: the POST becomes a GET and the event body and its -`Content-Type` are dropped, so the destination the chain ends at -receives no event at all — and the delivery is still recorded -`Delivered` on that hop's `2xx`. Redirects within the target's own -origin keep everything, so a destination that redirects its own paths -is unaffected; the drop is per hop rather than permanent, so a chain -that returns to the configured origin carries the headers again, -exactly as `net/http` treats `Authorization`. Each hop is dialled -through the same SSRF guard as the first, so a redirect aimed at a +**Redirects.** A redirect from an `http` target's destination is followed, up to +ten hops, and the delivery's recorded status and body come from the final hop. +One rule governs every header the delivery carries for someone else — the +configured `headers` and the inbound event headers forwarded from the sender +alike: **a hop that leaves the origin the target names carries none of them.** +Leaving the origin means a different host, a different port, or a step down from +`https` to `http`. Both classes routinely carry a secret — a configured +`X-Api-Key` or `PRIVATE-TOKEN`, an inbound `X-Hub-Signature` — and an open +redirect at the destination would otherwise hand it to a host the operator never +chose. `net/http` already does this for `Authorization` and `Cookie`. The +delivery path's own headers (`Content-Type`, `User-Agent`) are not origin-scoped +and always travel, so a body preserved across a `307` is still typed. A `301`, +`302` or `303` is a different matter, and this is `net/http`'s behaviour rather +than webhooker's: the POST becomes a GET and the event body and its +`Content-Type` are dropped, so the destination the chain ends at receives no +event at all — and the delivery is still recorded `Delivered` on that hop's +`2xx`. Redirects within the target's own origin keep everything, so a +destination that redirects its own paths is unaffected; the drop is per hop +rather than permanent, so a chain that returns to the configured origin carries +the headers again, exactly as `net/http` treats `Authorization`. Each hop is +dialled through the same SSRF guard as the first, so a redirect aimed at a private or reserved address is refused at connect time. #### APIKey A programmatic access credential for API authentication. -| Field | Type | Description | -| -------------- | --------- | ----------- | -| `id` | UUID | Primary key | -| `user_id` | UUID | Foreign key → User | -| `key` | string | Unique API key value | -| `description` | string | Optional description | +| Field | Type | Description | +| -------------- | --------- | -------------------------------------- | +| `id` | UUID | Primary key | +| `user_id` | UUID | Foreign key → User | +| `key` | string | Unique API key value | +| `description` | string | Optional description | | `last_used_at` | timestamp | Last time this key was used (nullable) | **Relations:** Belongs to User. #### Event -A captured incoming webhook request. Stores the complete HTTP request -data for auditing, for replay, and for resubmission. +A captured incoming webhook request. Stores the complete HTTP request data for +auditing, for replay, and for resubmission. -| Field | Type | Description | -| -------------- | ------ | ----------- | -| `id` | UUID | Primary key | -| `webhook_id` | UUID | Foreign key → Webhook | -| `entrypoint_id` | UUID | Foreign key → Entrypoint | -| `method` | string | HTTP method of the captured request. Always `POST`: the receiver answers every other method with 405 before an Event is created | -| `headers` | JSON | Complete request headers | -| `body` | text | Raw request body | -| `content_type` | string | Content-Type header value | -| `body_bytes` | integer | The body's size in bytes, recorded when the event is stored, on receipt and on resubmit | -| `resubmitted_from_id` | UUID | The event this one was copied from by a resubmit (nullable; empty for an event that arrived on the receiver). Not a foreign key: the source event can be reaped by retention while its copies remain | +| Field | Type | Description | +| --------------------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `id` | UUID | Primary key | +| `webhook_id` | UUID | Foreign key → Webhook | +| `entrypoint_id` | UUID | Foreign key → Entrypoint | +| `method` | string | HTTP method of the captured request. Always `POST`: the receiver answers every other method with 405 before an Event is created | +| `headers` | JSON | Complete request headers | +| `body` | text | Raw request body | +| `content_type` | string | Content-Type header value | +| `body_bytes` | integer | The body's size in bytes, recorded when the event is stored, on receipt and on resubmit | +| `resubmitted_from_id` | UUID | The event this one was copied from by a resubmit (nullable; empty for an event that arrived on the receiver). Not a foreign key: the source event can be reaped by retention while its copies remain | -**Relations:** Belongs to Webhook. Belongs to Entrypoint. Has many -Deliveries. +**Relations:** Belongs to Webhook. Belongs to Entrypoint. Has many Deliveries. -When a request arrives at an entrypoint, the full request (method, -headers, body) is captured as an Event. The event is then queued for -delivery to every active target configured on the parent webhook. +When a request arrives at an entrypoint, the full request (method, headers, +body) is captured as an Event. The event is then queued for delivery to every +active target configured on the parent webhook. #### Delivery -The pairing of an event with a target. Tracks the overall delivery -status across potentially multiple attempts. +The pairing of an event with a target. Tracks the overall delivery status across +potentially multiple attempts. -| Field | Type | Description | -| ---------- | -------------- | ----------- | -| `id` | UUID | Primary key | -| `event_id` | UUID | Foreign key → Event | -| `target_id`| UUID | Foreign key → Target | -| `status` | DeliveryStatus | One of: `pending`, `delivered`, `failed`, `retrying` | -| `finished_at` | timestamp | When the delivery became `delivered` or `failed` (nullable; empty while `pending` or `retrying`) | -| `replay` | boolean | Whether the delivery was created by **Replay** | +| Field | Type | Description | +| ------------- | -------------- | ------------------------------------------------------------------------------------------------ | +| `id` | UUID | Primary key | +| `event_id` | UUID | Foreign key → Event | +| `target_id` | UUID | Foreign key → Target | +| `status` | DeliveryStatus | One of: `pending`, `delivered`, `failed`, `retrying` | +| `finished_at` | timestamp | When the delivery became `delivered` or `failed` (nullable; empty while `pending` or `retrying`) | +| `replay` | boolean | Whether the delivery was created by **Replay** | -**Relations:** Belongs to Event. Belongs to Target. Has many -DeliveryResults. +**Relations:** Belongs to Event. Belongs to Target. Has many DeliveryResults. **Delivery statuses:** - **`pending`** — Created but not yet attempted. - **`retrying`** — At least one attempt failed; more attempts remain. -- **`delivered`** — Successfully delivered (at least one attempt - succeeded). +- **`delivered`** — Successfully delivered (at least one attempt succeeded). - **`failed`** — All retry attempts exhausted without success. -**Replay.** A `delivered` or `failed` delivery is finished as far as -the engine is concerned, but the event is still stored, so the event -log offers a per-delivery **Replay** action for it. Replay creates a -NEW `pending` delivery for the same event and target and hands it to -the engine on the ordinary path — same retries, same SSRF guard, same -circuit breaker as a first attempt. It never touches the delivery it -repeats: that row's status, timestamps and recorded attempts stand as -the record of what happened. The new delivery records `replay`, and the -event log and the event's page label it a replay. +**Replay.** A `delivered` or `failed` delivery is finished as far as the engine +is concerned, but the event is still stored, so the event log offers a +per-delivery **Replay** action for it. Replay creates a NEW `pending` delivery +for the same event and target and hands it to the engine on the ordinary path — +same retries, same SSRF guard, same circuit breaker as a first attempt. It never +touches the delivery it repeats: that row's status, timestamps and recorded +attempts stand as the record of what happened. The new delivery records +`replay`, and the event log and the event's page label it a replay. -What is re-sent is the stored event body, against the target's -configuration **as it stands now** — the point of a replay is to -deliver where the destination has since been fixed. A target that has -been deleted or deactivated therefore refuses the replay with a -message on the event log rather than delivering from stale -configuration, and a replay is refused while an earlier one for the -same event and target is still pending or retrying. A delivery whose -target has been deleted shows no **Replay** action at all: recreating -the target makes a new one that the old delivery does not name, so -**Resubmit** is how that event reaches the webhook's currently active -targets. +What is re-sent is the stored event body, against the target's configuration +**as it stands now** — the point of a replay is to deliver where the destination +has since been fixed. A target that has been deleted or deactivated therefore +refuses the replay with a message on the event log rather than delivering from +stale configuration, and a replay is refused while an earlier one for the same +event and target is still pending or retrying. A delivery whose target has been +deleted shows no **Replay** action at all: recreating the target makes a new one +that the old delivery does not name, so **Resubmit** is how that event reaches +the webhook's currently active targets. -**Resubmit.** Replay recovers one delivery; **resubmit** re-injects one -EVENT. The event log offers a per-event **Resubmit** action that stores -a NEW event copying the stored one's `method`, `headers`, `body` and -`content_type` verbatim, then fans it out to the webhook's currently -**active** targets — resolved fresh by the same query the receiver -uses, so a target created long after the original event arrived -receives it. That is the difference that matters: a target added to -test a backend under development has no prior delivery, so there is -nothing to replay to it, while a resubmit reaches it like any other -active target. Inactive targets are skipped, exactly as the receiver +**Resubmit.** Replay recovers one delivery; **resubmit** re-injects one EVENT. +The event log offers a per-event **Resubmit** action that stores a NEW event +copying the stored one's `method`, `headers`, `body` and `content_type` +verbatim, then fans it out to the webhook's currently **active** targets — +resolved fresh by the same query the receiver uses, so a target created long +after the original event arrived receives it. That is the difference that +matters: a target added to test a backend under development has no prior +delivery, so there is nothing to replay to it, while a resubmit reaches it like +any other active target. Inactive targets are skipped, exactly as the receiver skips them. -The new event is a first-class event in the log with its own -deliveries, not a marker on the one it came from, and the original's -deliveries are left untouched. It records `resubmitted_from_id`, and -the event log shows the relationship both ways, so a captured event -fired twenty times at a backend stays traceable. Resubmitting the same -event repeatedly is supported and is the point of the action — there is -no in-flight refusal; the route's own rate limit is what bounds it. +The new event is a first-class event in the log with its own deliveries, not a +marker on the one it came from, and the original's deliveries are left +untouched. It records `resubmitted_from_id`, and the event log shows the +relationship both ways, so a captured event fired twenty times at a backend +stays traceable. Resubmitting the same event repeatedly is supported and is the +point of the action — there is no in-flight refusal; the route's own rate limit +is what bounds it. #### DeliveryResult -The result of a single delivery attempt. Every attempt (including -retries) is individually logged for full observability. +The result of a single delivery attempt. Every attempt (including retries) is +individually logged for full observability. -| Field | Type | Description | -| --------------- | ------- | ----------- | -| `id` | UUID | Primary key | -| `delivery_id` | UUID | Foreign key → Delivery | -| `attempt_num` | integer | Attempt number (1-based) | -| `success` | boolean | Whether this attempt succeeded | +| Field | Type | Description | +| --------------- | ------- | ----------------------------------------- | +| `id` | UUID | Primary key | +| `delivery_id` | UUID | Foreign key → Delivery | +| `attempt_num` | integer | Attempt number (1-based) | +| `success` | boolean | Whether this attempt succeeded | | `status_code` | integer | HTTP response status code (if applicable) | -| `response_body` | text | Response body (if applicable) | -| `error` | string | Error message (on failure) | -| `duration` | integer | Request duration in milliseconds | +| `response_body` | text | Response body (if applicable) | +| `error` | string | Error message (on failure) | +| `duration` | integer | Request duration in milliseconds | -A `database` or `log` target sends no HTTP request, so in the event log and -on the event's page its attempts show no status: a successful one reads -"archived" or "written to the log". +A `database` or `log` target sends no HTTP request, so in the event log and on +the event's page its attempts show no status: a successful one reads "archived" +or "written to the log". -The event log and the event's page show when each attempt was recorded and -when each delivery was created, as the recent events list shows when an event +The event log and the event's page show when each attempt was recorded and when +each delivery was created, as the recent events list shows when an event arrived: how long ago, with the full UTC time on hover. **Relations:** Belongs to Delivery. #### EventTotals, TargetTotals and EntrypointTotals -Running counts in each event database, read by the statistics pane at the -top of the webhook page and by the webhook list, and each entrypoint's last -event, read by the webhook page's entrypoint list. `EventTotals` is one row: +Running counts in each event database, read by the statistics pane at the top of +the webhook page and by the webhook list, and each entrypoint's last event, read +by the webhook page's entrypoint list. `EventTotals` is one row: -| Field | Type | Description | -| ---------------- | --------- | ----------- | -| `events` | integer | Events ever stored, resubmitted copies included | -| `events_removed` | integer | Events retention has deleted | +| Field | Type | Description | +| ---------------- | --------- | ---------------------------------------------------------------------------------------------- | +| `events` | integer | Events ever stored, resubmitted copies included | +| `events_removed` | integer | Events retention has deleted | | `last_event_at` | timestamp | When the newest event arrived (nullable; empty before the first); retention leaves it as it is | `TargetTotals` is one row per target, created by the first delivery to it: -| Field | Type | Description | -| -------------------- | ------- | ----------- | -| `target_id` | UUID | The target (primary key) | +| Field | Type | Description | +| -------------------- | ------- | ----------------------------------------------- | +| `target_id` | UUID | The target (primary key) | | `deliveries` | integer | Deliveries to it ever created, replays included | -| `delivered` | integer | Of those, how many became `delivered` | -| `failed` | integer | Of those, how many became `failed` | -| `deliveries_removed` | integer | Its deliveries retention has deleted | -| `failed_removed` | integer | Its failed deliveries retention has deleted | +| `delivered` | integer | Of those, how many became `delivered` | +| `failed` | integer | Of those, how many became `failed` | +| `deliveries_removed` | integer | Its deliveries retention has deleted | +| `failed_removed` | integer | Its failed deliveries retention has deleted | -`EntrypointTotals` is one row per entrypoint, created by the first event -that arrives on its URL: +`EntrypointTotals` is one row per entrypoint, created by the first event that +arrives on its URL: -| Field | Type | Description | -| --------------- | --------- | ----------- | -| `entrypoint_id` | UUID | The entrypoint (primary key) | +| Field | Type | Description | +| --------------- | --------- | ------------------------------------------------------------------------------------------------------- | +| `entrypoint_id` | UUID | The entrypoint (primary key) | | `last_event_at` | timestamp | When the newest event arrived on its URL; a resubmitted event leaves it as it is, and so does retention | Each count changes in the transaction that writes or deletes the rows it counts, @@ -1968,34 +1845,33 @@ deliveries in one query grouped by 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 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. +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. -The target list on the webhook page shows, for each target, its -`delivered` and `failed` totals, which retention does not reduce, and its -deliveries that became `delivered` and `failed` in the last 24 hours, -counted with the pane's query. Deliveries still `pending` or `retrying` -count in neither. +The target list on the webhook page shows, for each target, its `delivered` and +`failed` totals, which retention does not reduce, and its deliveries that became +`delivered` and `failed` in the last 24 hours, counted with the pane's query. +Deliveries still `pending` or `retrying` count in neither. #### Event-tier indexes -These indexes on the per-webhook event databases are declared in the model -tags, so `AutoMigrate` creates them on a fresh database: +These indexes on the per-webhook event databases are declared in the model tags, +so `AutoMigrate` creates them on a fresh database: -| 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, the webhook page's statistics and target list and the webhook list, which count each target's deliveries by status and when they finished, and the event log, which lists and counts the events with a failed delivery or one pending or retrying | -| `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 | -| `events` | `deleted_at`, `created_at` | The webhook page's statistics, which count recent events | -| `events` | `resubmitted_from_id`, `deleted_at` | The event log, which counts the events resubmitted from each event on a page | -| `events` | `entrypoint_id`, `deleted_at`, `resubmitted_from_id`, `created_at` | The webhook page's entrypoint list, which counts the events that arrived on each entrypoint's URL within the retention period | -| `events` | `created_at` | Retention, which selects expired events by age | +| 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, the webhook page's statistics and target list and the webhook list, which count each target's deliveries by status and when they finished, and the event log, which lists and counts the events with a failed delivery or one pending or retrying | +| `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 | +| `events` | `deleted_at`, `created_at` | The webhook page's statistics, which count recent events | +| `events` | `resubmitted_from_id`, `deleted_at` | The event log, which counts the events resubmitted from each event on a page | +| `events` | `entrypoint_id`, `deleted_at`, `resubmitted_from_id`, `created_at` | The webhook page's entrypoint list, which counts the events that arrived on each entrypoint's URL within the retention period | +| `events` | `created_at` | Retention, which selects expired events by age | GORM's soft delete adds `deleted_at IS NULL` to these queries; retention leaves it out. SQLite keeps no statistics on these tables, and without them it rates @@ -2020,247 +1896,229 @@ totals tables, `event_totals` holds counts and `last_event_at`, keyed by a numeric `id`; `target_totals` holds counts, keyed by `target_id`; and `entrypoint_totals` holds `last_event_at`, keyed by `entrypoint_id`: -| Field | Type | Description | -| ------------ | --------- | ----------- | -| `id` | UUID | Auto-generated UUIDv4 primary key | -| `created_at` | timestamp | Record creation time | -| `updated_at` | timestamp | Last modification time | +| Field | Type | Description | +| ------------ | --------- | --------------------------------------------------- | +| `id` | UUID | Auto-generated UUIDv4 primary key | +| `created_at` | timestamp | Record creation time | +| `updated_at` | timestamp | Last modification time | | `deleted_at` | timestamp | Soft-delete timestamp (nullable; GORM soft deletes) | ### Database Architecture #### Per-Webhook Event Databases -webhooker uses **separate SQLite database files**: a main application -database for configuration data and per-webhook databases for event -storage. All database files live in the `DATA_DIR` directory. +webhooker uses **separate SQLite database files**: a main application database +for configuration data and per-webhook databases for event storage. All database +files live in the `DATA_DIR` directory. -Every one of them is created `0600`, and so is each `-wal` and `-shm` -sidecar. See -[Running with Docker](#running-with-docker) for what that does and -does not protect. +Every one of them is created `0600`, and so is each `-wal` and `-shm` sidecar. +See [Running with Docker](#running-with-docker) for what that does and does not +protect. -**Main Application Database** (`{DATA_DIR}/webhooker.db`) — stores -configuration and application state: +**Main Application Database** (`{DATA_DIR}/webhooker.db`) — stores configuration +and application state: -- **Settings** — auto-managed key-value config (e.g. session encryption - key) +- **Settings** — auto-managed key-value config (e.g. session encryption key) - **Users** — accounts and Argon2id password hashes - **Webhooks** — webhook configurations - **Entrypoints** — receiver URL definitions - **Targets** — delivery destination configurations - **APIKeys** — programmatic access credentials -On first startup the main database is auto-migrated, a session -encryption key is generated and stored, and an `admin` user is created. +On first startup the main database is auto-migrated, a session encryption key is +generated and stored, and an `admin` user is created. -**Per-Webhook Event Databases** (`{DATA_DIR}/events-{webhook_uuid}.db`) -— each webhook gets its own dedicated SQLite file containing: +**Per-Webhook Event Databases** (`{DATA_DIR}/events-{webhook_uuid}.db`) — each +webhook gets its own dedicated SQLite file containing: - **Events** — captured incoming webhook payloads - **Deliveries** — event-to-target pairings and their status - **DeliveryResults** — individual delivery attempt logs -- **EventTotals**, **TargetTotals** and **EntrypointTotals** — running - counts of the above, the deliveries per target, and each entrypoint's - last event, kept through retention +- **EventTotals**, **TargetTotals** and **EntrypointTotals** — running counts of + the above, the deliveries per target, and each entrypoint's last event, kept + through retention -Per-webhook databases are created automatically when a webhook is -created. They are managed by the `WebhookDBManager` component, which -handles connection pooling, lazy opening, migrations, and cleanup. +Per-webhook databases are created automatically when a webhook is created. They +are managed by the `WebhookDBManager` component, which handles connection +pooling, lazy opening, migrations, and cleanup. -A per-webhook database that is missing or zero-length later means its -webhook's events and pending deliveries are gone. The next time it is -opened, an empty one is created in its place, so the webhook keeps -receiving, and `created a new, empty database` is logged at `WARN` with -the file's path. Every webhook's database is opened when the service -starts, so this appears at the latest at the first start after the -file was lost. A file there that SQLite cannot open fails that -webhook alone, with an `ERROR` naming the webhook on every access and a -500 to its senders, so one damaged file does not stop the others. +A per-webhook database that is missing or zero-length later means its webhook's +events and pending deliveries are gone. The next time it is opened, an empty one +is created in its place, so the webhook keeps receiving, and +`created a new, empty database` is logged at `WARN` with the file's path. Every +webhook's database is opened when the service starts, so this appears at the +latest at the first start after the file was lost. A file there that SQLite +cannot open fails that webhook alone, with an `ERROR` naming the webhook on +every access and a 500 to its senders, so one damaged file does not stop the +others. This separation provides: -- **Isolation** — a high-volume webhook won't cause lock contention or - journal growth affecting the main application or other webhooks. -- **Independent lifecycle** — event databases can be independently - backed up, archived, rotated, or size-limited without impacting the - application. -- **Clean deletion** — removing a webhook and all its history is as - simple as deleting one file. Configuration is soft-deleted in the main - DB; the event database file is hard-deleted (permanently removed). +- **Isolation** — a high-volume webhook won't cause lock contention or journal + growth affecting the main application or other webhooks. +- **Independent lifecycle** — event databases can be independently backed up, + archived, rotated, or size-limited without impacting the application. +- **Clean deletion** — removing a webhook and all its history is as simple as + deleting one file. Configuration is soft-deleted in the main DB; the event + database file is hard-deleted (permanently removed). - **Per-webhook retention** — the `retention_days` field on each webhook - controls automatic cleanup of old events in that webhook's database - only, or disables cleanup entirely when set to `0` (retain forever). -- **Performance** — each webhook's database has its own page cache and - its own lock, so concurrent event ingestion across webhooks won't - contend. Every database — main, per-webhook, and archive — is opened - through one code path (`internal/database/sqlite_open.go`) in WAL - journal mode, with a 10-second busy timeout, `BEGIN IMMEDIATE` - transactions, and a bounded connection pool. Under WAL a reader never - blocks a writer, so an operator reading a database does not stall - event ingestion into it. + controls automatic cleanup of old events in that webhook's database only, or + disables cleanup entirely when set to `0` (retain forever). +- **Performance** — each webhook's database has its own page cache and its own + lock, so concurrent event ingestion across webhooks won't contend. Every + database — main, per-webhook, and archive — is opened through one code path + (`internal/database/sqlite_open.go`) in WAL journal mode, with a 10-second + busy timeout, `BEGIN IMMEDIATE` transactions, and a bounded connection pool. + Under WAL a reader never blocks a writer, so an operator reading a database + does not stall event ingestion into it. -The **database target type** builds on this architecture to provide -long-term archiving, separate from the per-webhook event database (which -may prune events under its own retention). Delivering to a database -target writes the full event — body, headers, method, content type, and -webhook/entrypoint/event identifiers — as a row into the target's own -archive database, `archive-{webhook_name}-{target_name}-{target_uuid}.db`, -stored under the data directory beside the event database. Each -`database` target has its own archive file, even when one webhook has -several. +The **database target type** builds on this architecture to provide long-term +archiving, separate from the per-webhook event database (which may prune events +under its own retention). Delivering to a database target writes the full event +— body, headers, method, content type, and webhook/entrypoint/event identifiers +— as a row into the target's own archive database, +`archive-{webhook_name}-{target_name}-{target_uuid}.db`, stored under the data +directory beside the event database. Each `database` target has its own archive +file, even when one webhook has several. -Both names are made safe for a file name the same way: lowercased, ASCII -letters and digits kept, every other run of characters turned into a -single `-`, no `-` at either end, cut to 40 characters, and `unnamed` -when nothing is left. The target UUID keeps the file name unique. A -webhook named `Orders (EU)` with a target named `Long-term archive` -archives into `archive-orders-eu-long-term-archive-{target_uuid}.db`. +Both names are made safe for a file name the same way: lowercased, ASCII letters +and digits kept, every other run of characters turned into a single `-`, no `-` +at either end, cut to 40 characters, and `unnamed` when nothing is left. The +target UUID keeps the file name unique. A webhook named `Orders (EU)` with a +target named `Long-term archive` archives into +`archive-orders-eu-long-term-archive-{target_uuid}.db`. -An optional `rotation` in the target's config JSON (e.g. -`{"rotation":"daily"}`) is `none`, the default, which keeps the one -file, or `monthly`, `daily` or `hourly`. A target that rotates writes -each event to a file named for the period of the event's receive time, -in UTC, put before the `.db`: +An optional `rotation` in the target's config JSON (e.g. `{"rotation":"daily"}`) +is `none`, the default, which keeps the one file, or `monthly`, `daily` or +`hourly`. A target that rotates writes each event to a file named for the period +of the event's receive time, in UTC, put before the `.db`: `archive-orders-eu-long-term-archive-{target_uuid}-2026-10.db` monthly, -`…-2026-10-01.db` daily and `…-2026-10-01-19.db` hourly. Each file holds -exactly its period's events, and the first event of a new period starts -the next file, so a finished period's file can be moved away like any -archive. A changed rotation applies from the next event: the files -already written keep their names and stay, pruned, shown and downloaded -with the rest, since every file named for the target is its archive, -whichever rotation wrote it. +`…-2026-10-01.db` daily and `…-2026-10-01-19.db` hourly. Each file holds exactly +its period's events, and the first event of a new period starts the next file, +so a finished period's file can be moved away like any archive. A changed +rotation applies from the next event: the files already written keep their names +and stay, pruned, shown and downloaded with the rest, since every file named for +the target is its archive, whichever rotation wrote it. -Renaming the webhook or the target renames every one of the target's -files, each keeping its period, under the same lock the archive writes -and the archive sweeper take. Webhook edits, target edits and target -creation run one at a time, so no edit can rename the files between -another's rename and save, and the names on disk match the UI. A rename -never replaces a file: if one already has a new name, the edit is -refused with an error naming that file, nothing is moved, and the -stored name stays. If the archive is not there (the operator moved it -away), the rename is not an error, and the next write creates the file -under the new name. +Renaming the webhook or the target renames every one of the target's files, each +keeping its period, under the same lock the archive writes and the archive +sweeper take. Webhook edits, target edits and target creation run one at a time, +so no edit can rename the files between another's rename and save, and the names +on disk match the UI. A rename never replaces a file: if one already has a new +name, the edit is refused with an error naming that file, nothing is moved, and +the stored name stays. If the archive is not there (the operator moved it away), +the rename is not an error, and the next write creates the file under the new +name. -The file is moved just before the new name is saved. If the process -stops between the two, the archive is left under the new name while the -UI still shows the old one, and the next delivery starts a second -archive under the name shown. To bring them back together, stop the -service before moving anything, and move each archive as its `.db` -together with any `-wal` and `-shm` beside it, since the `-wal` can hold -rows that are not yet in the `.db`. If no file has the name shown, move -the archive under the new name back to it. If a second archive already -has the name shown, move the archive under the new name out of the data -directory instead and keep it as you would any archive moved away. Then -start the service again. +The file is moved just before the new name is saved. If the process stops +between the two, the archive is left under the new name while the UI still shows +the old one, and the next delivery starts a second archive under the name shown. +To bring them back together, stop the service before moving anything, and move +each archive as its `.db` together with any `-wal` and `-shm` beside it, since +the `-wal` can hold rows that are not yet in the `.db`. If no file has the name +shown, move the archive under the new name back to it. If a second archive +already has the name shown, move the archive under the new name out of the data +directory instead and keep it as you would any archive moved away. Then start +the service again. -After each write the archive handle is closed -and reopened, debounced to at most once per second, so an operator can -move the archive file away for offline archiving without stopping the -service; a moved or removed archive file is recreated automatically on -the next write. A zero-length archive file is written to as a new -archive: SQLite opens it as an empty database, so it holds nothing to -lose. An optional `expiry` in the target's config JSON (e.g. -`{"expiry":"720h"}`) is validated when the target is created — the -default (unset or the literal `never`) keeps rows forever — and rows -older than the expiry are pruned each time the archive is (re)opened. An -archive write failure is never silent success: the delivery records a -failed attempt with the error and is marked failed. +After each write the archive handle is closed and reopened, debounced to at most +once per second, so an operator can move the archive file away for offline +archiving without stopping the service; a moved or removed archive file is +recreated automatically on the next write. A zero-length archive file is written +to as a new archive: SQLite opens it as an empty database, so it holds nothing +to lose. An optional `expiry` in the target's config JSON (e.g. +`{"expiry":"720h"}`) is validated when the target is created — the default +(unset or the literal `never`) keeps rows forever — and rows older than the +expiry are pruned each time the archive is (re)opened. An archive write failure +is never silent success: the delivery records a failed attempt with the error +and is marked failed. -Because reopens only happen on writes, an archive whose target has -stopped receiving events would never be pruned. A background **archive -sweeper** closes that gap: on the same interval as the event retention -reaper (`RETENTION_SWEEP_INTERVAL`) it prunes every archive whose -database target declares a positive expiry, whether or not the target -is still receiving traffic. The sweep never creates an archive — a -target whose archive file does not yet exist is skipped, not initialised -— it takes the same per-target lock the write path uses, so it can never -interleave with a write, and it leaves the archive closed afterwards so -the move-the-file-away workflow keeps working. Archives with no expiry, -or the expiry `never`, are not touched by the sweep at all. +Because reopens only happen on writes, an archive whose target has stopped +receiving events would never be pruned. A background **archive sweeper** closes +that gap: on the same interval as the event retention reaper +(`RETENTION_SWEEP_INTERVAL`) it prunes every archive whose database target +declares a positive expiry, whether or not the target is still receiving +traffic. The sweep never creates an archive — a target whose archive file does +not yet exist is skipped, not initialised — it takes the same per-target lock +the write path uses, so it can never interleave with a write, and it leaves the +archive closed afterwards so the move-the-file-away workflow keeps working. +Archives with no expiry, or the expiry `never`, are not touched by the sweep at +all. -For a target with several files, the write path prunes only the file it -writes to, and the sweep prunes every one of them, taking the target's -lock for one file at a time, so a write to the target waits for at most -one file's prune. A file that is gone by the time the sweep reaches it -is skipped. A file named for a period that the sweep leaves empty is -deleted, with any `-wal` and `-shm` beside it; the file without a period -is kept even when empty, as it always has been. +For a target with several files, the write path prunes only the file it writes +to, and the sweep prunes every one of them, taking the target's lock for one +file at a time, so a write to the target waits for at most one file's prune. A +file that is gone by the time the sweep reaches it is skipped. A file named for +a period that the sweep leaves empty is deleted, with any `-wal` and `-shm` +beside it; the file without a period is kept even when empty, as it always has +been. -Because each `database` target has its own archive file, a target's -`expiry` governs only its own archive. Two `database` targets on one -webhook with different expiries keep two archives, each pruned on its -own schedule. +Because each `database` target has its own archive file, a target's `expiry` +governs only its own archive. Two `database` targets on one webhook with +different expiries keep two archives, each pruned on its own schedule. -The webhook page shows, for each `database` target, the name of the -archive file an event received now would go to, the size on disk of all -the target's archive files together, how many there are when there is -more than one, and when the latest of them was last written. The size -counts each `.db` and its `-wal` together, and the last write is the -latest of their modification times, since a write lands in the `-wal` -first. All are read from the files' metadata; the archive is never -opened. While the named file does not exist — before the first write, -before the first event of a new period, and after the file has been -moved away — the page shows `not created yet` beside the name. +The webhook page shows, for each `database` target, the name of the archive file +an event received now would go to, the size on disk of all the target's archive +files together, how many there are when there is more than one, and when the +latest of them was last written. The size counts each `.db` and its `-wal` +together, and the last write is the latest of their modification times, since a +write lands in the `-wal` first. All are read from the files' metadata; the +archive is never opened. While the named file does not exist — before the first +write, before the first event of a new period, and after the file has been moved +away — the page shows `not created yet` beside the name. -Each `database` target on the webhook page has a **Download** button, -which returns its archive as one gzipped JSON file, -`archive-{webhook_name}-{target_name}-{YYYYMMDDTHHMMSSZ}.json.gz`, the -names made safe as above and the time in UTC. The file holds one -object: `webhook` and `target`, each an `id` and a `name`; -`exported_at`; and `archived_events`, one object per archived row with -every column, keyed by column name. The rows come from every one of the -target's files: the file without a period first, then the others in -the order of their periods, oldest first, and each row from a file named -for a period has that `period` beside its columns. A body that is not -valid UTF-8 is written in base64, with `"body_encoding": "base64"` -beside it. An archive that does not exist yet, or was moved away, -downloads with an empty `archived_events`; the download never creates a -file. +Each `database` target on the webhook page has a **Download** button, which +returns its archive as one gzipped JSON file, +`archive-{webhook_name}-{target_name}-{YYYYMMDDTHHMMSSZ}.json.gz`, the names +made safe as above and the time in UTC. The file holds one object: `webhook` and +`target`, each an `id` and a `name`; `exported_at`; and `archived_events`, one +object per archived row with every column, keyed by column name. The rows come +from every one of the target's files: the file without a period first, then the +others in the order of their periods, oldest first, and each row from a file +named for a period has that `period` beside its columns. A body that is not +valid UTF-8 is written in base64, with `"body_encoding": "base64"` beside it. An +archive that does not exist yet, or was moved away, downloads with an empty +`archived_events`; the download never creates a file. -The download streams: each row is read and written out compressed -before the next is read, so neither the archive nor the JSON is held in -memory. When it starts it lists the target's files by the stored names, -under the lock that webhook edits, target edits and target creation -hold, and lets go. It then opens one file at a time, only when its rows -are about to be written out, and closes it before it opens the next, so -it never has more than one of the target's files open. To open each, it -takes the lock again just long enough to find the file by its period -under the names stored then, so a rename during the download loses no -file; a file that is gone by then, emptied by the sweep or moved away, -is skipped. Each file is read on a connection of its own inside one -read-only transaction, so it is written out as it stood when it was -opened, and archive writes go on meanwhile, since under WAL a reader -never blocks a writer. Until the open file is closed its `-wal` cannot -be checkpointed past what the download reads, so a long download lets -that `-wal` grow. +The download streams: each row is read and written out compressed before the +next is read, so neither the archive nor the JSON is held in memory. When it +starts it lists the target's files by the stored names, under the lock that +webhook edits, target edits and target creation hold, and lets go. It then opens +one file at a time, only when its rows are about to be written out, and closes +it before it opens the next, so it never has more than one of the target's files +open. To open each, it takes the lock again just long enough to find the file by +its period under the names stored then, so a rename during the download loses no +file; a file that is gone by then, emptied by the sweep or moved away, is +skipped. Each file is read on a connection of its own inside one read-only +transaction, so it is written out as it stood when it was opened, and archive +writes go on meanwhile, since under WAL a reader never blocks a writer. Until +the open file is closed its `-wal` cannot be checkpointed past what the download +reads, so a long download lets that `-wal` grow. -Deleting a webhook releases its archives: the delivery engine's cached -archive writers are dropped and their file handles closed, so nothing -lingers after the webhook is gone. The archive **files themselves are -deliberately left on disk**. Unlike the event database — per-webhook -working storage that is hard-deleted with the webhook — an archive is -long-term storage an operator may still want to keep or move away for -offline retention, and destroying it as a side effect of deleting a -webhook would be unrecoverable. Removing an `archive-….db` is the -operator's call. Deleting a `database` target releases its writer the -same way, and for the same reason leaves its file alone. +Deleting a webhook releases its archives: the delivery engine's cached archive +writers are dropped and their file handles closed, so nothing lingers after the +webhook is gone. The archive **files themselves are deliberately left on disk**. +Unlike the event database — per-webhook working storage that is hard-deleted +with the webhook — an archive is long-term storage an operator may still want to +keep or move away for offline retention, and destroying it as a side effect of +deleting a webhook would be unrecoverable. Removing an `archive-….db` is the +operator's call. Deleting a `database` target releases its writer the same way, +and for the same reason leaves its file alone. -The **Slack target type** sends webhook events as formatted messages to -any Slack-compatible incoming webhook URL (works with Slack, Mattermost, -and other compatible services). Each message includes event metadata -(HTTP method, content type, timestamp, body size) and the payload -pretty-printed in a code block. JSON payloads are automatically -formatted with indentation for readability; non-JSON payloads are shown -as raw text. Large payloads are truncated to keep messages reasonable. -Config stores `webhookUrl` — the Slack/Mattermost incoming webhook +The **Slack target type** sends webhook events as formatted messages to any +Slack-compatible incoming webhook URL (works with Slack, Mattermost, and other +compatible services). Each message includes event metadata (HTTP method, content +type, timestamp, body size) and the payload pretty-printed in a code block. JSON +payloads are automatically formatted with indentation for readability; non-JSON +payloads are shown as raw text. Large payloads are truncated to keep messages +reasonable. Config stores `webhookUrl` — the Slack/Mattermost incoming webhook endpoint. That is the JSON key; the error text for a missing one reads `webhook_url is required`, which is the message, not the key. The database uses the -[modernc.org/sqlite](https://pkg.go.dev/modernc.org/sqlite) driver at -runtime, though CGO is required at build time due to the transitive -`mattn/go-sqlite3` dependency from `gorm.io/driver/sqlite`. +[modernc.org/sqlite](https://pkg.go.dev/modernc.org/sqlite) driver at runtime, +though CGO is required at build time due to the transitive `mattn/go-sqlite3` +dependency from `gorm.io/driver/sqlite`. ### Request Flow @@ -2306,88 +2164,85 @@ External Service ### Bounded Worker Pool -The delivery engine uses a **fixed-size worker pool** (default: 10 -workers) to process all deliveries. At most N deliveries are in-flight -at any time, preventing goroutine explosions regardless of queue depth. +The delivery engine uses a **fixed-size worker pool** (default: 10 workers) to +process all deliveries. At most N deliveries are in-flight at any time, +preventing goroutine explosions regardless of queue depth. **Architecture:** -- **Channels as queues:** Two buffered channels serve as bounded queues: - a delivery channel (new tasks from the webhook handler) and a retry - channel (tasks from backoff timers). Both are buffered to 10,000. -- **Fan-out via channel, not goroutines:** When an event arrives with - multiple targets, each `delivery.Task` is sent to the delivery channel. - Workers pick them up and process them — no goroutine-per-target. -- **Worker goroutines:** A fixed number of worker goroutines select from - both channels. Each worker processes one task at a time, then picks up - the next. Workers are the ONLY goroutines doing actual HTTP delivery. -- **Retry backpressure with DB fallback:** When a retry timer fires and - the retry channel is full, the timer is dropped — the delivery stays - in `retrying` status in the database. A periodic sweep (every 60s) - scans for these "orphaned" retries and re-queues them. No blocked - goroutines, no unbounded timer chains. -- **Bounded concurrency:** At most N deliveries (N = number of workers) - are in-flight simultaneously. Even if a circuit breaker is open for - hours and thousands of retries queue up in the channels, the workers - drain them at a controlled rate when the circuit closes. +- **Channels as queues:** Two buffered channels serve as bounded queues: a + delivery channel (new tasks from the webhook handler) and a retry channel + (tasks from backoff timers). Both are buffered to 10,000. +- **Fan-out via channel, not goroutines:** When an event arrives with multiple + targets, each `delivery.Task` is sent to the delivery channel. Workers pick + them up and process them — no goroutine-per-target. +- **Worker goroutines:** A fixed number of worker goroutines select from both + channels. Each worker processes one task at a time, then picks up the next. + Workers are the ONLY goroutines doing actual HTTP delivery. +- **Retry backpressure with DB fallback:** When a retry timer fires and the + retry channel is full, the timer is dropped — the delivery stays in `retrying` + status in the database. A periodic sweep (every 60s) scans for these + "orphaned" retries and re-queues them. No blocked goroutines, no unbounded + timer chains. +- **Bounded concurrency:** At most N deliveries (N = number of workers) are + in-flight simultaneously. Even if a circuit breaker is open for hours and + thousands of retries queue up in the channels, the workers drain them at a + controlled rate when the circuit closes. This means: -- **No goroutine explosion** — even with 10,000 queued retries, only - N worker goroutines exist. -- **Natural backpressure** — if workers are busy, new tasks wait in the - channel buffer rather than spawning more goroutines. -- **Independent results** — each worker records its own delivery result - in the per-webhook database without coordination. -- **Graceful shutdown** — cancel the context, workers finish their - current task and exit. The stop hook waits for the pool via - `lifecycle.WaitForShutdown`, which bounds that wait by fx's stop - timeout rather than blocking forever on a wedged worker. On timeout - it logs at `ERROR` and returns an error, and the goroutines that - did not finish are still running — an unclean shutdown is reported - rather than hidden. +- **No goroutine explosion** — even with 10,000 queued retries, only N worker + goroutines exist. +- **Natural backpressure** — if workers are busy, new tasks wait in the channel + buffer rather than spawning more goroutines. +- **Independent results** — each worker records its own delivery result in the + per-webhook database without coordination. +- **Graceful shutdown** — cancel the context, workers finish their current task + and exit. The stop hook waits for the pool via `lifecycle.WaitForShutdown`, + which bounds that wait by fx's stop timeout rather than blocking forever on a + wedged worker. On timeout it logs at `ERROR` and returns an error, and the + goroutines that did not finish are still running — an unclean shutdown is + reported rather than hidden. **Recovery paths:** 1. **Startup recovery:** When the engine starts, it scans all per-webhook - databases for `pending` and `retrying` deliveries. Pending deliveries - are sent to the delivery channel; retrying deliveries get backoff - timers scheduled. -2. **Periodic retry sweep (DB-mediated fallback):** Every 60 seconds the - engine scans for `retrying` deliveries whose backoff period has - elapsed. This catches "orphaned" retries — ones whose in-memory timer - was dropped because the retry channel was full. The database is the - durable fallback that ensures no retry is permanently lost, even under - extreme backpressure. + databases for `pending` and `retrying` deliveries. Pending deliveries are + sent to the delivery channel; retrying deliveries get backoff timers + scheduled. +2. **Periodic retry sweep (DB-mediated fallback):** Every 60 seconds the engine + scans for `retrying` deliveries whose backoff period has elapsed. This + catches "orphaned" retries — ones whose in-memory timer was dropped because + the retry channel was full. The database is the durable fallback that ensures + no retry is permanently lost, even under extreme backpressure. -**Changing a target's type does not migrate in-flight deliveries.** Only -`http` and `slack` targets own durable retries; `database` and `log` -targets are fire-and-forget and never produce a `retrying` delivery. If a -target's `type` is edited from a retrying type to a non-retrying (or -unknown) one while one of its deliveries is still `retrying`, both -recovery paths above terminally mark that delivery `failed` and record a -`DeliveryResult` naming the current target type as the reason, logging it -at warn level. The delivery is not re-dispatched under the new type — the -operator never asked for that delivery — and the event itself remains -stored in the per-webhook event database. Nothing is lost: the delivery -is `failed`, which is terminal, so the event log offers **Replay** on -it to re-send it to that target under the new type, and **Resubmit** on -the event to re-inject it to every currently active target. +**Changing a target's type does not migrate in-flight deliveries.** Only `http` +and `slack` targets own durable retries; `database` and `log` targets are +fire-and-forget and never produce a `retrying` delivery. If a target's `type` is +edited from a retrying type to a non-retrying (or unknown) one while one of its +deliveries is still `retrying`, both recovery paths above terminally mark that +delivery `failed` and record a `DeliveryResult` naming the current target type +as the reason, logging it at warn level. The delivery is not re-dispatched under +the new type — the operator never asked for that delivery — and the event itself +remains stored in the per-webhook event database. Nothing is lost: the delivery +is `failed`, which is terminal, so the event log offers **Replay** on it to +re-send it to that target under the new type, and **Resubmit** on the event to +re-inject it to every currently active target. ### Circuit Breaker (HTTP and Slack Targets with Retries) `http` and `slack` targets with `max_retries` > 0 are protected by a -**per-target circuit breaker** that prevents hammering a down target -with repeated failed delivery attempts. The circuit breaker is -in-memory only and resets on restart (which is fine — startup recovery -rescans the database anyway). +**per-target circuit breaker** that prevents hammering a down target with +repeated failed delivery attempts. The circuit breaker is in-memory only and +resets on restart (which is fine — startup recovery rescans the database +anyway). **States:** -| State | Behavior | -| ----------- | -------- | -| **Closed** | Normal operation. Deliveries flow through. Consecutive failures are counted. | -| **Open** | Target appears down. Deliveries are skipped and rescheduled for after the cooldown. | +| State | Behavior | +| ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Closed** | Normal operation. Deliveries flow through. Consecutive failures are counted. | +| **Open** | Target appears down. Deliveries are skipped and rescheduled for after the cooldown. | | **Half-Open** | Cooldown expired. One probe delivery is allowed to test if the target has recovered. Other deliveries are rescheduled for one whole cooldown later. | **Transitions:** @@ -2417,331 +2272,302 @@ rescans the database anyway). - **Cooldown:** 30 seconds in open state before probing **Scope:** Circuit breakers apply to **`http` and `slack` targets with -`max_retries` > 0**. The Slack target is built on the same HTTP core -and hands its own `max_retries` to the same retry path, so it gets a -breaker with the same 5-failure / 30-second defaults. Fire-and-forget -targets of either type (`max_retries` == 0), database targets (local -operations), and log targets (stdout) do not use circuit breakers. +`max_retries` > 0**. The Slack target is built on the same HTTP core and hands +its own `max_retries` to the same retry path, so it gets a breaker with the same +5-failure / 30-second defaults. Fire-and-forget targets of either type +(`max_retries` == 0), database targets (local operations), and log targets +(stdout) do not use circuit breakers. -When a circuit is open and a new delivery arrives, the engine marks the -delivery as `retrying` and schedules a retry timer for after the -remaining cooldown period. This ensures no deliveries are lost — they're -just delayed until the target is healthy again. A delivery already in -`retrying` keeps that status without another database write each time -the breaker turns it away. +When a circuit is open and a new delivery arrives, the engine marks the delivery +as `retrying` and schedules a retry timer for after the remaining cooldown +period. This ensures no deliveries are lost — they're just delayed until the +target is healthy again. A delivery already in `retrying` keeps that status +without another database write each time the breaker turns it away. -While a target's breaker is open, the target's row on the webhook page -says its deliveries are paused until the cooldown ends, in UTC and as a -time from now. Each of its `retrying` deliveries shows as waiting in the -event log and on the event's page, with the earliest it can be tried -next: the later of the cooldown's end and the end of its own backoff -after its last attempt. It is only the earliest: when the cooldown ends, -one of the target's waiting deliveries is sent to test it while the -others wait at least one more cooldown, as the row also says. A time not -on the current UTC day is shown with its date. While the breaker is -half-open, the row says instead that deliveries are held while one -delivery tests whether the target has recovered, with no time, and the -target's deliveries show their plain status, since any of them may be -the one being sent. +While a target's breaker is open, the target's row on the webhook page says its +deliveries are paused until the cooldown ends, in UTC and as a time from now. +Each of its `retrying` deliveries shows as waiting in the event log and on the +event's page, with the earliest it can be tried next: the later of the +cooldown's end and the end of its own backoff after its last attempt. It is only +the earliest: when the cooldown ends, one of the target's waiting deliveries is +sent to test it while the others wait at least one more cooldown, as the row +also says. A time not on the current UTC day is shown with its date. While the +breaker is half-open, the row says instead that deliveries are held while one +delivery tests whether the target has recovered, with no time, and the target's +deliveries show their plain status, since any of them may be the one being sent. ### Metrics `/metrics` serves one Prometheus registry behind basic auth (see -[Infrastructure Endpoints](#infrastructure-endpoints)). Alongside the -inbound HTTP metrics recorded by the middleware, it exposes the -delivery pipeline — the part of the service that can be failing while -the receive side looks perfectly healthy, because it is: events are -arriving and being stored, they are just not getting anywhere. +[Infrastructure Endpoints](#infrastructure-endpoints)). Alongside the inbound +HTTP metrics recorded by the middleware, it exposes the delivery pipeline — the +part of the service that can be failing while the receive side looks perfectly +healthy, because it is: events are arriving and being stored, they are just not +getting anywhere. -| Metric | Type | Meaning | -| ------ | ---- | ------- | -| `webhooker_events_received_total` | counter | Events received and durably stored. Compare against the delivery counters on one dashboard | -| `webhooker_delivery_attempts_total` | counter | Delivery attempts actually dispatched to a target. A delivery a circuit breaker refused is not one: it is counted as a retry instead, but only when the refusal moves it into `retrying` | -| `webhooker_deliveries_succeeded_total` | counter | Deliveries that reached `delivered` | -| `webhooker_deliveries_failed_total` | counter | Deliveries that failed terminally and will not be retried | -| `webhooker_delivery_retries_total` | counter | Deliveries put back into `retrying` | -| `webhooker_delivery_replays_total` | counter | Deliveries an operator replayed from the event log. A replay runs the ordinary engine path, so it also moves the attempt, outcome and duration series; this is the only one that separates it from ordinary traffic | -| `webhooker_events_resubmitted_total` | counter | Stored events an operator re-injected from the event log. The new event also moves `webhooker_events_received_total`, since it is a stored event the delivery side is compared against; this counter is what separates the two. Unlabelled: the target types it fans out to belong to the delivery series | -| `webhooker_delivery_duration_seconds` | histogram | Wall time of a single dispatched delivery attempt, the same duration the attempt's `DeliveryResult` records | -| `webhooker_deliveries_pending` | gauge | Deliveries currently in `pending` | -| `webhooker_deliveries_retrying` | gauge | Deliveries currently in `retrying` | -| `webhooker_circuit_breakers_open` | gauge | Circuit breakers currently open | +| Metric | Type | Meaning | +| -------------------------------------- | --------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `webhooker_events_received_total` | counter | Events received and durably stored. Compare against the delivery counters on one dashboard | +| `webhooker_delivery_attempts_total` | counter | Delivery attempts actually dispatched to a target. A delivery a circuit breaker refused is not one: it is counted as a retry instead, but only when the refusal moves it into `retrying` | +| `webhooker_deliveries_succeeded_total` | counter | Deliveries that reached `delivered` | +| `webhooker_deliveries_failed_total` | counter | Deliveries that failed terminally and will not be retried | +| `webhooker_delivery_retries_total` | counter | Deliveries put back into `retrying` | +| `webhooker_delivery_replays_total` | counter | Deliveries an operator replayed from the event log. A replay runs the ordinary engine path, so it also moves the attempt, outcome and duration series; this is the only one that separates it from ordinary traffic | +| `webhooker_events_resubmitted_total` | counter | Stored events an operator re-injected from the event log. The new event also moves `webhooker_events_received_total`, since it is a stored event the delivery side is compared against; this counter is what separates the two. Unlabelled: the target types it fans out to belong to the delivery series | +| `webhooker_delivery_duration_seconds` | histogram | Wall time of a single dispatched delivery attempt, the same duration the attempt's `DeliveryResult` records | +| `webhooker_deliveries_pending` | gauge | Deliveries currently in `pending` | +| `webhooker_deliveries_retrying` | gauge | Deliveries currently in `retrying` | +| `webhooker_circuit_breakers_open` | gauge | Circuit breakers currently open | -Every delivery metric carries exactly one label, `target_type`, and -cardinality is the whole reason for that restriction. The two -event-level counters carry no label at all — an event is not the -property of any one target type. A target type is -one of four compile-time constants, so the label domain is bounded by -construction; a value outside that set collapses to `unknown` rather -than minting a series of its own. Target ids, event ids and entrypoint -ids are deliberately not labels: they are UUIDs minted per operator -action or per inbound request, a series is never reclaimed once it -exists, and labelling by any of them would make `/metrics` a memory -leak that grows with traffic. +Every delivery metric carries exactly one label, `target_type`, and cardinality +is the whole reason for that restriction. The two event-level counters carry no +label at all — an event is not the property of any one target type. A target +type is one of four compile-time constants, so the label domain is bounded by +construction; a value outside that set collapses to `unknown` rather than +minting a series of its own. Target ids, event ids and entrypoint ids are +deliberately not labels: they are UUIDs minted per operator action or per +inbound request, a series is never reclaimed once it exists, and labelling by +any of them would make `/metrics` a memory leak that grows with traffic. -The two queue-depth gauges are counted out of the databases by a -sampler that runs every 30 seconds for as long as the delivery engine -does, rather than tracked as deltas alongside the status transitions: a -delta would have to be seeded at startup from rows a previous process -wrote, and would drift permanently on any transition that failed to -persist. +The two queue-depth gauges are counted out of the databases by a sampler that +runs every 30 seconds for as long as the delivery engine does, rather than +tracked as deltas alongside the status transitions: a delta would have to be +seeded at startup from rows a previous process wrote, and would drift +permanently on any transition that failed to persist. -Those two gauges also publish an `unknown` series, from startup rather -than on first occurrence. Deliveries queued against a target that has -since been deleted are counted there: that backlog is the one nobody is -watching, so it is the one that must not silently vanish from the -gauge. The outcome counters move only after the status change has been -written, so a transition the database rejected is never reported as an -outcome that happened. +Those two gauges also publish an `unknown` series, from startup rather than on +first occurrence. Deliveries queued against a target that has since been deleted +are counted there: that backlog is the one nobody is watching, so it is the one +that must not silently vanish from the gauge. The outcome counters move only +after the status change has been written, so a transition the database rejected +is never reported as an outcome that happened. #### Inbound HTTP metrics The middleware records three more on the same registry: -| Metric | Type | Labels | -| ------ | ---- | ------ | +| Metric | Type | Labels | +| ------------------------------- | --------- | -------------------------------------- | | `http_request_duration_seconds` | histogram | `service`, `handler`, `method`, `code` | -| `http_response_size_bytes` | histogram | `service`, `handler`, `method`, `code` | -| `http_requests_inflight` | gauge | `service`, `handler` | +| `http_response_size_bytes` | histogram | `service`, `handler`, `method`, `code` | +| `http_requests_inflight` | gauge | `service`, `handler` | -Two of those labels are written once per request from bytes the client -chose, so both are bounded to something this service registers: +Two of those labels are written once per request from bytes the client chose, so +both are bounded to something this service registers: -- `handler` is the chi route pattern — `/h/{uuid}`, never the - concrete path. A request matching no route carries `(unmatched)`, - and no entrypoint UUID ever reaches a label. -- `method` is the request method when the router can route it, and - `(unmatched)` otherwise. `net/http` accepts any RFC 9110 token as a - method, so the raw value bounds the label at nothing; the nine chi - matches routes for stay distinguishable, and a token that could only - ever have produced a 405 does not get a series of its own. +- `handler` is the chi route pattern — `/h/{uuid}`, never the concrete path. A + request matching no route carries `(unmatched)`, and no entrypoint UUID ever + reaches a label. +- `method` is the request method when the router can route it, and `(unmatched)` + otherwise. `net/http` accepts any RFC 9110 token as a method, so the raw value + bounds the label at nothing; the nine chi matches routes for stay + distinguishable, and a token that could only ever have produced a 405 does not + get a series of its own. -The other two are not request-controlled: `code` is the status one of -this service's own handlers wrote, and `service` is a fixed empty -string. +The other two are not request-controlled: `code` is the status one of this +service's own handlers wrote, and `service` is a fixed empty string. -`http_requests_inflight` is deliberately aggregate — its `handler` is -always `(all)`, one series counting the requests in flight across the -whole service. The gauge is incremented before routing and decremented -after the handler returns, and the route pattern exists only between -those two moments, so labelling it by pattern would increment one -series and decrement another, leaving every pattern permanently off by -the number of requests it served. +`http_requests_inflight` is deliberately aggregate — its `handler` is always +`(all)`, one series counting the requests in flight across the whole service. +The gauge is incremented before routing and decremented after the handler +returns, and the route pattern exists only between those two moments, so +labelling it by pattern would increment one series and decrement another, +leaving every pattern permanently off by the number of requests it served. ### Rate Limiting -Global blanket rate limiting middleware (e.g., a per-IP throttle shared -with the web UI) **must not** apply to webhook receiver endpoints. -Webhook endpoints receive automated traffic from external services at -unpredictable rates, and blanket limits shared with other routes would -cause legitimate deliveries to be dropped. +Global blanket rate limiting middleware (e.g., a per-IP throttle shared with the +web UI) **must not** apply to webhook receiver endpoints. Webhook endpoints +receive automated traffic from external services at unpredictable rates, and +blanket limits shared with other routes would cause legitimate deliveries to be +dropped. The receiver instead has its own dedicated abuse limit, scoped to the `/h/{uuid}` route only and keyed per client IP per request path (`httprate.KeyByEndpoint`): one misbehaving sender is throttled without -affecting other senders of the same entrypoint or the same sender's -other entrypoints. Keying on the path rather than on the entrypoint -matters — see the aggregate limit below. The limit is -`RECEIVER_RATE_LIMIT` requests per minute (default 120, generous for -legitimate webhook senders). Requests over the limit receive HTTP 429 -with a `Retry-After` header. A set-but-invalid `RECEIVER_RATE_LIMIT` -value aborts startup rather than silently falling back to the default. +affecting other senders of the same entrypoint or the same sender's other +entrypoints. Keying on the path rather than on the entrypoint matters — see the +aggregate limit below. The limit is `RECEIVER_RATE_LIMIT` requests per minute +(default 120, generous for legitimate webhook senders). Requests over the limit +receive HTTP 429 with a `Retry-After` header. A set-but-invalid +`RECEIVER_RATE_LIMIT` value aborts startup rather than silently falling back to +the default. -A second limit sits in front of that one, keyed on the client IP alone -and covering the whole route at ten times `RECEIVER_RATE_LIMIT` requests -per minute (default 1200). The per-entrypoint limit needs it: the route -pattern matches any single path segment, so a client that invents a -fresh path per request gets a fresh per-entrypoint bucket every time and -would otherwise have no aggregate limit at all — while each of those -requests still costs an entrypoint lookup before it 404s. The aggregate -limit leaves room for one address to drive several entrypoints at their -full rate, and it is not configurable separately. +A second limit sits in front of that one, keyed on the client IP alone and +covering the whole route at ten times `RECEIVER_RATE_LIMIT` requests per minute +(default 1200). The per-entrypoint limit needs it: the route pattern matches any +single path segment, so a client that invents a fresh path per request gets a +fresh per-entrypoint bucket every time and would otherwise have no aggregate +limit at all — while each of those requests still costs an entrypoint lookup +before it 404s. The aggregate limit leaves room for one address to drive several +entrypoints at their full rate, and it is not configurable separately. -What that aggregate limit bounds is the database work an invented path -costs; log volume it caps rather than eliminates. A path that names no -entrypoint is recorded by the handler at `DEBUG`, and the aggregate -limiter logs its own rejections at `DEBUG` and without the path, so -neither appears at all under the default level. The per-entrypoint -limiter is the loud one: it logs every rejection at `WARN` with the -request path, which on this route is attacker-controlled text. A client -hammering a single invented path is served `RECEIVER_RATE_LIMIT` -requests and has the rest of its aggregate budget rejected there, so -the aggregate limit is what bounds the _number_ of those `WARN` lines — -to under ten times `RECEIVER_RATE_LIMIT` per minute per client IP, 1080 -at the defaults, where before it there was no bound at all. Their -_width_ is bounded by the field budgets below, the same ones the access -log spends. The access log is bounded by neither limit: every request -is recorded once at `INFO`, served or rejected alike. +What that aggregate limit bounds is the database work an invented path costs; +log volume it caps rather than eliminates. A path that names no entrypoint is +recorded by the handler at `DEBUG`, and the aggregate limiter logs its own +rejections at `DEBUG` and without the path, so neither appears at all under the +default level. The per-entrypoint limiter is the loud one: it logs every +rejection at `WARN` with the request path, which on this route is +attacker-controlled text. A client hammering a single invented path is served +`RECEIVER_RATE_LIMIT` requests and has the rest of its aggregate budget rejected +there, so the aggregate limit is what bounds the _number_ of those `WARN` lines +— to under ten times `RECEIVER_RATE_LIMIT` per minute per client IP, 1080 at the +defaults, where before it there was no bound at all. Their _width_ is bounded by +the field budgets below, the same ones the access log spends. The access log is +bounded by neither limit: every request is recorded once at `INFO`, served or +rejected alike. -What the access log does bound is the _content_ of those lines. A 3xx -or 4xx response logs the chi route pattern — `/h/{uuid}`, -`/user/{username}//`, or the literal `(unmatched)` when the request hit -no route at all — in place of the concrete URL. Those are the outcomes -an unauthenticated client can drive for free: 404 and 429 on any -invented receiver path, a login redirect on any invented profile path. -Logging the URL there would let a flood write text of its own choosing, -at a length of its own choosing, into the log. 2xx and 5xx responses -keep the concrete path — a success resolved against a static route or -against the operator's own data (on the receiver, against a stored -entrypoint UUID), and a 5xx is a bug in this service, where the exact -path is the evidence and no client can provoke one at will. +What the access log does bound is the _content_ of those lines. A 3xx or 4xx +response logs the chi route pattern — `/h/{uuid}`, `/user/{username}//`, or the +literal `(unmatched)` when the request hit no route at all — in place of the +concrete URL. Those are the outcomes an unauthenticated client can drive for +free: 404 and 429 on any invented receiver path, a login redirect on any +invented profile path. Logging the URL there would let a flood write text of its +own choosing, at a length of its own choosing, into the log. 2xx and 5xx +responses keep the concrete path — a success resolved against a static route or +against the operator's own data (on the receiver, against a stored entrypoint +UUID), and a 5xx is a bug in this service, where the exact path is the evidence +and no client can provoke one at will. The query string is never logged; it is replaced by the fixed marker `?(redacted)`. It is client-chosen on every route, and -`/.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 -`next`, the page to return to, `notice`, which names the line a page -shows after an action, and the event log's `show`, which picks the events -it lists. +`/.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 `next`, the page to return +to, `notice`, which names the line a page shows after an action, and the event +log's `show`, which picks the events it lists. -Client-supplied request content does not leave the host by the other -route either. The Sentry SDK attaches the request to every event it -captures, independently of the access log, and `SendDefaultPII=false` -does not cover all of what it copies: the raw query string and the -first 10 KiB of the request body are both taken unconditionally, the -body precisely because these handlers call `ParseForm`. A `BeforeSend` -hook therefore replaces the query string and the body with -`(redacted)`, drops cookies and the remote-address environment, and +Client-supplied request content does not leave the host by the other route +either. The Sentry SDK attaches the request to every event it captures, +independently of the access log, and `SendDefaultPII=false` does not cover all +of what it copies: the raw query string and the first 10 KiB of the request body +are both taken unconditionally, the body precisely because these handlers call +`ParseForm`. A `BeforeSend` hook therefore replaces the query string and the +body with `(redacted)`, drops cookies and the remote-address environment, and reduces the headers to a fixed allowlist — `Accept`, `Content-Length`, -`Content-Type`, `Host`, `Origin`, `Referer`, `User-Agent` and -`X-Request-Id`. +`Content-Type`, `Host`, `Origin`, `Referer`, `User-Agent` and `X-Request-Id`. The same hook rewrites the request URL. The SDK builds it as -`scheme://host/path` from the concrete path, which on the receiver -route is `/h/` in full — and that UUID is a write -capability, not an identifier: anyone holding it can post events this -service accepts and its targets then deliver. A tracker has its own -retention, access control and deletion policy, so the rule the access -log follows above does not carry across that boundary. What is sent is -the chi route pattern instead: `http://host/h/{uuid}`. +`scheme://host/path` from the concrete path, which on the receiver route is +`/h/` in full — and that UUID is a write capability, not an identifier: +anyone holding it can post events this service accepts and its targets then +deliver. A tracker has its own retention, access control and deletion policy, so +the rule the access log follows above does not carry across that boundary. What +is sent is the chi route pattern instead: `http://host/h/{uuid}`. -The scheme and the host are kept, and everything else in the URL is -discarded rather than edited, so a future SDK version that starts -appending a query string cannot widen this. The scheme has to survive -for the reason given below. The host is whatever the request's `Host` -header carried — this service validates no hostname, so on a directly -exposed deployment a client sets it — and that same header is on the -allowlist above, so scrubbing the host out of the URL would withhold -nothing that is not sent anyway. +The scheme and the host are kept, and everything else in the URL is discarded +rather than edited, so a future SDK version that starts appending a query string +cannot widen this. The scheme has to survive for the reason given below. The +host is whatever the request's `Host` header carried — this service validates no +hostname, so on a directly exposed deployment a client sets it — and that same +header is on the allowlist above, so scrubbing the host out of the URL would +withhold nothing that is not sent anyway. -The body, the query string and the URL are all handled on every route -rather than filtered by route. For the URL that is also what keeps the -event locatable: an error event is grouped by its exception and stack -trace, not by its URL, so replacing the path with the pattern costs no -grouping and the pattern still names the route in the UI. And an -unconditional rule cannot leak on a route somebody forgets to add to -it, which a route-conditional one can. For the body there is a second -reason: nothing debuggable is lost, because every handler reads its -fields with `PostFormValue`, so the body is exactly where the +The body, the query string and the URL are all handled on every route rather +than filtered by route. For the URL that is also what keeps the event locatable: +an error event is grouped by its exception and stack trace, not by its URL, so +replacing the path with the pattern costs no grouping and the pattern still +names the route in the UI. And an unconditional rule cannot leak on a route +somebody forgets to add to it, which a route-conditional one can. For the body +there is a second reason: nothing debuggable is lost, because every handler +reads its fields with `PostFormValue`, so the body is exactly where the credentials are — the target destination URL, the login password, both -password-change fields — and the one route whose body is genuine -signal is the receiver, whose body is already stored on the event and -served from the UI, so a tracker is not where anyone reads it. +password-change fields — and the one route whose body is genuine signal is the +receiver, whose body is already stored on the event and served from the UI, so a +tracker is not where anyone reads it. -The route is reachable from the hook only on the error dispatch. -`sentryhttp`'s recover path puts the request on the context it hands -to `RecoverWithContext`, and the SDK carries that context through to -`BeforeSend` as `hint.Context`, so -`hint.Context.Value(sentry.RequestContextKey)` yields the live request -and chi's `RoutePattern()` yields the matched pattern off it. The -transaction dispatch has no such request: a finished span captures -with a nil hint, which the client replaces with an empty one, so -`BeforeSendTransaction` sees no context at all. Tracing is off in this -service, so no transaction event is produced today, but the hook is -installed on both dispatches as a floor. +The route is reachable from the hook only on the error dispatch. `sentryhttp`'s +recover path puts the request on the context it hands to `RecoverWithContext`, +and the SDK carries that context through to `BeforeSend` as `hint.Context`, so +`hint.Context.Value(sentry.RequestContextKey)` yields the live request and chi's +`RoutePattern()` yields the matched pattern off it. The transaction dispatch has +no such request: a finished span captures with a nil hint, which the client +replaces with an empty one, so `BeforeSendTransaction` sees no context at all. +Tracing is off in this service, so no transaction event is produced today, but +the hook is installed on both dispatches as a floor. -Where the pattern is out of reach — the transaction dispatch, an event -captured outside the router, or a request that matched no route — the -fallback is never the concrete path. The path becomes the literal -`/(redacted)`, so the URL reads `http://host/(redacted)`; a URL the -rewrite cannot parse into a scheme is withheld whole. A transaction -event additionally carries the SDK's own `METHOD /path` name, built -from the concrete path as well; it is rewritten on the same terms, to -`POST /h/{uuid}` where the pattern is known and `POST /(redacted)` -where it is not. +Where the pattern is out of reach — the transaction dispatch, an event captured +outside the router, or a request that matched no route — the fallback is never +the concrete path. The path becomes the literal `/(redacted)`, so the URL reads +`http://host/(redacted)`; a URL the rewrite cannot parse into a scheme is +withheld whole. A transaction event additionally carries the SDK's own +`METHOD /path` name, built from the concrete path as well; it is rewritten on +the same terms, to `POST /h/{uuid}` where the pattern is known and +`POST /(redacted)` where it is not. The headers are an allowlist for the same reason the rules above are -unconditional: the SDK's own filter removes four names and passes -everything else, which would ship `X-CSRF-Token` and the shared -secrets senders put on the receiver route. What survives still names -the failing route — scheme, host, route pattern, method — and -`X-Request-Id` ties the event to the local access log line that holds -the rest. Nothing dropped is needed for the likeliest use, debugging a -CSRF rejection. Its three inputs are the TLS decision, `Origin` and -`Referer`; the latter two are kept, and the first is the scheme of the -retained URL, because the SDK derives that scheme from -`r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https"`. That -predicate is the SDK's own and is stricter than `internal/reqtls.IsTLS`, -which this service now uses everywhere it decides transport: the SDK -reports `http` for the `HTTPS` and `https, http` spellings `reqtls` -accepts. Only a reported scheme is affected, no decision is, so it is -left to the SDK rather than reimplemented. That is what the rewrite -above preserves it for, and it is why dropping `X-Forwarded-Proto` -costs nothing. The dropped provider headers (`X-GitHub-Event`, -`X-Gitlab-Event` and the like) are real signal but are recorded -locally on the event, and -`Sentry-Trace`/`Baggage` are already reflected in the event's trace -context. +unconditional: the SDK's own filter removes four names and passes everything +else, which would ship `X-CSRF-Token` and the shared secrets senders put on the +receiver route. What survives still names the failing route — scheme, host, +route pattern, method — and `X-Request-Id` ties the event to the local access +log line that holds the rest. Nothing dropped is needed for the likeliest use, +debugging a CSRF rejection. Its three inputs are the TLS decision, `Origin` and +`Referer`; the latter two are kept, and the first is the scheme of the retained +URL, because the SDK derives that scheme from +`r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https"`. That predicate +is the SDK's own and is stricter than `internal/reqtls.IsTLS`, which this +service now uses everywhere it decides transport: the SDK reports `http` for the +`HTTPS` and `https, http` spellings `reqtls` accepts. Only a reported scheme is +affected, no decision is, so it is left to the SDK rather than reimplemented. +That is what the rewrite above preserves it for, and it is why dropping +`X-Forwarded-Proto` costs nothing. The dropped provider headers +(`X-GitHub-Event`, `X-Gitlab-Event` and the like) are real signal but are +recorded locally on the event, and `Sentry-Trace`/`Baggage` are already +reflected in the event's trace context. -The remaining client-supplied fields are truncated rather than dropped, -each to a fixed budget: 512 bytes for `url`, `useragent` and `referer`, -128 for `request_id` (chi passes an inbound `X-Request-Id` header -through), and 32 for `method`. A truncated `User-Agent` is still worth -reading; an absent one is not. A cut value ends in `[truncated]`, which -is charged on top of the budget rather than inside it. +The remaining client-supplied fields are truncated rather than dropped, each to +a fixed budget: 512 bytes for `url`, `useragent` and `referer`, 128 for +`request_id` (chi passes an inbound `X-Request-Id` header through), and 32 for +`method`. A truncated `User-Agent` is still worth reading; an absent one is not. +A cut value ends in `[truncated]`, which is charged on top of the budget rather +than inside it. -Each budget is spent in _encoded_ bytes, not in the bytes the client -sent. Every rune is charged what the wider of the two log handlers -emits for it: two bytes for a quotation mark, a backslash or a tab; six -for a non-printable rune below U+10000; ten for one at or above it, -which the text handler spells `\UXXXXXXXX`. Go's header parser accepts -all of them in a header value, so a budget counted raw would buy a -field several times its nominal size — and the line, not the header, is -what an operator has to store. Plain ASCII encodes one byte for one, so -a real browser's `User-Agent` still fits whole; a value built out of -escapes keeps a proportionally shorter prefix, which is the right -trade. +Each budget is spent in _encoded_ bytes, not in the bytes the client sent. Every +rune is charged what the wider of the two log handlers emits for it: two bytes +for a quotation mark, a backslash or a tab; six for a non-printable rune below +U+10000; ten for one at or above it, which the text handler spells `\UXXXXXXXX`. +Go's header parser accepts all of them in a header value, so a budget counted +raw would buy a field several times its nominal size — and the line, not the +header, is what an operator has to store. Plain ASCII encodes one byte for one, +so a real browser's `User-Agent` still fits whole; a value built out of escapes +keeps a proportionally shorter prefix, which is the right trade. -Net: **one `INFO` line per request, of at most 2,560 bytes.** That -ceiling is arithmetic, not an observation: 3 × (512 + 11) for `url`, -`useragent` and `referer`, plus 128 + 11 for `request_id`, plus 32 + 11 -for `method`, plus a 405-byte fixed portion (the field names, the -punctuation, both timestamps at their longest, `remoteIP` and -`clientIP` each charged as an IPv6 address with a zone, the status and -the latency) — 2,156 bytes, stated at 2,560 so the figure has headroom. -`internal/middleware/accesslog_test.go` asserts it against 8 KB of -client-chosen text in the path, in the query, and in each of -`User-Agent`, `Referer`, `X-Request-Id` and `X-Forwarded-For`, -including cases built from the characters the handlers escape, and -against a 5xx that keeps its concrete path while all three header fields -are also at their budget and an `X-Forwarded-For` sent from a trusted -proxy ends in an IPv6 client address at its longest followed by an 8 KB -zone, where `clientIP` must name the address without the zone. Every -case runs through both handlers `internal/logger` can select — the JSON -one and the text one it installs on a tty — since the two do not escape -alike and the ceiling is quoted unqualified. +Net: **one `INFO` line per request, of at most 2,560 bytes.** That ceiling is +arithmetic, not an observation: 3 × (512 + 11) for `url`, `useragent` and +`referer`, plus 128 + 11 for `request_id`, plus 32 + 11 for `method`, plus a +405-byte fixed portion (the field names, the punctuation, both timestamps at +their longest, `remoteIP` and `clientIP` each charged as an IPv6 address with a +zone, the status and the latency) — 2,156 bytes, stated at 2,560 so the figure +has headroom. `internal/middleware/accesslog_test.go` asserts it against 8 KB of +client-chosen text in the path, in the query, and in each of `User-Agent`, +`Referer`, `X-Request-Id` and `X-Forwarded-For`, including cases built from the +characters the handlers escape, and against a 5xx that keeps its concrete path +while all three header fields are also at their budget and an `X-Forwarded-For` +sent from a trusted proxy ends in an IPv6 client address at its longest followed +by an 8 KB zone, where `clientIP` must name the address without the zone. Every +case runs through both handlers `internal/logger` can select — the JSON one and +the text one it installs on a tty — since the two do not escape alike and the +ceiling is quoted unqualified. -Multiply that ceiling by the request rate to size log storage. Note -that the rate is not bounded by the limits above on every route: -`/.well-known/healthcheck` and `/s/*` sit behind no limiter, so there -the multiplier is whatever the deployment will serve. +Multiply that ceiling by the request rate to size log storage. Note that the +rate is not bounded by the limits above on every route: +`/.well-known/healthcheck` and `/s/*` sit behind no limiter, so there the +multiplier is whatever the deployment will serve. -**The same ceiling covers every other line the service writes through -`slog` that carries text an unauthenticated client supplies**, with one -exception stated below it: the recovered-panic record, which carries a -whole goroutine stack alongside its client-supplied fields and so has -its own wider ceiling. The access log is not the only line a client can -put its own text into, and a budget that held for one line and not the -others would be worse than no stated budget at all. Every `slog` call an -unauthenticated request can reach spends the same per-field budget -through `internal/logfield`, and each carries strictly fewer -client-supplied fields than the access log does, so none of them can be -wider than it: +**The same ceiling covers every other line the service writes through `slog` +that carries text an unauthenticated client supplies**, with one exception +stated below it: the recovered-panic record, which carries a whole goroutine +stack alongside its client-supplied fields and so has its own wider ceiling. The +access log is not the only line a client can put its own text into, and a budget +that held for one line and not the others would be worse than no stated budget +at all. Every `slog` call an unauthenticated request can reach spends the same +per-field budget through `internal/logfield`, and each carries strictly fewer +client-supplied fields than the access log does, so none of them can be wider +than it: -| Log line | Level | Client-chosen value | Reachable unauthenticated | -| ------------------------------------------ | ------- | ------------------- | ----------------------------------------- | +| Log line | Level | Client-chosen value | Reachable unauthenticated | +| ------------------------------------------ | ------- | ------------------- | ------------------------------------------ | | `request body exceeds limit` (413) | `WARN` | path, method | yes — `MaxBodySize` precedes `RequireAuth` | | `csrf: token validation failed` (403) | `WARN` | path, method | yes — `CSRF` precedes `RequireAuth` | | `... rate limit exceeded` (429) | `WARN` | path | yes, on the receiver | @@ -2751,406 +2577,370 @@ wider than it: | `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 | -`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, -so those lines are capped too. +`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, so those +lines are capped too. -The last two rows are capped defensively rather than against a -demonstrated width: chi routes `POST /pages/login` on a static pattern, -so `r.URL.Path` there is the 12-byte constant `/pages/login` and each -line lands near 120 bytes. `RecordLoginFailure` is nonetheless an -exported method taking any `*http.Request`, and a future caller on a -route with a URL parameter would widen the line. Since no request -through the mux can, both caps are pinned by tests that call those two -entry points directly with the path such a caller would supply. -Removing either cap fails 14 subtests. +The last two rows are capped defensively rather than against a demonstrated +width: chi routes `POST /pages/login` on a static pattern, so `r.URL.Path` there +is the 12-byte constant `/pages/login` and each line lands near 120 bytes. +`RecordLoginFailure` is nonetheless an exported method taking any +`*http.Request`, and a future caller on a route with a URL parameter would widen +the line. Since no request through the mux can, both caps are pinned by tests +that call those two entry points directly with the path such a caller would +supply. Removing either cap fails 14 subtests. -`internal/middleware/logbound_test.go` and -`internal/handlers/logbound_test.go` drive 8 KB of client-chosen text -at each of these — just under 1 KB at `invalid password`, whose -accounts are shared with the successful-login line and so must stay -within the 1024-byte username limit — through both handlers, and -through seven fills: plain text as the baseline, and then the -quotation mark, backslash, tab, newline, C0 control and astral -non-printable, six characters the wider of the two handlers spends -more on than the client spent sending them. Every case holds each -line to the 2,560-byte ceiling. That per-line ceiling -is what the figure above states, and every row establishes it. +`internal/middleware/logbound_test.go` and `internal/handlers/logbound_test.go` +drive 8 KB of client-chosen text at each of these — just under 1 KB at +`invalid password`, whose accounts are shared with the successful-login line and +so must stay within the 1024-byte username limit — through both handlers, and +through seven fills: plain text as the baseline, and then the quotation mark, +backslash, tab, newline, C0 control and astral non-printable, six characters the +wider of the two handlers spends more on than the client spent sending them. +Every case holds each line to the 2,560-byte ceiling. That per-line ceiling is +what the figure above states, and every row establishes it. -Three of the sites go further and bound the whole flood's output — the -total bytes a run of distinct invented values wrote, which is the -shape an operator sizing storage cares about. They are -`request body exceeds limit` +Three of the sites go further and bound the whole flood's output — the total +bytes a run of distinct invented values wrote, which is the shape an operator +sizing storage cares about. They are `request body exceeds limit` (`TestMaxBodySize_FloodOfOversizePathsDoesNotGrowTheLog`), `entrypoint not found` and `user not found` (the last two through -`assertBoundedFlood`). The other rows carry no aggregate assertion; -the per-line ceiling is what they establish. +`assertBoundedFlood`). The other rows carry no aggregate assertion; the per-line +ceiling is what they establish. -`internal/logfield/logfield_test.go` measures the per-rune charge -against what the handlers really emit, over roughly 3,000 code points on -each, so an undercharged rune fails a test rather than quietly -falsifying the ceiling. +`internal/logfield/logfield_test.go` measures the per-rune charge against what +the handlers really emit, over roughly 3,000 code points on each, so an +undercharged rune fails a test rather than quietly falsifying the ceiling. -**It covers GORM's statement logging as well.** GORM's own default -logger printed the fully interpolated SQL — parameters and all — to -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 -picks outright. Every -`gorm.Open` in the service now installs the adapter in -`internal/gormlog` instead. It writes through the same `slog` logger as -everything else, so its lines take the level the operator set and the -handler `internal/logger` selected, and every value it emits is spent -through the same `internal/logfield` budget. A record-not-found is not -logged as an error: it is the expected outcome on both of those paths, -and each handler already records its own miss at `DEBUG` — bounded, per -the table above — without the SQL. Slow statements are kept, at `WARN`, -above the same 200 ms threshold GORM used and with the statement -bounded, because that report is the one thing GORM's logger gave an -operator that nothing else here does. The adapter orders its cases -exactly as GORM's own `Trace` orders them, so a statement that both -missed and ran slow is still reported as slow, and dropping the miss -costs an operator no report `IgnoreRecordNotFoundError` would have -kept. A GORM line spends at most two of those budgets — the statement -and the driver error — against a smaller fixed portion than the access -log's, and `internal/gormlog/gormlog_test.go` asserts each line against -`MaxAccessLogLineBytes` directly rather than leaving it as arithmetic. +**It covers GORM's statement logging as well.** GORM's own default logger +printed the fully interpolated SQL — parameters and all — to 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 picks outright. Every `gorm.Open` in the service now installs the adapter +in `internal/gormlog` instead. It writes through the same `slog` logger as +everything else, so its lines take the level the operator set and the handler +`internal/logger` selected, and every value it emits is spent through the same +`internal/logfield` budget. A record-not-found is not logged as an error: it is +the expected outcome on both of those paths, and each handler already records +its own miss at `DEBUG` — bounded, per the table above — without the SQL. Slow +statements are kept, at `WARN`, above the same 200 ms threshold GORM used and +with the statement bounded, because that report is the one thing GORM's logger +gave an operator that nothing else here does. The adapter orders its cases +exactly as GORM's own `Trace` orders them, so a statement that both missed and +ran slow is still reported as slow, and dropping the miss costs an operator no +report `IgnoreRecordNotFoundError` would have kept. A GORM line spends at most +two of those budgets — the statement and the driver error — against a smaller +fixed portion than the access log's, and `internal/gormlog/gormlog_test.go` +asserts each line against `MaxAccessLogLineBytes` directly rather than leaving +it as arithmetic. -The adapter also logs no bound value at all: it implements -`gorm.ParamsFilter` and discards the parameters, so GORM renders the -statement with its placeholders intact instead of substituting the -values into it. That is a separate property from the size bound and it -is what a bound is no substitute for — the session encryption key is 44 -base64 characters and an Argon2id hash under 100, so both fit inside -every budget above and a truncated secret is still a secret. It holds -on all three arms of `Trace`, including the routine one an operator -reaches at `DEBUG`, which is the only level at which a successful -`INSERT` is written at all. One GORM path does not consult the filter — -`(*gorm.DB).Scan`, which records the statement through GORM's own trace -recorder. No production code path calls it; only tests do, and what a -test binds is fixture data. `internal/gormlog/scan_guard_test.go` fails -if a non-test file calls it. `Pluck`, `Row` and `Raw` all run through -the normal callback processor and are filtered. -See `#### What DEBUG=true exposes` under Configuration. +The adapter also logs no bound value at all: it implements `gorm.ParamsFilter` +and discards the parameters, so GORM renders the statement with its placeholders +intact instead of substituting the values into it. That is a separate property +from the size bound and it is what a bound is no substitute for — the session +encryption key is 44 base64 characters and an Argon2id hash under 100, so both +fit inside every budget above and a truncated secret is still a secret. It holds +on all three arms of `Trace`, including the routine one an operator reaches at +`DEBUG`, which is the only level at which a successful `INSERT` is written at +all. One GORM path does not consult the filter — `(*gorm.DB).Scan`, which +records the statement through GORM's own trace recorder. No production code path +calls it; only tests do, and what a test binds is fixture data. +`internal/gormlog/scan_guard_test.go` fails if a non-test file calls it. +`Pluck`, `Row` and `Raw` all run through the normal callback processor and are +filtered. See `#### What DEBUG=true exposes` under Configuration. -What that ceiling does **not** cover, stated here so the figure is not -read as more than it is: +What that ceiling does **not** cover, stated here so the figure is not read as +more than it is: -- **Lines carrying an authenticated operator's own input**, which are - not truncated at all. `webhook created` logs the submitted `name` - verbatim (`internal/handlers/webhook_create.go`) and +- **Lines carrying an authenticated operator's own input**, which are not + truncated at all. `webhook created` logs the submitted `name` verbatim + (`internal/handlers/webhook_create.go`) and `target URL blocked by SSRF protection` logs the target host - (`internal/handlers/target_create.go`), as do the - `target_name` lines in `internal/delivery/engine.go` and - `internal/delivery/target_http.go`. The only bound on any of them is - the 1 MB form body cap, so a 100 KB `name` writes a single line of - roughly 600 KB — measured. This is deliberate: every one of these - requires an authenticated operator on a service with no - self-registration, and truncating the operator's own configuration - echoed back would cost debuggability against no adversary. It does - mean the 2,560-byte figure sizes unauthenticated traffic, not the - operator's own administrative requests. -- The **`log` delivery target**, which writes the whole inbound event — - headers and body — to the log. This one is deliberate: capping it - would defeat the target, since emitting the payload is the delivery. - It costs nothing unless an authenticated operator creates a target of - that type on a specific webhook, and each line it writes is bounded - per event by the 1 MB receiver body cap. Adding one is a decision to - spend log volume on that webhook's payloads. -- **The Go runtime**, which does not go through `internal/logger`. The - runtime writes an unrecovered panic or a fatal error itself, as plain - text on standard error, and that output cannot be redirected. A panic - in a background worker rather than in a request handler is the case - that reaches it, since nothing recovers those. It carries no - client-chosen value at a client-chosen length: the service's own - `panic` calls are invariant guards over constants and over - `crypto/rand`, apart from the one that hands `http.ErrAbortHandler` - back to `net/http`, described below. -- **A failure before fx's logger is built**, such as an invalid - configuration value. fx's logger takes the configuration, so when - that fails fx's own console logger still prints the failure as plain - text on standard error. Its values come from the operator's - environment, not from a client. + (`internal/handlers/target_create.go`), as do the `target_name` lines in + `internal/delivery/engine.go` and `internal/delivery/target_http.go`. The only + bound on any of them is the 1 MB form body cap, so a 100 KB `name` writes a + single line of roughly 600 KB — measured. This is deliberate: every one of + these requires an authenticated operator on a service with no + self-registration, and truncating the operator's own configuration echoed back + would cost debuggability against no adversary. It does mean the 2,560-byte + figure sizes unauthenticated traffic, not the operator's own administrative + requests. +- The **`log` delivery target**, which writes the whole inbound event — headers + and body — to the log. This one is deliberate: capping it would defeat the + target, since emitting the payload is the delivery. It costs nothing unless an + authenticated operator creates a target of that type on a specific webhook, + and each line it writes is bounded per event by the 1 MB receiver body cap. + Adding one is a decision to spend log volume on that webhook's payloads. +- **The Go runtime**, which does not go through `internal/logger`. The runtime + writes an unrecovered panic or a fatal error itself, as plain text on standard + error, and that output cannot be redirected. A panic in a background worker + rather than in a request handler is the case that reaches it, since nothing + recovers those. It carries no client-chosen value at a client-chosen length: + the service's own `panic` calls are invariant guards over constants and over + `crypto/rand`, apart from the one that hands `http.ErrAbortHandler` back to + `net/http`, described below. +- **A failure before fx's logger is built**, such as an invalid configuration + value. fx's logger takes the configuration, so when that fails fx's own + console logger still prints the failure as plain text on standard error. Its + values come from the operator's environment, not from a client. - **`net/http`'s own faults**, which are _not_ a separate writer. `internal/server/http.go` builds its server with a nil `ErrorLog`, so `net/http` falls back to the `log` package's default logger — and - `internal/logger` calls `slog.SetDefault`, which redirects that logger - into whichever handler it installed. Those lines therefore arrive on - standard output, shaped like every other line, at `INFO`. They are not - truncated. A handler panic is no longer one of them: the recover - middleware below answers it and writes it as the bounded record - described there instead, and `internal/server/recoverer_test.go` - requires that `http: panic serving` appear in neither of the process's - two streams when a panic is driven through the production router. The - one panic still handed back to `net/http` is `http.ErrAbortHandler`, - which it special-cases and does not log at all. What is left on this - path is `net/http`'s own diagnostics, whose values are the runtime's, - not a client's. + `internal/logger` calls `slog.SetDefault`, which redirects that logger into + whichever handler it installed. Those lines therefore arrive on standard + output, shaped like every other line, at `INFO`. They are not truncated. A + handler panic is no longer one of them: the recover middleware below answers + it and writes it as the bounded record described there instead, and + `internal/server/recoverer_test.go` requires that `http: panic serving` appear + in neither of the process's two streams when a panic is driven through the + production router. The one panic still handed back to `net/http` is + `http.ErrAbortHandler`, which it special-cases and does not log at all. What + is left on this path is `net/http`'s own diagnostics, whose values are the + runtime's, not a client's. -Wider than that 2,560-byte ceiling, and stated separately rather than -carved out of it: the record a recovered panic produces. The recover -middleware in `internal/middleware` answers `500` and writes one `ERROR` -record through `internal/logger` carrying the panic value, the stack and -the request id — the same `request_id` the access log line for that -request carries, which is how the two are joined. It replaced chi's -`middleware.Recoverer`, which on a current Go release crashed inside its -own stack pretty-printer: the connection was dropped rather than -answered, and what reached the operator described that crash rather than -the fault behind it. +Wider than that 2,560-byte ceiling, and stated separately rather than carved out +of it: the record a recovered panic produces. The recover middleware in +`internal/middleware` answers `500` and writes one `ERROR` record through +`internal/logger` carrying the panic value, the stack and the request id — the +same `request_id` the access log line for that request carries, which is how the +two are joined. It replaced chi's `middleware.Recoverer`, which on a current Go +release crashed inside its own stack pretty-printer: the connection was dropped +rather than answered, and what reached the operator described that crash rather +than the fault behind it. -That record is bounded the same way, in the same encoded bytes and -through the same `internal/logfield` budget: 512 for the panic value, -because a handler is free to build one out of the request, 128 for the -request id, which a client supplies outright through `X-Request-Id`, -and 8,192 for the stack, cut at its far end so that the panic site -survives a cut and `net/http`'s accept frames are what is lost. Net: -**at most 10,240 bytes, once per recovered panic** — 9,121 by the -arithmetic (523 + 8,203 + 139 + a 256-byte fixed portion), stated at -10,240 for headroom. +That record is bounded the same way, in the same encoded bytes and through the +same `internal/logfield` budget: 512 for the panic value, because a handler is +free to build one out of the request, 128 for the request id, which a client +supplies outright through `X-Request-Id`, and 8,192 for the stack, cut at its +far end so that the panic site survives a cut and `net/http`'s accept frames are +what is lost. Net: **at most 10,240 bytes, once per recovered panic** — 9,121 by +the arithmetic (523 + 8,203 + 139 + a 256-byte fixed portion), stated at 10,240 +for headroom. -Those two numbers are the claim; the measurements below only -illustrate it. `internal/middleware/recoverer_test.go` drives all -three growable fields past their budgets on one record, over both -handlers, and measured 9,009 bytes on the JSON handler and -8,982–8,983 on the text one in one checkout. Neither is an invariant: -the stack's own content decides where its cut lands, so the figures -move by a byte or so between runs. The real case is far below both — -through the shipped middleware chain the whole record measures -roughly 3,960 bytes over a roughly 3,690-byte stack, taken by -`internal/server/recoverer_test.go` from the process's own file -descriptors while driving a panic through the production router over -a real server in a subprocess. That pair moves further still, since -`debug.Stack()` embeds absolute source paths and so depends on where -the tree is checked out: four checkouts have reported 3,959, 3,961, -3,984 and 4,026. What the tests assert is the ceiling, that every -client-supplied field was cut, and that the shipped chain's stack -arrived uncut — never the numbers. +Those two numbers are the claim; the measurements below only illustrate it. +`internal/middleware/recoverer_test.go` drives all three growable fields past +their budgets on one record, over both handlers, and measured 9,009 bytes on the +JSON handler and 8,982–8,983 on the text one in one checkout. Neither is an +invariant: the stack's own content decides where its cut lands, so the figures +move by a byte or so between runs. The real case is far below both — through the +shipped middleware chain the whole record measures roughly 3,960 bytes over a +roughly 3,690-byte stack, taken by `internal/server/recoverer_test.go` from the +process's own file descriptors while driving a panic through the production +router over a real server in a subprocess. That pair moves further still, since +`debug.Stack()` embeds absolute source paths and so depends on where the tree is +checked out: four checkouts have reported 3,959, 3,961, 3,984 and 4,026. What +the tests assert is the ceiling, that every client-supplied field was cut, and +that the shipped chain's stack arrived uncut — never the numbers. -Every limiter here — receiver, login, password change, delivery replay -and event resubmit — identifies the client the same way, through one -shared key function: the connection's own address, unless the peer is -inside `TRUSTED_PROXIES`, in which case the forwarded client address is -used instead. That address becomes a bucket by family: IPv4 keys on -the full address, IPv6 on its `/64` prefix. A routed `/64` is the normal -residential and mobile IPv6 allocation, so keying IPv6 per address would -let one subscriber rotate source addresses and mint a fresh bucket per -request, evading these limits at the network layer without spoofing -anything; the cost is that distinct clients inside one `/64` share a -bucket. IPv4-mapped addresses (`::ffff:1.2.3.4`) key as the IPv4 address -they carry. See [Trusted proxies](#trusted-proxies). When that variable -does not cover the reverse proxy, a client behind it shares one bucket -with every other client behind the same proxy. Set `TRUSTED_PROXIES` to -the proxy's address to get per-client limits back. What the shared bucket -costs is not the same for every limiter, and the two cases pull in -opposite directions: +Every limiter here — receiver, login, password change, delivery replay and event +resubmit — identifies the client the same way, through one shared key function: +the connection's own address, unless the peer is inside `TRUSTED_PROXIES`, in +which case the forwarded client address is used instead. That address becomes a +bucket by family: IPv4 keys on the full address, IPv6 on its `/64` prefix. A +routed `/64` is the normal residential and mobile IPv6 allocation, so keying +IPv6 per address would let one subscriber rotate source addresses and mint a +fresh bucket per request, evading these limits at the network layer without +spoofing anything; the cost is that distinct clients inside one `/64` share a +bucket. IPv4-mapped addresses (`::ffff:1.2.3.4`) key as the IPv4 address they +carry. See [Trusted proxies](#trusted-proxies). When that variable does not +cover the reverse proxy, a client behind it shares one bucket with every other +client behind the same proxy. Set `TRUSTED_PROXIES` to the proxy's address to +get per-client limits back. What the shared bucket costs is not the same for +every limiter, and the two cases pull in opposite directions: -- For the **receiver** limits it costs throughput, which is the safe - direction to be wrong in: sharing can only make a limit bind sooner, - never let a sender past it. It matters more for the aggregate limit - than for the per-entrypoint one: with every request keyed on the - proxy, the aggregate limit becomes a service-wide ceiling of 1200 - requests per minute across all senders and all entrypoints, where the - per-entrypoint limit's capacity still grows with the number of - entrypoints. +- For the **receiver** limits it costs throughput, which is the safe direction + to be wrong in: sharing can only make a limit bind sooner, never let a sender + past it. It matters more for the aggregate limit than for the per-entrypoint + one: with every request keyed on the proxy, the aggregate limit becomes a + service-wide ceiling of 1200 requests per minute across all senders and all + entrypoints, where the per-entrypoint limit's capacity still grows with the + number of entrypoints. - For the **login and password-change** limits it costs precision, not - availability. Login failures from every client land in one counter, - so a stranger's wrong passwords make the operator's own wrong - passwords answer `429` sooner; the operator's _correct_ password is - never affected, because it is never counted. + availability. Login failures from every client land in one counter, so a + stranger's wrong passwords make the operator's own wrong passwords answer + `429` sooner; the operator's _correct_ password is never affected, because it + is never counted. #### The login endpoint -The login `POST` is the one endpoint with no pre-emptive limiter in -front of it, and that is deliberate. A limiter that spends budget on -arrival is a lockout wherever clients share one bucket, as they do -behind a reverse proxy that `TRUSTED_PROXIES` does not cover: a -stranger sending five POSTs a minute — about 0.08 requests per second, -from anywhere — keeps it permanently full, and the operator has no -second administrative path. So the handler inverts the order: +The login `POST` is the one endpoint with no pre-emptive limiter in front of it, +and that is deliberate. A limiter that spends budget on arrival is a lockout +wherever clients share one bucket, as they do behind a reverse proxy that +`TRUSTED_PROXIES` does not cover: a stranger sending five POSTs a minute — about +0.08 requests per second, from anywhere — keeps it permanently full, and the +operator has no second administrative path. So the handler inverts the order: -1. **Credentials are verified first, and only a failed attempt spends - budget.** A correct password is never rate-limited, whatever the - counters hold. This is what guarantees the admin UI stays - reachable. -2. **Failures are counted per (client bucket, submitted username)**, - five per minute, after which further _failures_ from that pair are - answered `429` with a `Retry-After`. That `429` is a label on the - response, not a gate in front of the work: the credential check has - already run by the time the counter is consulted, so a throttled - client's guess is still evaluated. See the guessing rate below. A - successful login clears the counter, so mistyping a few times and - then getting it right leaves you unthrottled. Because the submitted - username is attacker-controlled, at most 1024 username counters and - 1024 fallback address counters are tracked; past the first cap - failures fall back to the address counter, and past both they are - answered as throttled without being recorded. Total tracked state - is under half a megabyte and does not grow with the number of - usernames an attacker invents. -3. **Concurrent password verifications are capped at two, and the - queue for them at 16.** Verifying before counting means every login - request costs an Argon2id hash, and Argon2id here is 64 MB per - hash — two slots is a 128 MB ceiling on password hashing. Every - endpoint that hashes a password takes a slot, including the - password-change endpoint, which holds one across both the - verification and the new hash. A request that waits five seconds - without getting a slot is answered `503 Service Unavailable` and no - hash is computed for it. The wait alone does not bound memory, only - how long one request holds some, so the number of waiters is capped - as well. Size the queue from what a parked waiter actually retains, - not from the 1 MB body cap: that caps the raw body read, while the - body-cap, CSRF and form-parsing middleware all run before the - guard, so a waiter holds its parsed form plus its request header - block for the whole wait. Measured on the pinned Go 1.26.1 - toolchain, as the heap delta with 64 waiters parked in the handler, - an ordinary two-field login form retains ~0 MB, a 1 MB urlencoded - body at Go's 10,000-parameter parse cap retains 2.82 MB (3.09 MB - with `%41` escapes), and the ~0.9 MB of headers the 1 MB header cap - allows takes it to **4.18 MB** — the retained parse and the headers - dominate, not the raw body. So the cap is 16 waiters: 16 x 4.18 MB - is about 67 MB of committed queue memory, and two slots drain a - full 16-deep queue in roughly 0.6 s, far inside the five-second - deadline. A request arriving past the cap is shed with `503` - immediately instead of joining the queue. **Peak commitment for the - endpoint is therefore about 203 MB**: 128 MB of Argon2id, plus the - 18 requests holding a parsed form — 16 queued and the 2 being - hashed — at about 75 MB. That 203 MB is _live_ commitment, not - resident size: the Go collector lets the heap reach roughly twice - the live set before collecting, with transient parse garbage on top - of it. The independent review of this endpoint fired 18 adversarial - requests at an idle guard and measured a peak `HeapAlloc` of - 392 MB. **Provision on the order of 400 MB**, not for the 203 MB - itemised here and not for the hashing budget alone. +1. **Credentials are verified first, and only a failed attempt spends budget.** + A correct password is never rate-limited, whatever the counters hold. This is + what guarantees the admin UI stays reachable. +2. **Failures are counted per (client bucket, submitted username)**, five per + minute, after which further _failures_ from that pair are answered `429` with + a `Retry-After`. That `429` is a label on the response, not a gate in front + of the work: the credential check has already run by the time the counter is + consulted, so a throttled client's guess is still evaluated. See the guessing + rate below. A successful login clears the counter, so mistyping a few times + and then getting it right leaves you unthrottled. Because the submitted + username is attacker-controlled, at most 1024 username counters and 1024 + fallback address counters are tracked; past the first cap failures fall back + to the address counter, and past both they are answered as throttled without + being recorded. Total tracked state is under half a megabyte and does not + grow with the number of usernames an attacker invents. +3. **Concurrent password verifications are capped at two, and the queue for them + at 16.** Verifying before counting means every login request costs an + Argon2id hash, and Argon2id here is 64 MB per hash — two slots is a 128 MB + ceiling on password hashing. Every endpoint that hashes a password takes a + slot, including the password-change endpoint, which holds one across both the + verification and the new hash. A request that waits five seconds without + getting a slot is answered `503 Service Unavailable` and no hash is computed + for it. The wait alone does not bound memory, only how long one request holds + some, so the number of waiters is capped as well. Size the queue from what a + parked waiter actually retains, not from the 1 MB body cap: that caps the raw + body read, while the body-cap, CSRF and form-parsing middleware all run + before the guard, so a waiter holds its parsed form plus its request header + block for the whole wait. Measured on the pinned Go 1.26.1 toolchain, as the + heap delta with 64 waiters parked in the handler, an ordinary two-field login + form retains ~0 MB, a 1 MB urlencoded body at Go's 10,000-parameter parse cap + retains 2.82 MB (3.09 MB with `%41` escapes), and the ~0.9 MB of headers the + 1 MB header cap allows takes it to **4.18 MB** — the retained parse and the + headers dominate, not the raw body. So the cap is 16 waiters: 16 x 4.18 MB is + about 67 MB of committed queue memory, and two slots drain a full 16-deep + queue in roughly 0.6 s, far inside the five-second deadline. A request + arriving past the cap is shed with `503` immediately instead of joining the + queue. **Peak commitment for the endpoint is therefore about 203 MB**: 128 MB + of Argon2id, plus the 18 requests holding a parsed form — 16 queued and the 2 + being hashed — at about 75 MB. That 203 MB is _live_ commitment, not resident + size: the Go collector lets the heap reach roughly twice the live set before + collecting, with transient parse garbage on top of it. The independent review + of this endpoint fired 18 adversarial requests at an idle guard and measured + a peak `HeapAlloc` of 392 MB. **Provision on the order of 400 MB**, not for + the 203 MB itemised here and not for the hashing budget alone. -An unknown username is verified against a dummy hash rather than -rejected early, so a nonexistent account costs the same time as a real -one and the response cannot be used to enumerate usernames. +An unknown username is verified against a dummy hash rather than rejected early, +so a nonexistent account costs the same time as a real one and the response +cannot be used to enumerate usernames. -**This raises online guessing throughput by about 300x, and that is -the trade.** Because the credential check always precedes the counter, -what bounds online brute force is the semaphore, not the failure -counter. Two slots at the cost of one Argon2id verification is on the -order of **27 guesses per second, about 2.3 million per day**, against -5 per minute under the pre-emptive limiter this replaced. Treat that -figure as a lower bound rather than a ceiling: it was measured with -Go's race detector enabled, so real hardware verifies faster and -guesses faster. Choose the admin password to survive millions of -online guesses per day — a long random passphrase, not a memorable -one. Rate-limiting `POST /pages/login` at the reverse proxy, where the -real client address is visible, is the way to put a cheaper bound back -on top. +**This raises online guessing throughput by about 300x, and that is the trade.** +Because the credential check always precedes the counter, what bounds online +brute force is the semaphore, not the failure counter. Two slots at the cost of +one Argon2id verification is on the order of **27 guesses per second, about 2.3 +million per day**, against 5 per minute under the pre-emptive limiter this +replaced. Treat that figure as a lower bound rather than a ceiling: it was +measured with Go's race detector enabled, so real hardware verifies faster and +guesses faster. Choose the admin password to survive millions of online guesses +per day — a long random passphrase, not a memorable one. Rate-limiting +`POST /pages/login` at the reverse proxy, where the real client address is +visible, is the way to put a cheaper bound back on top. -The residual exposure is a bounded, self-clearing loss of login -**availability** — not merely of latency. A flood can keep both -verification slots busy, and a request that neither gets a slot within -five seconds nor finds room in the queue is answered `503`. Above -roughly 27 requests per second the operator is not served slowly, it -is shed: its chance per attempt is about the ratio of service rate to -flood rate, so at 400 requests per second it is roughly one attempt in -fourteen. A sufficiently determined flood still denies login for as -long as it runs. +The residual exposure is a bounded, self-clearing loss of login **availability** +— not merely of latency. A flood can keep both verification slots busy, and a +request that neither gets a slot within five seconds nor finds room in the queue +is answered `503`. Above roughly 27 requests per second the operator is not +served slowly, it is shed: its chance per attempt is about the ratio of service +rate to flood rate, so at 400 requests per second it is roughly one attempt in +fourteen. A sufficiently determined flood still denies login for as long as it +runs. -What changed is the price and the aftermath. Denying login used to -cost an attacker 0.08 requests per second from anywhere; it now costs -30 or more sustained, about 400 times as much. Nothing accumulates -while the flood runs, nothing needs resetting when it stops, and the -operator's correct password succeeds on the first attempt afterwards. -Restarting the service is **not** a remedy: a restart clears the -failure counters, which are not what is saturated, and the flood -re-fills both verification slots on its first two requests. The -remedies are to block the source at the reverse proxy, or to -rate-limit `POST /pages/login` there — the one place a limit can be -applied without reintroducing the lockout, because the proxy sees the -real client address. `TRUSTED_PROXIES` does not stop the saturation. -The flood's source is in the `clientIP` field of webhooker's access -log while `TRUSTED_PROXIES` covers the proxy, and in the proxy's own -access log either way (see [Trusted proxies](#trusted-proxies)). +What changed is the price and the aftermath. Denying login used to cost an +attacker 0.08 requests per second from anywhere; it now costs 30 or more +sustained, about 400 times as much. Nothing accumulates while the flood runs, +nothing needs resetting when it stops, and the operator's correct password +succeeds on the first attempt afterwards. Restarting the service is **not** a +remedy: a restart clears the failure counters, which are not what is saturated, +and the flood re-fills both verification slots on its first two requests. The +remedies are to block the source at the reverse proxy, or to rate-limit +`POST /pages/login` there — the one place a limit can be applied without +reintroducing the lockout, because the proxy sees the real client address. +`TRUSTED_PROXIES` does not stop the saturation. The flood's source is in the +`clientIP` field of webhooker's access log while `TRUSTED_PROXIES` covers the +proxy, and in the proxy's own access log either way (see +[Trusted proxies](#trusted-proxies)). -Finer-grained per-webhook rate limits (configured in the web UI and -enforced in the webhook handler) can layer on top of this env-level -abuse limit later; they are tracked as future work. +Finer-grained per-webhook rate limits (configured in the web UI and enforced in +the webhook handler) can layer on top of this env-level abuse limit later; they +are tracked as future work. ### API Endpoints #### Public Endpoints -| Method | Path | Description | -| ------ | --------------------------- | ----------- | -| `GET` | `/` | Root redirect, 303 (authenticated → `/hooks`, unauthenticated → `/pages/login`) | -| `GET` | `/.well-known/healthcheck` | Health check (JSON: `status`, `now`, `uptimeSeconds`, `uptimeHuman`, `version`, `appname`) | -| `GET`, `HEAD` | `/s/*` | Static file serving (embedded CSS, JS). `GET` and `HEAD` only — `POST`, `PUT`, `PATCH`, `DELETE`, `OPTIONS`, `TRACE` and `CONNECT` are answered `405 Method Not Allowed` with `Allow: GET, HEAD`. Any other method (such as `PROPFIND`) is refused by chi before it reaches this route, and gets `405` without an `Allow` header. Pinned by `TestStaticServesOnlyGetAndHead` | -| `POST` | `/h/{uuid}` | Webhook receiver endpoint. `POST` only — every other method is answered `405 Method Not Allowed` with `Allow: POST`. Rate limited (see [Rate Limiting](#rate-limiting)) | +| Method | Path | Description | +| ------------- | -------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `GET` | `/` | Root redirect, 303 (authenticated → `/hooks`, unauthenticated → `/pages/login`) | +| `GET` | `/.well-known/healthcheck` | Health check (JSON: `status`, `now`, `uptimeSeconds`, `uptimeHuman`, `version`, `appname`) | +| `GET`, `HEAD` | `/s/*` | Static file serving (embedded CSS, JS). `GET` and `HEAD` only — `POST`, `PUT`, `PATCH`, `DELETE`, `OPTIONS`, `TRACE` and `CONNECT` are answered `405 Method Not Allowed` with `Allow: GET, HEAD`. Any other method (such as `PROPFIND`) is refused by chi before it reaches this route, and gets `405` without an `Allow` header. Pinned by `TestStaticServesOnlyGetAndHead` | +| `POST` | `/h/{uuid}` | Webhook receiver endpoint. `POST` only — every other method is answered `405 Method Not Allowed` with `Allow: POST`. Rate limited (see [Rate Limiting](#rate-limiting)) | #### Authentication Endpoints -| Method | Path | Description | -| ------ | --------------- | ----------- | -| `GET` | `/pages/login` | Sign-in page (not rate limited). Its `next` parameter names the page to return to after signing in; anything but a path on this site is replaced with `/` | +| Method | Path | Description | +| ------ | --------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `GET` | `/pages/login` | Sign-in page (not rate limited). Its `next` parameter names the page to return to after signing in; anything but a path on this site is replaced with `/` | | `POST` | `/pages/login` | Sign-in form submission. On success, redirects to the form's `next` when it is a path on this site, otherwise to `/`. Credentials are verified before any limit is consulted, so a correct password is never throttled; 5 FAILED attempts per minute per bucket per submitted username, then `429`. `503` if no verification slot frees up within 5s, or immediately if 16 requests are already queued for one (see [Rate Limiting](#rate-limiting)) | -| `POST` | `/pages/logout` | Sign out (destroys session) | +| `POST` | `/pages/logout` | Sign out (destroys session) | #### 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 -returns to the page that was asked for. +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 returns to the +page that was asked for. -| Method | Path | Description | -| ------ | ------------------------ | ----------- | -| `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) | -| `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/new` | Create webhook form | -| `POST` | `/hooks/new` | Create webhook submission | -| `GET` | `/hook/{id}` | Webhook detail view | -| `GET` | `/hook/{id}/edit` | Edit webhook form | -| `POST` | `/hook/{id}/edit` | Edit webhook submission | -| `POST` | `/hook/{id}/delete` | Delete webhook | -| `GET` | `/hook/{id}/events` | Full Event Log. `?show=failed` lists only the events with a failed delivery, and `?show=pending` only those with a delivery pending or retrying | -| `GET` | `/hook/{id}/events/{eventID}` | One event's own page: its details, the entrypoint it arrived at (for a resubmitted copy, the one the request it copies arrived at), its request headers, its whole body and every delivery of it | -| `GET` | `/hook/{id}/events/{eventID}/body` | Download an event's stored body. The pages show a body as text, cut at 32 KiB in the recent events and the event log, and leave a binary one out, so this is the only route that serves the stored bytes; it is offered wherever a body is cut or binary | -| `POST` | `/hook/{id}/deliveries/{deliveryID}/replay` | Replay a finished delivery: creates a new delivery for the same event against the target's current configuration (30 per minute per bucket, then `429`) | -| `POST` | `/hook/{id}/events/{eventID}/resubmit` | Resubmit a stored event: creates a new event copying it and fans that out to every currently active target (30 per minute per bucket, then `429`) | -| `POST` | `/hook/{id}/entrypoints` | Add entrypoint to webhook | -| `POST` | `/hook/{id}/entrypoints/{entrypointID}/edit` | Change an entrypoint's description; its URL stays the same | -| `POST` | `/hook/{id}/entrypoints/{entrypointID}/delete` | Delete an entrypoint | -| `POST` | `/hook/{id}/entrypoints/{entrypointID}/toggle` | Enable or disable an entrypoint | -| `POST` | `/hook/{id}/targets` | Add target to webhook | -| `GET` | `/hook/{id}/targets/{targetID}/edit` | Edit target form. The one page that renders a target's destination URL and header values in full, rather than masked | -| `POST` | `/hook/{id}/targets/{targetID}/edit` | Edit target submission | -| `GET` | `/hook/{id}/targets/{targetID}/download` | Download a `database` target's archive as one gzipped JSON file. See [Database Architecture](#database-architecture) | -| `POST` | `/hook/{id}/targets/{targetID}/delete` | Delete a target | -| `POST` | `/hook/{id}/targets/{targetID}/toggle` | Enable or disable a target | +| Method | Path | Description | +| ------ | ---------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `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) | +| `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/new` | Create webhook form | +| `POST` | `/hooks/new` | Create webhook submission | +| `GET` | `/hook/{id}` | Webhook detail view | +| `GET` | `/hook/{id}/edit` | Edit webhook form | +| `POST` | `/hook/{id}/edit` | Edit webhook submission | +| `POST` | `/hook/{id}/delete` | Delete webhook | +| `GET` | `/hook/{id}/events` | Full Event Log. `?show=failed` lists only the events with a failed delivery, and `?show=pending` only those with a delivery pending or retrying | +| `GET` | `/hook/{id}/events/{eventID}` | One event's own page: its details, the entrypoint it arrived at (for a resubmitted copy, the one the request it copies arrived at), its request headers, its whole body and every delivery of it | +| `GET` | `/hook/{id}/events/{eventID}/body` | Download an event's stored body. The pages show a body as text, cut at 32 KiB in the recent events and the event log, and leave a binary one out, so this is the only route that serves the stored bytes; it is offered wherever a body is cut or binary | +| `POST` | `/hook/{id}/deliveries/{deliveryID}/replay` | Replay a finished delivery: creates a new delivery for the same event against the target's current configuration (30 per minute per bucket, then `429`) | +| `POST` | `/hook/{id}/events/{eventID}/resubmit` | Resubmit a stored event: creates a new event copying it and fans that out to every currently active target (30 per minute per bucket, then `429`) | +| `POST` | `/hook/{id}/entrypoints` | Add entrypoint to webhook | +| `POST` | `/hook/{id}/entrypoints/{entrypointID}/edit` | Change an entrypoint's description; its URL stays the same | +| `POST` | `/hook/{id}/entrypoints/{entrypointID}/delete` | Delete an entrypoint | +| `POST` | `/hook/{id}/entrypoints/{entrypointID}/toggle` | Enable or disable an entrypoint | +| `POST` | `/hook/{id}/targets` | Add target to webhook | +| `GET` | `/hook/{id}/targets/{targetID}/edit` | Edit target form. The one page that renders a target's destination URL and header values in full, rather than masked | +| `POST` | `/hook/{id}/targets/{targetID}/edit` | Edit target submission | +| `GET` | `/hook/{id}/targets/{targetID}/download` | Download a `database` target's archive as one gzipped JSON file. See [Database Architecture](#database-architecture) | +| `POST` | `/hook/{id}/targets/{targetID}/delete` | Delete a target | +| `POST` | `/hook/{id}/targets/{targetID}/toggle` | Enable or disable a target | #### Infrastructure Endpoints -| Method | Path | Description | -| ------ | ---------- | ----------- | +| Method | Path | Description | +| ------ | ---------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `GET` | `/metrics` | Prometheus metrics, behind basic auth. The route is registered only when `METRICS_USERNAME` and `METRICS_PASSWORD` are both set; with neither set it does not exist and returns 404, and with only one set the process refuses to start | #### API (Planned) -| Method | Path | Description | -| -------- | ------------------------------ | ----------- | -| `GET` | `/api/v1/webhooks` | List webhooks | -| `POST` | `/api/v1/webhooks` | Create webhook | -| `GET` | `/api/v1/webhooks/{id}` | Get webhook details | -| `PUT` | `/api/v1/webhooks/{id}` | Update webhook | -| `DELETE` | `/api/v1/webhooks/{id}` | Delete webhook | -| `GET` | `/api/v1/webhooks/{id}/events` | List events for webhook | -| `POST` | `/api/v1/events/{id}/redeliver`| Redeliver an event | +| Method | Path | Description | +| -------- | ------------------------------- | ----------------------- | +| `GET` | `/api/v1/webhooks` | List webhooks | +| `POST` | `/api/v1/webhooks` | Create webhook | +| `GET` | `/api/v1/webhooks/{id}` | Get webhook details | +| `PUT` | `/api/v1/webhooks/{id}` | Update webhook | +| `DELETE` | `/api/v1/webhooks/{id}` | Delete webhook | +| `GET` | `/api/v1/webhooks/{id}/events` | List events for webhook | +| `POST` | `/api/v1/events/{id}/redeliver` | Redeliver an event | -None of these exist yet. `/api/v1` is mounted with no routes, so every -path under it returns 404 today. API authentication will use API keys -passed via `Authorization: Bearer ` header; no Bearer middleware -is implemented either. +None of these exist yet. `/api/v1` is mounted with no routes, so every path +under it returns 404 today. API authentication will use API keys passed via +`Authorization: Bearer ` header; no Bearer middleware is implemented +either. ### Package Layout -All application code lives under `internal/` to prevent external -imports. The entry point is `cmd/webhooker/main.go`. +All application code lives under `internal/` to prevent external imports. The +entry point is `cmd/webhooker/main.go`. ``` webhooker/ @@ -3267,13 +3057,14 @@ webhooker/ │ └── 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 -├── Dockerfile # Stages: lint, stylesheet, JavaScript lint, test+build, Alpine runtime +├── Dockerfile # Stages: lint, stylesheet, JavaScript lint, Markdown, test+build, Alpine runtime ├── Dockerfile.lint # Lint-only image built by script/lint ├── Dockerfile.browser # Browser test image built by script/test-browser ├── Makefile # 13 of 19 targets shim script/; 6 are inline ├── go.mod / go.sum -├── package.json / yarn.lock # ESLint, pinned, for the JavaScript lint stage +├── package.json / yarn.lock # ESLint and prettier, pinned, for the JavaScript lint and Markdown stages ├── eslint.config.mjs # ESLint configuration for static/js/ +├── .prettierrc # prettier settings for the Markdown └── .golangci.yml # golangci-lint configuration ``` @@ -3284,10 +3075,8 @@ Components are wired via Uber fx in this order: 1. `globals.New` — Build-time variables (appname, version, arch) 2. `logger.New` — Structured logging (slog with TTY detection) 3. `config.New` — Configuration loading (environment variables) -4. `database.New` — Main SQLite connection, config migrations, admin - user seed -5. `database.NewWebhookDBManager` — Per-webhook event database - lifecycle manager +4. `database.New` — Main SQLite connection, config migrations, admin user seed +5. `database.NewWebhookDBManager` — Per-webhook event database lifecycle manager 6. `database.NewRetentionReaper` — Per-webhook event retention sweep 7. `healthcheck.New` — Health check service 8. `session.New` — Cookie-based session manager (key from database) @@ -3298,50 +3087,48 @@ Components are wired via Uber fx in this order: 13. `delivery.New` — Event-driven delivery engine 14. `delivery.NewArchiveSweeper` — Periodic pruning of idle archives 15. `delivery.Engine` → `delivery.Notifier` — interface bridge -16. `delivery.Engine` → `delivery.Archives` — interface bridge so - deleting or renaming a webhook or target reaches its archive files +16. `delivery.Engine` → `delivery.Archives` — interface bridge so deleting or + renaming a webhook or target reaches its archive files 17. `server.New` — HTTP server and router -The server starts via `fx.Invoke(func(*server.Server, *delivery.Engine, -*database.RetentionReaper, *delivery.ArchiveSweeper) {})`, which -triggers the fx lifecycle hooks in dependency order. The -`delivery.Notifier` interface allows the webhook handler to send -self-contained `delivery.Task` slices to the engine without a direct -package dependency. Each task carries all target config and event data -inline (for bodies under 16 KiB, `delivery.MaxInlineBodySize`), so the -engine can deliver without reading from any database — it only writes -to record results. +The server starts via +`fx.Invoke(func(*server.Server, *delivery.Engine, *database.RetentionReaper, *delivery.ArchiveSweeper) {})`, +which triggers the fx lifecycle hooks in dependency order. The +`delivery.Notifier` interface allows the webhook handler to send self-contained +`delivery.Task` slices to the engine without a direct package dependency. Each +task carries all target config and event data inline (for bodies under 16 KiB, +`delivery.MaxInlineBodySize`), so the engine can deliver without reading from +any database — it only writes to record results. ### Middleware Stack 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, X-Content-Type-Options, X-Frame-Options, CSP, Referrer-Policy, +2. **SecurityHeaders** — Production security headers on every response (HSTS, + X-Content-Type-Options, X-Frame-Options, CSP, Referrer-Policy, Permissions-Policy) -3. **Logging** — Structured request logging (method, URL, status, - latency, remote IP, client IP, user agent, request ID) +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 `METRICS_PASSWORD` are both set) 5. **CORS** — Cross-origin resource sharing headers 6. **Timeout** — 60-second request timeout -7. **Recoverer** — Panic recovery: one `ERROR` record through - `internal/logger` and a `500` -8. **Sentry** — Error reporting to Sentry (if `SENTRY_DSN` is set; - configured with `Repanic: true` so panics still reach Recoverer) +7. **Recoverer** — Panic recovery: one `ERROR` record through `internal/logger` + and a `500` +8. **Sentry** — Error reporting to Sentry (if `SENTRY_DSN` is set; configured + with `Repanic: true` so panics still reach Recoverer) -Recoverer sits seventh rather than first, and both neighbours are the -reason. It runs **inside** everything that observes the response, so -the `500` it writes for a panicking handler is the status the access -log records and the metrics count; registered first, as chi's own -`middleware.Recoverer` was, the same request was logged as a `200` that -the client never received. It runs **outside** the Sentry handler, so -`Repanic: true` has something to re-raise into: an operator with -`SENTRY_DSN` set keeps the report, and one without it now gets the -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 -more than set a header or start a timer. +Recoverer sits seventh rather than first, and both neighbours are the reason. It +runs **inside** everything that observes the response, so the `500` it writes +for a panicking handler is the status the access log records and the metrics +count; registered first, as chi's own `middleware.Recoverer` was, the same +request was logged as a `200` that the client never received. It runs +**outside** the Sentry handler, so `Repanic: true` has something to re-raise +into: an operator with `SENTRY_DSN` set keeps the report, and one without it now +gets the 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 more than +set a header or start a timer. Each admin page route group (`/pages`, `/user/*`, `/settings`, `/hooks`, `/hook/*`) starts with its own **Recoverer** and, if `SENTRY_DSN` is set, its @@ -3349,358 +3136,334 @@ own **Sentry** error reporting. That Recoverer answers a panic with the `500` error page in the normal layout; the global one keeps the plain-text `500` for every other route. -Additionally, form endpoints (`/pages`, `/user/*`, `/settings`, -`/hooks`, `/hook/*`) apply a **MaxBodySize** middleware that limits -POST/PUT/PATCH request bodies to 1 MB. It is registered ahead of the -CSRF middleware in every one of those route groups, because -gorilla/csrf parses the form; if the cap were installed after it, form -parsing would run under net/http's 10 MB default and the 1 MB limit -would never apply. A request that declares a `Content-Length` over the -limit is answered with `413 Request Entity Too Large` without its body -being read and without reaching CSRF, the route group's remaining -middleware, or the handler. It is not rejected before *any* other -middleware, though: the global entries listed above all run first, so -such a request is still logged and given the security headers — and -counted in the metrics, on a deployment where the `/metrics` -credentials are set and the Metrics middleware is therefore registered -at all. The -rejection itself is logged at `WARN` with the method, path and -declared length. A chunked request, or -one that lies about its length, is hard-capped by -`http.MaxBytesReader` and fails downstream at form-parse time. +Additionally, form endpoints (`/pages`, `/user/*`, `/settings`, `/hooks`, +`/hook/*`) apply a **MaxBodySize** middleware that limits POST/PUT/PATCH request +bodies to 1 MB. It is registered ahead of the CSRF middleware in every one of +those route groups, because gorilla/csrf parses the form; if the cap were +installed after it, form parsing would run under net/http's 10 MB default and +the 1 MB limit would never apply. A request that declares a `Content-Length` +over the limit is answered with `413 Request Entity Too Large` without its body +being read and without reaching CSRF, the route group's remaining middleware, or +the handler. It is not rejected before _any_ other middleware, though: the +global entries listed above all run first, so such a request is still logged and +given the security headers — and counted in the metrics, on a deployment where +the `/metrics` credentials are set and the Metrics middleware is therefore +registered at all. The rejection itself is logged at `WARN` with the method, +path and declared length. A chunked request, or one that lies about its length, +is hard-capped by `http.MaxBytesReader` and fails downstream at form-parse time. Those same five route groups then apply **CSRF** and **NoCache** -(`Cache-Control: no-store`, `Pragma: no-cache`), and every group except -`/pages` applies **RequireAuth**. The rate limiters are per-route -rather than global: **PasswordChangeRateLimit** on -`/user/{username}/password` and **ReceiverRateLimit** on -`/h/{uuid}`. There is deliberately none on `/pages/login` — that -endpoint counts failures inside the handler, after the credential -check, see [The login endpoint](#the-login-endpoint). +(`Cache-Control: no-store`, `Pragma: no-cache`), and every group except `/pages` +applies **RequireAuth**. The rate limiters are per-route rather than global: +**PasswordChangeRateLimit** on `/user/{username}/password` and +**ReceiverRateLimit** on `/h/{uuid}`. There is deliberately none on +`/pages/login` — that endpoint counts failures inside the handler, after the +credential check, see [The login endpoint](#the-login-endpoint). ### Authentication -- **Webhook receiver:** the entrypoint UUID in the URL, and nothing - else. No shared secret, no HMAC signature, no token header, and none - will be added — see +- **Webhook receiver:** the entrypoint UUID in the URL, and nothing else. No + shared secret, no HMAC signature, no token header, and none will be added — + see [The entrypoint URL is the authentication secret](#the-entrypoint-url-is-the-authentication-secret). -- **Web UI:** Cookie-based sessions using gorilla/sessions with - encrypted cookies. Sessions are configured with HttpOnly, SameSite - Lax, and Secure whenever the request is on TLS — the flag follows the - request's transport, not the environment. Absolute session lifetime - is 7 days, with a sliding idle timeout on top of it (see - [Sessions](#sessions)). -- **API (planned):** API key authentication via `Authorization: Bearer` - header. API keys are stored per-user with usage tracking - (`last_used_at`). +- **Web UI:** Cookie-based sessions using gorilla/sessions with encrypted + cookies. Sessions are configured with HttpOnly, SameSite Lax, and Secure + whenever the request is on TLS — the flag follows the request's transport, not + the environment. Absolute session lifetime is 7 days, with a sliding idle + timeout on top of it (see [Sessions](#sessions)). +- **API (planned):** API key authentication via `Authorization: Bearer` header. + API keys are stored per-user with usage tracking (`last_used_at`). - **Metrics:** Basic authentication protecting the `/metrics` endpoint. -- **Recovery:** `webhooker resetpw ` on a stopped deployment - is the only way back into an account whose password was lost (see +- **Recovery:** `webhooker resetpw ` on a stopped deployment is the + only way back into an account whose password was lost (see [Recovering a lost admin password](#recovering-a-lost-admin-password)). ### Security - Passwords hashed with Argon2id (64 MB memory cost) -- Session cookies are HttpOnly, SameSite Lax, and Secure on any request - that arrived over TLS (directly or through a reverse proxy reporting - it), decided per-request by `internal/reqtls.IsTLS` rather than by the - configured environment +- Session cookies are HttpOnly, SameSite Lax, and Secure on any request that + arrived over TLS (directly or through a reverse proxy reporting it), decided + per-request by `internal/reqtls.IsTLS` rather than by the configured + environment - Session regeneration on login to prevent session fixation attacks -- Session key is a 32-byte value auto-generated on first startup and - stored in the database +- Session key is a 32-byte value auto-generated on first startup and stored in + the database - Production security headers on all responses: HSTS, X-Content-Type-Options - (`nosniff`), X-Frame-Options (`DENY`), Content-Security-Policy, Referrer-Policy, - and Permissions-Policy -- 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 state-changing forms (cookie-based double-submit tokens with - HMAC authentication). Applied to `/pages`, `/hooks`, `/hook`, - `/settings`, and `/user` routes. Excluded from `/h` (inbound webhook - POSTs) and `/api` (stateless API). The middleware detects TLS - per-request through `internal/reqtls.IsTLS` — the same predicate the - session cookie uses — to set appropriate cookie security flags and - Origin/Referer validation mode -- **The entrypoint URL is the receiver's only credential.** Nothing - about an inbound request is verified; possession of the UUID - authorises submission, and no shared secret or signature check will - be added alongside it (see + (`nosniff`), X-Frame-Options (`DENY`), Content-Security-Policy, + Referrer-Policy, and Permissions-Policy +- 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 + state-changing forms (cookie-based double-submit tokens with HMAC + authentication). Applied to `/pages`, `/hooks`, `/hook`, `/settings`, and + `/user` routes. Excluded from `/h` (inbound webhook POSTs) and `/api` + (stateless API). The middleware detects TLS per-request through + `internal/reqtls.IsTLS` — the same predicate the session cookie uses — to set + appropriate cookie security flags and Origin/Referer validation mode +- **The entrypoint URL is the receiver's only credential.** Nothing about an + inbound request is verified; possession of the UUID authorises submission, and + no shared secret or signature check will be added alongside it (see [The entrypoint URL is the authentication secret](#the-entrypoint-url-is-the-authentication-secret)) -- **SSRF prevention** for HTTP delivery targets: private/reserved IP - ranges (RFC 1918, loopback, link-local, cloud metadata) are blocked - both at target creation time (URL validation) and at delivery time - (custom HTTP transport with SSRF-safe dialer that validates resolved - IPs before connecting, preventing DNS rebinding attacks). Both paths - route through a single decision function, so they cannot disagree - about a destination. An operator can permit specific blocks with - [`ALLOWED_EGRESS_CIDRS`](#allowing-egress-to-your-own-network); the - guard cannot be switched off, and link-local, the unspecified - addresses `0.0.0.0` and `::`, and a - [pinned set](#allowing-egress-to-your-own-network) of known cloud - metadata endpoints — several of which are ULAs outside link-local — - stay blocked whatever is listed, though listing `0.0.0.0/0` or - `::/0` does open every other private range -- **Login limiting is inverted, deliberately.** The login `POST` has - no pre-emptive rate limiter in front of it. Credentials are - verified first and only a _failed_ attempt spends budget, so a - correct password is never throttled and no flood of wrong ones can - deny the operator the only administrative path. Failures are - counted per (bucket, submitted username), five per minute, after - which further failures are answered `429` with a `Retry-After`. - What bounds brute force is not that counter but the cap of two - concurrent Argon2id verifications: a throttled client's guess is - 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 -- **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 - valid session reaches it, and an operator throttled out of changing - a password can still log in. The bucket is per client IP only when - `TRUSTED_PROXIES` covers the reverse proxy; otherwise every client - shares one bucket, which costs precision rather than availability +- **SSRF prevention** for HTTP delivery targets: private/reserved IP ranges (RFC + 1918, loopback, link-local, cloud metadata) are blocked both at target + creation time (URL validation) and at delivery time (custom HTTP transport + with SSRF-safe dialer that validates resolved IPs before connecting, + preventing DNS rebinding attacks). Both paths route through a single decision + function, so they cannot disagree about a destination. An operator can permit + specific blocks with + [`ALLOWED_EGRESS_CIDRS`](#allowing-egress-to-your-own-network); the guard + cannot be switched off, and link-local, the unspecified addresses `0.0.0.0` + and `::`, and a [pinned set](#allowing-egress-to-your-own-network) of known + cloud metadata endpoints — several of which are ULAs outside link-local — stay + blocked whatever is listed, though listing `0.0.0.0/0` or `::/0` does open + every other private range +- **Login limiting is inverted, deliberately.** The login `POST` has no + pre-emptive rate limiter in front of it. Credentials are verified first and + only a _failed_ attempt spends budget, so a correct password is never + throttled and no flood of wrong ones can deny the operator the only + administrative path. Failures are counted per (bucket, submitted username), + five per minute, after which further failures are answered `429` with a + `Retry-After`. What bounds brute force is not that counter but the cap of two + concurrent Argon2id verifications: a throttled client's guess is 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 +- **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 valid session reaches it, and an operator + throttled out of changing a password can still log in. The bucket is per + client IP only when `TRUSTED_PROXIES` covers the reverse proxy; otherwise + every client shares one bucket, which costs precision rather than availability (see [Rate Limiting](#rate-limiting)) - Prometheus metrics behind basic auth -- Static assets embedded in binary (no filesystem access needed at - runtime) -- The app runs as the non-root `webhooker` user (UID 1000) in the - container. The image sets no `USER`, so these run as root: the - `ENTRYPOINT` script, which sets the data directory's owner and mode - before the app starts; the image's health check; and `docker exec`, - unless given `--user` +- Static assets embedded in binary (no filesystem access needed at runtime) +- The app runs as the non-root `webhooker` user (UID 1000) in the container. The + image sets no `USER`, so these run as root: the `ENTRYPOINT` script, which + sets the data directory's owner and mode before the app starts; the image's + health check; and `docker exec`, unless given `--user` - GORM soft deletes on every entity that carries `BaseModel`, which is all of - them but `Setting`, `EventTotals`, `TargetTotals` and `EntrypointTotals` - (data preserved for audit) + them but `Setting`, `EventTotals`, `TargetTotals` and `EntrypointTotals` (data + preserved for audit) ### Shutdown -On SIGINT or SIGTERM, fx runs the registered stop hooks in reverse -dependency order under a **5 second budget** (`fx.StopTimeout` in -`cmd/webhooker/main.go`). That budget covers the whole sequence, not -each hook. The order, read off the fx stop-hook log: +On SIGINT or SIGTERM, fx runs the registered stop hooks in reverse dependency +order under a **5 second budget** (`fx.StopTimeout` in `cmd/webhooker/main.go`). +That budget covers the whole sequence, not each hook. The order, read off the fx +stop-hook log: 1. `ArchiveSweeper` 2. `RetentionReaper` -3. `server` — the HTTP drain, bounded by `server.ShutdownTimeout` - (**3 seconds**) and by what the hooks before it left, then a Sentry - flush if `SENTRY_DSN` is set -4. `delivery.Engine` — waits for its workers, then closes the archive - databases +3. `server` — the HTTP drain, bounded by `server.ShutdownTimeout` (**3 + seconds**) and by what the hooks before it left, then a Sentry flush if + `SENTRY_DSN` is set +4. `delivery.Engine` — waits for its workers, then closes the archive databases 5. `healthcheck` 6. `WebhookDBManager` 7. the database close -The two components that can realistically hold the budget run -first: a retention sweep or an archive prune caught mid-tick each -waits on its `WaitGroup` bounded by the stop context, so a wedge -there consumes the 5 seconds before the HTTP server hook is ever -entered. The hooks after the server are microsecond-scale in normal -operation. +The two components that can realistically hold the budget run first: a retention +sweep or an archive prune caught mid-tick each waits on its `WaitGroup` bounded +by the stop context, so a wedge there consumes the 5 seconds before the HTTP +server hook is ever entered. The hooks after the server are microsecond-scale in +normal operation. -The HTTP drain budget is deliberately **shorter** than the sequence -budget. Were the two equal, a drain that used its whole budget would -exhaust the sequence budget at the instant it finished, and every -later hook — the delivery engine, the healthcheck, the webhook DB -manager and the database close — would be skipped in exactly the -case where the drain mattered. 3 seconds leaves 2 seconds -(`server.TailHookReserve`) for the tail. The reserve is that -remainder, not a figure sized to the tail, which takes about a -millisecond. +The HTTP drain budget is deliberately **shorter** than the sequence budget. Were +the two equal, a drain that used its whole budget would exhaust the sequence +budget at the instant it finished, and every later hook — the delivery engine, +the healthcheck, the webhook DB manager and the database close — would be +skipped in exactly the case where the drain mattered. 3 seconds leaves 2 seconds +(`server.TailHookReserve`) for the tail. The reserve is that remainder, not a +figure sized to the tail, which takes about a millisecond. -That reserve belongs to the tail hooks, not to the server hook, and -the server hook could take it in two ways. The hooks before it may -already have spent part of the budget, so a full 3-second drain -would come out of the reserve; the drain is therefore also bounded -by whatever is left on the stop context minus the reserve. And the -Sentry flush runs after the drain **inside the same hook**, and -`sentry.Flush` takes a bare duration and honours no context, so an -unreachable Sentry endpoint would add its own timeout on top of a -full-length drain and consume the whole sequence budget by itself. -It is clamped the same way, and skipped when that leaves too little -to be worth attempting — so a full-length drain means Sentry events -are dropped rather than the database close being skipped. +That reserve belongs to the tail hooks, not to the server hook, and the server +hook could take it in two ways. The hooks before it may already have spent part +of the budget, so a full 3-second drain would come out of the reserve; the drain +is therefore also bounded by whatever is left on the stop context minus the +reserve. And the Sentry flush runs after the drain **inside the same hook**, and +`sentry.Flush` takes a bare duration and honours no context, so an unreachable +Sentry endpoint would add its own timeout on top of a full-length drain and +consume the whole sequence budget by itself. It is clamped the same way, and +skipped when that leaves too little to be worth attempting — so a full-length +drain means Sentry events are dropped rather than the database close being +skipped. -This does not make the database close unconditional. A slow -`ArchiveSweeper` or `RetentionReaper` is enough to cut the shutdown -short, not only one that consumes the whole budget: what they spend -comes out of the drain first, so after 2 seconds of theirs a request -still in flight gets 1 second to finish, and after 3 it gets none. -Past 3 seconds they spend the reserve itself, and one that takes the -whole budget skips every hook after it, the database close included. +This does not make the database close unconditional. A slow `ArchiveSweeper` or +`RetentionReaper` is enough to cut the shutdown short, not only one that +consumes the whole budget: what they spend comes out of the drain first, so +after 2 seconds of theirs a request still in flight gets 1 second to finish, and +after 3 it gets none. Past 3 seconds they spend the reserve itself, and one that +takes the whole budget skips every hook after it, the database close included. -The value is chosen to sit inside the container stop grace period. -Docker's default `docker stop` grace is 10 seconds and the Dockerfile -sets no `STOPSIGNAL` or grace override, so the process must be gone -before that. fx's own default is 15 seconds, which is past the grace: -the container would be SIGKILLed (exit 137) before the bound could -fire, and nothing that depends on it — including the -`shutdown timed out, goroutines still running` error log that tells -an operator a component is wedged — would ever be reached. +The value is chosen to sit inside the container stop grace period. Docker's +default `docker stop` grace is 10 seconds and the Dockerfile sets no +`STOPSIGNAL` or grace override, so the process must be gone before that. fx's +own default is 15 seconds, which is past the grace: the container would be +SIGKILLed (exit 137) before the bound could fire, and nothing that depends on it +— including the `shutdown timed out, goroutines still running` error log that +tells an operator a component is wedged — would ever be reached. Two operational consequences follow from bounding the sequence: -- **A wedged component aborts the rest of the shutdown.** fx checks - the stop context before each remaining hook and returns outright - once it has expired, skipping the hooks it has not reached. If the - first-stopped component consumes the whole budget, the later hooks - never run — **the database close among them**. SQLite is crash-safe, - so this is not corruption, but it is not a clean close either. -- **Lowering the grace below 5 seconds reintroduces the silent - truncation.** `docker stop --time`, Compose's `stop_grace_period`, - or Kubernetes' `terminationGracePeriodSeconds` set under 5 seconds - put SIGKILL back in front of the bound, and the process dies with - no shutdown diagnostics at all. Keep the deployment's grace above - the stop timeout. +- **A wedged component aborts the rest of the shutdown.** fx checks the stop + context before each remaining hook and returns outright once it has expired, + skipping the hooks it has not reached. If the first-stopped component consumes + the whole budget, the later hooks never run — **the database close among + them**. SQLite is crash-safe, so this is not corruption, but it is not a clean + close either. +- **Lowering the grace below 5 seconds reintroduces the silent truncation.** + `docker stop --time`, Compose's `stop_grace_period`, or Kubernetes' + `terminationGracePeriodSeconds` set under 5 seconds put SIGKILL back in front + of the bound, and the process dies with no shutdown diagnostics at all. Keep + the deployment's grace above the stop timeout. ### Linting -golangci-lint never runs on the host. `script/lint` builds -`Dockerfile.lint`, which copies the repo into the digest-pinned -golangci-lint image and lints as a build step, so a successful build is -a clean lint. A host binary would share one cache and one lock with -every other checkout on the machine, which has produced both invented -findings attributed to other worktrees and unearned passes. +golangci-lint never runs on the host. `script/lint` builds `Dockerfile.lint`, +which copies the repo into the digest-pinned golangci-lint image and lints as a +build step, so a successful build is a clean lint. A host binary would share one +cache and one lock with every other checkout on the machine, which has produced +both invented findings attributed to other worktrees and unearned passes. Three properties are load-bearing: -- `script/lint` passes `--no-cache-filter=lint`. Without it an unchanged - tree replays the lint layer from cache and the build exits 0 in under - a second having linted nothing. The `deps` stage stays cacheable, so - module downloads are not repeated. Invalidation is scoped to the one - stage; never prune the shared build cache. +- `script/lint` passes `--no-cache-filter=lint`. Without it an unchanged tree + replays the lint layer from cache and the build exits 0 in under a second + having linted nothing. The `deps` stage stays cacheable, so module downloads + are not repeated. Invalidation is scoped to the one stage; never prune the + shared build cache. - `script/lint` does not trust that flag. Docker silently ignores - `--no-cache-filter` for a stage name that does not match, so a stage - rename or a one-character typo would restore the cached false green - with no warning and a fast exit 0. The script therefore tees the - build output and treats a run as a pass only if golangci-lint's own - summary line (`N issues.` / `N issues:`) appears in it: no summary, - no lint, whatever the exit code says. -- Both lint steps use `RUN --network=none`. `golangci-lint config - verify` is documented as fetching its JSON schema over HTTPS, which - would be an unpinned remote dependency; the pinned image resolves the - schema without network access, and `--network=none` enforces that - instead of trusting it. Verify is worth keeping because - `golangci-lint run` silently ignores config keys it does not - recognize, so a typo would disable a setting with no warning. + `--no-cache-filter` for a stage name that does not match, so a stage rename or + a one-character typo would restore the cached false green with no warning and + a fast exit 0. The script therefore tees the build output and treats a run as + a pass only if golangci-lint's own summary line (`N issues.` / `N issues:`) + appears in it: no summary, no lint, whatever the exit code says. +- Both lint steps use `RUN --network=none`. `golangci-lint config verify` is + documented as fetching its JSON schema over HTTPS, which would be an unpinned + remote dependency; the pinned image resolves the schema without network + access, and `--network=none` enforces that instead of trusting it. Verify is + worth keeping because `golangci-lint run` silently ignores config keys it does + not recognize, so a typo would disable a setting with no warning. -ESLint never runs on the host either. It lints `static/js/` (not the -extracted Alpine.js) in the Dockerfile's `js-lint` stage, which -`script/lint` builds after `Dockerfile.lint` and the image build runs -before the builder stage. Its version is pinned in `package.json` and -every package's hash in `yarn.lock`. The `js-deps` stage before it -installs ESLint and stays cached until either file changes, so only the -lint step re-runs and ESLint is not downloaded again. +ESLint never runs on the host either. It lints `static/js/` (not the extracted +Alpine.js) in the Dockerfile's `js-lint` stage, which `script/lint` builds after +`Dockerfile.lint` and the image build runs before the builder stage. Its version +is pinned in `package.json` and every package's hash in `yarn.lock`. The +`js-deps` stage before it installs ESLint and stays cached until either file +changes, so only the lint step re-runs and ESLint is not downloaded again. `eslint.config.mjs` turns on the rules of the JavaScript styleguide `REPO_POLICIES.md` links to that a linter can check: `no-var` and -`prefer-const`. ESLint prints nothing on a pass, so `script/lint` has no -summary line to look for; it names the stage once for both `--target` -and `--no-cache-filter`, and `--target` fails on a name that matches no -stage. +`prefer-const`. ESLint prints nothing on a pass, so `script/lint` has no summary +line to look for; it names the stage once for both `--target` and +`--no-cache-filter`, and `--target` fails on a name that matches no stage. + +prettier formats the Markdown, and it never runs on the host either. It is +pinned in `package.json` and `yarn.lock` beside ESLint, installed by the same +`js-deps` stage, and reads its settings from `.prettierrc`. `make fmt` builds +the `markdown-output` stage and writes the formatted files back into the tree; +`make fmt-check` and the image build run the `markdown-check` stage, which fails +on any Markdown file prettier would change. ### Docker -The Dockerfile uses a multi-stage build. Each stage is pinned by -digest, and the lint and builder stages are separate images so the -linter's version is fixed independently of the compiler's: +The Dockerfile uses a multi-stage build. Each stage is pinned by digest, and the +lint and builder stages are separate images so the linter's version is fixed +independently of the compiler's: -1. **Lint stage** (`golangci/golangci-lint:v2.12.2`, Debian-based) — - installs `make`, downloads dependencies, copies the source, and runs - `make fmt-check`, then `script/assets` to extract Alpine.js from - `3p/`, then `golangci-lint config verify` and `golangci-lint run`, - both with `--network=none`. -2. **Stylesheet stages** (`debian:bookworm-slim`, with the Tailwind - standalone CLI pinned by version and sha256, one binary per - architecture) — generate `static/css/tailwind.css` from - `static/css/input.css` and the files its `@source` lines name. - `css-check` fails when the committed file differs from the generated - one, and `make css` writes the generated file out from `css-output` - (see [Stylesheet](#stylesheet)). -3. **JavaScript lint stages** (`node:24.21.0-alpine`, with yarn) — - `js-deps` installs ESLint from `yarn.lock` and `js-lint` runs it over +1. **Lint stage** (`golangci/golangci-lint:v2.12.2`, Debian-based) — downloads + dependencies, copies the source, and runs the `gofmt` check, then + `script/assets` to extract Alpine.js from `3p/`, then + `golangci-lint config verify` and `golangci-lint run`, both with + `--network=none`. +2. **Stylesheet stages** (`debian:bookworm-slim`, with the Tailwind standalone + CLI pinned by version and sha256, one binary per architecture) — generate + `static/css/tailwind.css` from `static/css/input.css` and the files its + `@source` lines name. `css-check` fails when the committed file differs from + the generated one, and `make css` writes the generated file out from + `css-output` (see [Stylesheet](#stylesheet)). +3. **JavaScript lint stages** (`node:24.21.0-alpine`, with yarn) — `js-deps` + installs ESLint and prettier from `yarn.lock` and `js-lint` runs ESLint over `static/js/` (see [Linting](#linting)). -4. **Builder stage** (`golang:1.26.1-bookworm`) — depends on the lint, - `css-check` and `js-lint` stages passing (it copies a file from - each), runs - `make test` and `make build` (both extract Alpine.js from `3p/` - first), and finally - rebuilds the binary with `CGO_ENABLED=1` and static linking so it - runs on musl. Both builds go through `make build`, the relink adding - its `-extldflags` via `GO_LDFLAGS`, so neither can drop the `-X` that - stamps the version. The version is the `VERSION` build arg if one is - given, otherwise derived from the `.git` in the context, and the - stage fails if a context with `.git` would stamp `unknown` (see +4. **Markdown stages** (on `js-deps`) — `markdown-check` runs prettier over the + Markdown and fails on any file it would change, and `make fmt` writes the + formatted files out from `markdown-output`. +5. **Builder stage** (`golang:1.26.1-bookworm`) — depends on the lint, + `css-check`, `js-lint` and `markdown-check` stages passing (it copies a file + from each), runs `make test` and `make build` (both extract Alpine.js from + `3p/` first), and finally rebuilds the binary with `CGO_ENABLED=1` and static + linking so it runs on musl. Both builds go through `make build`, the relink + adding its `-extldflags` via `GO_LDFLAGS`, so neither can drop the `-X` that + stamps the version. The version is the `VERSION` build arg if one is given, + otherwise derived from the `.git` in the context, and the stage fails if a + context with `.git` would stamp `unknown` (see [Version stamping](#version-stamping)). -5. **Runtime stage** (`alpine:3.21`) — copies the static binary and - `deploy/docker-entrypoint.sh`, creates the `/var/lib/webhooker` - directory for all SQLite databases, exposes port 8080, and includes - a health check against `/.well-known/healthcheck`. It sets no - `USER`: the `ENTRYPOINT` script starts as root, sets the data - directory's owner and mode, and runs the app as the non-root - `webhooker` user (UID 1000) through `su-exec`. +6. **Runtime stage** (`alpine:3.21`) — copies the static binary and + `deploy/docker-entrypoint.sh`, creates the `/var/lib/webhooker` directory for + all SQLite databases, exposes port 8080, and includes a health check against + `/.well-known/healthcheck`. It sets no `USER`: the `ENTRYPOINT` script starts + as root, sets the data directory's owner and mode, and runs the app as the + non-root `webhooker` user (UID 1000) through `su-exec`. -The lint stage invokes `golangci-lint` directly rather than `make lint`: -it is already the pinned linter image, and `make lint` builds -`Dockerfile.lint`, which would need a docker daemon inside this build. +The lint stage invokes `gofmt` and `golangci-lint` directly rather than +`make fmt-check` and `make lint`: it is already the pinned linter image, and +both targets build docker stages, which would need a docker daemon inside this +build. The lint and builder stages use Debian rather than Alpine because -`gorm.io/driver/sqlite` pulls in `mattn/go-sqlite3`, which needs CGO -and does not compile against musl. Only the final binary is statically -linked, which is what lets it run on the Alpine runtime image. +`gorm.io/driver/sqlite` pulls in `mattn/go-sqlite3`, which needs CGO and does +not compile against musl. Only the final binary is statically linked, which is +what lets it run on the Alpine runtime image. -`script/cibuild` — `docker build .` — is the CI gate: the checks run -inside the image, so a build that succeeds is a repo that is formatted, -linted, tested and compiled, with a current stylesheet. `script/lint` -also uses Docker (`Dockerfile.lint` and the `js-lint` stage, see Linting -above), so `make lint` and `make check` run the same pinned linter -versions the gate does; of -the steps `make check` runs, only `script/test` and `script/fmt-check` -run on the host. +`script/cibuild` — `docker build .` — is the CI gate: the checks run inside the +image, so a build that succeeds is a repo that is formatted, linted, tested and +compiled, with a current stylesheet. `script/lint` also uses Docker +(`Dockerfile.lint` and the `js-lint` stage, see Linting above), so `make lint` +and `make check` run the same pinned linter versions the gate does; of the steps +`make check` runs, only `script/test` and the `gofmt` check in +`script/fmt-check` run on the host. #### CI gate honesty -A layer cache lets `docker build .` exit 0 in seconds with the lint and -test stages replayed rather than executed, which would make a green -check meaningless. The `check` workflow therefore writes -`.ci-fingerprint` into the build context before building. Its value is -the hash of the commit being checked, so every commit, docs-only ones -and a squash merge whose tree matches an already-built branch included, -gets a new fingerprint, invalidates the `COPY . .` layer of every check -stage, and really runs `make fmt-check`, `golangci-lint`, the stylesheet -check, ESLint, `make test`, and `make build`. A run that reports success -ran them. +A layer cache lets `docker build .` exit 0 in seconds with the lint and test +stages replayed rather than executed, which would make a green check +meaningless. The `check` workflow therefore writes `.ci-fingerprint` into the +build context before building. Its value is the hash of the commit being +checked, so every commit, docs-only ones and a squash merge whose tree matches +an already-built branch included, gets a new fingerprint, invalidates the +`COPY . .` layer of every check stage, and really runs the `gofmt` check, +`golangci-lint`, the stylesheet check, ESLint, the Markdown check, `make test`, +and `make build`. A run that reports success ran them. The module download layer sits above `COPY . .` and stays cached. -A separate workflow step, run before the fingerprint is written, covers -a second way the gate lied: Gitea cancels an in-flight run when a newer -commit lands on the same branch and records that cancellation as a -`failure` status, so a commit nothing ever tested reads as a test -result. Cancellation is unconditional server-side for push events, so -the superseding run calls `script/ci-mark-superseded`, which rewrites -that exact status to `failure` / +A separate workflow step, run before the fingerprint is written, covers a second +way the gate lied: Gitea cancels an in-flight run when a newer commit lands on +the same branch and records that cancellation as a `failure` status, so a commit +nothing ever tested reads as a test result. Cancellation is unconditional +server-side for push events, so the superseding run calls +`script/ci-mark-superseded`, which rewrites that exact status to `failure` / `Superseded by a newer commit; never tested`. -The state stays `failure` on purpose: Gitea's combined status folds -`skipped` into `success`, so marking a never-tested commit `skipped` -made the status API report green for it, indistinguishable from a commit -that passed. Reading a commit's status on this repo therefore goes: +The state stays `failure` on purpose: Gitea's combined status folds `skipped` +into `success`, so marking a never-tested commit `skipped` made the status API +report green for it, indistinguishable from a commit that passed. Reading a +commit's status on this repo therefore goes: - `success` / `Successful in ...` — the checks ran and passed. - `failure` / `Failing after ...` — the checks ran and failed. - `failure` / `Superseded by a newer commit; never tested` — the run was - cancelled, by a newer push or by hand, and nothing was verified about - this commit. Test the commit itself before concluding anything about - it. + cancelled, by a newer push or by hand, and nothing was verified about this + commit. Test the commit itself before concluding anything about it. Genuine failures and successes are never touched, and no status is left -`pending`, which would block the commit indefinitely. The step derives -its context string from the workflow name, the job **id** and the event. -That is deliberately not byte-identical to Gitea's own rule, which uses -the job's display `name:` where the runner exports the id, so giving the -job a `name:` — or renaming the workflow — makes the derived context -stop matching. The step fails loudly when no status on the commit -carries that context, so no rename can silently disable the rewrite. +`pending`, which would block the commit indefinitely. The step derives its +context string from the workflow name, the job **id** and the event. That is +deliberately not byte-identical to Gitea's own rule, which uses the job's +display `name:` where the runner exports the id, so giving the job a `name:` — +or renaming the workflow — makes the derived context stop matching. The step +fails loudly when no status on the commit carries that context, so no rename can +silently disable the rewrite. ## TODO @@ -3713,5 +3476,3 @@ MIT ## Author [@sneak](https://sneak.berlin) - - diff --git a/TODO.md b/TODO.md index 1041e64..f99594f 100644 --- a/TODO.md +++ b/TODO.md @@ -2,403 +2,367 @@ One issue per unit of work, one branch and one PR per issue: -* ensure a tracked issue exists with a definition of done -* branch from `next` (never from `main`) -* do the work; open a PR based on `next` (never on `main`) -* pass an independent review, then the manager squash-merges into `next` -* push; nothing stays local-only +- ensure a tracked issue exists with a definition of done +- branch from `next` (never from `main`) +- do the work; open a PR based on `next` (never on `main`) +- pass an independent review, then the manager squash-merges into `next` +- push; nothing stays local-only -`next` is the branch for the next milestone and must stay green and -mergeable to `main` without notice. One `next` -> `main` PR accumulates -the milestone; releases are cut from `main` separately. +`next` is the branch for the next milestone and must stay green and mergeable to +`main` without notice. One `next` -> `main` PR accumulates the milestone; +releases are cut from `main` separately. -Issue branches do NOT touch this file — the manager maintains it on -`next`. Every branch editing `TODO.md` conflicts with every other -(#112). +Issue branches do NOT touch this file — the manager maintains it on `next`. +Every branch editing `TODO.md` conflicts with every other (#112). # Status The milestone (https://git.eeqj.de/sneak/webhooker/milestone/9) is the -authoritative list, and the only place to read a count or a state of -play from. This file records where the project is, not what is in -flight: a sentence whose truth depends on a branch being unmerged is -wrong the moment it merges, and this file has been wrong that way -before. +authoritative list, and the only place to read a count or a state of play from. +This file records where the project is, not what is in flight: a sentence whose +truth depends on a branch being unmerged is wrong the moment it merges, and this +file has been wrong that way before. The durability defect that held the tag has landed -(https://git.eeqj.de/sneak/webhooker/issues/256, commit `8d64259`). -Every SQLite handle opens with WAL journaling and a busy timeout, a -bookkeeping write that fails leaves its delivery in a recoverable -state rather than a lying one, and recovery skips a delivery that -already has a successful result row. Final pre-tag verification -exercised it and confirmed it holds. Whatever the milestone still -shows open is what remains before `v1.0.0`. +(https://git.eeqj.de/sneak/webhooker/issues/256, commit `8d64259`). Every SQLite +handle opens with WAL journaling and a busy timeout, a bookkeeping write that +fails leaves its delivery in a recoverable state rather than a lying one, and +recovery skips a delivery that already has a successful result row. Final +pre-tag verification exercised it and confirmed it holds. Whatever the milestone +still shows open is what remains before `v1.0.0`. -Delivery is at-least-once by design, not by accident: a send whose -result row does not land is attempted again, so a receiver can see a -duplicate. That is deliberate — the alternative is a silent lost -delivery — and the README says so under Rationale. It is not a defect -to re-file. +Delivery is at-least-once by design, not by accident: a send whose result row +does not land is attempted again, so a receiver can see a duplicate. That is +deliberate — the alternative is a silent lost delivery — and the README says so +under Rationale. It is not a defect to re-file. # Next Step Clear the rest of the open 1.0.0 milestone -(https://git.eeqj.de/sneak/webhooker/milestone/9) and tag `v1.0.0`. -Merging `next` into `main` is a separate act from tagging and waits on -neither of those: `next` is kept mergeable at all times, which is the -point of the branch. +(https://git.eeqj.de/sneak/webhooker/milestone/9) and tag `v1.0.0`. Merging +`next` into `main` is a separate act from tagging and waits on neither of those: +`next` is kept mergeable at all times, which is the point of the branch. # Completed Steps -- 2026-08-24 Bind the plaintext HTTP listener deliberately, via - `BIND_ADDRESS` defaulting to `127.0.0.1`, and document the - reverse-proxy deployment. A hostname, an empty value or a value - carrying a port is a startup error, and the `Dockerfile` sets - `0.0.0.0` because a loopback bind inside a container is unreachable - (https://git.eeqj.de/sneak/webhooker/issues/268). The same commit - removed the shutdown race: `httpServer` is built in the constructor - rather than assigned from the serving goroutine, which orders the - write before every fx hook and rules out the nil dereference a - SIGTERM arriving first would have caused, and `sentryEnabled` is an - `atomic.Bool` (https://git.eeqj.de/sneak/webhooker/issues/226) -- 2026-08-24 Remove inbound request signature verification. The - entrypoint UUID is the authentication secret, so the per-entrypoint - shared secret, the `internal/signature` package, the receiver check, - the model fields and the forms are all gone. This reverses the - feature that landed earlier in the same milestone - (https://git.eeqj.de/sneak/webhooker/issues/67, +- 2026-08-24 Bind the plaintext HTTP listener deliberately, via `BIND_ADDRESS` + defaulting to `127.0.0.1`, and document the reverse-proxy deployment. A + hostname, an empty value or a value carrying a port is a startup error, and + the `Dockerfile` sets `0.0.0.0` because a loopback bind inside a container is + unreachable (https://git.eeqj.de/sneak/webhooker/issues/268). The same commit + removed the shutdown race: `httpServer` is built in the constructor rather + than assigned from the serving goroutine, which orders the write before every + fx hook and rules out the nil dereference a SIGTERM arriving first would have + caused, and `sentryEnabled` is an `atomic.Bool` + (https://git.eeqj.de/sneak/webhooker/issues/226) +- 2026-08-24 Remove inbound request signature verification. The entrypoint UUID + is the authentication secret, so the per-entrypoint shared secret, the + `internal/signature` package, the receiver check, the model fields and the + forms are all gone. This reverses the feature that landed earlier in the same + milestone (https://git.eeqj.de/sneak/webhooker/issues/67, https://git.eeqj.de/sneak/webhooker/issues/279) -- 2026-08-24 Stamp the build version into the binary and render it in - the UI footer. `script/version` is the single source — `$VERSION`, - else `git describe --tags --always --dirty`, else `unknown` — so a - `make build` binary and a `make docker` image from one checkout - report the same thing, and nothing in it varies between two builds - of the same commit, which the release gate's byte-identical - assertion would catch +- 2026-08-24 Stamp the build version into the binary and render it in the UI + footer. `script/version` is the single source — `$VERSION`, else + `git describe --tags --always --dirty`, else `unknown` — so a `make build` + binary and a `make docker` image from one checkout report the same thing, and + nothing in it varies between two builds of the same commit, which the release + gate's byte-identical assertion would catch (https://git.eeqj.de/sneak/webhooker/issues/253) -- 2026-08-24 Derive cookie `Secure` and CSRF strictness from the - request transport rather than from `WEBHOOKER_ENVIRONMENT`. Behind a - real TLS proxy with the environment left at its `dev` default, the - session cookie silently lost `Secure` while the CSRF cookie on the - same response kept it. `X-Forwarded-Proto` is now matched - case-insensitively on its first comma-separated element, so `HTTPS` - and `https, http` no longer fall to the relaxed CSRF path - (https://git.eeqj.de/sneak/webhooker/issues/269) -- 2026-08-24 Roll back a failed webhook deletion instead of committing - it. A failing delete committed whatever had already succeeded, - hard-deleted the per-webhook event database anyway, and redirected as - though it had worked — orphaned config plus permanently destroyed - history, reported as success. All three delete positions now roll - back with the event database intact +- 2026-08-24 Derive cookie `Secure` and CSRF strictness from the request + transport rather than from `WEBHOOKER_ENVIRONMENT`. Behind a real TLS proxy + with the environment left at its `dev` default, the session cookie silently + lost `Secure` while the CSRF cookie on the same response kept it. + `X-Forwarded-Proto` is now matched case-insensitively on its first + comma-separated element, so `HTTPS` and `https, http` no longer fall to the + relaxed CSRF path (https://git.eeqj.de/sneak/webhooker/issues/269) +- 2026-08-24 Roll back a failed webhook deletion instead of committing it. A + failing delete committed whatever had already succeeded, hard-deleted the + per-webhook event database anyway, and redirected as though it had worked — + orphaned config plus permanently destroyed history, reported as success. All + three delete positions now roll back with the event database intact (https://git.eeqj.de/sneak/webhooker/issues/262) - 2026-08-24 Name a deleted target on its historical deliveries, marked - `(deleted)`, rather than leaving the event log unable to say where a - delivery went. A deleted target's credentials stay masked exactly as - a live one's, and it cannot become deliverable again through the - receiver, resubmit, replay, the edit form or the toggle - (https://git.eeqj.de/sneak/webhooker/issues/211) -- 2026-08-24 Bound both request-controlled `/metrics` label dimensions, - so the unauthenticated receiver is no longer a memory-exhaustion - vector: `handler` carries the chi route pattern, and `method` folds - anything chi cannot route onto a single `(unmatched)` sentinel. Both - were reproduced before the fix — 300 random method tokens took the - series count from 106 to 7,631, and path flooding reached 62,532 — - and a label audit across a live scrape found no third unbounded - dimension (https://git.eeqj.de/sneak/webhooker/issues/254, + `(deleted)`, rather than leaving the event log unable to say where a delivery + went. A deleted target's credentials stay masked exactly as a live one's, and + it cannot become deliverable again through the receiver, resubmit, replay, the + edit form or the toggle (https://git.eeqj.de/sneak/webhooker/issues/211) +- 2026-08-24 Bound both request-controlled `/metrics` label dimensions, so the + unauthenticated receiver is no longer a memory-exhaustion vector: `handler` + carries the chi route pattern, and `method` folds anything chi cannot route + onto a single `(unmatched)` sentinel. Both were reproduced before the fix — + 300 random method tokens took the series count from 106 to 7,631, and path + flooding reached 62,532 — and a label audit across a live scrape found no + third unbounded dimension (https://git.eeqj.de/sneak/webhooker/issues/254, https://git.eeqj.de/sneak/webhooker/issues/261) -- 2026-08-24 Validate `max_retries` on both target forms. `abc`, `2.7` - and `-5` silently became 0 — fire-and-forget — including on the edit - path, where it destroyed a working value, and `999999999` stored - verbatim. The ceiling of 20 is the `max` both templates already - declared (https://git.eeqj.de/sneak/webhooker/issues/221) -- 2026-08-24 Resubmit a stored event as a new undelivered event, so a - backend under development can be tested against real captured - traffic. Per-delivery replay cannot serve that: it re-sends one - finished delivery to its own original target, and a target created - for a dev backend has no prior delivery to replay. Resubmit - re-injects the stored event at the top of the receiver path and fans - it out to whatever targets are active now +- 2026-08-24 Validate `max_retries` on both target forms. `abc`, `2.7` and `-5` + silently became 0 — fire-and-forget — including on the edit path, where it + destroyed a working value, and `999999999` stored verbatim. The ceiling of 20 + is the `max` both templates already declared + (https://git.eeqj.de/sneak/webhooker/issues/221) +- 2026-08-24 Resubmit a stored event as a new undelivered event, so a backend + under development can be tested against real captured traffic. Per-delivery + replay cannot serve that: it re-sends one finished delivery to its own + original target, and a target created for a dev backend has no prior delivery + to replay. Resubmit re-injects the stored event at the top of the receiver + path and fans it out to whatever targets are active now (https://git.eeqj.de/sneak/webhooker/issues/250) -- 2026-08-20 Take an exclusive lock on `DATA_DIR` at startup, so two - instances on one directory cannot both deliver +- 2026-08-20 Take an exclusive lock on `DATA_DIR` at startup, so two instances + on one directory cannot both deliver (https://git.eeqj.de/sneak/webhooker/issues/201) -- 2026-08-20 Shut down the app when the HTTP listener fails. The - `OnStart` hook returned as soon as the serving goroutine was - spawned, so a failed listen left fx reporting RUNNING and a live - process with nothing bound — invisible to systemd and Docker restart - policies (https://git.eeqj.de/sneak/webhooker/issues/200) +- 2026-08-20 Shut down the app when the HTTP listener fails. The `OnStart` hook + returned as soon as the serving goroutine was spawned, so a failed listen left + fx reporting RUNNING and a live process with nothing bound — invisible to + systemd and Docker restart policies + (https://git.eeqj.de/sneak/webhooker/issues/200) - 2026-08-20 Stop target credentials leaking into the per-webhook event - databases (https://git.eeqj.de/sneak/webhooker/issues/206), log SQL - with placeholders rather than bound values - (https://git.eeqj.de/sneak/webhooker/issues/207), and fail loudly on - half-set metrics auth credentials - (https://git.eeqj.de/sneak/webhooker/issues/205) -- 2026-08-20 Read queue depths with `Find`, not `Scan`. `Scan` swaps - GORM's own trace recorder in for the logging adapter, and that - recorder does not implement `gorm.ParamsFilter`, so those statements - logged their bound values interpolated and bypassed the suppression - above. The two units gated green against a `next` that lacked the - other, and `next` went red when both landed + databases (https://git.eeqj.de/sneak/webhooker/issues/206), log SQL with + placeholders rather than bound values + (https://git.eeqj.de/sneak/webhooker/issues/207), and fail loudly on half-set + metrics auth credentials (https://git.eeqj.de/sneak/webhooker/issues/205) +- 2026-08-20 Read queue depths with `Find`, not `Scan`. `Scan` swaps GORM's own + trace recorder in for the logging adapter, and that recorder does not + implement `gorm.ParamsFilter`, so those statements logged their bound values + interpolated and bypassed the suppression above. The two units gated green + against a `next` that lacked the other, and `next` went red when both landed (https://git.eeqj.de/sneak/webhooker/issues/234) - 2026-08-20 Render per-attempt delivery detail in the event log (https://git.eeqj.de/sneak/webhooker/issues/202) and add replay of a - terminally failed delivery - (https://git.eeqj.de/sneak/webhooker/issues/203) + terminally failed delivery (https://git.eeqj.de/sneak/webhooker/issues/203) - 2026-08-20 Expose delivery metrics on `/metrics` - (https://git.eeqj.de/sneak/webhooker/issues/209) and document the - backup, restore and upgrade procedures + (https://git.eeqj.de/sneak/webhooker/issues/209) and document the backup, + restore and upgrade procedures (https://git.eeqj.de/sneak/webhooker/issues/210) -- 2026-08-20 Add a `webhooker resetpw` subcommand and a bootstrap - banner. The admin bootstrap password was printed once among roughly - 45 fx lines, and under `docker run -d` went to container logs subject - to rotation; there was no reset path at all, so recovery meant - hand-deleting the users row, documented nowhere. The password is read - from stdin or generated, never from argv where `/proc` would publish - it (https://git.eeqj.de/sneak/webhooker/issues/208) -- 2026-08-20 Add `ALLOWED_EGRESS_CIDRS`, an allowlist-only escape hatch - for the SSRF guard, so a self-hosted proxy can forward into the - operator's own network. The guard's always-blocked set cannot be - reopened by configuration +- 2026-08-20 Add a `webhooker resetpw` subcommand and a bootstrap banner. The + admin bootstrap password was printed once among roughly 45 fx lines, and under + `docker run -d` went to container logs subject to rotation; there was no reset + path at all, so recovery meant hand-deleting the users row, documented + nowhere. The password is read from stdin or generated, never from argv where + `/proc` would publish it (https://git.eeqj.de/sneak/webhooker/issues/208) +- 2026-08-20 Add `ALLOWED_EGRESS_CIDRS`, an allowlist-only escape hatch for the + SSRF guard, so a self-hosted proxy can forward into the operator's own + network. The guard's always-blocked set cannot be reopened by configuration (https://git.eeqj.de/sneak/webhooker/issues/204) -- 2026-08-20 Harden operator-set target headers, which were carried - unsafely across a redirect - (https://git.eeqj.de/sneak/webhooker/issues/233) +- 2026-08-20 Harden operator-set target headers, which were carried unsafely + across a redirect (https://git.eeqj.de/sneak/webhooker/issues/233) - 2026-08-20 Add a target edit form with headers and timeout fields (https://git.eeqj.de/sneak/webhooker/issues/127) -- 2026-08-18 Raise `script/test`'s per-package timeout from 30s to 90s, - matching the org-wide backstop. `go test` applies `-timeout` per - package, and `internal/handlers` had grown past the old budget: a - cache-defeated build failed outright at `GOMAXPROCS=4`, and every run - under deliberate host load breached 30s. The measurement table lives - in the script (#194) -- 2026-08-18 Re-sync `REPO_POLICIES.md` from `prompts`. The local copy - was stale and still mandated a 20s test target with a 30s timeout, - which the org replaced with a 60s cap and a 90s backstop. A synced - copy is not a source; reading it as one nearly produced a PR against - `prompts` proposing a change already merged there (#196) -- 2026-08-18 Report handler panics through the logger and answer 500. - chi v1.5.5's `Recoverer` scans for a `panic(0x` frame the runtime no - longer emits, then indexes `pkg[-1:]`, so it panicked inside its own - stack printer before writing a byte: the recovery never ran, the - client got a dropped connection instead of a 500, and the original - panic was lost. A local middleware replaces it, bounded by - `MaxPanicLogLineBytes` (#187) -- 2026-08-18 Route GORM's logger through `slog` and bound it. Every - `gorm.Open` left `logger.Default` in place at `Warn` with - `IgnoreRecordNotFoundError` false, so **every record-not-found - printed the fully interpolated SQL to stdout** — including the - client-chosen path on `/webhook/{uuid}` and the submitted username on - the login form, at no level the operator set and outside - `internal/logger` entirely. Three call sites, not the two the issue - named (#178) -- 2026-08-18 Bound every `slog` line against client-chosen text. Eight - sites reachable unauthenticated, found by reading every `slog` call in - the tree rather than only the one reported; the budget moved to a - shared `internal/logfield` so no second truncation exists. `DEBUG` - being off by default is not a bound and is not treated as one (#176) -- 2026-08-18 Stop a slow host turning a login-guard test into a - segfault. A non-fatal `assert` on an acquire result was dereferenced - on the next line, so one timing miss killed the whole - `internal/middleware` binary and reddened CI for unrelated PRs. The - fix also removed a real production race — `acquire` could shed a - request with a slot standing free, because Go picks uniformly among - ready `select` cases (#186) -- 2026-08-18 Send the chi route pattern to Sentry rather than the - concrete path. The receiver's path carries the entrypoint capability - token, so every Sentry event from `/webhook/{uuid}` shipped a live - credential to a third party. Request `Data`, `QueryString`, `Cookies` - and `Env` are dropped and headers reduced to an allowlist (#179) -- 2026-08-18 Read form fields from the POST body only. `r.FormValue` - merges the query string, so a login could be driven by URL parameters - — putting the password somewhere that lands in access logs, proxy - logs and browser history (#160) -- 2026-08-18 Verify login credentials before spending rate-limit - budget, so a flood of wrong passwords cannot lock out the account it - is guessing at. The manager took this decision rather than stall the - queue; it is flagged on the issue for reversal (#150) -- 2026-08-18 Run all linting in Docker via `Dockerfile.lint`. Host lint - was wrong in both directions from version skew and shared caches. - `script/lint` asserts the summary line, because `--no-cache-filter` - silently ignores a stage name it does not match — the flag that makes - the gate meaningful fails open (#109) -- 2026-08-18 Serve an event's full stored body over HTTP. The list - query truncates for rendering, and that truncated value was the only - way to read a body, so the full payload was unreachable (#157) +- 2026-08-18 Raise `script/test`'s per-package timeout from 30s to 90s, matching + the org-wide backstop. `go test` applies `-timeout` per package, and + `internal/handlers` had grown past the old budget: a cache-defeated build + failed outright at `GOMAXPROCS=4`, and every run under deliberate host load + breached 30s. The measurement table lives in the script (#194) +- 2026-08-18 Re-sync `REPO_POLICIES.md` from `prompts`. The local copy was stale + and still mandated a 20s test target with a 30s timeout, which the org + replaced with a 60s cap and a 90s backstop. A synced copy is not a source; + reading it as one nearly produced a PR against `prompts` proposing a change + already merged there (#196) +- 2026-08-18 Report handler panics through the logger and answer 500. chi + v1.5.5's `Recoverer` scans for a `panic(0x` frame the runtime no longer emits, + then indexes `pkg[-1:]`, so it panicked inside its own stack printer before + writing a byte: the recovery never ran, the client got a dropped connection + instead of a 500, and the original panic was lost. A local middleware replaces + it, bounded by `MaxPanicLogLineBytes` (#187) +- 2026-08-18 Route GORM's logger through `slog` and bound it. Every `gorm.Open` + left `logger.Default` in place at `Warn` with `IgnoreRecordNotFoundError` + false, so **every record-not-found printed the fully interpolated SQL to + stdout** — including the client-chosen path on `/webhook/{uuid}` and the + submitted username on the login form, at no level the operator set and outside + `internal/logger` entirely. Three call sites, not the two the issue named + (#178) +- 2026-08-18 Bound every `slog` line against client-chosen text. Eight sites + reachable unauthenticated, found by reading every `slog` call in the tree + rather than only the one reported; the budget moved to a shared + `internal/logfield` so no second truncation exists. `DEBUG` being off by + default is not a bound and is not treated as one (#176) +- 2026-08-18 Stop a slow host turning a login-guard test into a segfault. A + non-fatal `assert` on an acquire result was dereferenced on the next line, so + one timing miss killed the whole `internal/middleware` binary and reddened CI + for unrelated PRs. The fix also removed a real production race — `acquire` + could shed a request with a slot standing free, because Go picks uniformly + among ready `select` cases (#186) +- 2026-08-18 Send the chi route pattern to Sentry rather than the concrete path. + The receiver's path carries the entrypoint capability token, so every Sentry + event from `/webhook/{uuid}` shipped a live credential to a third party. + Request `Data`, `QueryString`, `Cookies` and `Env` are dropped and headers + reduced to an allowlist (#179) +- 2026-08-18 Read form fields from the POST body only. `r.FormValue` merges the + query string, so a login could be driven by URL parameters — putting the + password somewhere that lands in access logs, proxy logs and browser history + (#160) +- 2026-08-18 Verify login credentials before spending rate-limit budget, so a + flood of wrong passwords cannot lock out the account it is guessing at. The + manager took this decision rather than stall the queue; it is flagged on the + issue for reversal (#150) +- 2026-08-18 Run all linting in Docker via `Dockerfile.lint`. Host lint was + wrong in both directions from version skew and shared caches. `script/lint` + asserts the summary line, because `--no-cache-filter` silently ignores a stage + name it does not match — the flag that makes the gate meaningful fails open + (#109) +- 2026-08-18 Serve an event's full stored body over HTTP. The list query + truncates for rendering, and that truncated value was the only way to read a + body, so the full payload was unreachable (#157) - 2026-08-18 Bound the access log line against client-chosen text. - `internal/logfield` budgets by *encoded* bytes, not runes, so a - handler's JSON escaping cannot multiply a field past its allowance - (#146) -- 2026-08-18 Mark superseded CI commits `failure` rather than - `skipped`. A skipped run rolls up green, so a commit that was never - tested reported success (#152) -- 2026-08-18 Set `fx.StopTimeout` inside the container stop grace, so - shutdown hooks are bounded by a deadline the orchestrator will - actually honour rather than being killed mid-flush (#134) -- 2026-08-17 Bucket IPv6 rate-limit keys by `/64`. A single allocation - hands out 2^64 addresses, so per-address keying let one client mint - unlimited buckets. Manager decision, recorded on the issue (#125) -- 2026-08-17 Correct release-blocking README and startup-warning - inaccuracies, including claims about behaviour the code does not have - (#151) + `internal/logfield` budgets by _encoded_ bytes, not runes, so a handler's JSON + escaping cannot multiply a field past its allowance (#146) +- 2026-08-18 Mark superseded CI commits `failure` rather than `skipped`. A + skipped run rolls up green, so a commit that was never tested reported success + (#152) +- 2026-08-18 Set `fx.StopTimeout` inside the container stop grace, so shutdown + hooks are bounded by a deadline the orchestrator will actually honour rather + than being killed mid-flush (#134) +- 2026-08-17 Bucket IPv6 rate-limit keys by `/64`. A single allocation hands out + 2^64 addresses, so per-address keying let one client mint unlimited buckets. + Manager decision, recorded on the issue (#125) +- 2026-08-17 Correct release-blocking README and startup-warning inaccuracies, + including claims about behaviour the code does not have (#151) - 2026-08-17 Fetch and verify Alpine.js at build time against - `static/vendor.sha256` instead of committing the minified blob, so - the dependency is pinned by hash rather than by trust (#145) -- 2026-08-17 Bound the event log's rendered bodies in the query itself, - so a large stored payload cannot be read into memory just to be - truncated for display (#135) -- 2026-08-17 Mask the `http` target's destination URL in the UI: it can - carry a bearer credential in its path or query, and was rendered - verbatim. Manager decision to mask unconditionally (#115) -- 2026-08-14 Bound shutdown hooks by their stop context, so a hook that - hangs cannot hold the process past its grace period (#102) -- 2026-08-14 Render templates via a buffer rather than the - `ResponseWriter`, so a template error part-way through cannot commit - a 200 and then fail — the response is written only once it is whole - (#123) -- 2026-08-14 Align the session codec's max-age with the 7-day absolute - cap. The codec accepted cookies the session layer considered expired, - so the cap was enforced in one place and not the other (#108) -- 2026-08-12 Warn when `TRUSTED_PROXIES` is empty in production, where - the safe default silently discards forwarded headers and every client - rate-limits as the proxy's address (#149) -- 2026-08-12 Bound the receiver rate limit per client IP across the - whole `/webhook/*` route. The existing limiter keyed on the request - path and `/webhook/{uuid}` matches any single segment, so a client - that invented a fresh path per request minted a fresh bucket per - request: the limit on the only unauthenticated endpoint bounded - nothing in aggregate, and every request still cost an entrypoint - lookup before it 404ed. An outer limiter keyed on the client address - alone now bounds that, chained in front of the unchanged + `static/vendor.sha256` instead of committing the minified blob, so the + dependency is pinned by hash rather than by trust (#145) +- 2026-08-17 Bound the event log's rendered bodies in the query itself, so a + large stored payload cannot be read into memory just to be truncated for + display (#135) +- 2026-08-17 Mask the `http` target's destination URL in the UI: it can carry a + bearer credential in its path or query, and was rendered verbatim. Manager + decision to mask unconditionally (#115) +- 2026-08-14 Bound shutdown hooks by their stop context, so a hook that hangs + cannot hold the process past its grace period (#102) +- 2026-08-14 Render templates via a buffer rather than the `ResponseWriter`, so + a template error part-way through cannot commit a 200 and then fail — the + response is written only once it is whole (#123) +- 2026-08-14 Align the session codec's max-age with the 7-day absolute cap. The + codec accepted cookies the session layer considered expired, so the cap was + enforced in one place and not the other (#108) +- 2026-08-12 Warn when `TRUSTED_PROXIES` is empty in production, where the safe + default silently discards forwarded headers and every client rate-limits as + the proxy's address (#149) +- 2026-08-12 Bound the receiver rate limit per client IP across the whole + `/webhook/*` route. The existing limiter keyed on the request path and + `/webhook/{uuid}` matches any single segment, so a client that invented a + fresh path per request minted a fresh bucket per request: the limit on the + only unauthenticated endpoint bounded nothing in aggregate, and every request + still cost an entrypoint lookup before it 404ed. An outer limiter keyed on the + client address alone now bounds that, chained in front of the unchanged per-entrypoint limiter (#139) -- 2026-08-12 Correct release-blocking documentation inaccuracies: the - README promised manual redelivery in the present tense in three - places when nothing implements it (the same false claim also sat in - the doc comment that was its source text), the env table omitted - `RETENTION_SWEEP_INTERVAL`, and `TODO.md` itself omitted five landed - units (#141) -- 2026-08-12 Make the CI gate execute the checks it reports on. The - workflow now writes a build-context fingerprint before calling - `script/cibuild`, so a code commit invalidates the `COPY` layer of - the lint and builder stages while a docs-only commit still replays - from cache; a superseding run also rewrites the `failure` status - Gitea leaves on commits it cancelled and never tested. Verified by - pushing a deliberately broken test and watching CI go red (#119) -- 2026-08-12 Require a positive `RETENTION_SWEEP_INTERVAL`: a - non-positive value reached `time.NewTicker` in both the retention - reaper and the archive sweeper, panicking two goroutines with no - recover after startup had already reported success (#140) -- 2026-08-12 Bound the `X-Forwarded-For` scan's allocation to the hop - cap: the reverse walk cuts entries with `strings.LastIndexByte` - instead of joining and splitting, so a 1 MB header allocates 16 bytes - rather than 1.6 MB per request on the unauthenticated receiver. - Semantics proven unchanged by differential testing against the - previous implementation (#133) +- 2026-08-12 Correct release-blocking documentation inaccuracies: the README + promised manual redelivery in the present tense in three places when nothing + implements it (the same false claim also sat in the doc comment that was its + source text), the env table omitted `RETENTION_SWEEP_INTERVAL`, and `TODO.md` + itself omitted five landed units (#141) +- 2026-08-12 Make the CI gate execute the checks it reports on. The workflow now + writes a build-context fingerprint before calling `script/cibuild`, so a code + commit invalidates the `COPY` layer of the lint and builder stages while a + docs-only commit still replays from cache; a superseding run also rewrites the + `failure` status Gitea leaves on commits it cancelled and never tested. + Verified by pushing a deliberately broken test and watching CI go red (#119) +- 2026-08-12 Require a positive `RETENTION_SWEEP_INTERVAL`: a non-positive value + reached `time.NewTicker` in both the retention reaper and the archive sweeper, + panicking two goroutines with no recover after startup had already reported + success (#140) +- 2026-08-12 Bound the `X-Forwarded-For` scan's allocation to the hop cap: the + reverse walk cuts entries with `strings.LastIndexByte` instead of joining and + splitting, so a 1 MB header allocates 16 bytes rather than 1.6 MB per request + on the unauthenticated receiver. Semantics proven unchanged by differential + testing against the previous implementation (#133) - 2026-08-12 Cap the `X-Forwarded-For` hop walk at 64 entries, so an - attacker-supplied chain cannot burn unbounded CPU in the rate-limit - key function; running off the end falls back to the peer address - (#124) -- 2026-08-12 Gate forwarded-header trust behind a `TRUSTED_PROXIES` CIDR - list: all three rate limiters key on the connection's own address - unless the direct peer is a configured proxy, in which case - `X-Forwarded-For` is walked right to left for the first non-proxy hop. - Default trusts nothing, and a set-but-unparseable value aborts - startup. Before this, any client could mint a fresh bucket or drain - another's by rotating a spoofed header (#88) -- 2026-08-11 Web UI cleanup: nav terminology unified on Webhooks, the - Profile settings placeholder removed, a progressive-enhancement copy - button for the entrypoint URL, and retention form copy that states the - actual policy (deletion by the reaper, 0 retains forever) (#57) -- 2026-08-11 Mask the webhook credential in delivery errors and logs: - Go embeds the request URL in `*url.Error`, so every transport failure - persisted the full Slack webhook URL into the per-webhook event - database via `DeliveryResult.Error`, a field a future REST API would - have served. `maskURLError` drops path, query and userinfo while - preserving the wrapped cause, so `errors.Is`/`As` and `Timeout()` - still work and DNS, TLS and timeout failures still read differently - (#118) + attacker-supplied chain cannot burn unbounded CPU in the rate-limit key + function; running off the end falls back to the peer address (#124) +- 2026-08-12 Gate forwarded-header trust behind a `TRUSTED_PROXIES` CIDR list: + all three rate limiters key on the connection's own address unless the direct + peer is a configured proxy, in which case `X-Forwarded-For` is walked right to + left for the first non-proxy hop. Default trusts nothing, and a + set-but-unparseable value aborts startup. Before this, any client could mint a + fresh bucket or drain another's by rotating a spoofed header (#88) +- 2026-08-11 Web UI cleanup: nav terminology unified on Webhooks, the Profile + settings placeholder removed, a progressive-enhancement copy button for the + entrypoint URL, and retention form copy that states the actual policy + (deletion by the reaper, 0 retains forever) (#57) +- 2026-08-11 Mask the webhook credential in delivery errors and logs: Go embeds + the request URL in `*url.Error`, so every transport failure persisted the full + Slack webhook URL into the per-webhook event database via + `DeliveryResult.Error`, a field a future REST API would have served. + `maskURLError` drops path, query and userinfo while preserving the wrapped + cause, so `errors.Is`/`As` and `Timeout()` still work and DNS, TLS and timeout + failures still read differently (#118) - 2026-08-11 Rate-limit the public webhook receiver endpoint - (`RECEIVER_RATE_LIMIT`, default 120/min), keyed on client IP plus - entrypoint path so one entrypoint cannot exhaust another's budget; - over-limit requests get 429 with `Retry-After`. It was the one - unauthenticated, internet-facing endpoint with no limit at all (#64) + (`RECEIVER_RATE_LIMIT`, default 120/min), keyed on client IP plus entrypoint + path so one entrypoint cannot exhaust another's budget; over-limit requests + get 429 with `Retry-After`. It was the one unauthenticated, internet-facing + endpoint with no limit at all (#64) - 2026-08-11 Enforce the body size limit before CSRF parses the form: - `MaxBodySize` is now first in all four form-parsing route groups, so - an oversized request is rejected with 413 instead of being read in - full by the CSRF middleware before any cap applied (#90) -- 2026-08-11 Mask target config on the source detail page, which - rendered the stored blob verbatim and so exposed the Slack - incoming-webhook URL — a bearer credential that cannot be revoked - per-holder. Config reaches the template only as a `TargetView` of - labelled fields, and header values are rendered as a count (#113) -- 2026-08-11 Allow `retention_days` of 0 to mean retain forever, via a - sentinel written in `BeforeSave` so the GORM column default cannot - win the race. Also bounds the reaper's cutoff arithmetic: day counts - above 106751 overflowed `time.Duration` and wrapped the cutoff into - the future, where every row matched and the sweep deleted everything - (#79) + `MaxBodySize` is now first in all four form-parsing route groups, so an + oversized request is rejected with 413 instead of being read in full by the + CSRF middleware before any cap applied (#90) +- 2026-08-11 Mask target config on the source detail page, which rendered the + stored blob verbatim and so exposed the Slack incoming-webhook URL — a bearer + credential that cannot be revoked per-holder. Config reaches the template only + as a `TargetView` of labelled fields, and header values are rendered as a + count (#113) +- 2026-08-11 Allow `retention_days` of 0 to mean retain forever, via a sentinel + written in `BeforeSave` so the GORM column default cannot win the race. Also + bounds the reaper's cutoff arithmetic: day counts above 106751 overflowed + `time.Duration` and wrapped the cutoff into the future, where every row + matched and the sweep deleted everything (#79) - 2026-08-09 Inactivity-based session timeout: sliding idle expiry - (`SESSION_IDLE_TIMEOUT`, default `24h`) refreshed on authenticated - requests, with the 7-day absolute cap kept as an independent - backstop that activity never extends (#66) + (`SESSION_IDLE_TIMEOUT`, default `24h`) refreshed on authenticated requests, + with the 7-day absolute cap kept as an independent backstop that activity + never extends (#66) - 2026-08-09 Restart recovery and the 60s retry sweep terminally fail an - orphaned `retrying` delivery whose target type no longer supports - retries, recording a `DeliveryResult` with the reason instead of - leaving the delivery stuck forever (#82) -- 2026-08-09 Root the delivery engine's worker pool and the retention - reaper's sweep loop at `context.Background()` rather than the fx - `OnStart` hook context (#97), which carries fx's 15s start timeout and - killed both roughly fifteen seconds after boot: the proxy silently - stopped delivering webhooks entirely, and the reaper never ran a - single sweep under its default one-hour interval -- 2026-08-09 Archive writer lifecycle (#89): deleting a webhook (or its - last `database` target) evicts the cached archive writer and closes - its handle while deliberately leaving `archive-{webhookID}.db` on - disk, and a new `ArchiveSweeper` prunes idle archives on the existing + orphaned `retrying` delivery whose target type no longer supports retries, + recording a `DeliveryResult` with the reason instead of leaving the delivery + stuck forever (#82) +- 2026-08-09 Root the delivery engine's worker pool and the retention reaper's + sweep loop at `context.Background()` rather than the fx `OnStart` hook context + (#97), which carries fx's 15s start timeout and killed both roughly fifteen + seconds after boot: the proxy silently stopped delivering webhooks entirely, + and the reaper never ran a single sweep under its default one-hour interval +- 2026-08-09 Archive writer lifecycle (#89): deleting a webhook (or its last + `database` target) evicts the cached archive writer and closes its handle + while deliberately leaving `archive-{webhookID}.db` on disk, and a new + `ArchiveSweeper` prunes idle archives on the existing `RETENTION_SWEEP_INTERVAL` without ever creating an archive file - 2026-08-09 Configuration parsing fails loudly on set-but-unparseable - environment values: `envInt` removed in favour of `envPositiveInt` - plus a `PORT` range check, `envBool` now parses with - `strconv.ParseBool`, and defaults apply only to unset variables (#80) -- 2026-08-07 Automatic event retention cleanup based on - `retention_days`, deleting expired events, deliveries, and delivery - results from each per-webhook event database (#63) + environment values: `envInt` removed in favour of `envPositiveInt` plus a + `PORT` range check, `envBool` now parses with `strconv.ParseBool`, and + defaults apply only to unset variables (#80) +- 2026-08-07 Automatic event retention cleanup based on `retention_days`, + deleting expired events, deliveries, and delivery results from each + per-webhook event database (#63) - 2026-08-07 Update golangci-lint to v2.12.2 (Docker image digest in - `Dockerfile`, release-archive sha256 pins in `script/bootstrap`), - adopt the canonical `.golangci.yml` (v2 `linters.settings` layout so - `lll`/`funlen`/`cyclop`/`dupl` thresholds actually apply), and fix - all newly surfaced lint findings -- 2026-07-07 Adopted scripts-to-rule-them-all: `script/` entrypoints, - Makefile shims, README Entrypoints section + `Dockerfile`, release-archive sha256 pins in `script/bootstrap`), adopt the + canonical `.golangci.yml` (v2 `linters.settings` layout so + `lll`/`funlen`/`cyclop`/`dupl` thresholds actually apply), and fix all newly + surfaced lint findings +- 2026-07-07 Adopted scripts-to-rule-them-all: `script/` entrypoints, Makefile + shims, README Entrypoints section - 2026-03-25 pin golangci-lint Docker image for linting (#55) -- 2026-03-18 CSRF middleware detects TLS per-request, fixing login over - plain HTTP and behind reverse proxies (#54) +- 2026-03-18 CSRF middleware detects TLS per-request, fixing login over plain + HTTP and behind reverse proxies (#54) - 2026-03-17 root path redirects based on auth state (#52) -- 2026-03-17 CSRF protection, SSRF prevention for HTTP delivery targets - with DNS rebinding defense, and per-IP login rate limiting (#42) +- 2026-03-17 CSRF protection, SSRF prevention for HTTP delivery targets with DNS + rebinding defense, and per-IP login rate limiting (#42) - 2026-03-17 Slack target type for incoming webhook notifications (#47) -- 2026-03-17 Dockerfile absolute paths and static linking (#49); - absolute dev DATA_DIR default and clarified env docs (#46) -- 2026-03-05 security headers middleware, session regeneration on - login, request body size limits (#41) -- 2026-03-04 tests for delivery, middleware, and session packages - (#32); removed the build-architecture global (#31) -- 2026-03-04 1.0 MVP merge: Webhook/Entrypoint/Target rename, core - delivery engine with bounded worker pool and circuit breaker, - parallel fan-out, per-webhook event databases, management UI (#16) -- 2026-03-01 repo brought to REPO_POLICIES standards; TODO.md folded - into README (#6) +- 2026-03-17 Dockerfile absolute paths and static linking (#49); absolute dev + DATA_DIR default and clarified env docs (#46) +- 2026-03-05 security headers middleware, session regeneration on login, request + body size limits (#41) +- 2026-03-04 tests for delivery, middleware, and session packages (#32); removed + the build-architecture global (#31) +- 2026-03-04 1.0 MVP merge: Webhook/Entrypoint/Target rename, core delivery + engine with bounded worker pool and circuit breaker, parallel fan-out, + per-webhook event databases, management UI (#16) +- 2026-03-01 repo brought to REPO_POLICIES standards; TODO.md folded into README + (#6) # Future Steps -- Delivery status and retry management UI. Replay of a terminally - failed delivery and per-attempt detail already landed +- Delivery status and retry management UI. Replay of a terminally failed + delivery and per-attempt detail already landed (https://git.eeqj.de/sneak/webhooker/issues/203, https://git.eeqj.de/sneak/webhooker/issues/202) -- Per-webhook rate limiting in the receiver handler (per-webhook config - plus handler enforcement; global limits must not apply to receiver - endpoints) -- API key authentication for programmatic access (APIKey model exists; - Bearer token middleware does not) +- Per-webhook rate limiting in the receiver handler (per-webhook config plus + handler enforcement; global limits must not apply to receiver endpoints) +- API key authentication for programmatic access (APIKey model exists; Bearer + token middleware does not) - REST API v1 - CRUD for webhooks, entrypoints, targets - event viewing and filtering endpoints @@ -406,9 +370,9 @@ point of the branch. - OpenAPI specification - Analytics dashboard: success rates, response times, volume - A remember-me option at login -- Password reset flow for a forgotten password over the web. The - authenticated password *change* flow already landed, and a lost - password is recoverable from the console with `webhooker resetpw` +- Password reset flow for a forgotten password over the web. The authenticated + password _change_ flow already landed, and a lost password is recoverable from + the console with `webhooker resetpw` (https://git.eeqj.de/sneak/webhooker/issues/208) - Later, nice to have - email delivery target type diff --git a/package.json b/package.json index 86b0f33..84c7df0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,7 @@ { "private": true, "devDependencies": { - "eslint": "10.11.0" + "eslint": "10.11.0", + "prettier": "3.9.9" } } diff --git a/script/bootstrap b/script/bootstrap index f275e8d..bdd28d9 100755 --- a/script/bootstrap +++ b/script/bootstrap @@ -3,8 +3,8 @@ # this repo. Idempotent: every install is guarded by a check so already # installed tools are skipped. Base tooling comes from nix, apt, brew, # or apk (detected in that order); assumes NOTHING is present (not git, -# make, or go). golangci-lint, node and ESLint are deliberately not -# installed: linting runs only in docker, via script/lint. +# make, or go). golangci-lint, node, ESLint and prettier are deliberately +# not installed: they run only in docker, via script/lint and script/fmt. set -eu ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" @@ -60,9 +60,10 @@ main() { if missing go; then pkg_install go golang go go; fi # Not installed here: docker is platform-specific and out of scope for a - # package-manager bootstrap, but script/lint and script/css need it. + # package-manager bootstrap, but script/lint, script/fmt and script/css + # need it. if missing docker; then - echo "bootstrap: docker not found; script/lint and script/css require it" >&2 + echo "bootstrap: docker not found; script/lint, script/fmt and script/css require it" >&2 fi go mod download diff --git a/script/cibuild b/script/cibuild index e20e047..ae14eec 100755 --- a/script/cibuild +++ b/script/cibuild @@ -1,8 +1,8 @@ #!/bin/sh -# script/cibuild: run the CI build. The Dockerfile runs the checks -# (make fmt-check, lint, test), so a successful build implies a green -# repo. Generic: needs no adaptation. The Gitea workflow runs this on -# push. +# script/cibuild: run the CI build. The Dockerfile runs the checks (the +# gofmt check, golangci-lint, the stylesheet check, ESLint, the Markdown +# check, make test), so a successful build implies a green repo. Generic: +# needs no adaptation. The Gitea workflow runs this on push. set -eu ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" diff --git a/script/fmt b/script/fmt index 4e91f00..450cce6 100755 --- a/script/fmt +++ b/script/fmt @@ -1,5 +1,7 @@ #!/bin/sh -# script/fmt: format all files (writes). +# script/fmt: format all files (writes): the Go code with gofmt and +# goimports, the Markdown with prettier. prettier is never installed +# locally: it runs in docker, in the Dockerfile's Markdown stages. set -eu ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" @@ -10,6 +12,7 @@ main() { if command -v goimports >/dev/null 2>&1; then goimports -w . fi + docker build --target markdown-output --output type=local,dest=. . } main "$@" diff --git a/script/fmt-check b/script/fmt-check index bde55cb..1a0fa50 100755 --- a/script/fmt-check +++ b/script/fmt-check @@ -12,6 +12,7 @@ main() { gofmt -s -l . exit 1 fi + docker build --target markdown-check --output type=cacheonly . } main "$@" diff --git a/yarn.lock b/yarn.lock index 6d62236..4ac26ac 100644 --- a/yarn.lock +++ b/yarn.lock @@ -470,6 +470,11 @@ prelude-ls@^1.2.1: resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.2.1.tgz#debc6489d7a6e6b0e7611888cec880337d316396" integrity sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g== +prettier@3.9.9: + version "3.9.9" + resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.9.9.tgz#09b826918c91cd4cbc80e0cbd1d2a922ff04f233" + integrity sha512-Z/CJHIkdujO/OtN7nXUii0Rf3VT5SRuhjBA82Xvu2XhBUgX3nhP67T0LHceBdQLex7OOFGTox+Q5Yg8Jk2Qivg== + punycode@^2.1.0: version "2.3.1" resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.3.1.tgz#027422e2faec0b25e1549c3e1bd8309b9133b6e5" -- 2.54.0 From 9ccaa8ce01040fce9c483873a0f2b401bae5da45 Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Sat, 3 Oct 2026 06:56:43 +0200 Subject: [PATCH 093/126] Break long values on the webhook page and event log at phone width (closes #391) At phone width, the event log cut off long event IDs and content types along with the statuses and times after them, and the webhook page ran long names past their cards and scrolled sideways. Elements that hold only a long value (a webhook, target or entrypoint name or description, an event ID, a content type) now break inside it, and the event log's title row wraps; rows themselves still wrap rather than squeeze. Wide screens are unchanged. A 390-pixel browser check of both pages, an event's attempts open, fails when anything runs past the page's or a card's edge or the page scrolls sideways. Model: opus-5-5 --- README.md | 4 +- internal/handlers/event_request_test.go | 7 +-- internal/server/alpine_browser_test.go | 69 ++++++++++++++++++++++++- static/css/tailwind.css | 2 +- templates/delivery_row.html | 2 +- templates/event_request.html | 4 +- templates/source_detail.html | 13 +++-- templates/source_logs.html | 15 ++++-- 8 files changed, 96 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 16d6005..f9923cf 100644 --- a/README.md +++ b/README.md @@ -1280,7 +1280,9 @@ 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. It also fails if the browser reports a console warning or error, an +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 diff --git a/internal/handlers/event_request_test.go b/internal/handlers/event_request_test.go index f2bab00..5b4eb58 100644 --- a/internal/handlers/event_request_test.go +++ b/internal/handlers/event_request_test.go @@ -17,14 +17,15 @@ import ( // arrivedAt is how a page names the entrypoint an event arrived at. func arrivedAt(name string) string { - return `Arrived at ` + name + `` + return `Arrived at ` + name + + `` } // copiedRequestArrivedAt is how a page names, for a resubmitted copy, // the entrypoint the request it copies arrived at. func copiedRequestArrivedAt(name string) string { - return `The request it copies arrived at ` + - name + `` + return `The request it copies arrived at ` + + `` + name + `` } // headerBox is how a page shows an event's request header lines: as diff --git a/internal/server/alpine_browser_test.go b/internal/server/alpine_browser_test.go index 1d6d892..96593c9 100644 --- a/internal/server/alpine_browser_test.go +++ b/internal/server/alpine_browser_test.go @@ -98,20 +98,25 @@ func TestAlpineRunsUnderTheSecurityPolicy(t *testing.T) { checkRefusedNewWebhook(ctx, t, srv.URL+"/hooks/new") checkEventLog(ctx, t, page+"/events", event.ID, older.ID, target.Name) checkMobileMenu(ctx, t, page) + checkPhoneWidth(ctx, t, page, page+"/events", target.Name) assert.Empty(t, problems(), "the browser reported problems") } // seedBrowserWebhook seeds the webhook the browser test loads, owned by // userID: an entrypoint, two events, and a target whose delivery of the -// newer event failed once with a 502. It returns the webhook, the older -// and the newer event, and the target. +// newer event failed once with a 502. The webhook's name and the newer +// event's content type are each too long for one line on a phone. It +// returns the webhook, the older and the newer event, and the target. func seedBrowserWebhook( t *testing.T, env *testEnv, userID string, ) (*database.Webhook, *database.Event, *database.Event, *database.Target) { t.Helper() webhook := env.seedWebhook(t, userID) + require.NoError(t, env.db.DB().Model(webhook).Update( + "name", "payment_provider_production_notifications", + ).Error) require.NoError(t, env.db.DB().Omit(clause.Associations).Create( &database.Entrypoint{ WebhookID: webhook.ID, @@ -126,6 +131,9 @@ func seedBrowserWebhook( webhookDB, err := env.dbMgr.GetDB(webhook.ID) require.NoError(t, err) + require.NoError(t, webhookDB.Model(event).Update( + "content_type", "application/vnd.paymentprovider.event+json", + ).Error) require.NoError(t, webhookDB.Omit(clause.Associations).Create( &database.DeliveryResult{ DeliveryID: dlv.ID, @@ -1292,3 +1300,60 @@ func checkMobileMenu(ctx context.Context, t *testing.T, url string) { click(ctx, t, button) assert.True(t, hidden(ctx, menu), "the menu button does not close the menu") } + +// scrollsSideways reports whether the page is wider than the window. A +// page's clientWidth is the window's width less its scroll bar. +const scrollsSideways = `document.documentElement.scrollWidth > + document.documentElement.clientWidth` + +// cutOffElements lists each element, without elements inside it, that +// is shown but runs past the page's edge or its card's, by more than a +// pixel of rounding. A card hides what runs past its edge. +const cutOffElements = `[...document.querySelectorAll("body *")] + .filter((el) => { + const box = el.getBoundingClientRect(); + const card = el.closest(".card")?.getBoundingClientRect(); + const left = card ? card.left : 0; + const right = card ? card.right : document.documentElement.clientWidth; + return el.children.length === 0 && box.width > 0 && + (box.left < left - 1 || box.right > right + 1); + }) + .map((el) => el.outerHTML.slice(0, 120))` + +// checkPhoneWidth loads the webhook page, url, and its event log, +// eventLog, in a phone-sized window, the event log with the attempts of +// the newest event's delivery to targetName shown. It checks that +// neither page scrolls sideways and that nothing shown on either, no +// status, time or control, is cut off at the page's or its card's edge. +func checkPhoneWidth( + ctx context.Context, t *testing.T, url, eventLog, targetName string, +) { + t.Helper() + + var ( + sideways bool + cutOff []string + ) + + measure := chromedp.Tasks{ + chromedp.Evaluate(scrollsSideways, &sideways), + chromedp.Evaluate(cutOffElements, &cutOff), + } + + require.NoError(t, chromedp.Run( + ctx, + chromedp.EmulateViewport(phoneWidth, phoneHeight), + loadPage(url), + measure, + )) + assert.False(t, sideways, "the webhook page scrolls sideways on a phone") + assert.Empty(t, cutOff, "the webhook page cuts these off on a phone") + + require.NoError(t, chromedp.Run(ctx, loadPage(eventLog))) + click(ctx, t, `//span[text()="`+targetName+`"]`) + require.True(t, shown(ctx, `//span[text()="Attempt 1"]`), + "clicking the delivery does not show its attempts") + require.NoError(t, chromedp.Run(ctx, measure)) + 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") +} diff --git a/static/css/tailwind.css b/static/css/tailwind.css index 23eb6dd..43b9ac5 100644 --- a/static/css/tailwind.css +++ b/static/css/tailwind.css @@ -1,2 +1,2 @@ /*! tailwindcss v4.2.1 | MIT License | https://tailwindcss.com */ -@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-space-y-reverse:0;--tw-divide-y-reverse:0;--tw-border-style:solid;--tw-font-weight:initial;--tw-tracking:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-duration:initial}}}@layer theme{:root,:host{--font-sans:ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--color-red-500:oklch(63.7% .237 25.331);--color-red-600:oklch(57.7% .245 27.325);--color-red-700:oklch(50.5% .213 27.518);--color-red-800:oklch(44.4% .177 26.899);--color-yellow-600:oklch(68.1% .162 75.834);--color-green-600:oklch(62.7% .194 149.214);--color-gray-50:oklch(98.5% .002 247.839);--color-gray-100:oklch(96.7% .003 264.542);--color-gray-200:oklch(92.8% .006 264.531);--color-gray-300:oklch(87.2% .01 258.338);--color-gray-400:oklch(70.7% .022 261.325);--color-gray-500:oklch(55.1% .027 264.364);--color-gray-600:oklch(44.6% .03 256.802);--color-gray-700:oklch(37.3% .034 259.733);--color-gray-900:oklch(21% .034 264.665);--color-white:#fff;--spacing:.25rem;--container-md:28rem;--container-2xl:42rem;--container-4xl:56rem;--container-6xl:72rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--text-xl:1.25rem;--text-xl--line-height:calc(1.75 / 1.25);--text-2xl:1.5rem;--text-2xl--line-height:calc(2 / 1.5);--text-3xl:1.875rem;--text-3xl--line-height:calc(2.25 / 1.875);--font-weight-light:300;--font-weight-medium:500;--tracking-wide:.025em;--radius-md:.375rem;--radius-lg:.5rem;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono);--color-primary-50:#e3f2fd;--color-primary-500:#2196f3;--color-primary-600:#1e88e5;--color-primary-700:#1976d2;--color-primary-800:#1565c0;--color-error-50:#ffebee;--color-error-500:#f44336;--color-error-700:#d32f2f;--color-success-50:#e8f5e9;--color-success-500:#4caf50;--color-success-700:#388e3c}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab, currentcolor 50%, transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}}@layer components{.btn-primary{border-radius:var(--radius-md);background-color:var(--color-primary-600);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 2);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);color:var(--color-white);--tw-shadow:0 1px 3px var(--tw-shadow-color,#0000001f), 0 1px 2px var(--tw-shadow-color,#0000003d);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));--tw-duration:.2s;justify-content:center;align-items:center;transition-duration:.2s;display:inline-flex}@media (hover:hover){.btn-primary:hover{background-color:var(--color-primary-700);--tw-shadow:0 3px 6px var(--tw-shadow-color,#00000029), 0 3px 6px var(--tw-shadow-color,#0000003b);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}}.btn-primary:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);--tw-ring-color:var(--color-primary-500);--tw-ring-offset-width:2px;--tw-ring-offset-shadow:var(--tw-ring-inset,) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-outline-style:none;outline-style:none}.btn-primary:active{background-color:var(--color-primary-800)}.btn-primary:disabled{cursor:not-allowed;opacity:.5}.btn-secondary{border-radius:var(--radius-md);border-style:var(--tw-border-style);border-width:1px;border-color:var(--color-gray-300);background-color:var(--color-white);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 2);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);color:var(--color-gray-700);transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));--tw-duration:.2s;justify-content:center;align-items:center;transition-duration:.2s;display:inline-flex}@media (hover:hover){.btn-secondary:hover{background-color:var(--color-gray-50)}}.btn-secondary:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);--tw-ring-color:var(--color-primary-500);--tw-ring-offset-width:2px;--tw-ring-offset-shadow:var(--tw-ring-inset,) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-outline-style:none;outline-style:none}.btn-secondary:active{background-color:var(--color-gray-100)}.btn-secondary:disabled{cursor:not-allowed;opacity:.5}.btn-danger{border-radius:var(--radius-md);background-color:var(--color-error-500);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 2);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);color:var(--color-white);--tw-shadow:0 1px 3px var(--tw-shadow-color,#0000001f), 0 1px 2px var(--tw-shadow-color,#0000003d);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));--tw-duration:.2s;justify-content:center;align-items:center;transition-duration:.2s;display:inline-flex}@media (hover:hover){.btn-danger:hover{background-color:var(--color-error-700);--tw-shadow:0 3px 6px var(--tw-shadow-color,#00000029), 0 3px 6px var(--tw-shadow-color,#0000003b);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}}.btn-danger:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);--tw-ring-color:var(--color-red-500);--tw-ring-offset-width:2px;--tw-ring-offset-shadow:var(--tw-ring-inset,) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-outline-style:none;outline-style:none}.btn-danger:active{background-color:var(--color-red-800)}.btn-danger:disabled{cursor:not-allowed;opacity:.5}.card{border-radius:var(--radius-lg);background-color:var(--color-white);--tw-shadow:0 1px 3px var(--tw-shadow-color,#0000001f), 0 1px 2px var(--tw-shadow-color,#0000003d);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);overflow:hidden}.card-elevated{border-radius:var(--radius-lg);background-color:var(--color-white);--tw-shadow:0 1px 3px var(--tw-shadow-color,#0000001f), 0 1px 2px var(--tw-shadow-color,#0000003d);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);transition-property:box-shadow;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));overflow:hidden}@media (hover:hover){.card-elevated:hover{--tw-shadow:0 3px 6px var(--tw-shadow-color,#00000029), 0 3px 6px var(--tw-shadow-color,#0000003b);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}}.input{border-radius:var(--radius-md);border-style:var(--tw-border-style);border-width:1px;border-color:var(--color-gray-300);width:100%;padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 3);color:var(--color-gray-900)}.input::placeholder{color:var(--color-gray-500)}.input{transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.input:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);--tw-ring-color:var(--color-primary-500);--tw-outline-style:none;border-color:#0000;outline-style:none}.label{margin-bottom:calc(var(--spacing) * 1);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);color:var(--color-gray-700);display:block}.form-group{margin-bottom:calc(var(--spacing) * 4)}.badge-success{background-color:var(--color-success-50);padding-inline:calc(var(--spacing) * 2.5);padding-block:calc(var(--spacing) * .5);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);color:var(--color-success-700);border-radius:3.40282e38px;align-items:center;display:inline-flex}.badge-error{background-color:var(--color-error-50);padding-inline:calc(var(--spacing) * 2.5);padding-block:calc(var(--spacing) * .5);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);color:var(--color-error-700);border-radius:3.40282e38px;align-items:center;display:inline-flex}.badge-info{background-color:var(--color-primary-50);padding-inline:calc(var(--spacing) * 2.5);padding-block:calc(var(--spacing) * .5);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);color:var(--color-primary-700);border-radius:3.40282e38px;align-items:center;display:inline-flex}.app-bar{background-color:var(--color-white);padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 4);--tw-shadow:0 1px 3px var(--tw-shadow-color,#0000001f), 0 1px 2px var(--tw-shadow-color,#0000003d);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.alert-error{margin-bottom:calc(var(--spacing) * 4);border-radius:var(--radius-md);border-style:var(--tw-border-style);border-width:1px;border-color:#f4433633}@supports (color:color-mix(in lab, red, red)){.alert-error{border-color:color-mix(in oklab, var(--color-error-500) 20%, transparent)}}.alert-error{background-color:var(--color-error-50);padding:calc(var(--spacing) * 4);color:var(--color-error-700)}.alert-success{margin-bottom:calc(var(--spacing) * 4);border-radius:var(--radius-md);border-style:var(--tw-border-style);border-width:1px;border-color:#4caf5033}@supports (color:color-mix(in lab, red, red)){.alert-success{border-color:color-mix(in oklab, var(--color-success-500) 20%, transparent)}}.alert-success{background-color:var(--color-success-50);padding:calc(var(--spacing) * 4);color:var(--color-success-700)}}@layer utilities{.start{inset-inline-start:var(--spacing)}.mx-1{margin-inline:calc(var(--spacing) * 1)}.mx-3{margin-inline:calc(var(--spacing) * 3)}.mx-auto{margin-inline:auto}.mt-1{margin-top:calc(var(--spacing) * 1)}.mt-2{margin-top:calc(var(--spacing) * 2)}.mt-3{margin-top:calc(var(--spacing) * 3)}.mt-4{margin-top:calc(var(--spacing) * 4)}.mt-6{margin-top:calc(var(--spacing) * 6)}.mt-8{margin-top:calc(var(--spacing) * 8)}.mr-1{margin-right:calc(var(--spacing) * 1)}.mr-2{margin-right:calc(var(--spacing) * 2)}.mr-4{margin-right:calc(var(--spacing) * 4)}.mb-1{margin-bottom:calc(var(--spacing) * 1)}.mb-2{margin-bottom:calc(var(--spacing) * 2)}.mb-3{margin-bottom:calc(var(--spacing) * 3)}.mb-4{margin-bottom:calc(var(--spacing) * 4)}.mb-6{margin-bottom:calc(var(--spacing) * 6)}.mb-8{margin-bottom:calc(var(--spacing) * 8)}.block{display:block}.flex{display:flex}.grid{display:grid}.hidden{display:none}.inline{display:inline}.h-3{height:calc(var(--spacing) * 3)}.h-4{height:calc(var(--spacing) * 4)}.h-5{height:calc(var(--spacing) * 5)}.h-6{height:calc(var(--spacing) * 6)}.h-16{height:calc(var(--spacing) * 16)}.min-h-screen{min-height:100vh}.w-3{width:calc(var(--spacing) * 3)}.w-4{width:calc(var(--spacing) * 4)}.w-5{width:calc(var(--spacing) * 5)}.w-6{width:calc(var(--spacing) * 6)}.w-16{width:calc(var(--spacing) * 16)}.w-24{width:calc(var(--spacing) * 24)}.w-28{width:calc(var(--spacing) * 28)}.w-32{width:calc(var(--spacing) * 32)}.w-full{width:100%}.max-w-2xl{max-width:var(--container-2xl)}.max-w-4xl{max-width:var(--container-4xl)}.max-w-6xl{max-width:var(--container-6xl)}.max-w-md{max-width:var(--container-md)}.flex-1{flex:1}.flex-shrink-0{flex-shrink:0}.flex-grow{flex-grow:1}.rotate-180{rotate:180deg}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-center{align-items:center}.items-start{align-items:flex-start}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.gap-2{gap:calc(var(--spacing) * 2)}.gap-3{gap:calc(var(--spacing) * 3)}.gap-4{gap:calc(var(--spacing) * 4)}.gap-6{gap:calc(var(--spacing) * 6)}:where(.space-y-2>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 2) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-3>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 3) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 3) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-6>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 6) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 6) * calc(1 - var(--tw-space-y-reverse)))}:where(.divide-y>:not(:last-child)){--tw-divide-y-reverse:0;border-bottom-style:var(--tw-border-style);border-top-style:var(--tw-border-style);border-top-width:calc(1px * var(--tw-divide-y-reverse));border-bottom-width:calc(1px * calc(1 - var(--tw-divide-y-reverse)))}:where(.divide-gray-100>:not(:last-child)){border-color:var(--color-gray-100)}:where(.divide-gray-200>:not(:last-child)){border-color:var(--color-gray-200)}.overflow-x-auto{overflow-x:auto}.rounded-md{border-radius:var(--radius-md)}.border{border-style:var(--tw-border-style);border-width:1px}.border-t{border-top-style:var(--tw-border-style);border-top-width:1px}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-gray-200{border-color:var(--color-gray-200)}.border-primary-500{border-color:var(--color-primary-500)}.bg-gray-50{background-color:var(--color-gray-50)}.bg-gray-100{background-color:var(--color-gray-100)}.bg-primary-50{background-color:var(--color-primary-50)}.bg-white{background-color:var(--color-white)}.p-2{padding:calc(var(--spacing) * 2)}.p-3{padding:calc(var(--spacing) * 3)}.p-4{padding:calc(var(--spacing) * 4)}.p-6{padding:calc(var(--spacing) * 6)}.p-8{padding:calc(var(--spacing) * 8)}.p-12{padding:calc(var(--spacing) * 12)}.px-4{padding-inline:calc(var(--spacing) * 4)}.px-6{padding-inline:calc(var(--spacing) * 6)}.px-8{padding-inline:calc(var(--spacing) * 8)}.py-2{padding-block:calc(var(--spacing) * 2)}.py-3{padding-block:calc(var(--spacing) * 3)}.py-6{padding-block:calc(var(--spacing) * 6)}.py-8{padding-block:calc(var(--spacing) * 8)}.py-12{padding-block:calc(var(--spacing) * 12)}.pt-3{padding-top:calc(var(--spacing) * 3)}.pt-4{padding-top:calc(var(--spacing) * 4)}.text-center{text-align:center}.text-left{text-align:left}.font-mono{font-family:var(--font-mono)}.text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}.text-3xl{font-size:var(--text-3xl);line-height:var(--tw-leading,var(--text-3xl--line-height))}.text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xl{font-size:var(--text-xl);line-height:var(--tw-leading,var(--text-xl--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.font-light{--tw-font-weight:var(--font-weight-light);font-weight:var(--font-weight-light)}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.tracking-wide{--tw-tracking:var(--tracking-wide);letter-spacing:var(--tracking-wide)}.break-all{word-break:break-all}.whitespace-pre-wrap{white-space:pre-wrap}.text-gray-300{color:var(--color-gray-300)}.text-gray-400{color:var(--color-gray-400)}.text-gray-500{color:var(--color-gray-500)}.text-gray-600{color:var(--color-gray-600)}.text-gray-700{color:var(--color-gray-700)}.text-gray-900{color:var(--color-gray-900)}.text-green-600{color:var(--color-green-600)}.text-primary-500{color:var(--color-primary-500)}.text-red-600{color:var(--color-red-600)}.text-red-700{color:var(--color-red-700)}.text-yellow-600{color:var(--color-yellow-600)}.uppercase{text-transform:uppercase}.shadow-\[0_-4px_6px_-1px_rgba\(0\,0\,0\,0\.1\)\]{--tw-shadow:0 -4px 6px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.filter{filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.transition-transform{transition-property:transform,translate,scale,rotate;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}@media (min-width:48rem){.md\:flex{display:flex}.md\:hidden{display:none}}@media (min-width:64rem){.lg\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}}}@property --tw-space-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-divide-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-blur{syntax:"*";inherits:false}@property --tw-brightness{syntax:"*";inherits:false}@property --tw-contrast{syntax:"*";inherits:false}@property --tw-grayscale{syntax:"*";inherits:false}@property --tw-hue-rotate{syntax:"*";inherits:false}@property --tw-invert{syntax:"*";inherits:false}@property --tw-opacity{syntax:"*";inherits:false}@property --tw-saturate{syntax:"*";inherits:false}@property --tw-sepia{syntax:"*";inherits:false}@property --tw-drop-shadow{syntax:"*";inherits:false}@property --tw-drop-shadow-color{syntax:"*";inherits:false}@property --tw-drop-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false}@property --tw-duration{syntax:"*";inherits:false} \ No newline at end of file +@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-space-y-reverse:0;--tw-divide-y-reverse:0;--tw-border-style:solid;--tw-font-weight:initial;--tw-tracking:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-duration:initial}}}@layer theme{:root,:host{--font-sans:ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--color-red-500:oklch(63.7% .237 25.331);--color-red-600:oklch(57.7% .245 27.325);--color-red-700:oklch(50.5% .213 27.518);--color-red-800:oklch(44.4% .177 26.899);--color-yellow-600:oklch(68.1% .162 75.834);--color-green-600:oklch(62.7% .194 149.214);--color-gray-50:oklch(98.5% .002 247.839);--color-gray-100:oklch(96.7% .003 264.542);--color-gray-200:oklch(92.8% .006 264.531);--color-gray-300:oklch(87.2% .01 258.338);--color-gray-400:oklch(70.7% .022 261.325);--color-gray-500:oklch(55.1% .027 264.364);--color-gray-600:oklch(44.6% .03 256.802);--color-gray-700:oklch(37.3% .034 259.733);--color-gray-900:oklch(21% .034 264.665);--color-white:#fff;--spacing:.25rem;--container-md:28rem;--container-2xl:42rem;--container-4xl:56rem;--container-6xl:72rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--text-xl:1.25rem;--text-xl--line-height:calc(1.75 / 1.25);--text-2xl:1.5rem;--text-2xl--line-height:calc(2 / 1.5);--text-3xl:1.875rem;--text-3xl--line-height:calc(2.25 / 1.875);--font-weight-light:300;--font-weight-medium:500;--tracking-wide:.025em;--radius-md:.375rem;--radius-lg:.5rem;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono);--color-primary-50:#e3f2fd;--color-primary-500:#2196f3;--color-primary-600:#1e88e5;--color-primary-700:#1976d2;--color-primary-800:#1565c0;--color-error-50:#ffebee;--color-error-500:#f44336;--color-error-700:#d32f2f;--color-success-50:#e8f5e9;--color-success-500:#4caf50;--color-success-700:#388e3c}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab, currentcolor 50%, transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}}@layer components{.btn-primary{border-radius:var(--radius-md);background-color:var(--color-primary-600);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 2);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);color:var(--color-white);--tw-shadow:0 1px 3px var(--tw-shadow-color,#0000001f), 0 1px 2px var(--tw-shadow-color,#0000003d);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));--tw-duration:.2s;justify-content:center;align-items:center;transition-duration:.2s;display:inline-flex}@media (hover:hover){.btn-primary:hover{background-color:var(--color-primary-700);--tw-shadow:0 3px 6px var(--tw-shadow-color,#00000029), 0 3px 6px var(--tw-shadow-color,#0000003b);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}}.btn-primary:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);--tw-ring-color:var(--color-primary-500);--tw-ring-offset-width:2px;--tw-ring-offset-shadow:var(--tw-ring-inset,) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-outline-style:none;outline-style:none}.btn-primary:active{background-color:var(--color-primary-800)}.btn-primary:disabled{cursor:not-allowed;opacity:.5}.btn-secondary{border-radius:var(--radius-md);border-style:var(--tw-border-style);border-width:1px;border-color:var(--color-gray-300);background-color:var(--color-white);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 2);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);color:var(--color-gray-700);transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));--tw-duration:.2s;justify-content:center;align-items:center;transition-duration:.2s;display:inline-flex}@media (hover:hover){.btn-secondary:hover{background-color:var(--color-gray-50)}}.btn-secondary:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);--tw-ring-color:var(--color-primary-500);--tw-ring-offset-width:2px;--tw-ring-offset-shadow:var(--tw-ring-inset,) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-outline-style:none;outline-style:none}.btn-secondary:active{background-color:var(--color-gray-100)}.btn-secondary:disabled{cursor:not-allowed;opacity:.5}.btn-danger{border-radius:var(--radius-md);background-color:var(--color-error-500);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 2);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);color:var(--color-white);--tw-shadow:0 1px 3px var(--tw-shadow-color,#0000001f), 0 1px 2px var(--tw-shadow-color,#0000003d);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));--tw-duration:.2s;justify-content:center;align-items:center;transition-duration:.2s;display:inline-flex}@media (hover:hover){.btn-danger:hover{background-color:var(--color-error-700);--tw-shadow:0 3px 6px var(--tw-shadow-color,#00000029), 0 3px 6px var(--tw-shadow-color,#0000003b);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}}.btn-danger:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);--tw-ring-color:var(--color-red-500);--tw-ring-offset-width:2px;--tw-ring-offset-shadow:var(--tw-ring-inset,) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-outline-style:none;outline-style:none}.btn-danger:active{background-color:var(--color-red-800)}.btn-danger:disabled{cursor:not-allowed;opacity:.5}.card{border-radius:var(--radius-lg);background-color:var(--color-white);--tw-shadow:0 1px 3px var(--tw-shadow-color,#0000001f), 0 1px 2px var(--tw-shadow-color,#0000003d);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);overflow:hidden}.card-elevated{border-radius:var(--radius-lg);background-color:var(--color-white);--tw-shadow:0 1px 3px var(--tw-shadow-color,#0000001f), 0 1px 2px var(--tw-shadow-color,#0000003d);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);transition-property:box-shadow;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));overflow:hidden}@media (hover:hover){.card-elevated:hover{--tw-shadow:0 3px 6px var(--tw-shadow-color,#00000029), 0 3px 6px var(--tw-shadow-color,#0000003b);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}}.input{border-radius:var(--radius-md);border-style:var(--tw-border-style);border-width:1px;border-color:var(--color-gray-300);width:100%;padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 3);color:var(--color-gray-900)}.input::placeholder{color:var(--color-gray-500)}.input{transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.input:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);--tw-ring-color:var(--color-primary-500);--tw-outline-style:none;border-color:#0000;outline-style:none}.label{margin-bottom:calc(var(--spacing) * 1);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);color:var(--color-gray-700);display:block}.form-group{margin-bottom:calc(var(--spacing) * 4)}.badge-success{background-color:var(--color-success-50);padding-inline:calc(var(--spacing) * 2.5);padding-block:calc(var(--spacing) * .5);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);color:var(--color-success-700);border-radius:3.40282e38px;align-items:center;display:inline-flex}.badge-error{background-color:var(--color-error-50);padding-inline:calc(var(--spacing) * 2.5);padding-block:calc(var(--spacing) * .5);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);color:var(--color-error-700);border-radius:3.40282e38px;align-items:center;display:inline-flex}.badge-info{background-color:var(--color-primary-50);padding-inline:calc(var(--spacing) * 2.5);padding-block:calc(var(--spacing) * .5);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);color:var(--color-primary-700);border-radius:3.40282e38px;align-items:center;display:inline-flex}.app-bar{background-color:var(--color-white);padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 4);--tw-shadow:0 1px 3px var(--tw-shadow-color,#0000001f), 0 1px 2px var(--tw-shadow-color,#0000003d);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.alert-error{margin-bottom:calc(var(--spacing) * 4);border-radius:var(--radius-md);border-style:var(--tw-border-style);border-width:1px;border-color:#f4433633}@supports (color:color-mix(in lab, red, red)){.alert-error{border-color:color-mix(in oklab, var(--color-error-500) 20%, transparent)}}.alert-error{background-color:var(--color-error-50);padding:calc(var(--spacing) * 4);color:var(--color-error-700)}.alert-success{margin-bottom:calc(var(--spacing) * 4);border-radius:var(--radius-md);border-style:var(--tw-border-style);border-width:1px;border-color:#4caf5033}@supports (color:color-mix(in lab, red, red)){.alert-success{border-color:color-mix(in oklab, var(--color-success-500) 20%, transparent)}}.alert-success{background-color:var(--color-success-50);padding:calc(var(--spacing) * 4);color:var(--color-success-700)}}@layer utilities{.start{inset-inline-start:var(--spacing)}.mx-1{margin-inline:calc(var(--spacing) * 1)}.mx-3{margin-inline:calc(var(--spacing) * 3)}.mx-auto{margin-inline:auto}.mt-1{margin-top:calc(var(--spacing) * 1)}.mt-2{margin-top:calc(var(--spacing) * 2)}.mt-3{margin-top:calc(var(--spacing) * 3)}.mt-4{margin-top:calc(var(--spacing) * 4)}.mt-6{margin-top:calc(var(--spacing) * 6)}.mt-8{margin-top:calc(var(--spacing) * 8)}.mr-1{margin-right:calc(var(--spacing) * 1)}.mr-2{margin-right:calc(var(--spacing) * 2)}.mr-4{margin-right:calc(var(--spacing) * 4)}.mb-1{margin-bottom:calc(var(--spacing) * 1)}.mb-2{margin-bottom:calc(var(--spacing) * 2)}.mb-3{margin-bottom:calc(var(--spacing) * 3)}.mb-4{margin-bottom:calc(var(--spacing) * 4)}.mb-6{margin-bottom:calc(var(--spacing) * 6)}.mb-8{margin-bottom:calc(var(--spacing) * 8)}.block{display:block}.flex{display:flex}.grid{display:grid}.hidden{display:none}.inline{display:inline}.h-3{height:calc(var(--spacing) * 3)}.h-4{height:calc(var(--spacing) * 4)}.h-5{height:calc(var(--spacing) * 5)}.h-6{height:calc(var(--spacing) * 6)}.h-16{height:calc(var(--spacing) * 16)}.min-h-screen{min-height:100vh}.w-3{width:calc(var(--spacing) * 3)}.w-4{width:calc(var(--spacing) * 4)}.w-5{width:calc(var(--spacing) * 5)}.w-6{width:calc(var(--spacing) * 6)}.w-16{width:calc(var(--spacing) * 16)}.w-24{width:calc(var(--spacing) * 24)}.w-28{width:calc(var(--spacing) * 28)}.w-32{width:calc(var(--spacing) * 32)}.w-full{width:100%}.max-w-2xl{max-width:var(--container-2xl)}.max-w-4xl{max-width:var(--container-4xl)}.max-w-6xl{max-width:var(--container-6xl)}.max-w-md{max-width:var(--container-md)}.flex-1{flex:1}.flex-shrink-0{flex-shrink:0}.flex-grow{flex-grow:1}.rotate-180{rotate:180deg}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-center{align-items:center}.items-start{align-items:flex-start}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.gap-2{gap:calc(var(--spacing) * 2)}.gap-3{gap:calc(var(--spacing) * 3)}.gap-4{gap:calc(var(--spacing) * 4)}.gap-6{gap:calc(var(--spacing) * 6)}:where(.space-y-2>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 2) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-3>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 3) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 3) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-6>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 6) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 6) * calc(1 - var(--tw-space-y-reverse)))}:where(.divide-y>:not(:last-child)){--tw-divide-y-reverse:0;border-bottom-style:var(--tw-border-style);border-top-style:var(--tw-border-style);border-top-width:calc(1px * var(--tw-divide-y-reverse));border-bottom-width:calc(1px * calc(1 - var(--tw-divide-y-reverse)))}:where(.divide-gray-100>:not(:last-child)){border-color:var(--color-gray-100)}:where(.divide-gray-200>:not(:last-child)){border-color:var(--color-gray-200)}.overflow-x-auto{overflow-x:auto}.rounded-md{border-radius:var(--radius-md)}.border{border-style:var(--tw-border-style);border-width:1px}.border-t{border-top-style:var(--tw-border-style);border-top-width:1px}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-gray-200{border-color:var(--color-gray-200)}.border-primary-500{border-color:var(--color-primary-500)}.bg-gray-50{background-color:var(--color-gray-50)}.bg-gray-100{background-color:var(--color-gray-100)}.bg-primary-50{background-color:var(--color-primary-50)}.bg-white{background-color:var(--color-white)}.p-2{padding:calc(var(--spacing) * 2)}.p-3{padding:calc(var(--spacing) * 3)}.p-4{padding:calc(var(--spacing) * 4)}.p-6{padding:calc(var(--spacing) * 6)}.p-8{padding:calc(var(--spacing) * 8)}.p-12{padding:calc(var(--spacing) * 12)}.px-4{padding-inline:calc(var(--spacing) * 4)}.px-6{padding-inline:calc(var(--spacing) * 6)}.px-8{padding-inline:calc(var(--spacing) * 8)}.py-2{padding-block:calc(var(--spacing) * 2)}.py-3{padding-block:calc(var(--spacing) * 3)}.py-6{padding-block:calc(var(--spacing) * 6)}.py-8{padding-block:calc(var(--spacing) * 8)}.py-12{padding-block:calc(var(--spacing) * 12)}.pt-3{padding-top:calc(var(--spacing) * 3)}.pt-4{padding-top:calc(var(--spacing) * 4)}.text-center{text-align:center}.text-left{text-align:left}.font-mono{font-family:var(--font-mono)}.text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}.text-3xl{font-size:var(--text-3xl);line-height:var(--tw-leading,var(--text-3xl--line-height))}.text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xl{font-size:var(--text-xl);line-height:var(--tw-leading,var(--text-xl--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.font-light{--tw-font-weight:var(--font-weight-light);font-weight:var(--font-weight-light)}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.tracking-wide{--tw-tracking:var(--tracking-wide);letter-spacing:var(--tracking-wide)}.wrap-anywhere{overflow-wrap:anywhere}.break-all{word-break:break-all}.whitespace-pre-wrap{white-space:pre-wrap}.text-gray-300{color:var(--color-gray-300)}.text-gray-400{color:var(--color-gray-400)}.text-gray-500{color:var(--color-gray-500)}.text-gray-600{color:var(--color-gray-600)}.text-gray-700{color:var(--color-gray-700)}.text-gray-900{color:var(--color-gray-900)}.text-green-600{color:var(--color-green-600)}.text-primary-500{color:var(--color-primary-500)}.text-red-600{color:var(--color-red-600)}.text-red-700{color:var(--color-red-700)}.text-yellow-600{color:var(--color-yellow-600)}.uppercase{text-transform:uppercase}.shadow-\[0_-4px_6px_-1px_rgba\(0\,0\,0\,0\.1\)\]{--tw-shadow:0 -4px 6px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.filter{filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.transition-transform{transition-property:transform,translate,scale,rotate;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}@media (min-width:48rem){.md\:flex{display:flex}.md\:hidden{display:none}}@media (min-width:64rem){.lg\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}}}@property --tw-space-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-divide-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-blur{syntax:"*";inherits:false}@property --tw-brightness{syntax:"*";inherits:false}@property --tw-contrast{syntax:"*";inherits:false}@property --tw-grayscale{syntax:"*";inherits:false}@property --tw-hue-rotate{syntax:"*";inherits:false}@property --tw-invert{syntax:"*";inherits:false}@property --tw-opacity{syntax:"*";inherits:false}@property --tw-saturate{syntax:"*";inherits:false}@property --tw-sepia{syntax:"*";inherits:false}@property --tw-drop-shadow{syntax:"*";inherits:false}@property --tw-drop-shadow-color{syntax:"*";inherits:false}@property --tw-drop-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false}@property --tw-duration{syntax:"*";inherits:false} \ No newline at end of file diff --git a/templates/delivery_row.html b/templates/delivery_row.html index eaa33d2..193ac28 100644 --- a/templates/delivery_row.html +++ b/templates/delivery_row.html @@ -4,7 +4,7 @@ an event's own page. Spans only, since a button may hold no div. --> - {{.Target.DisplayName}} + {{.Target.DisplayName}} {{if .Replay}}replay{{end}} {{with .Paused}}waiting: target paused after repeated failures, next try no earlier than {{.Until}} ({{.Relative}}){{else}}{{.Status}}{{end}} diff --git a/templates/event_request.html b/templates/event_request.html index ca72f6b..943615e 100644 --- a/templates/event_request.html +++ b/templates/event_request.html @@ -6,9 +6,9 @@ entrypoint; the request it copies did. -->
{{if .ResubmittedFrom}} -

The request it copies arrived at {{.Entrypoint}}

+

The request it copies arrived at {{.Entrypoint}}

{{else}} -

Arrived at {{.Entrypoint}}

+

Arrived at {{.Entrypoint}}

{{end}} {{if .HeadersCut}}

The request headers are larger than the event log shows. Show the request headers

diff --git a/templates/source_detail.html b/templates/source_detail.html index 4d9a83a..467cb53 100644 --- a/templates/source_detail.html +++ b/templates/source_detail.html @@ -6,15 +6,18 @@ + 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. -->
← Back to webhooks
-

{{.Webhook.Name}}

+

{{.Webhook.Name}}

{{if .Webhook.Description}} -

{{.Webhook.Description}}

+

{{.Webhook.Description}}

{{end}}
@@ -60,7 +63,7 @@ {{range .Entrypoints}}
- {{if .Description}}{{.Description}}{{else}}Entrypoint{{end}} + {{if .Description}}{{.Description}}{{else}}Entrypoint{{end}}
- ← Back to {{.Webhook.Name}} -
+ ← Back to {{.Webhook.Name}} +

Full Event Log

{{if gt .TotalEvents (len .Events)}}{{len .Events}} most recent of {{.TotalEvents}} events{{else}}{{.TotalEvents}}{{if not .Show}} total{{end}} event{{if ne .TotalEvents 1}}s{{end}}{{end}}{{if eq .Show "failed"}} with a failed delivery{{else if eq .Show "pending"}} with a delivery pending or retrying{{end}} @@ -28,8 +33,8 @@
{{.Method}} - {{.ID}} - {{.ContentType}} + {{.ID}} + {{.ContentType}} {{if .ResubmittedFrom}} resubmitted copy {{end}} @@ -54,7 +59,7 @@
- {{if .ResubmittedFrom}}Resubmitted from event {{.ResubmittedFromID}}.{{end}} + {{if .ResubmittedFrom}}Resubmitted from event {{.ResubmittedFromID}}.{{end}} {{if .ResubmitCount}}Resubmitted as {{.ResubmitCount}} new event{{if ne .ResubmitCount 1}}s{{end}}.{{end}}
-- 2.54.0 From fe5e0d41733617022fb3a7b003c3c2b118059111 Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Sat, 3 Oct 2026 07:03:31 +0200 Subject: [PATCH 094/126] Install ESLint and prettier with yarn 4 through corepack (closes #493) The `js-deps` stage installed ESLint and prettier with yarn 1, which is no longer developed and made node print a `url.parse()` deprecation warning on every install. `package.json` now pins yarn 4 by version and hash in its `packageManager` field; the stage enables it through the node image's own corepack and installs with `yarn install --immutable`. `yarn.lock` is regenerated in yarn 4's format with every package at its previously locked version, and `.yarnrc.yml` keeps the install in `node_modules/`, where the lint and Markdown stages run the tools from. The install prints no warning and stays cached until the manifests change. Model: opus-5-5 --- .yarnrc.yml | 3 + Dockerfile | 19 +- README.md | 26 +- package.json | 3 +- yarn.lock | 1020 ++++++++++++++++++++++++++++++-------------------- 5 files changed, 638 insertions(+), 433 deletions(-) create mode 100644 .yarnrc.yml diff --git a/.yarnrc.yml b/.yarnrc.yml new file mode 100644 index 0000000..6d8ebc7 --- /dev/null +++ b/.yarnrc.yml @@ -0,0 +1,3 @@ +# Install into node_modules/: the Dockerfile's lint and Markdown stages run +# ESLint and prettier from node_modules/.bin. +nodeLinker: node-modules diff --git a/Dockerfile b/Dockerfile index e050d4b..e116b5d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -67,15 +67,20 @@ RUN sed 's/}/}\n/g' static/css/tailwind.css > /tmp/committed.css \ # JavaScript lint stages: ESLint, at the version package.json and yarn.lock # pin, checks static/js/ against eslint.config.mjs. js-deps installs it, and -# prettier for the Markdown stages below, and stays cached until those two -# files change. script/lint forces only js-lint to re-run, and the build stage -# below runs it too. COPY . . brings in the CI cache barrier described in the -# lint stage above. -# node:24.21.0-alpine (LTS, with yarn 1.22.22), 2026-09-18 +# prettier for the Markdown stages below, and stays cached until package.json, +# yarn.lock or .yarnrc.yml changes. script/lint forces only js-lint to re-run, +# and the build stage below runs it too. COPY . . brings in the CI cache +# barrier described in the lint stage above. +# +# The image's own corepack runs the yarn that package.json's packageManager +# field names, yarn 4.18.1 (released 2026-09-24), and checks it against the +# hash there. The image also ships yarn 1, which `corepack enable yarn` +# replaces. +# node:24.21.0-alpine (LTS), 2026-09-18 FROM node:24.21.0-alpine@sha256:ebfe2f90462722a7a4de65e91990e97fe0d401c70e0e762c5b53302f905ec1c1 AS js-deps WORKDIR /src -COPY package.json yarn.lock ./ -RUN yarn install --frozen-lockfile --ignore-scripts +COPY package.json yarn.lock .yarnrc.yml ./ +RUN corepack enable yarn && yarn install --immutable --mode=skip-build FROM js-deps AS js-lint COPY . . diff --git a/README.md b/README.md index f9923cf..a143ac7 100644 --- a/README.md +++ b/README.md @@ -3064,7 +3064,8 @@ webhooker/ ├── Dockerfile.browser # Browser test image built by script/test-browser ├── Makefile # 13 of 19 targets shim script/; 6 are inline ├── go.mod / go.sum -├── package.json / yarn.lock # ESLint and prettier, pinned, for the JavaScript lint and Markdown stages +├── package.json / yarn.lock # ESLint, prettier and yarn, pinned, for the JavaScript lint and Markdown stages +├── .yarnrc.yml # yarn settings: install into node_modules/ ├── eslint.config.mjs # ESLint configuration for static/js/ ├── .prettierrc # prettier settings for the Markdown └── .golangci.yml # golangci-lint configuration @@ -3352,13 +3353,17 @@ ESLint never runs on the host either. It lints `static/js/` (not the extracted Alpine.js) in the Dockerfile's `js-lint` stage, which `script/lint` builds after `Dockerfile.lint` and the image build runs before the builder stage. Its version is pinned in `package.json` and every package's hash in `yarn.lock`. The -`js-deps` stage before it installs ESLint and stays cached until either file -changes, so only the lint step re-runs and ESLint is not downloaded again. -`eslint.config.mjs` turns on the rules of the JavaScript styleguide -`REPO_POLICIES.md` links to that a linter can check: `no-var` and -`prefer-const`. ESLint prints nothing on a pass, so `script/lint` has no summary -line to look for; it names the stage once for both `--target` and -`--no-cache-filter`, and `--target` fails on a name that matches no stage. +`js-deps` stage before it installs ESLint with `yarn install --immutable`, which +fails rather than change `yarn.lock`. The yarn it runs is the one the +`packageManager` field in `package.json` pins by version and hash, which the +node image's own corepack fetches and checks. The stage stays cached until +`package.json`, `yarn.lock` or `.yarnrc.yml` changes, so only the lint step +re-runs and ESLint is not downloaded again. `eslint.config.mjs` turns on the +rules of the JavaScript styleguide `REPO_POLICIES.md` links to that a linter can +check: `no-var` and `prefer-const`. ESLint prints nothing on a pass, so +`script/lint` has no summary line to look for; it names the stage once for both +`--target` and `--no-cache-filter`, and `--target` fails on a name that matches +no stage. prettier formats the Markdown, and it never runs on the host either. It is pinned in `package.json` and `yarn.lock` beside ESLint, installed by the same @@ -3384,8 +3389,9 @@ independently of the compiler's: `@source` lines name. `css-check` fails when the committed file differs from the generated one, and `make css` writes the generated file out from `css-output` (see [Stylesheet](#stylesheet)). -3. **JavaScript lint stages** (`node:24.21.0-alpine`, with yarn) — `js-deps` - installs ESLint and prettier from `yarn.lock` and `js-lint` runs ESLint over +3. **JavaScript lint stages** (`node:24.21.0-alpine`, with the yarn + `package.json` pins, run through the image's corepack) — `js-deps` installs + ESLint and prettier from `yarn.lock` and `js-lint` runs ESLint over `static/js/` (see [Linting](#linting)). 4. **Markdown stages** (on `js-deps`) — `markdown-check` runs prettier over the Markdown and fails on any file it would change, and `make fmt` writes the diff --git a/package.json b/package.json index 84c7df0..b392fe6 100644 --- a/package.json +++ b/package.json @@ -3,5 +3,6 @@ "devDependencies": { "eslint": "10.11.0", "prettier": "3.9.9" - } + }, + "packageManager": "yarn@4.18.1+sha512.b2e1e7524f654f2749d32b4ebcb4622473cb5bcbc485df2007e12a154e50162a4d795526768bc5f5b8f81717bfd79deb2472813d86fb5ae2eb551fa9c872b08f" } diff --git a/yarn.lock b/yarn.lock index 4ac26ac..ce81f0d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1,531 +1,721 @@ -# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. -# yarn lockfile v1 +# This file is generated by running "yarn install" inside your project. +# Manual changes might be lost - proceed with caution! +__metadata: + version: 10 + cacheKey: 10c0 -"@cacheable/memory@^2.2.0": - version "2.2.0" - resolved "https://registry.yarnpkg.com/@cacheable/memory/-/memory-2.2.0.tgz#72aeb8b051f6d597d10ac8595b94ad8302bd2239" - integrity sha512-CTLKqLItRCEixEAewD3/j9DB3/o96gpTPD4eJ1v+DGOlxZRZncRQkGYqqnAGCscYd6RNeXfGeiuCphsPtqyIfQ== +"@cacheable/memory@npm:^2.2.0": + version: 2.2.0 + resolution: "@cacheable/memory@npm:2.2.0" dependencies: - "@cacheable/utils" "^2.5.0" - "@keyv/bigmap" "^1.3.1" - hookified "^1.15.1" - keyv "^5.6.0" + "@cacheable/utils": "npm:^2.5.0" + "@keyv/bigmap": "npm:^1.3.1" + hookified: "npm:^1.15.1" + keyv: "npm:^5.6.0" + checksum: 10c0/c74889eb09871be0696065778fff013a498ddc73b538d113c2cb6ece361746f2959fbddfa4ce03ce53c9259998be05949e393212e72413dbd07616744eabd261 + languageName: node + linkType: hard -"@cacheable/utils@^2.5.0": - version "2.5.0" - resolved "https://registry.yarnpkg.com/@cacheable/utils/-/utils-2.5.0.tgz#534c91113aa48fe43baedb169550b6ee070ef303" - integrity sha512-buipgOVDkkPXNR5+xBpDw7Zk2n1EvU7qBJCNUcL7rhQ//kfpOXPAvQ511Os0vpLYJ1pZnvudNytkQt2hst3wqA== +"@cacheable/utils@npm:^2.5.0": + version: 2.5.0 + resolution: "@cacheable/utils@npm:2.5.0" dependencies: - hashery "^1.5.1" - keyv "^5.6.0" + hashery: "npm:^1.5.1" + keyv: "npm:^5.6.0" + checksum: 10c0/99e901accd71eeb0bce91484d6c66d7ef2c731b541561eb5dc9ea7fed78bdc0b65e5dd2a80edde50191143bcfc5f238fc4742475b32e82d43b43eea224038e35 + languageName: node + linkType: hard -"@eslint-community/eslint-utils@^4.8.0": - version "4.10.1" - resolved "https://registry.yarnpkg.com/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz#8911bd72b2c3640a543609e0400b8c4d2e7e7cb6" - integrity sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg== +"@eslint-community/eslint-utils@npm:^4.8.0": + version: 4.10.1 + resolution: "@eslint-community/eslint-utils@npm:4.10.1" dependencies: - eslint-visitor-keys "^3.4.3" + eslint-visitor-keys: "npm:^3.4.3" + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + checksum: 10c0/b514586655698bc6b74db72a496c77e813c78b63e36a83429845ac7057dd77113b0b6c31e6e590d5baaeee2abae6ea22363a41209bcafa078d895ab83ce83011 + languageName: node + linkType: hard -"@eslint-community/regexpp@^4.12.2": - version "4.12.2" - resolved "https://registry.yarnpkg.com/@eslint-community/regexpp/-/regexpp-4.12.2.tgz#bccdf615bcf7b6e8db830ec0b8d21c9a25de597b" - integrity sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew== +"@eslint-community/regexpp@npm:^4.12.2": + version: 4.12.2 + resolution: "@eslint-community/regexpp@npm:4.12.2" + checksum: 10c0/fddcbc66851b308478d04e302a4d771d6917a0b3740dc351513c0da9ca2eab8a1adf99f5e0aa7ab8b13fa0df005c81adeee7e63a92f3effd7d367a163b721c2d + languageName: node + linkType: hard -"@eslint/config-array@^0.23.5": - version "0.23.5" - resolved "https://registry.yarnpkg.com/@eslint/config-array/-/config-array-0.23.5.tgz#56e86d243049195d8acc0c06a1b3dfdc3fa3de95" - integrity sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA== +"@eslint/config-array@npm:^0.23.5": + version: 0.23.5 + resolution: "@eslint/config-array@npm:0.23.5" dependencies: - "@eslint/object-schema" "^3.0.5" - debug "^4.3.1" - minimatch "^10.2.4" + "@eslint/object-schema": "npm:^3.0.5" + debug: "npm:^4.3.1" + minimatch: "npm:^10.2.4" + checksum: 10c0/b24833c4c76e78ee075d306cd3f095db46b2db0f90cc13a6ee6e4275f9889731c05bf5403ab5fefb79c756e07ac9184ed0e04570341382f9eccbccc80e6d1a0c + languageName: node + linkType: hard -"@eslint/config-helpers@^0.7.0": - version "0.7.0" - resolved "https://registry.yarnpkg.com/@eslint/config-helpers/-/config-helpers-0.7.0.tgz#09ee4aa07b73f059ec2d4c74bf4b2ff02b322377" - integrity sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw== +"@eslint/config-helpers@npm:^0.7.0": + version: 0.7.0 + resolution: "@eslint/config-helpers@npm:0.7.0" dependencies: - "@eslint/core" "^1.2.1" + "@eslint/core": "npm:^1.2.1" + checksum: 10c0/fd40d57d6f1db49f7b647048b88a433dc7f6522ef3edf855a43cb526ef4fc40622ceed0dc8de2e03d254f30f8e035370570de1d4bd8e7c2b1200131451e0d331 + languageName: node + linkType: hard -"@eslint/core@^1.2.1": - version "1.2.1" - resolved "https://registry.yarnpkg.com/@eslint/core/-/core-1.2.1.tgz#c1da7cd1b82fa8787f98b5629fb811848a1b63ce" - integrity sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ== +"@eslint/core@npm:^1.2.1": + version: 1.2.1 + resolution: "@eslint/core@npm:1.2.1" dependencies: - "@types/json-schema" "^7.0.15" + "@types/json-schema": "npm:^7.0.15" + checksum: 10c0/10979b40588ecfef771fcb5013a542a35fb30692cc95a65f3481b0b36fbd89f5679efeb30d57f4eed35203d859aabace2a620177d6c536f71b299a1af2f3398f + languageName: node + linkType: hard -"@eslint/object-schema@^3.0.5": - version "3.0.5" - resolved "https://registry.yarnpkg.com/@eslint/object-schema/-/object-schema-3.0.5.tgz#88e9bf4d11d2b19c082e78ebe7ce88724a5eb091" - integrity sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw== +"@eslint/object-schema@npm:^3.0.5": + version: 3.0.5 + resolution: "@eslint/object-schema@npm:3.0.5" + checksum: 10c0/1db337431f520b99e9edda64ef5fafd7ec6a029843eeb608753025125b6649d861d843cffafafd3c4e37926d7d5f9ec0c6a8e3665c13c3da2144e8132892e92e + languageName: node + linkType: hard -"@eslint/plugin-kit@^0.7.3": - version "0.7.3" - resolved "https://registry.yarnpkg.com/@eslint/plugin-kit/-/plugin-kit-0.7.3.tgz#cc7268cc36405b331ef92db1bc37971f21d66fe1" - integrity sha512-IkO+/KEUvwbVpiURZg+P7zF74z5Jxe0UgJxVni+RtoHQ6IZieXaO02kmadomap/q+l6bc/jdPGGqTjhuZnuz1Q== +"@eslint/plugin-kit@npm:^0.7.3": + version: 0.7.3 + resolution: "@eslint/plugin-kit@npm:0.7.3" dependencies: - "@eslint/core" "^1.2.1" - levn "^0.4.1" + "@eslint/core": "npm:^1.2.1" + levn: "npm:^0.4.1" + checksum: 10c0/2daebaedcfbd261478e8b87dfb46d3118f48277c19eb298483ebc6f3b438e9688b86ddca13b3aeb08dfb06be00fb6073506852386aa124645c1170e7e33d140c + languageName: node + linkType: hard -"@humanfs/core@^0.19.2": - version "0.19.2" - resolved "https://registry.yarnpkg.com/@humanfs/core/-/core-0.19.2.tgz#a8272ca03b2acf492670222b2320b6c421bfde60" - integrity sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA== +"@humanfs/core@npm:^0.19.2": + version: 0.19.2 + resolution: "@humanfs/core@npm:0.19.2" dependencies: - "@humanfs/types" "^0.15.0" + "@humanfs/types": "npm:^0.15.0" + checksum: 10c0/d0a1d52d7b30c27d49475a53072d1510b81c5803e44b342fb8faf3887f1aa27593a1e6dc76a45268e7892d3f4e198146659281f6b6d55eacf3fd5a38bac30c5c + languageName: node + linkType: hard -"@humanfs/node@^0.16.6": - version "0.16.8" - resolved "https://registry.yarnpkg.com/@humanfs/node/-/node-0.16.8.tgz#8f800cccc13f4f8cd3116e2d9c0a94939da3e3ed" - integrity sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ== +"@humanfs/node@npm:^0.16.6": + version: 0.16.8 + resolution: "@humanfs/node@npm:0.16.8" dependencies: - "@humanfs/core" "^0.19.2" - "@humanfs/types" "^0.15.0" - "@humanwhocodes/retry" "^0.4.0" + "@humanfs/core": "npm:^0.19.2" + "@humanfs/types": "npm:^0.15.0" + "@humanwhocodes/retry": "npm:^0.4.0" + checksum: 10c0/56140579db811af4e160b195d45d0f29acf644d192c93fe24c9e594ebf06f19dfc157494a07c84540b8a071c0e4b37209c2362765d31734f4d0be869c2422e25 + languageName: node + linkType: hard -"@humanfs/types@^0.15.0": - version "0.15.0" - resolved "https://registry.yarnpkg.com/@humanfs/types/-/types-0.15.0.tgz#f2a09f62012390b2bff3fc6fb248ddec8c09a090" - integrity sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q== +"@humanfs/types@npm:^0.15.0": + version: 0.15.0 + resolution: "@humanfs/types@npm:0.15.0" + checksum: 10c0/fc26b9a024b0e55f7eaf64036df94345bf5d36d6a41ef80ef38e78f1f7430ce26cf435af736adae58913baae18eac3f38c18739054a3d379102015978eae862e + languageName: node + linkType: hard -"@humanwhocodes/module-importer@^1.0.1": - version "1.0.1" - resolved "https://registry.yarnpkg.com/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz#af5b2691a22b44be847b0ca81641c5fb6ad0172c" - integrity sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA== +"@humanwhocodes/module-importer@npm:^1.0.1": + version: 1.0.1 + resolution: "@humanwhocodes/module-importer@npm:1.0.1" + checksum: 10c0/909b69c3b86d482c26b3359db16e46a32e0fb30bd306a3c176b8313b9e7313dba0f37f519de6aa8b0a1921349e505f259d19475e123182416a506d7f87e7f529 + languageName: node + linkType: hard -"@humanwhocodes/retry@^0.4.0", "@humanwhocodes/retry@^0.4.2": - version "0.4.3" - resolved "https://registry.yarnpkg.com/@humanwhocodes/retry/-/retry-0.4.3.tgz#c2b9d2e374ee62c586d3adbea87199b1d7a7a6ba" - integrity sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ== +"@humanwhocodes/retry@npm:^0.4.0, @humanwhocodes/retry@npm:^0.4.2": + version: 0.4.3 + resolution: "@humanwhocodes/retry@npm:0.4.3" + checksum: 10c0/3775bb30087d4440b3f7406d5a057777d90e4b9f435af488a4923ef249e93615fb78565a85f173a186a076c7706a81d0d57d563a2624e4de2c5c9c66c486ce42 + languageName: node + linkType: hard -"@keyv/bigmap@^1.3.1": - version "1.3.1" - resolved "https://registry.yarnpkg.com/@keyv/bigmap/-/bigmap-1.3.1.tgz#fc82fa83947e7ff68c6798d08907db842771ef2c" - integrity sha512-WbzE9sdmQtKy8vrNPa9BRnwZh5UF4s1KTmSK0KUVLo3eff5BlQNNWDnFOouNpKfPKDnms9xynJjsMYjMaT/aFQ== +"@keyv/bigmap@npm:^1.3.1": + version: 1.3.1 + resolution: "@keyv/bigmap@npm:1.3.1" dependencies: - hashery "^1.4.0" - hookified "^1.15.0" + hashery: "npm:^1.4.0" + hookified: "npm:^1.15.0" + peerDependencies: + keyv: ^5.6.0 + checksum: 10c0/acc6a4a5edf462ce23e95672ab4bfaf7cd1941dff6bf3a2f671ce467961ace1fac9d3eb75a9ed9a8e92012e00a7d8b16ad1677bc539a52c3ad0cec31473e2349 + languageName: node + linkType: hard -"@keyv/serialize@^1.1.1": - version "1.1.1" - resolved "https://registry.yarnpkg.com/@keyv/serialize/-/serialize-1.1.1.tgz#0c01dd3a3483882af7cf3878d4e71d505c81fc4a" - integrity sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA== +"@keyv/serialize@npm:^1.1.1": + version: 1.1.1 + resolution: "@keyv/serialize@npm:1.1.1" + checksum: 10c0/b0008cae4a54400c3abf587b8cc2474c6f528ee58969ce6cf9cb07a04006f80c73c85971d6be6544408318a2bc40108236a19a82aea0a6de95aae49533317374 + languageName: node + linkType: hard -"@types/esrecurse@^4.3.1": - version "4.3.1" - resolved "https://registry.yarnpkg.com/@types/esrecurse/-/esrecurse-4.3.1.tgz#6f636af962fbe6191b830bd676ba5986926bccec" - integrity sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw== +"@types/esrecurse@npm:^4.3.1": + version: 4.3.1 + resolution: "@types/esrecurse@npm:4.3.1" + checksum: 10c0/90dad74d5da3ad27606d8e8e757322f33171cfeaa15ad558b615cf71bb2a516492d18f55f4816384685a3eb2412142e732bbae9a4a7cd2cf3deb7572aa4ebe03 + languageName: node + linkType: hard -"@types/estree@^1.0.6", "@types/estree@^1.0.8": - version "1.0.9" - resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.9.tgz#cf3f0e876d7bee15a93ab925b82bf570a3904a24" - integrity sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg== +"@types/estree@npm:^1.0.6, @types/estree@npm:^1.0.8": + version: 1.0.9 + resolution: "@types/estree@npm:1.0.9" + checksum: 10c0/3ad3286ca2988cd550dafb8f2ad599c8474868e954fa601a36655bdfefd8039f7c714b8c1c7f2ae219ffbd58bd4660e66fa7479a0120fc02d4777057d4865387 + languageName: node + linkType: hard -"@types/json-schema@^7.0.15": - version "7.0.15" - resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.15.tgz#596a1747233694d50f6ad8a7869fcb6f56cf5841" - integrity sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA== +"@types/json-schema@npm:^7.0.15": + version: 7.0.15 + resolution: "@types/json-schema@npm:7.0.15" + checksum: 10c0/a996a745e6c5d60292f36731dd41341339d4eeed8180bb09226e5c8d23759067692b1d88e5d91d72ee83dfc00d3aca8e7bd43ea120516c17922cbcb7c3e252db + languageName: node + linkType: hard -acorn-jsx@^5.3.2: - version "5.3.2" - resolved "https://registry.yarnpkg.com/acorn-jsx/-/acorn-jsx-5.3.2.tgz#7ed5bb55908b3b2f1bc55c6af1653bada7f07937" - integrity sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ== +"acorn-jsx@npm:^5.3.2": + version: 5.3.2 + resolution: "acorn-jsx@npm:5.3.2" + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + checksum: 10c0/4c54868fbef3b8d58927d5e33f0a4de35f59012fe7b12cf9dfbb345fb8f46607709e1c4431be869a23fb63c151033d84c4198fa9f79385cec34fcb1dd53974c1 + languageName: node + linkType: hard -acorn@^8.16.0: - version "8.18.0" - resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.18.0.tgz#4faf01b2d6d326bfeed97aea1f52220b5f4c1940" - integrity sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ== +"acorn@npm:^8.16.0": + version: 8.18.0 + resolution: "acorn@npm:8.18.0" + bin: + acorn: bin/acorn + checksum: 10c0/be771be2135cc07910cf76f444ad514d7dcfd6d4a8026e597e93155275abc8ef61eee12211d52146e9d962874269b634f397464942087be013c89d0c54c5f8e5 + languageName: node + linkType: hard -ajv@^6.14.0: - version "6.15.0" - resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.15.0.tgz#07e982c74626167aa7a2495c53817892d7139492" - integrity sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw== +"ajv@npm:^6.14.0": + version: 6.15.0 + resolution: "ajv@npm:6.15.0" dependencies: - fast-deep-equal "^3.1.1" - fast-json-stable-stringify "^2.0.0" - json-schema-traverse "^0.4.1" - uri-js "^4.2.2" + fast-deep-equal: "npm:^3.1.1" + fast-json-stable-stringify: "npm:^2.0.0" + json-schema-traverse: "npm:^0.4.1" + uri-js: "npm:^4.2.2" + checksum: 10c0/67966499dd272ecde1c2e467084411132891523d057487587879d39ac04207f4351b7b2324c83198013967fbfa632c1612adc960114a30770fbe07a0773b32c2 + languageName: node + linkType: hard -balanced-match@^4.0.2: - version "4.0.4" - resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-4.0.4.tgz#bfb10662feed8196a2c62e7c68e17720c274179a" - integrity sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA== +"balanced-match@npm:^4.0.2": + version: 4.0.4 + resolution: "balanced-match@npm:4.0.4" + checksum: 10c0/07e86102a3eb2ee2a6a1a89164f29d0dbaebd28f2ca3f5ca786f36b8b23d9e417eb3be45a4acf754f837be5ac0a2317de90d3fcb7f4f4dc95720a1f36b26a17b + languageName: node + linkType: hard -brace-expansion@^5.0.8: - version "5.0.12" - resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-5.0.12.tgz#995fbb4750a77c4d16a7dc942ff2ca6ef8e675ec" - integrity sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ== +"brace-expansion@npm:^5.0.8": + version: 5.0.12 + resolution: "brace-expansion@npm:5.0.12" dependencies: - balanced-match "^4.0.2" + balanced-match: "npm:^4.0.2" + checksum: 10c0/a40ac64d3bdb5bb222456514d3633c1f627f03b844f509c69e70f98b78be0ce85e21b44bbfecdd0ddd4cc0855ca4242b41ea96ddccf88c7c3c7a84405474bb3a + languageName: node + linkType: hard -cacheable@^2.5.0: - version "2.5.0" - resolved "https://registry.yarnpkg.com/cacheable/-/cacheable-2.5.0.tgz#d142d41043e5a865f6053cc70ef4e3ad068bd5ce" - integrity sha512-60cyAOytib/OzBw1JNSoSV/boK1AtHryDIjvVBk7XbN4ugfkM3+Sry7fEjNgPMGgOjuaZPAp8ruZ0Cxafwyq9g== +"cacheable@npm:^2.5.0": + version: 2.5.0 + resolution: "cacheable@npm:2.5.0" dependencies: - "@cacheable/memory" "^2.2.0" - "@cacheable/utils" "^2.5.0" - hookified "^1.15.0" - keyv "^5.6.0" - qified "^0.10.1" + "@cacheable/memory": "npm:^2.2.0" + "@cacheable/utils": "npm:^2.5.0" + hookified: "npm:^1.15.0" + keyv: "npm:^5.6.0" + qified: "npm:^0.10.1" + checksum: 10c0/ad4086a60ed327196901c16360312705e35bfde5118457449cfabaf4ece0fecad046985ea8b66b3ece46dd469cb33798d87e1dac3ed2c00746578054bccbf74d + languageName: node + linkType: hard -cross-spawn@^7.0.6: - version "7.0.6" - resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.6.tgz#8a58fe78f00dcd70c370451759dfbfaf03e8ee9f" - integrity sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA== +"cross-spawn@npm:^7.0.6": + version: 7.0.6 + resolution: "cross-spawn@npm:7.0.6" dependencies: - path-key "^3.1.0" - shebang-command "^2.0.0" - which "^2.0.1" + path-key: "npm:^3.1.0" + shebang-command: "npm:^2.0.0" + which: "npm:^2.0.1" + checksum: 10c0/053ea8b2135caff68a9e81470e845613e374e7309a47731e81639de3eaeb90c3d01af0e0b44d2ab9d50b43467223b88567dfeb3262db942dc063b9976718ffc1 + languageName: node + linkType: hard -debug@^4.3.1, debug@^4.3.2: - version "4.4.3" - resolved "https://registry.yarnpkg.com/debug/-/debug-4.4.3.tgz#c6ae432d9bd9662582fce08709b038c58e9e3d6a" - integrity sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA== +"debug@npm:^4.3.1, debug@npm:^4.3.2": + version: 4.4.3 + resolution: "debug@npm:4.4.3" dependencies: - ms "^2.1.3" + ms: "npm:^2.1.3" + peerDependenciesMeta: + supports-color: + optional: true + checksum: 10c0/d79136ec6c83ecbefd0f6a5593da6a9c91ec4d7ddc4b54c883d6e71ec9accb5f67a1a5e96d00a328196b5b5c86d365e98d8a3a70856aaf16b4e7b1985e67f5a6 + languageName: node + linkType: hard -deep-is@^0.1.3: - version "0.1.4" - resolved "https://registry.yarnpkg.com/deep-is/-/deep-is-0.1.4.tgz#a6f2dce612fadd2ef1f519b73551f17e85199831" - integrity sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ== +"deep-is@npm:^0.1.3": + version: 0.1.4 + resolution: "deep-is@npm:0.1.4" + checksum: 10c0/7f0ee496e0dff14a573dc6127f14c95061b448b87b995fc96c017ce0a1e66af1675e73f1d6064407975bc4ea6ab679497a29fff7b5b9c4e99cb10797c1ad0b4c + languageName: node + linkType: hard -escape-string-regexp@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz#14ba83a5d373e3d311e5afca29cf5bfad965bf34" - integrity sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA== +"escape-string-regexp@npm:^4.0.0": + version: 4.0.0 + resolution: "escape-string-regexp@npm:4.0.0" + checksum: 10c0/9497d4dd307d845bd7f75180d8188bb17ea8c151c1edbf6b6717c100e104d629dc2dfb687686181b0f4b7d732c7dfdc4d5e7a8ff72de1b0ca283a75bbb3a9cd9 + languageName: node + linkType: hard -eslint-scope@^9.1.2: - version "9.1.2" - resolved "https://registry.yarnpkg.com/eslint-scope/-/eslint-scope-9.1.2.tgz#b9de6ace2fab1cff24d2e58d85b74c8fcea39802" - integrity sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ== +"eslint-scope@npm:^9.1.2": + version: 9.1.2 + resolution: "eslint-scope@npm:9.1.2" dependencies: - "@types/esrecurse" "^4.3.1" - "@types/estree" "^1.0.8" - esrecurse "^4.3.0" - estraverse "^5.2.0" + "@types/esrecurse": "npm:^4.3.1" + "@types/estree": "npm:^1.0.8" + esrecurse: "npm:^4.3.0" + estraverse: "npm:^5.2.0" + checksum: 10c0/9fb8bca5a73e5741efb6cec84467027b6cb6f4203ff9b43a938e272c5cd30800bde46a5c20dfd1609f840225f0b62b7673be391b20acadf8658ca9fa4729b3dd + languageName: node + linkType: hard -eslint-visitor-keys@^3.4.3: - version "3.4.3" - resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz#0cd72fe8550e3c2eae156a96a4dddcd1c8ac5800" - integrity sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag== +"eslint-visitor-keys@npm:^3.4.3": + version: 3.4.3 + resolution: "eslint-visitor-keys@npm:3.4.3" + checksum: 10c0/92708e882c0a5ffd88c23c0b404ac1628cf20104a108c745f240a13c332a11aac54f49a22d5762efbffc18ecbc9a580d1b7ad034bf5f3cc3307e5cbff2ec9820 + languageName: node + linkType: hard -eslint-visitor-keys@^5.0.1: - version "5.0.1" - resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz#9e3c9489697824d2d4ce3a8ad12628f91e9f59be" - integrity sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA== +"eslint-visitor-keys@npm:^5.0.1": + version: 5.0.1 + resolution: "eslint-visitor-keys@npm:5.0.1" + checksum: 10c0/16190bdf2cbae40a1109384c94450c526a79b0b9c3cb21e544256ed85ac48a4b84db66b74a6561d20fe6ab77447f150d711c2ad5ad74df4fcc133736bce99678 + languageName: node + linkType: hard -eslint@10.11.0: - version "10.11.0" - resolved "https://registry.yarnpkg.com/eslint/-/eslint-10.11.0.tgz#304d1591b7c6a327e3f64b4f550f99fda160ae20" - integrity sha512-P7a6UEEqb9G95MYAtqkmsTbVXIYyzIfl6NGOIJk162PaahFxFyeGcrlXYFSiagECg4sEm8IseJdZBKR3rx6MsQ== +"eslint@npm:10.11.0": + version: 10.11.0 + resolution: "eslint@npm:10.11.0" dependencies: - "@eslint-community/eslint-utils" "^4.8.0" - "@eslint-community/regexpp" "^4.12.2" - "@eslint/config-array" "^0.23.5" - "@eslint/config-helpers" "^0.7.0" - "@eslint/core" "^1.2.1" - "@eslint/plugin-kit" "^0.7.3" - "@humanfs/node" "^0.16.6" - "@humanwhocodes/module-importer" "^1.0.1" - "@humanwhocodes/retry" "^0.4.2" - "@types/estree" "^1.0.6" - ajv "^6.14.0" - cross-spawn "^7.0.6" - debug "^4.3.2" - escape-string-regexp "^4.0.0" - eslint-scope "^9.1.2" - eslint-visitor-keys "^5.0.1" - espree "^11.2.0" - esquery "^1.7.0" - esutils "^2.0.2" - fast-deep-equal "^3.1.3" - file-entry-cache "11.1.5 || >11.1.6 <12" - find-up "^5.0.0" - glob-parent "^6.0.2" - ignore "^5.2.0" - imurmurhash "^0.1.4" - is-glob "^4.0.0" - json-stable-stringify-without-jsonify "^1.0.1" - minimatch "^10.2.5" - natural-compare "^1.4.0" - optionator "^0.9.3" + "@eslint-community/eslint-utils": "npm:^4.8.0" + "@eslint-community/regexpp": "npm:^4.12.2" + "@eslint/config-array": "npm:^0.23.5" + "@eslint/config-helpers": "npm:^0.7.0" + "@eslint/core": "npm:^1.2.1" + "@eslint/plugin-kit": "npm:^0.7.3" + "@humanfs/node": "npm:^0.16.6" + "@humanwhocodes/module-importer": "npm:^1.0.1" + "@humanwhocodes/retry": "npm:^0.4.2" + "@types/estree": "npm:^1.0.6" + ajv: "npm:^6.14.0" + cross-spawn: "npm:^7.0.6" + debug: "npm:^4.3.2" + escape-string-regexp: "npm:^4.0.0" + eslint-scope: "npm:^9.1.2" + eslint-visitor-keys: "npm:^5.0.1" + espree: "npm:^11.2.0" + esquery: "npm:^1.7.0" + esutils: "npm:^2.0.2" + fast-deep-equal: "npm:^3.1.3" + file-entry-cache: "npm:11.1.5 || >11.1.6 <12" + find-up: "npm:^5.0.0" + glob-parent: "npm:^6.0.2" + ignore: "npm:^5.2.0" + imurmurhash: "npm:^0.1.4" + is-glob: "npm:^4.0.0" + json-stable-stringify-without-jsonify: "npm:^1.0.1" + minimatch: "npm:^10.2.5" + natural-compare: "npm:^1.4.0" + optionator: "npm:^0.9.3" + peerDependencies: + jiti: "*" + peerDependenciesMeta: + jiti: + optional: true + bin: + eslint: bin/eslint.js + checksum: 10c0/c767f45f21d1921b41d70e30b1c8fa7ec4e65c0f43a8d80cc4990ca3d923127fa7b9b3416b8f5be6e877d9286f97d3c51a04ed341bc6aa73c0dba0f463e1f861 + languageName: node + linkType: hard -espree@^11.2.0: - version "11.2.0" - resolved "https://registry.yarnpkg.com/espree/-/espree-11.2.0.tgz#01d5e47dc332aaba3059008362454a8cc34ccaa5" - integrity sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw== +"espree@npm:^11.2.0": + version: 11.2.0 + resolution: "espree@npm:11.2.0" dependencies: - acorn "^8.16.0" - acorn-jsx "^5.3.2" - eslint-visitor-keys "^5.0.1" + acorn: "npm:^8.16.0" + acorn-jsx: "npm:^5.3.2" + eslint-visitor-keys: "npm:^5.0.1" + checksum: 10c0/cf87e18ffd9dc113eb8d16588e7757701bc10c9934a71cce8b89c2611d51672681a918307bd6b19ac3ccd0e7ba1cbccc2f815b36b52fa7e73097b251014c3d81 + languageName: node + linkType: hard -esquery@^1.7.0: - version "1.7.0" - resolved "https://registry.yarnpkg.com/esquery/-/esquery-1.7.0.tgz#08d048f261f0ddedb5bae95f46809463d9c9496d" - integrity sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g== +"esquery@npm:^1.7.0": + version: 1.7.0 + resolution: "esquery@npm:1.7.0" dependencies: - estraverse "^5.1.0" + estraverse: "npm:^5.1.0" + checksum: 10c0/77d5173db450b66f3bc685d11af4c90cffeedb340f34a39af96d43509a335ce39c894fd79233df32d38f5e4e219fa0f7076f6ec90bae8320170ba082c0db4793 + languageName: node + linkType: hard -esrecurse@^4.3.0: - version "4.3.0" - resolved "https://registry.yarnpkg.com/esrecurse/-/esrecurse-4.3.0.tgz#7ad7964d679abb28bee72cec63758b1c5d2c9921" - integrity sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag== +"esrecurse@npm:^4.3.0": + version: 4.3.0 + resolution: "esrecurse@npm:4.3.0" dependencies: - estraverse "^5.2.0" + estraverse: "npm:^5.2.0" + checksum: 10c0/81a37116d1408ded88ada45b9fb16dbd26fba3aadc369ce50fcaf82a0bac12772ebd7b24cd7b91fc66786bf2c1ac7b5f196bc990a473efff972f5cb338877cf5 + languageName: node + linkType: hard -estraverse@^5.1.0, estraverse@^5.2.0: - version "5.3.0" - resolved "https://registry.yarnpkg.com/estraverse/-/estraverse-5.3.0.tgz#2eea5290702f26ab8fe5370370ff86c965d21123" - integrity sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA== +"estraverse@npm:^5.1.0, estraverse@npm:^5.2.0": + version: 5.3.0 + resolution: "estraverse@npm:5.3.0" + checksum: 10c0/1ff9447b96263dec95d6d67431c5e0771eb9776427421260a3e2f0fdd5d6bd4f8e37a7338f5ad2880c9f143450c9b1e4fc2069060724570a49cf9cf0312bd107 + languageName: node + linkType: hard -esutils@^2.0.2: - version "2.0.3" - resolved "https://registry.yarnpkg.com/esutils/-/esutils-2.0.3.tgz#74d2eb4de0b8da1293711910d50775b9b710ef64" - integrity sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g== +"esutils@npm:^2.0.2": + version: 2.0.3 + resolution: "esutils@npm:2.0.3" + checksum: 10c0/9a2fe69a41bfdade834ba7c42de4723c97ec776e40656919c62cbd13607c45e127a003f05f724a1ea55e5029a4cf2de444b13009f2af71271e42d93a637137c7 + languageName: node + linkType: hard -fast-deep-equal@^3.1.1, fast-deep-equal@^3.1.3: - version "3.1.3" - resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525" - integrity sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q== +"fast-deep-equal@npm:^3.1.1, fast-deep-equal@npm:^3.1.3": + version: 3.1.3 + resolution: "fast-deep-equal@npm:3.1.3" + checksum: 10c0/40dedc862eb8992c54579c66d914635afbec43350afbbe991235fdcb4e3a8d5af1b23ae7e79bef7d4882d0ecee06c3197488026998fb19f72dc95acff1d1b1d0 + languageName: node + linkType: hard -fast-json-stable-stringify@^2.0.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz#874bf69c6f404c2b5d99c481341399fd55892633" - integrity sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw== +"fast-json-stable-stringify@npm:^2.0.0": + version: 2.1.0 + resolution: "fast-json-stable-stringify@npm:2.1.0" + checksum: 10c0/7f081eb0b8a64e0057b3bb03f974b3ef00135fbf36c1c710895cd9300f13c94ba809bb3a81cf4e1b03f6e5285610a61abbd7602d0652de423144dfee5a389c9b + languageName: node + linkType: hard -fast-levenshtein@^2.0.6: - version "2.0.6" - resolved "https://registry.yarnpkg.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz#3d8a5c66883a16a30ca8643e851f19baa7797917" - integrity sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw== +"fast-levenshtein@npm:^2.0.6": + version: 2.0.6 + resolution: "fast-levenshtein@npm:2.0.6" + checksum: 10c0/111972b37338bcb88f7d9e2c5907862c280ebf4234433b95bc611e518d192ccb2d38119c4ac86e26b668d75f7f3894f4ff5c4982899afced7ca78633b08287c4 + languageName: node + linkType: hard -"file-entry-cache@11.1.5 || >11.1.6 <12": - version "11.1.5" - resolved "https://registry.yarnpkg.com/file-entry-cache/-/file-entry-cache-11.1.5.tgz#c8210eb055de63e68685ccfb6a017e386d4577d0" - integrity sha512-+PFTHITI08JIGhnNpGNI8T8inUpgZfk3GNEqfT9R2zZV2iFXg3CvqzSl/uEhs7TSGujYRELEANyDvS8Fj7+S7Q== +"file-entry-cache@npm:11.1.5 || >11.1.6 <12": + version: 11.1.5 + resolution: "file-entry-cache@npm:11.1.5" dependencies: - flat-cache "^6.1.23" + flat-cache: "npm:^6.1.23" + checksum: 10c0/d05eca8794b27824d79a0f1a0bc76b81ca7232b6c9634bc086f365a6d9ddac749c4826b2b2d36bb7ef9ed84fdf0097f34979527daeb9255b4f17673a4ae50fc5 + languageName: node + linkType: hard -find-up@^5.0.0: - version "5.0.0" - resolved "https://registry.yarnpkg.com/find-up/-/find-up-5.0.0.tgz#4c92819ecb7083561e4f4a240a86be5198f536fc" - integrity sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng== +"find-up@npm:^5.0.0": + version: 5.0.0 + resolution: "find-up@npm:5.0.0" dependencies: - locate-path "^6.0.0" - path-exists "^4.0.0" + locate-path: "npm:^6.0.0" + path-exists: "npm:^4.0.0" + checksum: 10c0/062c5a83a9c02f53cdd6d175a37ecf8f87ea5bbff1fdfb828f04bfa021441bc7583e8ebc0872a4c1baab96221fb8a8a275a19809fb93fbc40bd69ec35634069a + languageName: node + linkType: hard -flat-cache@^6.1.23: - version "6.1.23" - resolved "https://registry.yarnpkg.com/flat-cache/-/flat-cache-6.1.23.tgz#735dc888c271868d7301b3bbb8edba5028afb61d" - integrity sha512-f++BY9pTk+983xK1FLzlLpmM0i0z+jHmx3QESGkURMXujQZz1k5wzwX6hjnQ8goaD0B+sYnDK1yZ6MTyZfUaqA== +"flat-cache@npm:^6.1.23": + version: 6.1.23 + resolution: "flat-cache@npm:6.1.23" dependencies: - cacheable "^2.5.0" - flatted "^3.4.2" - hookified "^1.15.0" + cacheable: "npm:^2.5.0" + flatted: "npm:^3.4.2" + hookified: "npm:^1.15.0" + checksum: 10c0/c757c24f51334e1917958cd9d62d4ed3d520a3815d5a1ec50d993ac3a93fa81b6de7e28264231914bb0d990babd426e4d713d3e2f3d782dae65c42a2ded1e651 + languageName: node + linkType: hard -flatted@^3.4.2: - version "3.4.4" - resolved "https://registry.yarnpkg.com/flatted/-/flatted-3.4.4.tgz#aeeca2a506303f0cee61c59e6c9f2a88d2f29fc6" - integrity sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q== +"flatted@npm:^3.4.2": + version: 3.4.4 + resolution: "flatted@npm:3.4.4" + checksum: 10c0/a3a52a88ea5a4c333e5a1f097dcd87a037fa31c236a77cf46222c2aa4036ef895ab9bc76138a66d9dc22bdb3652128f9598cd26e7439561bf19f67276e2bc37e + languageName: node + linkType: hard -glob-parent@^6.0.2: - version "6.0.2" - resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-6.0.2.tgz#6d237d99083950c79290f24c7642a3de9a28f9e3" - integrity sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A== +"glob-parent@npm:^6.0.2": + version: 6.0.2 + resolution: "glob-parent@npm:6.0.2" dependencies: - is-glob "^4.0.3" + is-glob: "npm:^4.0.3" + checksum: 10c0/317034d88654730230b3f43bb7ad4f7c90257a426e872ea0bf157473ac61c99bf5d205fad8f0185f989be8d2fa6d3c7dce1645d99d545b6ea9089c39f838e7f8 + languageName: node + linkType: hard -hashery@^1.4.0, hashery@^1.5.1: - version "1.5.1" - resolved "https://registry.yarnpkg.com/hashery/-/hashery-1.5.1.tgz#4ba82ad54911ac617467870845d57a9fe508a400" - integrity sha512-iZyKG96/JwPz1N55vj2Ie2vXbhu440zfUfJvSwEqEbeLluk7NnapfGqa7LH0mOsnDxTF85Mx8/dyR6HfqcbmbQ== +"hashery@npm:^1.4.0, hashery@npm:^1.5.1": + version: 1.5.1 + resolution: "hashery@npm:1.5.1" dependencies: - hookified "^1.15.0" + hookified: "npm:^1.15.0" + checksum: 10c0/ab4225b655a7b0d05df99b1a59d5b3a51fe433f82422ca25e6f3f4c4ddd30adb49ebd38e0047ef9bded93319c1e9fc857e16aa382e554929c871cb77d39fc463 + languageName: node + linkType: hard -hookified@^1.15.0, hookified@^1.15.1: - version "1.15.1" - resolved "https://registry.yarnpkg.com/hookified/-/hookified-1.15.1.tgz#b1fafeaa5489cdc29cb85546a8f837ed4ffbbcb6" - integrity sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg== +"hookified@npm:^1.15.0, hookified@npm:^1.15.1": + version: 1.15.1 + resolution: "hookified@npm:1.15.1" + checksum: 10c0/6b691374fa97ae57169fb29f90e723499fda5e85494654fbe55c4768b3ccbf3e14c0adc8d0f365f32c503b60d7c06f907781f5966c03d41c423575eb5e16860c + languageName: node + linkType: hard -hookified@^2.1.1: - version "2.2.0" - resolved "https://registry.yarnpkg.com/hookified/-/hookified-2.2.0.tgz#1d024ac1668973dd5bcc4a96ab9ccdb7639ef8d4" - integrity sha512-p/LgFzRN5FeoD3DLS6bkUapeye6E4SI6yJs6KetENd18S+FBthqYq2amJUWpt5z0EQwwHemidjY5OqJGEKm5uA== +"hookified@npm:^2.1.1": + version: 2.2.0 + resolution: "hookified@npm:2.2.0" + checksum: 10c0/7017d2b66945490293a5aba239e7b39f39071dd940fa019348c7ffea92b91b8c267853c4c51680ed0f3687b33352582fa4d2cff6dd4c5a1c5c44b037276f07aa + languageName: node + linkType: hard -ignore@^5.2.0: - version "5.3.2" - resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.3.2.tgz#3cd40e729f3643fd87cb04e50bf0eb722bc596f5" - integrity sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g== +"ignore@npm:^5.2.0": + version: 5.3.2 + resolution: "ignore@npm:5.3.2" + checksum: 10c0/f9f652c957983634ded1e7f02da3b559a0d4cc210fca3792cb67f1b153623c9c42efdc1c4121af171e295444459fc4a9201101fb041b1104a3c000bccb188337 + languageName: node + linkType: hard -imurmurhash@^0.1.4: - version "0.1.4" - resolved "https://registry.yarnpkg.com/imurmurhash/-/imurmurhash-0.1.4.tgz#9218b9b2b928a238b13dc4fb6b6d576f231453ea" - integrity sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA== +"imurmurhash@npm:^0.1.4": + version: 0.1.4 + resolution: "imurmurhash@npm:0.1.4" + checksum: 10c0/8b51313850dd33605c6c9d3fd9638b714f4c4c40250cff658209f30d40da60f78992fb2df5dabee4acf589a6a82bbc79ad5486550754bd9ec4e3fc0d4a57d6a6 + languageName: node + linkType: hard -is-extglob@^2.1.1: - version "2.1.1" - resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-2.1.1.tgz#a88c02535791f02ed37c76a1b9ea9773c833f8c2" - integrity sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ== +"is-extglob@npm:^2.1.1": + version: 2.1.1 + resolution: "is-extglob@npm:2.1.1" + checksum: 10c0/5487da35691fbc339700bbb2730430b07777a3c21b9ebaecb3072512dfd7b4ba78ac2381a87e8d78d20ea08affb3f1971b4af629173a6bf435ff8a4c47747912 + languageName: node + linkType: hard -is-glob@^4.0.0, is-glob@^4.0.3: - version "4.0.3" - resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-4.0.3.tgz#64f61e42cbbb2eec2071a9dac0b28ba1e65d5084" - integrity sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg== +"is-glob@npm:^4.0.0, is-glob@npm:^4.0.3": + version: 4.0.3 + resolution: "is-glob@npm:4.0.3" dependencies: - is-extglob "^2.1.1" + is-extglob: "npm:^2.1.1" + checksum: 10c0/17fb4014e22be3bbecea9b2e3a76e9e34ff645466be702f1693e8f1ee1adac84710d0be0bd9f967d6354036fd51ab7c2741d954d6e91dae6bb69714de92c197a + languageName: node + linkType: hard -isexe@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10" - integrity sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw== +"isexe@npm:^2.0.0": + version: 2.0.0 + resolution: "isexe@npm:2.0.0" + checksum: 10c0/228cfa503fadc2c31596ab06ed6aa82c9976eec2bfd83397e7eaf06d0ccf42cd1dfd6743bf9aeb01aebd4156d009994c5f76ea898d2832c1fe342da923ca457d + languageName: node + linkType: hard -json-schema-traverse@^0.4.1: - version "0.4.1" - resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz#69f6a87d9513ab8bb8fe63bdb0979c448e684660" - integrity sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg== +"json-schema-traverse@npm:^0.4.1": + version: 0.4.1 + resolution: "json-schema-traverse@npm:0.4.1" + checksum: 10c0/108fa90d4cc6f08243aedc6da16c408daf81793bf903e9fd5ab21983cda433d5d2da49e40711da016289465ec2e62e0324dcdfbc06275a607fe3233fde4942ce + languageName: node + linkType: hard -json-stable-stringify-without-jsonify@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz#9db7b59496ad3f3cfef30a75142d2d930ad72651" - integrity sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw== +"json-stable-stringify-without-jsonify@npm:^1.0.1": + version: 1.0.1 + resolution: "json-stable-stringify-without-jsonify@npm:1.0.1" + checksum: 10c0/cb168b61fd4de83e58d09aaa6425ef71001bae30d260e2c57e7d09a5fd82223e2f22a042dedaab8db23b7d9ae46854b08bb1f91675a8be11c5cffebef5fb66a5 + languageName: node + linkType: hard -keyv@^5.6.0: - version "5.6.0" - resolved "https://registry.yarnpkg.com/keyv/-/keyv-5.6.0.tgz#03044074c6b4d072d0a62c7b9fa649537baf0105" - integrity sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw== +"keyv@npm:^5.6.0": + version: 5.6.0 + resolution: "keyv@npm:5.6.0" dependencies: - "@keyv/serialize" "^1.1.1" + "@keyv/serialize": "npm:^1.1.1" + checksum: 10c0/c3ea795b6e03593ca57c8f70928a69bad14c13389a7fb75649a115ff55615244b04d8902798d841c17f0bb4a8a8866c97133b543b93f151b440170bba09176db + languageName: node + linkType: hard -levn@^0.4.1: - version "0.4.1" - resolved "https://registry.yarnpkg.com/levn/-/levn-0.4.1.tgz#ae4562c007473b932a6200d403268dd2fffc6ade" - integrity sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ== +"levn@npm:^0.4.1": + version: 0.4.1 + resolution: "levn@npm:0.4.1" dependencies: - prelude-ls "^1.2.1" - type-check "~0.4.0" + prelude-ls: "npm:^1.2.1" + type-check: "npm:~0.4.0" + checksum: 10c0/effb03cad7c89dfa5bd4f6989364bfc79994c2042ec5966cb9b95990e2edee5cd8969ddf42616a0373ac49fac1403437deaf6e9050fbbaa3546093a59b9ac94e + languageName: node + linkType: hard -locate-path@^6.0.0: - version "6.0.0" - resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-6.0.0.tgz#55321eb309febbc59c4801d931a72452a681d286" - integrity sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw== +"locate-path@npm:^6.0.0": + version: 6.0.0 + resolution: "locate-path@npm:6.0.0" dependencies: - p-locate "^5.0.0" + p-locate: "npm:^5.0.0" + checksum: 10c0/d3972ab70dfe58ce620e64265f90162d247e87159b6126b01314dd67be43d50e96a50b517bce2d9452a79409c7614054c277b5232377de50416564a77ac7aad3 + languageName: node + linkType: hard -minimatch@^10.2.4, minimatch@^10.2.5: - version "10.2.6" - resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-10.2.6.tgz#fd956bbe0b77241e9f15ac5dccb1c638060968ef" - integrity sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A== +"minimatch@npm:^10.2.4, minimatch@npm:^10.2.5": + version: 10.2.6 + resolution: "minimatch@npm:10.2.6" dependencies: - brace-expansion "^5.0.8" + brace-expansion: "npm:^5.0.8" + checksum: 10c0/4559a836243b98bd4d17ea9f7edae698717c76399eea7be374f3737f33164e4907f19e9726891ddeb122f750a5a7fa80d2ac43e851d6e5984dc4ff42ec127d3a + languageName: node + linkType: hard -ms@^2.1.3: - version "2.1.3" - resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2" - integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== +"ms@npm:^2.1.3": + version: 2.1.3 + resolution: "ms@npm:2.1.3" + checksum: 10c0/d924b57e7312b3b63ad21fc5b3dc0af5e78d61a1fc7cfb5457edaf26326bf62be5307cc87ffb6862ef1c2b33b0233cdb5d4f01c4c958cc0d660948b65a287a48 + languageName: node + linkType: hard -natural-compare@^1.4.0: - version "1.4.0" - resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7" - integrity sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw== +"natural-compare@npm:^1.4.0": + version: 1.4.0 + resolution: "natural-compare@npm:1.4.0" + checksum: 10c0/f5f9a7974bfb28a91afafa254b197f0f22c684d4a1731763dda960d2c8e375b36c7d690e0d9dc8fba774c537af14a7e979129bca23d88d052fbeb9466955e447 + languageName: node + linkType: hard -optionator@^0.9.3: - version "0.9.4" - resolved "https://registry.yarnpkg.com/optionator/-/optionator-0.9.4.tgz#7ea1c1a5d91d764fb282139c88fe11e182a3a734" - integrity sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g== +"optionator@npm:^0.9.3": + version: 0.9.4 + resolution: "optionator@npm:0.9.4" dependencies: - deep-is "^0.1.3" - fast-levenshtein "^2.0.6" - levn "^0.4.1" - prelude-ls "^1.2.1" - type-check "^0.4.0" - word-wrap "^1.2.5" + deep-is: "npm:^0.1.3" + fast-levenshtein: "npm:^2.0.6" + levn: "npm:^0.4.1" + prelude-ls: "npm:^1.2.1" + type-check: "npm:^0.4.0" + word-wrap: "npm:^1.2.5" + checksum: 10c0/4afb687a059ee65b61df74dfe87d8d6815cd6883cb8b3d5883a910df72d0f5d029821f37025e4bccf4048873dbdb09acc6d303d27b8f76b1a80dd5a7d5334675 + languageName: node + linkType: hard -p-limit@^3.0.2: - version "3.1.0" - resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-3.1.0.tgz#e1daccbe78d0d1388ca18c64fea38e3e57e3706b" - integrity sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ== +"p-limit@npm:^3.0.2": + version: 3.1.0 + resolution: "p-limit@npm:3.1.0" dependencies: - yocto-queue "^0.1.0" + yocto-queue: "npm:^0.1.0" + checksum: 10c0/9db675949dbdc9c3763c89e748d0ef8bdad0afbb24d49ceaf4c46c02c77d30db4e0652ed36d0a0a7a95154335fab810d95c86153105bb73b3a90448e2bb14e1a + languageName: node + linkType: hard -p-locate@^5.0.0: - version "5.0.0" - resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-5.0.0.tgz#83c8315c6785005e3bd021839411c9e110e6d834" - integrity sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw== +"p-locate@npm:^5.0.0": + version: 5.0.0 + resolution: "p-locate@npm:5.0.0" dependencies: - p-limit "^3.0.2" + p-limit: "npm:^3.0.2" + checksum: 10c0/2290d627ab7903b8b70d11d384fee714b797f6040d9278932754a6860845c4d3190603a0772a663c8cb5a7b21d1b16acb3a6487ebcafa9773094edc3dfe6009a + languageName: node + linkType: hard -path-exists@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-4.0.0.tgz#513bdbe2d3b95d7762e8c1137efa195c6c61b5b3" - integrity sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w== +"path-exists@npm:^4.0.0": + version: 4.0.0 + resolution: "path-exists@npm:4.0.0" + checksum: 10c0/8c0bd3f5238188197dc78dced15207a4716c51cc4e3624c44fc97acf69558f5ebb9a2afff486fe1b4ee148e0c133e96c5e11a9aa5c48a3006e3467da070e5e1b + languageName: node + linkType: hard -path-key@^3.1.0: - version "3.1.1" - resolved "https://registry.yarnpkg.com/path-key/-/path-key-3.1.1.tgz#581f6ade658cbba65a0d3380de7753295054f375" - integrity sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q== +"path-key@npm:^3.1.0": + version: 3.1.1 + resolution: "path-key@npm:3.1.1" + checksum: 10c0/748c43efd5a569c039d7a00a03b58eecd1d75f3999f5a28303d75f521288df4823bc057d8784eb72358b2895a05f29a070bc9f1f17d28226cc4e62494cc58c4c + languageName: node + linkType: hard -prelude-ls@^1.2.1: - version "1.2.1" - resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.2.1.tgz#debc6489d7a6e6b0e7611888cec880337d316396" - integrity sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g== +"prelude-ls@npm:^1.2.1": + version: 1.2.1 + resolution: "prelude-ls@npm:1.2.1" + checksum: 10c0/b00d617431e7886c520a6f498a2e14c75ec58f6d93ba48c3b639cf241b54232d90daa05d83a9e9b9fef6baa63cb7e1e4602c2372fea5bc169668401eb127d0cd + languageName: node + linkType: hard -prettier@3.9.9: - version "3.9.9" - resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.9.9.tgz#09b826918c91cd4cbc80e0cbd1d2a922ff04f233" - integrity sha512-Z/CJHIkdujO/OtN7nXUii0Rf3VT5SRuhjBA82Xvu2XhBUgX3nhP67T0LHceBdQLex7OOFGTox+Q5Yg8Jk2Qivg== +"prettier@npm:3.9.9": + version: 3.9.9 + resolution: "prettier@npm:3.9.9" + bin: + prettier: bin/prettier.cjs + checksum: 10c0/d50ea2537b2ff7dbd5088a214f4d936484e55c665cea2185b477a55b0770be55f3b156a71f63a9a639def6b334667bb327139dc85f2a1041c0a34752cdc74647 + languageName: node + linkType: hard -punycode@^2.1.0: - version "2.3.1" - resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.3.1.tgz#027422e2faec0b25e1549c3e1bd8309b9133b6e5" - integrity sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg== +"punycode@npm:^2.1.0": + version: 2.3.1 + resolution: "punycode@npm:2.3.1" + checksum: 10c0/14f76a8206bc3464f794fb2e3d3cc665ae416c01893ad7a02b23766eb07159144ee612ad67af5e84fa4479ccfe67678c4feb126b0485651b302babf66f04f9e9 + languageName: node + linkType: hard -qified@^0.10.1: - version "0.10.1" - resolved "https://registry.yarnpkg.com/qified/-/qified-0.10.1.tgz#0640bf21bbe6ca540db290ad8f5fde4d870b8bda" - integrity sha512-+Owyggi9IxT1ePKGafcI87ubSmxol6smwJ+RAHDQlx9+9cPwFWDiKFFCPuWhr9ignlGpZ9vDQLw67N4dcTVFEA== +"qified@npm:^0.10.1": + version: 0.10.1 + resolution: "qified@npm:0.10.1" dependencies: - hookified "^2.1.1" + hookified: "npm:^2.1.1" + checksum: 10c0/4a39d45492c65a4b9795381acede1bd566028ce9764ead5b41d776b391a898bf0855dd2b59d724a6711fe253ba3cacf7c9a8832887cf9dc6d48a2c5e4997c82d + languageName: node + linkType: hard -shebang-command@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-2.0.0.tgz#ccd0af4f8835fbdc265b82461aaf0c36663f34ea" - integrity sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA== +"root-workspace-0b6124@workspace:.": + version: 0.0.0-use.local + resolution: "root-workspace-0b6124@workspace:." dependencies: - shebang-regex "^3.0.0" + eslint: "npm:10.11.0" + prettier: "npm:3.9.9" + languageName: unknown + linkType: soft -shebang-regex@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/shebang-regex/-/shebang-regex-3.0.0.tgz#ae16f1644d873ecad843b0307b143362d4c42172" - integrity sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A== - -type-check@^0.4.0, type-check@~0.4.0: - version "0.4.0" - resolved "https://registry.yarnpkg.com/type-check/-/type-check-0.4.0.tgz#07b8203bfa7056c0657050e3ccd2c37730bab8f1" - integrity sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew== +"shebang-command@npm:^2.0.0": + version: 2.0.0 + resolution: "shebang-command@npm:2.0.0" dependencies: - prelude-ls "^1.2.1" + shebang-regex: "npm:^3.0.0" + checksum: 10c0/a41692e7d89a553ef21d324a5cceb5f686d1f3c040759c50aab69688634688c5c327f26f3ecf7001ebfd78c01f3c7c0a11a7c8bfd0a8bc9f6240d4f40b224e4e + languageName: node + linkType: hard -uri-js@^4.2.2: - version "4.4.1" - resolved "https://registry.yarnpkg.com/uri-js/-/uri-js-4.4.1.tgz#9b1a52595225859e55f669d928f88c6c57f2a77e" - integrity sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg== +"shebang-regex@npm:^3.0.0": + version: 3.0.0 + resolution: "shebang-regex@npm:3.0.0" + checksum: 10c0/1dbed0726dd0e1152a92696c76c7f06084eb32a90f0528d11acd764043aacf76994b2fb30aa1291a21bd019d6699164d048286309a278855ee7bec06cf6fb690 + languageName: node + linkType: hard + +"type-check@npm:^0.4.0, type-check@npm:~0.4.0": + version: 0.4.0 + resolution: "type-check@npm:0.4.0" dependencies: - punycode "^2.1.0" + prelude-ls: "npm:^1.2.1" + checksum: 10c0/7b3fd0ed43891e2080bf0c5c504b418fbb3e5c7b9708d3d015037ba2e6323a28152ec163bcb65212741fa5d2022e3075ac3c76440dbd344c9035f818e8ecee58 + languageName: node + linkType: hard -which@^2.0.1: - version "2.0.2" - resolved "https://registry.yarnpkg.com/which/-/which-2.0.2.tgz#7c6a8dd0a636a0327e10b59c9286eee93f3f51b1" - integrity sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA== +"uri-js@npm:^4.2.2": + version: 4.4.1 + resolution: "uri-js@npm:4.4.1" dependencies: - isexe "^2.0.0" + punycode: "npm:^2.1.0" + checksum: 10c0/4ef57b45aa820d7ac6496e9208559986c665e49447cb072744c13b66925a362d96dd5a46c4530a6b8e203e5db5fe849369444440cb22ecfc26c679359e5dfa3c + languageName: node + linkType: hard -word-wrap@^1.2.5: - version "1.2.5" - resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.5.tgz#d2c45c6dd4fbce621a66f136cbe328afd0410b34" - integrity sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA== +"which@npm:^2.0.1": + version: 2.0.2 + resolution: "which@npm:2.0.2" + dependencies: + isexe: "npm:^2.0.0" + bin: + node-which: ./bin/node-which + checksum: 10c0/66522872a768b60c2a65a57e8ad184e5372f5b6a9ca6d5f033d4b0dc98aff63995655a7503b9c0a2598936f532120e81dd8cc155e2e92ed662a2b9377cc4374f + languageName: node + linkType: hard -yocto-queue@^0.1.0: - version "0.1.0" - resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b" - integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q== +"word-wrap@npm:^1.2.5": + version: 1.2.5 + resolution: "word-wrap@npm:1.2.5" + checksum: 10c0/e0e4a1ca27599c92a6ca4c32260e8a92e8a44f4ef6ef93f803f8ed823f486e0889fc0b93be4db59c8d51b3064951d25e43d434e95dc8c960cc3a63d65d00ba20 + languageName: node + linkType: hard + +"yocto-queue@npm:^0.1.0": + version: 0.1.0 + resolution: "yocto-queue@npm:0.1.0" + checksum: 10c0/dceb44c28578b31641e13695d200d34ec4ab3966a5729814d5445b194933c096b7ced71494ce53a0e8820685d1d010df8b2422e5bf2cdea7e469d97ffbea306f + languageName: node + linkType: hard -- 2.54.0 From 16ed356b68fdc879d6e69bf8abadb24e04937389 Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Sat, 3 Oct 2026 14:08:51 +0200 Subject: [PATCH 095/126] main side of 414: cheaper test hashing, and failing tests visible in the build log (closes #414) (#416) The `main` side of https://git.eeqj.de/sneak/webhooker/issues/414: the two changes that make `next` green, and nothing else from `next`. Each is its own commit, so it can be compared with its `next` counterpart. - https://git.eeqj.de/sneak/webhooker/pulls/404, as merged to `next`: a test binary hashes passwords at a 1 MB Argon2id cost instead of 64 MB, `TestHashPassword_ShippedParameters` keeps the shipped cost covered, and `script/test` runs at most four packages and eight parallel tests at once. Every test that starts a database hashed the admin password at 64 MB, which on a busy host made `internal/handlers` overrun its application start and its 90-second timeout. That is what turned `main` red. - https://git.eeqj.de/sneak/webhooker/pulls/415: `script/test` runs without `-v`, so the build log, which the Docker build cuts off at 2 MiB, carries one result line per package and, for a package that fails, everything its tests wrote, application log lines included, instead of only passing packages. What the diff does not show: `script/test` differs from `next` by one line. `main` has no `script/assets` yet, so it is not called. Several packages failing at once can still reach the 2 MiB limit. - Judgement call: both commits keep their subjects from `next`, including their `closes` references. - Deviation and not fixed here: the same two as on https://git.eeqj.de/sneak/webhooker/pulls/415 (no `-v` rerun on failure, https://git.eeqj.de/sneak/webhooker/issues/315; remaining sensitivity to extreme CPU load, https://git.eeqj.de/sneak/webhooker/issues/225). Model: opus-5-5 Co-authored-by: sneak Reviewed-on: https://git.eeqj.de/sneak/webhooker/pulls/416 Co-authored-by: clawbot <35+clawbot@noreply.example.org> --- internal/database/export_test.go | 12 +++++++++++ internal/database/password.go | 23 ++++++++++++++++++++- internal/database/password_test.go | 33 ++++++++++++++++++++++++++++++ internal/resetpw/resetpw_test.go | 2 +- script/test | 12 ++++++++++- 5 files changed, 79 insertions(+), 3 deletions(-) diff --git a/internal/database/export_test.go b/internal/database/export_test.go index 5c10271..7ad4280 100644 --- a/internal/database/export_test.go +++ b/internal/database/export_test.go @@ -5,6 +5,7 @@ import ( "io" "log/slog" "os" + "testing" "time" "go.uber.org/fx" @@ -79,3 +80,14 @@ func (d *Database) ExportSetBannerOut(w io.Writer) { func DummyPasswordHashForTest() string { return dummyPasswordHash() } + +// HashAtShippedCostForTest makes HashPassword hash at the shipped +// memory cost until t ends. t must not run in parallel with other +// tests, which would hash at that cost alongside it. +func HashAtShippedCostForTest(t *testing.T) { + t.Helper() + + hashAtShippedCostInTest = true + + t.Cleanup(func() { hashAtShippedCostInTest = false }) +} diff --git a/internal/database/password.go b/internal/database/password.go index 92ce50a..e114cb6 100644 --- a/internal/database/password.go +++ b/internal/database/password.go @@ -9,6 +9,7 @@ import ( "math/big" "strings" "sync" + "testing" "golang.org/x/crypto/argon2" ) @@ -63,10 +64,30 @@ func DefaultPasswordConfig() *PasswordConfig { } } -// HashPassword generates an Argon2id hash of the password +// testArgon2Memory is the Argon2id memory cost, in KiB, that a test +// binary hashes with: 1 MB instead of the shipped 64 MB. Every test +// that starts a database hashes the bootstrap admin password, dozens +// of them run in parallel, and under the race detector each 64 MB hash +// holds about 150 MB. VerifyPassword reads the cost from the hash it +// checks, so verification follows. +const testArgon2Memory = 1024 + +// hashAtShippedCostInTest makes a test binary hash at the shipped +// memory cost. Only TestHashPassword_ShippedParameters sets it. +// +//nolint:gochecknoglobals // set by one test, see above +var hashAtShippedCostInTest bool + +// HashPassword generates an Argon2id hash of the password. A binary +// built by go test hashes at testArgon2Memory; one built by go build +// always hashes at the defaults. func HashPassword(password string) (string, error) { config := DefaultPasswordConfig() + if testing.Testing() && !hashAtShippedCostInTest { + config.Memory = testArgon2Memory + } + // Generate a salt salt := make([]byte, config.SaltLen) diff --git a/internal/database/password_test.go b/internal/database/password_test.go index e3f4726..d7f9448 100644 --- a/internal/database/password_test.go +++ b/internal/database/password_test.go @@ -192,6 +192,39 @@ func TestHashPasswordUniqueness(t *testing.T) { } } +// TestHashPassword_ShippedParameters hashes and verifies through +// HashPassword at the shipped Argon2id parameters. Every other test +// hashes at the lower memory cost a test binary uses, so this is the +// one that keeps production hashing covered. One hash and one +// verification: each costs 64 MB. +// +//nolint:paralleltest // changes the hashing cost for the whole binary +func TestHashPassword_ShippedParameters(t *testing.T) { + database.HashAtShippedCostForTest(t) + + password := "correct horse battery staple" + + hash, err := database.HashPassword(password) + if err != nil { + t.Fatalf("hashing with the shipped parameters: %v", err) + } + + const shipped = "$argon2id$v=19$m=65536,t=1,p=4$" + + if !strings.HasPrefix(hash, shipped) { + t.Errorf("hash = %q, want prefix %q", hash, shipped) + } + + valid, err := database.VerifyPassword(password, hash) + if err != nil { + t.Fatalf("VerifyPassword() error = %v", err) + } + + if !valid { + t.Error("VerifyPassword() returned false for correct password") + } +} + // TestVerifyDummyPassword_DoesRealWork covers the anti-enumeration // path. Login charges an unknown username a verification against a // dummy hash so that a nonexistent account is not answered in diff --git a/internal/resetpw/resetpw_test.go b/internal/resetpw/resetpw_test.go index cca9107..495dfc4 100644 --- a/internal/resetpw/resetpw_test.go +++ b/internal/resetpw/resetpw_test.go @@ -140,7 +140,7 @@ func (n *noopEvictor) EvictWebhook(string) {} // and the database, exactly as internal/handlers builds them. // // One application per test function, not per case: every start that -// finds no account seeds one at 64 MB of Argon2id, and this package's +// finds no account seeds one with an Argon2id hash, and this package's // budget is not the place to spend that repeatedly. func newServerApp( t *testing.T, dir string, diff --git a/script/test b/script/test index bfa9129..ca31f56 100755 --- a/script/test +++ b/script/test @@ -22,13 +22,23 @@ # The one figure above 90s is GOMAXPROCS 1, a synthetic core floor rather than # a condition CI runs under. If a CPU-limited runner ever puts a real run near # 67s, that is the datum to revisit the org figure with. +# +# -p 4 -parallel 8 keep the run under 2 GB of memory: at most four test +# binaries build or run at once, each with at most eight parallel tests. Under +# -race every test binary and every link costs a few hundred MB, so the +# defaults (one per core) add up to several GB on a many-core host. +# +# No -v: the Docker build cuts each step's log off at 2 MiB, and verbose output +# from the whole suite passes that before a failure is printed. Without it, go +# test prints one result line per package and, for a package that fails, +# everything its tests wrote, application log lines included. set -eu ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" main() { cd "$ROOT" - go test -v -race -timeout 90s ./... + go test -race -p 4 -parallel 8 -timeout 90s ./... } main "$@" -- 2.54.0 From ea8cba7264b592122b5209a4d6d2741ea8ed6efa Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Sun, 4 Oct 2026 03:00:34 +0200 Subject: [PATCH 096/126] Store and show an event's query string, and pass it on to an HTTP target when set (closes #312) The receiver dropped the query string of every request it received, so a sender's URL parameters were silently lost. Each event now keeps it, as sent, in a new `raw_query` column of the per-webhook `events` table; a resubmitted copy carries its original's. The event log and the event's page show it in the shared request block, the event log leaving out one over 32 KiB with a link, as for headers. The archive and log targets carry it. HTTP targets gain "Pass the query string on to this target", off by default: on, deliveries, replays and resubmits append it to the target URL, joined with `&` to one already there. The access log still hides it. Model: opus-5-5 --- README.md | 52 ++++-- internal/database/model_event.go | 4 +- internal/delivery/engine.go | 3 + internal/delivery/engine_integration_test.go | 7 + internal/delivery/engine_test.go | 6 + internal/delivery/target_config_edit.go | 10 +- internal/delivery/target_config_view.go | 7 + internal/delivery/target_config_view_test.go | 4 +- internal/delivery/target_database.go | 1 + internal/delivery/target_database_archive.go | 1 + internal/delivery/target_database_export.go | 1 + .../delivery/target_database_export_test.go | 6 +- internal/delivery/target_database_test.go | 2 + internal/delivery/target_http.go | 23 +++ internal/delivery/target_http_query_test.go | 172 ++++++++++++++++++ internal/delivery/target_log.go | 7 +- internal/handlers/delivery_replay.go | 1 + internal/handlers/delivery_replay_test.go | 8 + internal/handlers/event_log.go | 3 +- internal/handlers/event_log_view.go | 36 +++- internal/handlers/event_request_test.go | 71 ++++++++ internal/handlers/event_resubmit.go | 4 +- internal/handlers/event_resubmit_test.go | 13 +- internal/handlers/target_create.go | 31 ++-- internal/handlers/target_edit.go | 15 +- internal/handlers/target_edit_test.go | 53 ++++++ internal/handlers/webhook.go | 4 + internal/server/alpine_browser_test.go | 23 ++- static/js/app.js | 4 + templates/event_request.html | 18 +- templates/source_detail.html | 8 + templates/target_edit.html | 8 + 32 files changed, 537 insertions(+), 69 deletions(-) create mode 100644 internal/delivery/target_http_query_test.go diff --git a/README.md b/README.md index a143ac7..d97a78f 100644 --- a/README.md +++ b/README.md @@ -1638,11 +1638,19 @@ URL, custom headers, timeout settings). **`http` target configuration:** -| Key | Type | Description | -| --------- | ------------- | -------------------------------------------------------------------------------------- | -| `url` | string | Destination the event is POSTed to | -| `headers` | object | Extra request headers, applied last so they win over the event's own forwarded headers | -| `timeout` | integer (sec) | Per-target request timeout; unset (or 0) uses the shared 30-second client timeout | +| Key | Type | Description | +| -------------- | ------------- | ----------------------------------------------------------------------------------------- | +| `url` | string | Destination the event is POSTed to | +| `headers` | object | Extra request headers, applied last so they win over the event's own forwarded headers | +| `timeout` | integer (sec) | Per-target request timeout; unset (or 0) uses the shared 30-second client timeout | +| `forwardQuery` | boolean | Pass the query string each event arrived with on to the target; unset (or false) does not | + +`forwardQuery` is off by default, and the target URL is then sent exactly as +configured. On, each delivery appends the event's query string to the target +URL, joined with `&` when the URL already has a query string of its own; a +replayed delivery and a resubmitted event's deliveries do the same. Both target +forms offer it as "Pass the query string on to this target", and the target list +shows it when it is on. `timeout` is capped at **300 seconds**, and the form rejects anything above it rather than substituting the cap. A delivery attempt holds one of the bounded @@ -1704,6 +1712,7 @@ auditing, for replay, and for resubmission. | `webhook_id` | UUID | Foreign key → Webhook | | `entrypoint_id` | UUID | Foreign key → Entrypoint | | `method` | string | HTTP method of the captured request. Always `POST`: the receiver answers every other method with 405 before an Event is created | +| `raw_query` | text | The query string of the captured request, as sent, without the leading `?`; empty when there was none. A resubmitted copy carries its original's | | `headers` | JSON | Complete request headers | | `body` | text | Raw request body | | `content_type` | string | Content-Type header value | @@ -1712,9 +1721,15 @@ auditing, for replay, and for resubmission. **Relations:** Belongs to Webhook. Belongs to Entrypoint. Has many Deliveries. -When a request arrives at an entrypoint, the full request (method, headers, -body) is captured as an Event. The event is then queued for delivery to every -active target configured on the parent webhook. +When a request arrives at an entrypoint, the full request (method, query string, +headers, body) is captured as an Event. The event is then queued for delivery to +every active target configured on the parent webhook. + +The event log and the event's own page show the query string with the rest of +the request. The event log leaves out one larger than 32 KiB, as it does request +headers, and links to the event's page, which shows it whole. The `database` and +`log` targets carry it with the rest of the event. An `http` target receives it +only when its `forwardQuery` setting is on. #### Delivery @@ -1760,14 +1775,14 @@ the webhook's currently active targets. **Resubmit.** Replay recovers one delivery; **resubmit** re-injects one EVENT. The event log offers a per-event **Resubmit** action that stores a NEW event -copying the stored one's `method`, `headers`, `body` and `content_type` -verbatim, then fans it out to the webhook's currently **active** targets — -resolved fresh by the same query the receiver uses, so a target created long -after the original event arrived receives it. That is the difference that -matters: a target added to test a backend under development has no prior -delivery, so there is nothing to replay to it, while a resubmit reaches it like -any other active target. Inactive targets are skipped, exactly as the receiver -skips them. +copying the stored one's `method`, `raw_query`, `headers`, `body` and +`content_type` verbatim, then fans it out to the webhook's currently **active** +targets — resolved fresh by the same query the receiver uses, so a target +created long after the original event arrived receives it. That is the +difference that matters: a target added to test a backend under development has +no prior delivery, so there is nothing to replay to it, while a resubmit reaches +it like any other active target. Inactive targets are skipped, exactly as the +receiver skips them. The new event is a first-class event in the log with its own deliveries, not a marker on the one it came from, and the original's deliveries are left @@ -2438,7 +2453,8 @@ 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 `next`, the page to return to, `notice`, which names the line a page shows after an action, and the event -log's `show`, which picks the events it lists. +log's `show`, which picks the events it lists. A query string sent to an +entrypoint is not lost either: the event stores it, and the event log shows it. Client-supplied request content does not leave the host by the other route either. The Sentry SDK attaches the request to every event it captures, @@ -2901,7 +2917,7 @@ page that was asked for. | `POST` | `/hook/{id}/edit` | Edit webhook submission | | `POST` | `/hook/{id}/delete` | Delete webhook | | `GET` | `/hook/{id}/events` | Full Event Log. `?show=failed` lists only the events with a failed delivery, and `?show=pending` only those with a delivery pending or retrying | -| `GET` | `/hook/{id}/events/{eventID}` | One event's own page: its details, the entrypoint it arrived at (for a resubmitted copy, the one the request it copies arrived at), its request headers, its whole body and every delivery of it | +| `GET` | `/hook/{id}/events/{eventID}` | One event's own page: its details, the entrypoint it arrived at (for a resubmitted copy, the one the request it copies arrived at), its query string, its request headers, its whole body and every delivery of it | | `GET` | `/hook/{id}/events/{eventID}/body` | Download an event's stored body. The pages show a body as text, cut at 32 KiB in the recent events and the event log, and leave a binary one out, so this is the only route that serves the stored bytes; it is offered wherever a body is cut or binary | | `POST` | `/hook/{id}/deliveries/{deliveryID}/replay` | Replay a finished delivery: creates a new delivery for the same event against the target's current configuration (30 per minute per bucket, then `429`) | | `POST` | `/hook/{id}/events/{eventID}/resubmit` | Resubmit a stored event: creates a new event copying it and fans that out to every currently active target (30 per minute per bucket, then `429`) | diff --git a/internal/database/model_event.go b/internal/database/model_event.go index 606c5a6..b50fe32 100644 --- a/internal/database/model_event.go +++ b/internal/database/model_event.go @@ -30,8 +30,10 @@ type Event struct { WebhookID string `gorm:"type:uuid;not null" json:"webhookId"` EntrypointID string `gorm:"type:uuid;not null;index:idx_events_entrypoint_id,priority:1" json:"entrypointId"` - // Request data + // Request data. RawQuery is the receiving request's query string + // as sent, without the leading "?". Method string `gorm:"not null" json:"method"` + RawQuery string `gorm:"type:text" json:"rawQuery"` Headers string `gorm:"type:text" json:"headers"` // JSON Body string `gorm:"type:text" json:"body"` ContentType string `json:"contentType"` diff --git a/internal/delivery/engine.go b/internal/delivery/engine.go index ec78d7a..fcf2bc9 100644 --- a/internal/delivery/engine.go +++ b/internal/delivery/engine.go @@ -110,6 +110,7 @@ type Task struct { MaxRetries int Method string + RawQuery string Headers string ContentType string Body *string @@ -1752,6 +1753,7 @@ func buildEventFromTask(task *Task) database.Event { event := database.Event{ EntrypointID: task.EntrypointID, Method: task.Method, + RawQuery: task.RawQuery, Headers: task.Headers, ContentType: task.ContentType, } @@ -2102,6 +2104,7 @@ func buildRecoveryTask( TargetConfig: target.Config, MaxRetries: target.MaxRetries, Method: event.Method, + RawQuery: event.RawQuery, Headers: event.Headers, ContentType: event.ContentType, Body: bodyPtr, diff --git a/internal/delivery/engine_integration_test.go b/internal/delivery/engine_integration_test.go index 1676977..cc08753 100644 --- a/internal/delivery/engine_integration_test.go +++ b/internal/delivery/engine_integration_test.go @@ -673,6 +673,11 @@ func TestRecoverPendingDeliveries(t *testing.T) { t, s.WebhookDB, s.WebhookID, targetID, 3, ) + // A recovered delivery still carries its event's query string. + require.NoError(t, s.WebhookDB.Model(&database.Event{}). + Where("webhook_id = ?", s.WebhookID). + Update("raw_query", eventQuery).Error) + s.Engine.ExportRecoverPendingDeliveries( context.Background(), s.WebhookDB, s.WebhookID, @@ -687,6 +692,8 @@ func TestRecoverPendingDeliveries(t *testing.T) { database.TargetTypeLog, task.TargetType, ) + + assert.Equal(t, eventQuery, task.RawQuery) case <-time.After(2 * time.Second): t.Fatalf("expected task %d", i) } diff --git a/internal/delivery/engine_test.go b/internal/delivery/engine_test.go index 5f85ad8..0238e55 100644 --- a/internal/delivery/engine_test.go +++ b/internal/delivery/engine_test.go @@ -11,6 +11,7 @@ import ( "net/http/httptest" "os" "path/filepath" + "strconv" "strings" "sync" "sync/atomic" @@ -1990,6 +1991,10 @@ func assertLogLineComplete( "log line must contain the full request headers", ) + assert.Contains(t, out, "raw_query="+strconv.Quote(event.RawQuery), + "log line must contain the query string", + ) + assert.Contains(t, out, event.EntrypointID, "log line must contain the entrypoint id", ) @@ -2012,6 +2017,7 @@ func TestDeliverLog_LogsFullContent(t *testing.T) { event := seedEvent( t, db, `{"log-body-marker":"abc123"}`, ) + event.RawQuery = eventQuery dlv := seedDelivery( t, db, event.ID, uuid.New().String(), diff --git a/internal/delivery/target_config_edit.go b/internal/delivery/target_config_edit.go index a62429b..3e76a48 100644 --- a/internal/delivery/target_config_edit.go +++ b/internal/delivery/target_config_edit.go @@ -39,6 +39,9 @@ type TargetConfigForm struct { // Timeout is the HTTP target's per-request timeout in seconds, // empty when unset. Timeout string + // ForwardQuery is the HTTP target's setting that passes each + // event's query string on to it. + ForwardQuery bool // Expiry is the database (archive) target's row expiry. Expiry string // Rotation is the database (archive) target's rotation. @@ -64,9 +67,10 @@ func NewTargetConfigForm( } return TargetConfigForm{ - URL: cfg.URL, - Headers: FormatTargetHeaders(cfg.Headers), - Timeout: FormatTargetTimeout(cfg.Timeout), + URL: cfg.URL, + Headers: FormatTargetHeaders(cfg.Headers), + Timeout: FormatTargetTimeout(cfg.Timeout), + ForwardQuery: cfg.ForwardQuery, }, nil case database.TargetTypeSlack: cfg, err := parseSlackConfig(t.Config) diff --git a/internal/delivery/target_config_view.go b/internal/delivery/target_config_view.go index ad8fbcd..2f02285 100644 --- a/internal/delivery/target_config_view.go +++ b/internal/delivery/target_config_view.go @@ -171,6 +171,13 @@ func httpConfigFields(t *database.Target) []ConfigField { }) } + if cfg.ForwardQuery { + fields = append(fields, ConfigField{ + Label: "Query string", + Value: "passed on to this target", + }) + } + fields = append(fields, maxRetriesField(t)) return fields diff --git a/internal/delivery/target_config_view_test.go b/internal/delivery/target_config_view_test.go index e92e0f9..85ffde0 100644 --- a/internal/delivery/target_config_view_test.go +++ b/internal/delivery/target_config_view_test.go @@ -223,7 +223,8 @@ func TestNewTargetViews_HTTP(t *testing.T) { Type: database.TargetTypeHTTP, Config: `{"url":"` + viewExampleHook + `",` + `"timeout":30,` + - `"headers":{"Authorization":"Bearer sekrit"}}`, + `"headers":{"Authorization":"Bearer sekrit"},` + + `"forwardQuery":true}`, MaxRetries: 5, }) @@ -235,6 +236,7 @@ func TestNewTargetViews_HTTP(t *testing.T) { "Destination URL": viewMaskedOrigin, "Timeout": "30s", "Headers": "1 configured", + "Query string": "passed on to this target", viewMaxRetries: "5", }, fields, diff --git a/internal/delivery/target_database.go b/internal/delivery/target_database.go index 862b7e8..aead474 100644 --- a/internal/delivery/target_database.go +++ b/internal/delivery/target_database.go @@ -184,6 +184,7 @@ func (t *databaseTarget) archive(d *database.Delivery) error { WebhookID: webhookID, EntrypointID: d.Event.EntrypointID, Method: d.Event.Method, + RawQuery: d.Event.RawQuery, Headers: d.Event.Headers, Body: d.Event.Body, ContentType: d.Event.ContentType, diff --git a/internal/delivery/target_database_archive.go b/internal/delivery/target_database_archive.go index 506003b..08b7463 100644 --- a/internal/delivery/target_database_archive.go +++ b/internal/delivery/target_database_archive.go @@ -101,6 +101,7 @@ type archivedEvent struct { WebhookID string EntrypointID string Method string + RawQuery string Headers string Body string ContentType string diff --git a/internal/delivery/target_database_export.go b/internal/delivery/target_database_export.go index 48a6f92..450c60e 100644 --- a/internal/delivery/target_database_export.go +++ b/internal/delivery/target_database_export.go @@ -360,6 +360,7 @@ func writeRow(w io.Writer, ev *archivedEvent, period string) error { "webhook_id": ev.WebhookID, "entrypoint_id": ev.EntrypointID, "method": ev.Method, + "raw_query": ev.RawQuery, "headers": ev.Headers, "body": ev.Body, "content_type": ev.ContentType, diff --git a/internal/delivery/target_database_export_test.go b/internal/delivery/target_database_export_test.go index 8484be3..6515b7d 100644 --- a/internal/delivery/target_database_export_test.go +++ b/internal/delivery/target_database_export_test.go @@ -166,6 +166,7 @@ func TestArchiveExport_MatchesStoredRows(t *testing.T) { WebhookID: exportWebhookID, EntrypointID: "ep-1", Method: "POST", + RawQuery: eventQuery, Headers: `{"X-Test":["yes"]}`, Body: body, ContentType: testContentType, @@ -215,12 +216,13 @@ func assertExportedRow( assert.Equal(t, row.WebhookID, ev["webhook_id"]) assert.Equal(t, row.EntrypointID, ev["entrypoint_id"]) assert.Equal(t, row.Method, ev["method"]) + assert.Equal(t, row.RawQuery, ev["raw_query"]) assert.Equal(t, row.Headers, ev["headers"]) assert.Equal(t, row.ContentType, ev["content_type"]) if row.Body != binaryBody { assert.Equal(t, row.Body, ev["body"]) - assert.Len(t, ev, 9, "the nine columns and nothing else: %v", ev) + assert.Len(t, ev, 10, "the ten columns and nothing else: %v", ev) return } @@ -229,7 +231,7 @@ func assertExportedRow( require.NoError(t, err) assert.Equal(t, binaryBody, string(body)) assert.Equal(t, "base64", ev["body_encoding"]) - assert.Len(t, ev, 10, "the nine columns and body_encoding: %v", ev) + assert.Len(t, ev, 11, "the ten columns and body_encoding: %v", ev) } // TestArchiveExport_Empty proves an archive with nothing in it exports diff --git a/internal/delivery/target_database_test.go b/internal/delivery/target_database_test.go index ca70885..5f93849 100644 --- a/internal/delivery/target_database_test.go +++ b/internal/delivery/target_database_test.go @@ -85,6 +85,7 @@ func TestDeliverDatabase_ArchivesEvent(t *testing.T) { webhookDB := testWebhookDB(t) event := seedEvent(t, webhookDB, `{"archived":true}`) + event.RawQuery = eventQuery d := seedDatabaseTargetDelivery(t, webhookDB, event, tgt) env.eng.ExportDeliverDatabase(webhookDB, d) @@ -113,6 +114,7 @@ func TestDeliverDatabase_ArchivesEvent(t *testing.T) { assert.Equal(t, event.ID, rows[0].EventID) assert.Equal(t, event.WebhookID, rows[0].WebhookID) assert.Equal(t, event.Method, rows[0].Method) + assert.Equal(t, eventQuery, rows[0].RawQuery) assert.JSONEq(t, `{"archived":true}`, rows[0].Body) } diff --git a/internal/delivery/target_http.go b/internal/delivery/target_http.go index 896bd92..8733f31 100644 --- a/internal/delivery/target_http.go +++ b/internal/delivery/target_http.go @@ -8,6 +8,7 @@ import ( "fmt" "io" "net/http" + "net/url" "sort" "sync" "time" @@ -32,6 +33,11 @@ type HTTPTargetConfig struct { URL string `json:"url"` Headers map[string]string `json:"headers,omitempty"` Timeout int `json:"timeout,omitempty"` + + // ForwardQuery passes each event's query string on to the target, + // appended to URL. Off, the target URL is sent exactly as + // configured. + ForwardQuery bool `json:"forwardQuery,omitempty"` } // httpCore holds the retry, backoff, and circuit-breaker @@ -444,6 +450,10 @@ func (t *httpTarget) doHTTPRequest( ) } + if cfg.ForwardQuery { + appendQuery(req.URL, event.RawQuery) + } + originScoped := applyRequestHeaders( req, event, cfg, t.eng.userAgent(), ) @@ -474,6 +484,19 @@ func (t *httpTarget) doHTTPRequest( return resp.StatusCode, string(body), dur, nil } +// appendQuery adds an event's query string to a delivery's URL, joined +// with "&" to any query string the target URL already has. +func appendQuery(u *url.URL, rawQuery string) { + switch { + case rawQuery == "": + return + case u.RawQuery == "": + u.RawQuery = rawQuery + default: + u.RawQuery += "&" + rawQuery + } +} + // clientForRequest returns the client for one delivery attempt. // originScoped is the header set applyRequestHeaders built for that // attempt; a request with neither a per-target timeout nor an diff --git a/internal/delivery/target_http_query_test.go b/internal/delivery/target_http_query_test.go new file mode 100644 index 0000000..fb3a5de --- /dev/null +++ b/internal/delivery/target_http_query_test.go @@ -0,0 +1,172 @@ +package delivery_test + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "sneak.berlin/go/webhooker/internal/database" + "sneak.berlin/go/webhooker/internal/delivery" +) + +// eventQuery is the query string the events in these tests arrived +// with. +const eventQuery = "a=1&b=2" + +// httpTargetConfig is the stored configuration of an HTTP target at +// targetURL. +func httpTargetConfig( + t *testing.T, targetURL string, forwardQuery bool, +) string { + t.Helper() + + cfg, err := json.Marshal(delivery.HTTPTargetConfig{ + URL: targetURL, ForwardQuery: forwardQuery, + }) + require.NoError(t, err) + + return string(cfg) +} + +// deliverWithQuery sends one event that arrived with eventQuery to an +// HTTP target configured with cfg, through the path a received event's +// delivery takes, and returns the attempt it recorded. +func deliverWithQuery(t *testing.T, cfg string) database.DeliveryResult { + t.Helper() + + s := newISetup(t) + + event := iSeedEvent(t, s.WebhookDB, s.WebhookID, "{}") + d := iSeedDelivery( + t, s.WebhookDB, event.ID, uuid.NewString(), + database.DeliveryStatusPending, + ) + task := iTask( + d, event, s.WebhookID, d.TargetID, "query", cfg, 0, 1, &event.Body, + ) + task.RawQuery = eventQuery + + s.Engine.ExportProcessNewTask(context.TODO(), &task) + + var result database.DeliveryResult + + require.NoError(t, s.WebhookDB.Where( + "delivery_id = ?", d.ID, + ).First(&result).Error) + + return result +} + +// TestDeliverHTTP_ForwardQuery proves the URL a delivery is sent to: +// with the target's setting off, the target URL exactly as configured; +// with it on, the event's query string appended, joined with "&" to a +// query string the target URL already has. +func TestDeliverHTTP_ForwardQuery(t *testing.T) { + t.Parallel() + + // The target URL's path, without and with a query string of its + // own. + const ( + plain = "/in" + withQuery = "/in?key=k" + ) + + tests := map[string]struct { + path string + forwardQuery bool + want string + }{ + "off": { + path: plain, want: plain, + }, + "off, the target URL has a query string": { + path: withQuery, want: withQuery, + }, + "on": { + path: plain, forwardQuery: true, want: plain + "?" + eventQuery, + }, + "on, the target URL has a query string": { + path: withQuery, forwardQuery: true, + want: withQuery + "&" + eventQuery, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + + received := make(chan string, 1) + + ts := httptest.NewServer(http.HandlerFunc( + func(w http.ResponseWriter, r *http.Request) { + received <- r.RequestURI + + w.WriteHeader(http.StatusOK) + }, + )) + t.Cleanup(ts.Close) + + result := deliverWithQuery(t, httpTargetConfig( + t, ts.URL+tc.path, tc.forwardQuery, + )) + + assert.True(t, result.Success) + require.Len(t, received, 1) + assert.Equal(t, tc.want, <-received) + }) + } +} + +// TestDeliverHTTP_ForwardedQueryKeepsTheTargetURLMasked proves the +// credential in a target URL's own query string stays masked once the +// event's query string is appended to it: in a response or error that +// echoes the URL the target was sent, as the event log's Redactor shows +// it, and in the error a failed connection stores. +func TestDeliverHTTP_ForwardedQueryKeepsTheTargetURLMasked(t *testing.T) { + t.Parallel() + + const secret = "s3cr3t" + + received := make(chan string, 1) + + ts := httptest.NewServer(http.HandlerFunc( + func(w http.ResponseWriter, r *http.Request) { + received <- r.RequestURI + + w.WriteHeader(http.StatusBadRequest) + }, + )) + t.Cleanup(ts.Close) + + target := &database.Target{ + Type: database.TargetTypeHTTP, + Config: httpTargetConfig(t, ts.URL+"/in?token="+secret, true), + } + + deliverWithQuery(t, target.Config) + require.Len(t, received, 1) + + sent := <-received + require.Equal(t, "/in?token="+secret+"&"+eventQuery, sent) + + redactor := delivery.NewRedactor(target) + + for _, echoed := range []string{sent, ts.URL + sent} { + shown := redactor.Redact("rejected " + echoed) + assert.NotContains(t, shown, secret, echoed) + assert.Contains(t, shown, delivery.RedactionMarker, echoed) + } + + // Nothing listens on port 1. + failed := deliverWithQuery(t, httpTargetConfig( + t, "http://127.0.0.1:1/in?token="+secret, true, + )) + require.NotEmpty(t, failed.Error) + assert.NotContains(t, failed.Error, secret) + assert.NotContains(t, failed.Error, eventQuery) +} diff --git a/internal/delivery/target_log.go b/internal/delivery/target_log.go index 096a5c7..9a13229 100644 --- a/internal/delivery/target_log.go +++ b/internal/delivery/target_log.go @@ -9,9 +9,9 @@ import ( ) // logTarget is a fire-and-forget target that logs the entire -// inbound webhook — the full request body and headers, plus -// the method, content type, and the webhook and entrypoint -// ids — then records a single successful attempt. +// inbound webhook — the full request body, query string and +// headers, plus the method, content type, and the webhook and +// entrypoint ids — then records a single successful attempt. // // This is the one log call in the service that deliberately writes // unbounded client-chosen bytes, so it is the one exception to the @@ -46,6 +46,7 @@ func (t *logTarget) Deliver( "webhook_id", d.Event.WebhookID, "entrypoint_id", d.Event.EntrypointID, "method", d.Event.Method, + "raw_query", d.Event.RawQuery, "content_type", d.Event.ContentType, "headers", d.Event.Headers, "body", d.Event.Body, diff --git a/internal/handlers/delivery_replay.go b/internal/handlers/delivery_replay.go index 8516742..96a8cfc 100644 --- a/internal/handlers/delivery_replay.go +++ b/internal/handlers/delivery_replay.go @@ -310,6 +310,7 @@ func createReplayDelivery( TargetConfig: target.Config, MaxRetries: target.MaxRetries, Method: event.Method, + RawQuery: event.RawQuery, Headers: event.Headers, ContentType: event.ContentType, Body: replayBody(event.Body), diff --git a/internal/handlers/delivery_replay_test.go b/internal/handlers/delivery_replay_test.go index 58c6e03..c6756a7 100644 --- a/internal/handlers/delivery_replay_test.go +++ b/internal/handlers/delivery_replay_test.go @@ -26,6 +26,9 @@ const paramDeliveryID = "deliveryID" // dispatches to it: the notifier is recorded, not run. const replayTargetURL = "http://93.184.216.34/hook" +// replayEventQuery is the query string a seeded event arrived with. +const replayEventQuery = "a=1&b=2" + // seedFailedDelivery records an event, a terminally failed delivery of // it to the given target, and the attempt that failed. func seedFailedDelivery( @@ -42,6 +45,7 @@ func seedFailedDelivery( WebhookID: webhookID, EntrypointID: "entrypoint-" + webhookID, Method: http.MethodPost, + RawQuery: replayEventQuery, Headers: `{"X-Test":["yes"]}`, Body: `{"replay":"me"}`, ContentType: contentTypeJSON, @@ -296,6 +300,10 @@ func assertReplayTask( "replay must use the target's current configuration", ) assert.Equal(t, event.Method, task.Method) + assert.Equal( + t, replayEventQuery, task.RawQuery, + "replay re-sends the stored query string", + ) assert.Equal(t, event.Headers, task.Headers) assert.Equal(t, event.ContentType, task.ContentType) assert.Equal(t, 1, task.AttemptNum) diff --git a/internal/handlers/event_log.go b/internal/handlers/event_log.go index 2cad0dc..63a5d7c 100644 --- a/internal/handlers/event_log.go +++ b/internal/handlers/event_log.go @@ -324,7 +324,8 @@ func loadEventLogRows( var rows []eventLogRow err = eventsWithStatus(webhookDB, webhookID, statuses).Select( - eventLogColumns, maxRenderedBodyBytes, maxRenderedBodyBytes, + eventLogColumns, + maxRenderedBodyBytes, maxRenderedBodyBytes, maxRenderedBodyBytes, ).Order("created_at DESC").Limit(recentEventLimit).Find(&rows).Error return rows, totalEvents, err diff --git a/internal/handlers/event_log_view.go b/internal/handlers/event_log_view.go index 52735af..23f1129 100644 --- a/internal/handlers/event_log_view.go +++ b/internal/handlers/event_log_view.go @@ -17,19 +17,23 @@ import ( // bytes rather than characters, so the cap bounds the page in // bytes whatever the payload's encoding. Cutting in SQLite // rather than in Go is the point of the projection — an -// oversized body or set of request headers never becomes a Go -// string at all. +// oversized body, query string or set of request headers never +// becomes a Go string at all. const eventLogColumns = "id, created_at, method, content_type, " + "resubmitted_from_id, entrypoint_id, " + + "substr(cast(raw_query as blob), 1, ?) AS raw_query, " + + "length(cast(raw_query as blob)) AS raw_query_bytes, " + "substr(cast(headers as blob), 1, ?) AS headers, " + "length(cast(headers as blob)) AS headers_bytes, " + "substr(cast(body as blob), 1, ?) AS body, " + "length(cast(body as blob)) AS body_bytes" // eventColumns is eventLogColumns for the event's own page, which -// shows the whole body and every request header. +// shows the whole body, the whole query string and every request +// header. const eventColumns = "id, created_at, method, content_type, " + - "resubmitted_from_id, entrypoint_id, headers, " + + "resubmitted_from_id, entrypoint_id, raw_query, " + + "length(cast(raw_query as blob)) AS raw_query_bytes, headers, " + "length(cast(headers as blob)) AS headers_bytes, " + "cast(body as blob) AS body, " + "length(cast(body as blob)) AS body_bytes" @@ -57,6 +61,13 @@ type EventLogView struct { // entrypoint's secret. Entrypoint string + // RawQuery is the query string the event arrived with. + // RawQueryCut reports one left out, RawQuery then empty, because + // it holds more than maxRenderedBodyBytes; only the event log + // leaves it out. + RawQuery string + RawQueryCut bool + // Headers is the event's request headers as text, one // "Name: value" line per value, sorted by name. HeadersCut // reports headers left out because they hold more than @@ -85,9 +96,9 @@ func (v EventLogView) ResubmittedFrom() bool { } // eventLogRow is one row of the event log projection, or of -// eventColumns. In the event log its headers and body columns -// arrive already cut to the cap by SQLite, each with its true -// size beside it. +// eventColumns. In the event log its query string, headers and +// body columns arrive already cut to the cap by SQLite, each with +// its true size beside it. type eventLogRow struct { ID string CreatedAt time.Time @@ -95,6 +106,8 @@ type eventLogRow struct { ContentType string ResubmittedFromID *string EntrypointID string + RawQuery string + RawQueryBytes int64 Headers string HeadersBytes int64 Body []byte @@ -114,6 +127,13 @@ func (r *eventLogRow) view( headers, fit := requestHeaderLines(r.Headers, maxHeaderBytes) + rawQuery := r.RawQuery + rawQueryCut := r.RawQueryBytes > int64(len(rawQuery)) + + if rawQueryCut { + rawQuery = "" + } + return EventLogView{ ID: r.ID, Method: r.Method, @@ -123,6 +143,8 @@ func (r *eventLogRow) view( Body: newBodyView( "/hook/"+webhookID+"/events/"+r.ID, r.Body, r.BodyBytes, ), + RawQuery: rawQuery, + RawQueryCut: rawQueryCut, Headers: strings.Join(headers, "\n"), HeadersCut: !fit || r.HeadersBytes > int64(len(r.Headers)), ResubmittedFromID: from, diff --git a/internal/handlers/event_request_test.go b/internal/handlers/event_request_test.go index 5b4eb58..98740b5 100644 --- a/internal/handlers/event_request_test.go +++ b/internal/handlers/event_request_test.go @@ -1,13 +1,16 @@ package handlers_test import ( + "context" "encoding/json" "net/http" + "net/http/httptest" "slices" "strings" "testing" "time" + "github.com/go-chi/chi" "github.com/google/uuid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -116,6 +119,7 @@ func TestEventRequest_EachEventShowsItsOwnEntrypointAndHeaders( t.Helper() assert.Contains(t, page, arrivedAt("Billing sender")) + assert.Contains(t, page, "No query string.") assert.Contains(t, page, headerBox( "Accept: */*", "User-Agent: shop/1 build\t7", @@ -331,3 +335,70 @@ func TestEventRequest_ManyShortHeaderLines(t *testing.T) { }) } } + +// TestHandleWebhook_StoresAndShowsTheQueryString posts to an +// entrypoint's URL with a query string and proves the event stores it +// as sent, and shows it escaped in the event log and on its own page, +// in a box like the one the request headers show in. +func TestHandleWebhook_StoresAndShowsTheQueryString(t *testing.T) { + t.Parallel() + + f := newRecentEventsFixture(t) + ep := seedEntrypoint(t, f.db, f.webhook.ID) + + req := httptest.NewRequestWithContext( + context.Background(), http.MethodPost, + "/h/"+ep.Path+"?a=1&b=2", strings.NewReader("{}"), + ) + + rctx := chi.NewRouteContext() + rctx.URLParams.Add("uuid", ep.Path) + + req = req.WithContext(context.WithValue( + req.Context(), chi.RouteCtxKey, rctx, + )) + + w := httptest.NewRecorder() + f.h.HandleWebhook().ServeHTTP(w, req) + + require.Equal(t, http.StatusOK, w.Code) + + var stored database.Event + + require.NoError(t, f.webhookDB.First(&stored).Error) + assert.Equal(t, "a=1&b=2", stored.RawQuery) + + page := renderSourceLogsPage(t, f.h, f.sess, f.webhook.ID) + assert.Contains(t, page, headerBox("a=1&b=2")) + + w = serveEventPage(t, f.h, f.sess, f.webhook.ID, stored.ID) + require.Equal(t, http.StatusOK, w.Code) + assert.Contains(t, w.Body.String(), headerBox("a=1&b=2")) +} + +// TestEventRequest_QueryStringOverTheLimit proves the event log leaves +// out a query string that holds more than it shows of a body, and links +// to the event's own page, which shows it whole. +func TestEventRequest_QueryStringOverTheLimit(t *testing.T) { + t.Parallel() + + f := newRecentEventsFixture(t) + ep := f.entrypoint(t, "Billing sender") + event := f.eventAt(t, ep, `{}`, time.Now()) + query := "q=" + strings.Repeat("x", bodyCap) + + require.NoError(t, f.webhookDB.Model(event).Update( + "raw_query", query, + ).Error) + + page := renderSourceLogsPage(t, f.h, f.sess, f.webhook.ID) + assert.Contains(t, page, `Show the query string`) + assert.NotContains(t, page, "q=x") + assert.Less(t, len(page), 4*bodyCap) + + w := serveEventPage(t, f.h, f.sess, f.webhook.ID, event.ID) + require.Equal(t, http.StatusOK, w.Code) + assert.Contains(t, w.Body.String(), headerBox(query)) + assert.NotContains(t, w.Body.String(), "Show the query string") +} diff --git a/internal/handlers/event_resubmit.go b/internal/handlers/event_resubmit.go index fba7106..211ac00 100644 --- a/internal/handlers/event_resubmit.go +++ b/internal/handlers/event_resubmit.go @@ -30,6 +30,7 @@ type resubmitSource struct { ID string EntrypointID string Method string + RawQuery string Headers string ContentType string Body []byte @@ -39,7 +40,7 @@ type resubmitSource struct { // The cast to blob is what makes the driver hand back the stored bytes // rather than a string conversion, the same reason eventBodyQuery // casts. -const resubmitColumns = "id, entrypoint_id, method, headers, " + +const resubmitColumns = "id, entrypoint_id, method, raw_query, headers, " + "content_type, cast(body as blob) AS body" // HandleEventResubmit re-injects a stored event as a new undelivered @@ -193,6 +194,7 @@ func (h *Handlers) queueResubmit( WebhookID: webhook.ID, EntrypointID: src.EntrypointID, Method: src.Method, + RawQuery: src.RawQuery, HeadersJSON: src.Headers, ContentType: src.ContentType, Body: src.Body, diff --git a/internal/handlers/event_resubmit_test.go b/internal/handlers/event_resubmit_test.go index a7c92d2..7ba344e 100644 --- a/internal/handlers/event_resubmit_test.go +++ b/internal/handlers/event_resubmit_test.go @@ -22,9 +22,13 @@ import ( // dispatches to it: the notifier is recorded, not run. const resubmitTargetURL = "http://93.184.216.34/hook" -// resubmitEventHeaders is the stored header JSON a seeded event -// carries, so a test can prove the copy takes it verbatim. -const resubmitEventHeaders = `{"X-Test":["yes"],"X-Trace":["abc"]}` +// resubmitEventHeaders and resubmitEventQuery are the stored header +// JSON and query string a seeded event carries, so a test can prove the +// copy takes them verbatim. +const ( + resubmitEventHeaders = `{"X-Test":["yes"],"X-Trace":["abc"]}` + resubmitEventQuery = "a=1&b=2" +) // seedStoredEvent records one event in a webhook's own database with // no deliveries at all, which is the state a captured event is in when @@ -43,6 +47,7 @@ func seedStoredEvent( WebhookID: webhookID, EntrypointID: "entrypoint-" + webhookID, Method: http.MethodPost, + RawQuery: resubmitEventQuery, Headers: resubmitEventHeaders, Body: body, ContentType: contentTypeJSON, @@ -202,6 +207,7 @@ func assertEventCopy( t.Helper() assert.Equal(t, original.Method, fresh.Method) + assert.Equal(t, resubmitEventQuery, fresh.RawQuery) assert.Equal(t, original.Headers, fresh.Headers) assert.Equal(t, original.Body, fresh.Body) assert.Equal(t, int64(len(original.Body)), fresh.BodyBytes) @@ -236,6 +242,7 @@ func assertResubmitTask( assert.Equal(t, target.ID, task.TargetID) assert.Equal(t, target.Type, task.TargetType) assert.Equal(t, fresh.Method, task.Method) + assert.Equal(t, fresh.RawQuery, task.RawQuery) assert.Equal(t, fresh.Headers, task.Headers) assert.Equal(t, fresh.ContentType, task.ContentType) assert.Equal(t, 1, task.AttemptNum) diff --git a/internal/handlers/target_create.go b/internal/handlers/target_create.go index c8cd048..224cb3f 100644 --- a/internal/handlers/target_create.go +++ b/internal/handlers/target_create.go @@ -173,6 +173,9 @@ type targetFormInput struct { Headers string // Timeout is an HTTP target's per-request timeout in seconds. Timeout string + // ForwardQuery is an HTTP target's checkbox that passes each + // event's query string on to it. + ForwardQuery bool // MaxRetries is an HTTP or Slack target's max_retries. MaxRetries string // Expiry is a database (archive) target's row expiry. @@ -195,14 +198,15 @@ type targetFormInput struct { // tokens. func targetFormInputFrom(r *http.Request) targetFormInput { return targetFormInput{ - Name: r.PostFormValue("name"), - Type: database.TargetType(r.PostFormValue("type")), - URL: r.PostFormValue("url"), - Headers: r.PostFormValue("headers"), - Timeout: r.PostFormValue("timeout"), - MaxRetries: r.PostFormValue("max_retries"), - Expiry: r.PostFormValue("expiry"), - Rotation: r.PostFormValue("rotation"), + Name: r.PostFormValue("name"), + Type: database.TargetType(r.PostFormValue("type")), + URL: r.PostFormValue("url"), + Headers: r.PostFormValue("headers"), + Timeout: r.PostFormValue("timeout"), + ForwardQuery: r.PostFormValue("forward_query") != "", + MaxRetries: r.PostFormValue("max_retries"), + Expiry: r.PostFormValue("expiry"), + Rotation: r.PostFormValue("rotation"), } } @@ -232,8 +236,8 @@ func (h *Handlers) buildTargetConfig( } // buildHTTPTargetConfig builds config JSON for an HTTP target: an -// SSRF-validated destination plus the optional headers and timeout -// the delivery path honours. +// SSRF-validated destination plus the optional headers, timeout and +// query string setting the delivery path honours. func (h *Handlers) buildHTTPTargetConfig( ctx context.Context, in targetFormInput, @@ -256,9 +260,10 @@ func (h *Handlers) buildHTTPTargetConfig( } configJSON, err := marshalTargetConfig(delivery.HTTPTargetConfig{ - URL: in.URL, - Headers: headers, - Timeout: timeout, + URL: in.URL, + Headers: headers, + Timeout: timeout, + ForwardQuery: in.ForwardQuery, }) return configJSON, "", err diff --git a/internal/handlers/target_edit.go b/internal/handlers/target_edit.go index 6975c88..9a29fb4 100644 --- a/internal/handlers/target_edit.go +++ b/internal/handlers/target_edit.go @@ -77,13 +77,14 @@ func (h *Handlers) HandleTargetEdit() http.HandlerFunc { } form := targetFormInput{ - Name: target.Name, - URL: cfg.URL, - Headers: cfg.Headers, - Timeout: cfg.Timeout, - MaxRetries: strconv.Itoa(target.MaxRetries), - Expiry: cfg.Expiry, - Rotation: cfg.Rotation, + Name: target.Name, + URL: cfg.URL, + Headers: cfg.Headers, + Timeout: cfg.Timeout, + ForwardQuery: cfg.ForwardQuery, + MaxRetries: strconv.Itoa(target.MaxRetries), + Expiry: cfg.Expiry, + Rotation: cfg.Rotation, } h.renderTargetEdit( diff --git a/internal/handlers/target_edit_test.go b/internal/handlers/target_edit_test.go index 9580252..f73aada 100644 --- a/internal/handlers/target_edit_test.go +++ b/internal/handlers/target_edit_test.go @@ -442,6 +442,59 @@ func TestHandleTargetEdit_CallsTheDatabaseTypeArchive(t *testing.T) { assert.Contains(t, page, `class="label">Archive rotation`) } +// TestHandleTarget_ForwardQuery covers the HTTP target's setting that +// passes each event's query string on to it: the add target form +// stores it checked, the edit form starts with it checked and turns it +// off when saved unchecked, and both forms come back with it checked +// when refused. +func TestHandleTarget_ForwardQuery(t *testing.T) { + t.Parallel() + + const checkbox = `name="forward_query" value="on" checked` + + env := setupSourceTest(t) + webhook := seedWebhookWithRetention(t, env.db, 30) + targetsPath := "/hook/" + webhook.ID + "/targets" + + form := url.Values{} + form.Set("name", "forwarding") + form.Set("type", string(database.TargetTypeHTTP)) + form.Set("url", editOriginalURL) + form.Set("forward_query", "on") + + w := serveTarget(env, http.MethodPost, targetsPath, form) + require.Equal(t, http.StatusSeeOther, w.Code, w.Body.String()) + + targets := targetsForWebhook(t, env.db, webhook.ID) + require.Len(t, targets, 1) + assert.True(t, storedHTTPConfig(t, env, targets[0].ID).ForwardQuery) + + w = serveTarget( + env, http.MethodGet, targetsPath+"/"+targets[0].ID+"/edit", nil, + ) + require.Equal(t, http.StatusOK, w.Code) + assert.Contains(t, w.Body.String(), checkbox) + + edit := editForm(editOriginalURL, "", "") + + w = submitTargetEdit(env, webhook.ID, targets[0].ID, edit) + require.Equal(t, http.StatusSeeOther, w.Code, w.Body.String()) + assert.False(t, storedHTTPConfig(t, env, targets[0].ID).ForwardQuery) + + edit.Set("url", editBlockedURL) + edit.Set("forward_query", "on") + + w = submitTargetEdit(env, webhook.ID, targets[0].ID, edit) + require.Equal(t, http.StatusBadRequest, w.Code) + assert.Contains(t, w.Body.String(), checkbox) + + form.Set("url", editBlockedURL) + + w = serveTarget(env, http.MethodPost, targetsPath, form) + require.Equal(t, http.StatusBadRequest, w.Code) + assert.Contains(t, w.Body.String(), "data-forward-query") +} + // TestHandleTargetEditSubmit_Rejects covers every submission that // must not reach storage. // diff --git a/internal/handlers/webhook.go b/internal/handlers/webhook.go index a3bc7e0..76bdc4c 100644 --- a/internal/handlers/webhook.go +++ b/internal/handlers/webhook.go @@ -230,6 +230,7 @@ type eventSource struct { WebhookID string EntrypointID string Method string + RawQuery string HeadersJSON string ContentType string Body []byte @@ -245,6 +246,7 @@ func (s eventSource) event() *database.Event { WebhookID: s.WebhookID, EntrypointID: s.EntrypointID, Method: s.Method, + RawQuery: s.RawQuery, Headers: s.HeadersJSON, Body: string(s.Body), BodyBytes: int64(len(s.Body)), @@ -264,6 +266,7 @@ func requestEventSource( WebhookID: entrypoint.WebhookID, EntrypointID: entrypoint.ID, Method: r.Method, + RawQuery: r.URL.RawQuery, HeadersJSON: string(headersJSON), ContentType: r.Header.Get("Content-Type"), Body: body, @@ -441,6 +444,7 @@ func buildDeliveryTasks( TargetConfig: targets[i].Config, MaxRetries: targets[i].MaxRetries, Method: event.Method, + RawQuery: event.RawQuery, Headers: event.Headers, ContentType: event.ContentType, Body: bodyPtr, diff --git a/internal/server/alpine_browser_test.go b/internal/server/alpine_browser_test.go index 96593c9..367e0f9 100644 --- a/internal/server/alpine_browser_test.go +++ b/internal/server/alpine_browser_test.go @@ -507,9 +507,10 @@ func checkRefusedTarget(ctx context.Context, t *testing.T, url string) { t.Helper() const ( - refusedURL = "http://127.0.0.1/hook" - urlField = `form[action$="/targets"] input[name="url"]` - reason = `//div[@class="alert-error"]` + refusedURL = "http://127.0.0.1/hook" + urlField = `form[action$="/targets"] input[name="url"]` + forwardQuery = `form[action$="/targets"] input[name="forward_query"]` + reason = `//div[@class="alert-error"]` ) require.NoError(t, chromedp.Run(ctx, loadPage(url))) @@ -519,6 +520,7 @@ func checkRefusedTarget(ctx context.Context, t *testing.T, url string) { ctx, chromedp.SetValue(targetName, "refused", chromedp.ByQuery), chromedp.SetValue(urlField, refusedURL, chromedp.ByQuery), + chromedp.Click(forwardQuery, chromedp.ByQuery), )) click(ctx, t, saveButton) @@ -526,18 +528,26 @@ func checkRefusedTarget(ctx context.Context, t *testing.T, url string) { assert.True(t, shown(ctx, reason), "a refused target does not show the reason") - var name, typed string + var ( + name, typed string + checked bool + ) require.NoError(t, chromedp.Run( ctx, chromedp.Value(targetName, &name, chromedp.ByQuery), chromedp.Value(urlField, &typed, chromedp.ByQuery), + chromedp.JavascriptAttribute( + forwardQuery, "checked", &checked, chromedp.ByQuery, + ), )) assert.Equal(t, "refused", name, "a refused target does not keep the name entered") assert.Equal(t, refusedURL, typed, "a refused target does not keep the url entered") + assert.True(t, checked, + "a refused target does not keep the query string setting checked") assert.True(t, shown(ctx, targetName), "a refused target does not come back with the form open") assert.True(t, hidden(ctx, typeSelect), @@ -553,10 +563,15 @@ func checkRefusedTarget(ctx context.Context, t *testing.T, url string) { ctx, chromedp.Value(targetName, &name, chromedp.ByQuery), chromedp.Value(urlField, &typed, chromedp.ByQuery), + chromedp.JavascriptAttribute( + forwardQuery, "checked", &checked, chromedp.ByQuery, + ), )) assert.Empty(t, name, "after Cancel, the next Add keeps the name entered") assert.Empty(t, typed, "after Cancel, the next Add keeps the url entered") + assert.False(t, checked, + "after Cancel, the next Add keeps the query string setting checked") } // checkTargetDeliveries loads a webhook page and checks that the row of diff --git a/static/js/app.js b/static/js/app.js index c975c49..7b0e04d 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -138,6 +138,7 @@ document.addEventListener("alpine:init", function () { url: "", headers: "", timeout: "", + forwardQuery: false, maxRetries: "", expiry: "", rotation: "", @@ -150,6 +151,8 @@ document.addEventListener("alpine:init", function () { this.url = refused.destination; this.headers = refused.headers; this.timeout = refused.timeout; + this.forwardQuery = + this.$root.hasAttribute("data-forward-query"); this.maxRetries = refused.maxRetries; this.expiry = refused.expiry; this.rotation = refused.rotation; @@ -169,6 +172,7 @@ document.addEventListener("alpine:init", function () { this.url = ""; this.headers = ""; this.timeout = ""; + this.forwardQuery = false; this.maxRetries = ""; this.expiry = ""; this.rotation = ""; diff --git a/templates/event_request.html b/templates/event_request.html index 943615e..af014b7 100644 --- a/templates/event_request.html +++ b/templates/event_request.html @@ -1,15 +1,23 @@ {{define "event_request"}} - +
{{if .ResubmittedFrom}}

The request it copies arrived at {{.Entrypoint}}

{{else}}

Arrived at {{.Entrypoint}}

{{end}} + {{if .RawQueryCut}} +

The query string is larger than the event log shows. Show the query string

+ {{else if .RawQuery}} +

Query string

+
{{.RawQuery}}
+ {{else}} +

No query string.

+ {{end}} {{if .HeadersCut}}

The request headers are larger than the event log shows. Show the request headers

{{else if .Headers}} diff --git a/templates/source_detail.html b/templates/source_detail.html index 467cb53..7e19e35 100644 --- a/templates/source_detail.html +++ b/templates/source_detail.html @@ -135,6 +135,7 @@ data-destination="{{.TargetForm.URL}}" data-headers="{{.TargetForm.Headers}}" data-timeout="{{.TargetForm.Timeout}}" + {{if .TargetForm.ForwardQuery}}data-forward-query{{end}} data-max-retries="{{.TargetForm.MaxRetries}}" data-expiry="{{.TargetForm.Expiry}}" data-rotation="{{.TargetForm.Rotation}}"> @@ -183,6 +184,13 @@
+
+ +

Appends the query string each event arrived with to the URL above, after any query string the URL already has.

+
diff --git a/templates/target_edit.html b/templates/target_edit.html index 89c640d..42f6d79 100644 --- a/templates/target_edit.html +++ b/templates/target_edit.html @@ -47,6 +47,14 @@

Per-request timeout, at most {{.MaxTimeout}} seconds. Leave blank to use the default.

+ +
+ +

Appends the query string each event arrived with to the destination URL, after any query string the URL already has.

+
{{end}} {{if eq .Target.Type "slack"}} -- 2.54.0 From 46fe7baed04eba67dc14b893102d35857c138ec3 Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Sun, 4 Oct 2026 03:31:37 +0200 Subject: [PATCH 097/126] Mask a target URL's query string when the URL has no path (closes #500) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `urlSecrets` treated a target URL's request URI as a secret only when the URL had a path, so for a target URL such as `https://example.com/?token=…` a response echoing the request line showed the token in the event log and on the event's page. It now also treats the query string, and the request URI that carries it, as secrets whenever the URL has one, whatever its path. With "Pass the query string on to this target" on, only the target's own part is masked, not the event's. A URL with a path is masked as before. As for paths and userinfo, no length floor applies. Model: opus-5-5 --- internal/delivery/target_redact.go | 35 +++++++++++---------- internal/delivery/target_redact_test.go | 42 +++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 16 deletions(-) diff --git a/internal/delivery/target_redact.go b/internal/delivery/target_redact.go index a9050db..267e584 100644 --- a/internal/delivery/target_redact.go +++ b/internal/delivery/target_redact.go @@ -162,18 +162,22 @@ func targetSecrets(t *database.Target) []string { } // urlSecrets returns the substrings of a destination URL that -// must not survive into a rendered page: the whole URL, the -// parts of it MaskURL elides, and any userinfo. +// must not survive into a rendered page: the whole URL; its +// path, unless that is empty or "/"; its query string, and the +// request URI that carries it, which a remote echoing the +// request line shows even when the URL has no path; and its +// userinfo and password. // -// No length floor is applied to the path, and none to the -// userinfo. A short path or a four-byte username is treated as -// a credential exactly like a long one, because the field takes -// an arbitrary URL and no part of it can be assumed non-secret — -// the same rule MaskURL applies. headerSecrets does carry a -// floor, and the difference is deliberate: a header is picked -// out by a name-shaped guess and its value may be ordinary -// text, whereas a URL's path and userinfo are credential -// material by position. +// No length floor is applied to the path, the query string or +// the userinfo. A short path or a four-byte username is +// treated as a credential exactly like a long one, because the +// field takes an arbitrary URL and no part of it can be +// assumed non-secret — the same rule MaskURL applies. +// headerSecrets does carry a floor, and the difference is +// deliberate: a header is picked out by a name-shaped guess +// and its value may be ordinary text, whereas a URL's path, +// query string and userinfo are credential material by +// position. func urlSecrets(raw string) []string { raw = strings.TrimSpace(raw) if raw == "" { @@ -188,12 +192,11 @@ func urlSecrets(raw string) []string { } if parsed.Path != "" && parsed.Path != "/" { - requestURI := parsed.RequestURI() - secrets = append(secrets, requestURI) + secrets = append(secrets, parsed.EscapedPath()) + } - if escaped := parsed.EscapedPath(); escaped != requestURI { - secrets = append(secrets, escaped) - } + if parsed.RawQuery != "" { + secrets = append(secrets, parsed.RequestURI(), parsed.RawQuery) } if parsed.User != nil { diff --git a/internal/delivery/target_redact_test.go b/internal/delivery/target_redact_test.go index e28e302..52613de 100644 --- a/internal/delivery/target_redact_test.go +++ b/internal/delivery/target_redact_test.go @@ -202,6 +202,48 @@ func TestRedactor_RemovesHTTPURLQueryAndUserinfo(t *testing.T) { } } +// TestRedactor_RemovesEchoedQueryOfURLWithoutPath covers an +// HTTP target URL whose credential is all in its query string. +// Written with or without the "/", the request line sends it +// as "/?token=…", and a target passing the event's query string +// on sends that after an "&". The event's part stays visible: +// the event's page shows it anyway. +func TestRedactor_RemovesEchoedQueryOfURLWithoutPath(t *testing.T) { + t.Parallel() + + const secret = "s3cr3t" + + marker := delivery.RedactionMarker + + // An echoed request line, and what the event log shows of it. + echoes := map[string]string{ + "POST /?token=" + secret + " HTTP/1.1": "POST " + marker + + " HTTP/1.1", + "POST ?token=" + secret + " HTTP/1.1": "POST ?" + marker + + " HTTP/1.1", + "POST /?token=" + secret + "&a=1&b=2 HTTP/1.1": "POST " + + marker + "&a=1&b=2 HTTP/1.1", + "POST ?token=" + secret + "&a=1&b=2 HTTP/1.1": "POST ?" + + marker + "&a=1&b=2 HTTP/1.1", + } + + for _, dest := range []string{ + "https://example.com/?token=" + secret, + "https://example.com?token=" + secret, + } { + r := delivery.NewRedactor(&database.Target{ + Type: database.TargetTypeHTTP, + Config: `{"url":"` + dest + `"}`, + }) + + for echoed, want := range echoes { + assert.Equal( + t, want, r.Redact(echoed), "%s: %s", dest, echoed, + ) + } + } +} + // TestRedactor_LeavesUnrelatedTextAlone pins that the // redactor matches literally: it does not guess at what a // secret looks like, so ordinary response content survives. -- 2.54.0 From fa6a9ed4dc1bb85ff182eb61c32eb6504cfb0c24 Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Tue, 6 Oct 2026 06:05:42 +0200 Subject: [PATCH 098/126] Re-vendor the shared files from sneak/prompts at dd4027b (closes #504) The shared workflow, lint config, prettier settings and policies are the copies at `sneak/prompts` commit `dd4027b`. `.gitignore`, `.editorconfig` and `.dockerignore` are the shared copy followed by this repository's own entries. Linting is the image build's lint phase on golangci-lint v2.14.0, and tests run in their own test phase. Every scripted `docker build` passes `--no-cache`, so the CI fingerprint step and the superseded-run script are gone. The binary is built with `-trimpath -s -w`, and a build that has `.git` but no version fails. The development run keeps its databases outside the checkout. Deviation: `.dockerignore` also leaves out SQLite databases at any depth. Model: opus-5-5 --- .dockerignore | 112 +++-- .editorconfig | 3 + .gitea/workflows/check.yml | 42 +- .gitignore | 71 ++- .golangci.yml | 69 ++- .prettierignore | 2 + Dockerfile | 164 ++++--- Dockerfile.browser | 2 +- Dockerfile.lint | 43 -- Makefile | 6 +- README.md | 266 +++++------ REPO_POLICIES.md | 443 +++++++++++++++---- go.mod | 2 +- internal/ciscript/ci_mark_superseded_test.go | 387 ---------------- internal/ciscript/doc.go | 10 - internal/ciscript/fakegitea_test.go | 162 ------- internal/middleware/loginguard_test.go | 6 +- script/assets | 4 +- script/bootstrap | 6 +- script/check | 4 +- script/ci-mark-superseded | 152 ------- script/cibuild | 25 +- script/css | 6 +- script/css-check | 5 +- script/docker | 18 +- script/fmt | 6 +- script/fmt-check | 5 +- script/lint | 74 +--- script/test | 85 +--- script/test-browser | 5 +- script/version | 8 +- 31 files changed, 883 insertions(+), 1310 deletions(-) create mode 100644 .prettierignore delete mode 100644 Dockerfile.lint delete mode 100644 internal/ciscript/ci_mark_superseded_test.go delete mode 100644 internal/ciscript/doc.go delete mode 100644 internal/ciscript/fakegitea_test.go delete mode 100755 script/ci-mark-superseded diff --git a/.dockerignore b/.dockerignore index fe1d815..c4f5f96 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,30 +1,84 @@ -# .git is sent so the build can derive the version it stamps into the binary -# (script/version). Its config, which can hold a remote URL carrying a -# credential and which `git describe` does not need, is left out of a -# directory context. A context sent as a tar is not filtered by this file, so -# it carries .git/config unless its sender leaves it out. -.git/config - -# No tracked file may be listed here: git in the build would see it as -# deleted and mark the version -dirty. +# .dockerignore does NOT use .gitignore semantics. Docker matches with +# moby/patternmatcher: filepath.Match plus `**`, so `*` does not cross +# `/` and an unprefixed pattern is anchored at the context root. Every +# depth-independent pattern therefore needs `**/`, or `config/.env` and +# `certs/server.key` still ship while this file reads as solved. Only +# genuinely root-anchored entries go unprefixed. Never transplant these +# into .gitignore, where `**/` is wrong. # -# .ci-fingerprint is deliberately NOT excluded: it is the CI cache barrier -# that keeps the check stages from replaying a cached pass. See the lint -# stage of the Dockerfile. -bin/ -# Extracted from 3p/ by `make assets` inside the build; a host copy is not -# needed. The tarball in 3p/ must stay in the context. -static/js/alpine.min.js -# The js-deps stage installs ESLint and prettier; a host copy would overwrite -# them at the `COPY . .` of the stages built on it. -node_modules/ -.env -.env.* -*.db -*.sqlite -*.sqlite3 -.DS_Store -.idea/ -.vscode/ -tmp/ -temp/ +# Matching is case-sensitive, so secrets use character ranges rather +# than an ALL-CAPS twin, which would still miss `Server.Key`. +# +# Extend with this repo's own host-built artifacts, written anchored: +# `/myapp`, never `**/myapp`, which also matches `cmd/myapp/` and +# deletes the package directory from the context. + +# .git is sent without its config. Without a VERSION build argument the +# stage that compiles runs `git describe --tags --always` on .git, which +# does not need .git/config; that file can hold a credential, such as a +# password in a remote URL or the token the CI checkout step stores there. +# Each submodule keeps a config with the same exposure in its git directory +# under .git/modules/, nested again for a submodule's own submodules, or in +# its own .git directory when it keeps one. +# KNOWN GAP: a submodule whose name has a `config` segment (`config`, +# `deploy/config`, `config/lib`) loses its whole git directory, because +# `**/.git/modules/**/config` also matches that segment's directory +# under .git/modules/. Go's version stamping then fails the build; +# nothing leaks. Name such a submodule without that segment: +# `git submodule add --name`. +**/.git/config +**/.git/modules/**/config + +# Agent scratch: one full checkout of the repo per in-flight agent. +# Anchored because it occurs once where agents run at the repo root. +# KNOWN GAP: a repo running agents in subdirectories still ships +# `services/api/.claude/` and must add its own anchored entry. +.claude + +# Environment files. `*.env` covers bare `.env` and the `prod.env` +# convention. Re-include a committed template with a negation if the +# build needs one: `!docs/example.env`. +**/*.[eE][nN][vV] +**/.[eE][nN][vV].* +**/.[eE][nN][vV][rR][cC] + +# Private keys and the bundles carrying them. Public certificates +# (*.crt, *.cer) are deliberately absent: they are legitimate inputs. +**/*.[pP][eE][mM] +**/*.[kK][eE][yY] +**/*.[pP]12 +**/*.[pP][fF][xX] +**/[iI][dD]_[rR][sS][aA] +**/[iI][dD]_[dD][sS][aA] +**/[iI][dD]_[eE][cC][dD][sS][aA] +**/[iI][dD]_[eE][cC][dD][sS][aA]_[sS][kK] +**/[iI][dD]_[eE][dD]25519 +**/[iI][dD]_[eE][dD]25519_[sS][kK] + +# Dependencies: restored inside the image, never copied in. +**/node_modules + +# OS metadata. +**/.DS_Store +**/Thumbs.db + +# Editor state: never a build input, and it churns COPY. +**/*.swp +**/*.swo +**/*~ +**/*.bak +**/.idea +**/.vscode +**/*.sublime-* + +# This repository's own host-built artifacts: the binary `make build` +# writes, and the Alpine.js file `make assets` extracts from 3p/ (the +# build extracts its own). +/bin +/static/js/alpine.min.js + +# SQLite databases, which hold the session key and webhook payloads, at +# any depth. +**/*.db +**/*.sqlite +**/*.sqlite3 diff --git a/.editorconfig b/.editorconfig index 2fe0ce0..92ec261 100644 --- a/.editorconfig +++ b/.editorconfig @@ -10,3 +10,6 @@ insert_final_newline = true [Makefile] indent_style = tab + +[*.go] +indent_style = tab diff --git a/.gitea/workflows/check.yml b/.gitea/workflows/check.yml index 8143c1a..ee73864 100644 --- a/.gitea/workflows/check.yml +++ b/.gitea/workflows/check.yml @@ -1,37 +1,9 @@ name: check - -on: - push: - branches: - - '**' - +on: [push] jobs: - check: - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 2024-10-23 - with: - # The superseded-status step needs history to walk ancestors (it - # aborts on a shallow clone). - fetch-depth: 0 - - - name: Mark superseded run statuses - # Gitea cancels the in-flight run when another commit is pushed to the - # same branch and records the cancellation as `failure`, so a commit - # that was never tested reads as a test result. The script rewrites - # those statuses to say what happened. See its header for why the - # state stays `failure` and not `skipped`. - env: - GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} - run: script/ci-mark-superseded - - - name: Fingerprint the build context - # Writes the hash of the commit being checked into the context, which - # invalidates the `COPY . .` layer of every check stage: a commit - # that was never linted, format-checked, stylesheet-checked, tested - # and built cannot report success from cache. - run: git rev-parse HEAD > .ci-fingerprint - - - name: Build Docker image (runs the gofmt check, golangci-lint, the stylesheet check, ESLint, the Markdown check, make test, make build) - run: script/cibuild + check: + runs-on: ubuntu-latest + steps: + # actions/checkout v4.2.2, 2026-02-22 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + - run: script/cibuild diff --git a/.gitignore b/.gitignore index d11c6f5..e94615b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,53 @@ +# OS +.DS_Store +Thumbs.db + +# Editors +*.swp +*.swo +*~ +*.bak +.idea/ +.vscode/ +*.sublime-* + +# Agent scratch (worktrees of this repo, created and destroyed by +# in-flight tooling). Unanchored: .gitignore patterns already match at +# every depth, so no prefix is wanted here. This is not a .dockerignore +# entry and must not be given a `**/` prefix on the way into one. +.claude/ + +# Node +node_modules/ + +# Secrets. Unanchored like every entry above, so each matches at every +# depth. Matching is case-sensitive on Linux, so names use character +# ranges rather than a lowercase form that misses `Server.Key`. + +# Environment files. `*.env` covers bare `.env` and the `prod.env` +# convention. Only the templates `example.env` and `sample.env` are +# re-included below. A repository that commits any other template adds +# its own negation after these lines, for example `!.env.example`. +*.[eE][nN][vV] +.[eE][nN][vV].* +.[eE][nN][vV][rR][cC] +!example.env +!sample.env + +# Private keys and the bundles carrying them. +*.[pP][eE][mM] +*.[kK][eE][yY] +*.[pP]12 +*.[pP][fF][xX] +[iI][dD]_[rR][sS][aA] +[iI][dD]_[dD][sS][aA] +[iI][dD]_[eE][cC][dD][sS][aA] +[iI][dD]_[eE][cC][dD][sS][aA]_[sS][kK] +[iI][dD]_[eE][dD]25519 +[iI][dD]_[eE][dD]25519_[sS][kK] + +# This repository's own entries, after the shared content above. + # Binaries *.exe *.dll @@ -15,24 +65,6 @@ bin/ # Go vendor directory vendor/ -# ESLint, prettier and their dependencies, installed from yarn.lock -node_modules/ - -# IDE specific files -.idea/ -*.swp -*.swo -*~ -.vscode/ - -# OS specific files -.DS_Store -Thumbs.db - -# Environment and config files -.env -.env.local - # Data directory (SQLite databases) data/ *.db @@ -46,9 +78,6 @@ data/ tmp/ temp/ -# CI cache barrier, written into the build context by the check workflow -.ci-fingerprint - # Alpine.js, extracted by `make assets` from its tarball in 3p/, which is # what is committed. /static/js/alpine.min.js diff --git a/.golangci.yml b/.golangci.yml index 26b1610..1b73eb9 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -10,14 +10,21 @@ run: linters: default: all + enable: + # Successor to the deprecated gomodguard. Named explicitly, rather than + # left to `default: all`, because it carries the module policy below. + - gomodguard_v2 disable: # Genuinely incompatible with project patterns - exhaustruct # Requires all struct fields - - depguard # Dependency allow/block lists + - exhaustruct_v5 # Requires all struct fields (successor to exhaustruct) - godot # Requires comments to end with periods - - wsl # Deprecated, replaced by wsl_v5 - wrapcheck # Too verbose for internal packages - varnamelen # Short names like db, id are idiomatic Go + # Deprecated: the warning is attached to the old name, so it is + # silenced by disabling that name, not by enabling the successor. + - wsl # Deprecated, replaced by wsl_v5 + - gomodguard # Deprecated, replaced by gomodguard_v2 settings: lll: line-length: 88 @@ -28,6 +35,64 @@ linters: max-complexity: 15 dupl: threshold: 100 + depguard: + # Test-support code must not be compiled into the shipped binary. A + # test-support package exists to hand a test privileges the program + # itself must never have, so a file that is not a test must not import + # one. Test files, and the files inside a package whose directory name + # ends in `test`, are where that code belongs, and are exempt. + # + # The deny list below is the one part of this file a repository is + # expected to extend, and the only part it may. depguard matches an + # import path against a list of prefixes, so it cannot be told "any path + # whose last segment ends in test"; a repository's own test-support + # packages have to be named here one at a time, by full import path, + # under a module path that differs from repository to repository. Add + # them; change nothing else. + rules: + test-support: + list-mode: lax + files: + - "$all" + - "!$test" + - "!**/*test/**" + deny: + - pkg: net/http/httptest + desc: >- + Test-support code belongs in test files and in packages whose + directory name ends in test, not in the shipped binary. + # Only decisions already recorded in the Go package defaults are + # listed here. Every entry matches the module path exactly. + gomodguard_v2: + blocked: + - module: github.com/rs/zerolog + recommendations: + - log/slog + reason: "Structured logging is stdlib log/slog." + # One entry per pre-fork module path, because the later releases + # are separate paths. A prefix match would be shorter but would + # also reach github.com/go-redis/redismock, the test double for + # the successor these entries recommend. + - module: github.com/go-redis/redis + recommendations: + - github.com/redis/go-redis/v9 + reason: "Pre-fork module; use the maintained go-redis v9." + - module: github.com/go-redis/redis/v7 + recommendations: + - github.com/redis/go-redis/v9 + reason: "Pre-fork module; use the maintained go-redis v9." + - module: github.com/go-redis/redis/v8 + recommendations: + - github.com/redis/go-redis/v9 + reason: "Pre-fork module; use the maintained go-redis v9." + - module: github.com/sergi/go-diff + recommendations: + - github.com/aymanbagabas/go-udiff + reason: "No unified diff output; use go-udiff." + - module: github.com/hexops/gotextdiff + recommendations: + - github.com/aymanbagabas/go-udiff + reason: "Unmaintained fork; use go-udiff." issues: max-issues-per-linter: 0 diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..23d67fc --- /dev/null +++ b/.prettierignore @@ -0,0 +1,2 @@ +node_modules/ +yarn.lock diff --git a/Dockerfile b/Dockerfile index e116b5d..7a61e9a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,34 +1,3 @@ -# Lint stage -# golangci/golangci-lint:v2.12.2 (Debian-based), 2026-08-07 -# Using Debian-based image because mattn/go-sqlite3 (CGO) does not -# compile on Alpine musl (off64_t is a glibc type). -FROM golangci/golangci-lint:v2.12.2@sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240 AS lint - -WORKDIR /src - -# Copy go mod files first for better layer caching -COPY go.mod go.sum ./ -RUN go mod download - -# Copy source code. In CI the context also carries .ci-fingerprint, which -# holds the hash of the commit being checked (see -# .gitea/workflows/check.yml). That invalidates this layer, so the checks -# below cannot report success by replaying a cached pass. Do not add it to -# .dockerignore. -COPY . . - -# Run the Go formatting check and the linter. gofmt and golangci-lint are -# invoked directly rather than through `make fmt-check` and `make lint`: this -# stage is already the pinned linter image, and both scripts build docker -# stages, so calling them here would need a docker daemon inside the build. -# The Markdown half of `make fmt-check` is the markdown-check stage below. -# Keep the golangci-lint steps in step with Dockerfile.lint, including -# --network=none (see its header for why). -RUN if [ -n "$(gofmt -s -l .)" ]; then echo "gofmt needed on:"; gofmt -s -l .; exit 1; fi -RUN script/assets -RUN --network=none golangci-lint config verify --config .golangci.yml -RUN --network=none golangci-lint run --config .golangci.yml --build-tags browser ./... - # Stylesheet stages. static/css/tailwind.css is generated, by this pinned # tailwindcss, from static/css/input.css and the files its @source lines # name. `make css` (script/css) writes it out from the css-output stage. @@ -67,10 +36,7 @@ RUN sed 's/}/}\n/g' static/css/tailwind.css > /tmp/committed.css \ # JavaScript lint stages: ESLint, at the version package.json and yarn.lock # pin, checks static/js/ against eslint.config.mjs. js-deps installs it, and -# prettier for the Markdown stages below, and stays cached until package.json, -# yarn.lock or .yarnrc.yml changes. script/lint forces only js-lint to re-run, -# and the build stage below runs it too. COPY . . brings in the CI cache -# barrier described in the lint stage above. +# prettier for the Markdown stages below. The lint phase below runs js-lint. # # The image's own corepack runs the yarn that package.json's packageManager # field names, yarn 4.18.1 (released 2026-09-24), and checks it against the @@ -104,23 +70,109 @@ FROM js-deps AS markdown-check COPY . . RUN --network=none node_modules/.bin/prettier --check '**/*.md' +# Lint phase: the Go formatting check and golangci-lint over the Go code, +# and ESLint over static/js/ through the copy from js-lint at the end. +# `make lint` (script/lint) builds this stage alone; the build stage below +# depends on it. +# +# golangci/golangci-lint:v2.14.0 (Debian-based), 2026-09-24 +# Using Debian-based image because mattn/go-sqlite3 (CGO) does not +# compile on Alpine musl (off64_t is a glibc type). +FROM golangci/golangci-lint:v2.14.0@sha256:ad862ba6b3798cbe0fd9fd7408d498fd74fbd2623a92406b2fd3898faf0bf98f AS lint + +WORKDIR /src + +# Copy go mod files first for better layer caching +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . + +# gofmt and golangci-lint are invoked directly rather than through `make +# fmt-check` and `make lint`, which are themselves docker builds and would +# need a docker daemon inside this one. The Markdown half of `make +# fmt-check` is the markdown-check stage above. +RUN if [ -n "$(gofmt -s -l .)" ]; then echo "gofmt needed on:"; gofmt -s -l .; exit 1; fi + +# static/static.go embeds the Alpine.js file this extracts from 3p/; without +# it the static package does not compile and cannot be linted. +RUN script/assets + +# The golangci-lint steps run with --network=none. `golangci-lint config +# verify` is documented as fetching its JSON schema over HTTPS; this pinned +# image resolves the schema without any network, and --network=none enforces +# that. It also proves no linter reaches out at analysis time. +# +# `run` silently ignores config keys it does not recognize, so a typo would +# disable a setting without a word. `config verify` is what catches that. +RUN --network=none golangci-lint config verify --config .golangci.yml +# --build-tags browser also lints the browser test, which is built only with +# that tag (make test-browser). +RUN --network=none golangci-lint run --config .golangci.yml --build-tags browser ./... + +# Nothing is wanted from js-lint; the copy is what makes this phase run it. +COPY --from=js-lint /src/yarn.lock /dev/null + +# Test phase. -race needs cgo and so a C compiler, which the Debian Go image +# ships and the alpine one does not. `make test` (script/test) builds this +# stage alone; the build stage below depends on it. +# +# golang:1.26.1-bookworm (Debian-based), 2026-03-17 +FROM golang:1.26.1-bookworm@sha256:4465644228bc2857a954b092167e12aa59c006a3492282a6c820bf4755fd64a4 AS test + +WORKDIR /src + +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . + +# static/static.go embeds the Alpine.js file this extracts from 3p/. +RUN script/assets + +# -timeout applies to each package on its own, so 90s has only to clear the +# slowest one. -p 4 -parallel 8 keep the run under 2 GB of memory: at most +# four test binaries build or run at once, each with at most eight parallel +# tests. Under -race every test binary and every link costs a few hundred MB, +# so the defaults (one per core) add up to several GB on a many-core host. +# +# The first run has no -v: go test then prints one result line per package, +# with its coverage, and for a package that fails, everything its tests +# wrote. Verbose output from the whole suite passes the 2 MiB at which the +# Docker build cuts off a step's log, so on a failure only the tests that +# failed run again, with -v. go test reports a failed test as a line starting +# "--- FAIL: TestName" (a failed subtest's line is indented, and reruns with +# its parent) and a failed package as "FAILpackage/path...". A +# failure that names no test, such as a build error or a timeout, is already +# shown in full, so there is nothing to rerun. The step fails after the rerun +# whatever its result: the first run already showed the suite is broken. +# +# bash with pipefail, so that the first run's status is go test's, not tee's. +SHELL ["/bin/bash", "-o", "pipefail", "-c"] +RUN go test -race -cover -p 4 -parallel 8 -timeout 90s ./... 2>&1 | tee /tmp/go-test.log && exit 0; \ + tests="$(awk '/^--- FAIL: / { print $3 }' /tmp/go-test.log | paste -s -d '|' -)"; \ + packages="$(awk '/^FAIL\t/ { print $2 }' /tmp/go-test.log)"; \ + if [ -n "$tests" ]; then \ + echo "--- Rerunning the failed tests with -v for details ---"; \ + go test -race -v -p 4 -parallel 8 -timeout 90s -run "^($tests)\$" $packages; \ + fi; \ + exit 1 + # Build stage # golang:1.26.1-bookworm (Debian-based), 2026-03-17 # Using Debian-based image because gorm.io/driver/sqlite pulls in -# mattn/go-sqlite3 (CGO), which does not compile on Alpine musl. +# mattn/go-sqlite3 (CGO), which does not compile on Alpine musl. The image +# ships git and make, which the version step below uses. FROM golang:1.26.1-bookworm@sha256:4465644228bc2857a954b092167e12aa59c006a3492282a6c820bf4755fd64a4 AS builder -# Depend on the lint, stylesheet check, JavaScript lint and Markdown check -# stages passing +# Nothing is wanted from the lint and test phases or from the stylesheet and +# Markdown checks; the copies are what make BuildKit build them first, so +# this stage cannot run unless they all passed. COPY --from=lint /src/go.sum /dev/null +COPY --from=test /src/go.sum /dev/null COPY --from=css-check /out/tailwind.css /dev/null -COPY --from=js-lint /src/yarn.lock /dev/null COPY --from=markdown-check /src/yarn.lock /dev/null -# jq is a runtime dependency of script/ci-mark-superseded, which the test -# suite executes. git is what script/version derives the version with. -RUN apt-get update && apt-get install -y --no-install-recommends make curl ca-certificates jq git && rm -rf /var/lib/apt/lists/* - # A build context sent as a tar archive keeps its files' owners, and git # refuses to read a checkout owned by another user. Trust this one # whoever owns it. @@ -132,31 +184,27 @@ WORKDIR /build COPY go.mod go.sum ./ RUN go mod download -# Copy source code, including the .ci-fingerprint cache barrier described in -# the lint stage above. COPY . . -# Run tests and build. Both first run script/assets, which extracts Alpine.js -# from its tarball in 3p/. -RUN make test - # Version stamped into the binary: the VERSION build arg when one is # given, otherwise what script/version derives from the .git the build # context carries, so any `docker build .` of a clone stamps its commit. # With neither, as from a source tarball, it is "unknown". -# -# Declared here, below the test step, so a changed version does not -# invalidate its cached layer. ARG VERSION -# A context that carries .git must not stamp "unknown": that means git is -# missing here or could not read the checkout, and the image could not be -# traced back to its commit. -RUN if [ -d .git ] && [ "$(make version VERSION="$VERSION")" = unknown ]; then \ - echo "version is unknown although the build context carries .git" >&2; \ - exit 1; \ +# A context that carries .git must not stamp an empty version, "dev" or +# "unknown": that means git is missing here or could not read the +# checkout, and the image could not be traced back to its commit. +RUN version="$(make version VERSION="$VERSION")"; \ + if [ -e .git ]; then \ + case "$version" in ""|dev|unknown) \ + echo "version is '$version' although .git is present" >&2; \ + exit 1 ;; \ + esac; \ fi +# Builds through the Makefile's build target, which runs script/assets +# (Alpine.js, extracted from its tarball in 3p/) first. RUN make build VERSION="$VERSION" # Rebuild with static linking for Alpine runtime. diff --git a/Dockerfile.browser b/Dockerfile.browser index 9ead22e..d86835e 100644 --- a/Dockerfile.browser +++ b/Dockerfile.browser @@ -16,7 +16,7 @@ COPY . . # The test binary embeds the templates and static files, so the browser # stage needs nothing else. -p 4 keeps the compile's memory down, as in -# script/test. +# the test phase of Dockerfile. RUN make assets && go test -c -p 4 -tags browser -o /browser.test ./internal/server # chromedp/headless-shell:151.0.7922.109 (Debian trixie), 2026-08-11. The diff --git a/Dockerfile.lint b/Dockerfile.lint deleted file mode 100644 index 621e692..0000000 --- a/Dockerfile.lint +++ /dev/null @@ -1,43 +0,0 @@ -# Lint-only image, built by script/lint. golangci-lint is never installed on -# the host: the repo is COPYed into the pinned image and linted as a build -# step, so a successful build IS a clean lint. This works even when the docker -# daemon is remote and bind mounts are impossible. -# -# script/lint passes --no-cache-filter=lint. Without it an unchanged tree -# replays the lint stage from cache and the build succeeds in under a second -# having run no linter at all. Do not drop that flag. -# -# The lint steps run with --network=none. `golangci-lint config verify` is -# documented as fetching its JSON schema over HTTPS, which would make linting -# depend on an unpinned remote artifact; this pinned image resolves the schema -# without any network, and --network=none enforces that rather than trusting -# it. It also proves no linter reaches out at analysis time. If a future image -# bump makes either step need the network, this build fails loudly instead of -# quietly acquiring an unpinned dependency. - -# golangci/golangci-lint:v2.12.2 (Debian-based), 2026-08-07 -# Using Debian-based image because mattn/go-sqlite3 (CGO) does not -# compile on Alpine musl (off64_t is a glibc type). -FROM golangci/golangci-lint:v2.12.2@sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240 AS deps - -WORKDIR /src - -# Copy go mod files first for better layer caching. This stage is cacheable; -# only the lint stage below is forced to re-execute. -COPY go.mod go.sum ./ -RUN go mod download - -FROM deps AS lint - -COPY . . - -# static/static.go embeds the Alpine.js file this extracts from 3p/; without -# it the static package does not compile and cannot be linted. -RUN script/assets - -# `run` silently ignores config keys it does not recognize, so a typo would -# disable a setting without a word. `config verify` is what catches that. -RUN --network=none golangci-lint config verify --config .golangci.yml -# --build-tags browser also lints the browser test, which is built only with -# that tag (make test-browser). -RUN --network=none golangci-lint run --config .golangci.yml --build-tags browser ./... diff --git a/Makefile b/Makefile index 59afeeb..ea25515 100644 --- a/Makefile +++ b/Makefile @@ -19,6 +19,10 @@ override VERSION := $(or $(strip $(VERSION)),$(shell script/version)) # Extra linker flags for the build target. The static relink in the # Dockerfile adds -extldflags here rather than passing its own -ldflags, # so composing flags cannot drop the version stamp. +# +# The build target itself always passes -trimpath and -s -w, as the Go +# Dockerfile in REPO_POLICIES.md does: no build paths, symbol table or +# debug information in the binary. GO_LDFLAGS ?= bootstrap: @@ -49,7 +53,7 @@ check: @script/check build: assets - go build -ldflags '$(strip -X main.version=$(VERSION) $(GO_LDFLAGS))' -o bin/webhooker ./cmd/webhooker + go build -trimpath -ldflags '$(strip -s -w -X main.version=$(VERSION) $(GO_LDFLAGS))' -o bin/webhooker ./cmd/webhooker run: build ./bin/webhooker diff --git a/README.md b/README.md index d97a78f..2f9bd66 100644 --- a/README.md +++ b/README.md @@ -17,14 +17,14 @@ before deploying one. ### Prerequisites - Go 1.26.1+ (the version in `go.mod`) -- Docker (for `make lint`, `make fmt` and `make css`, and so for `make check`, - for the browser test in `make test-browser`, for the CI gate, and for - containerized deployment) +- Docker (for `make test`, `make lint`, `make fmt` and `make css`, and so for + `make check`, for the browser test in `make test-browser`, for the CI gate, + and for containerized deployment) golangci-lint is not a prerequisite and must not be installed on the host: `script/bootstrap` does not install it, and `make lint` runs the digest-pinned -linter image via `Dockerfile.lint`. The same holds for tailwindcss (see -[Stylesheet](#stylesheet)). ESLint, prettier, node and yarn are not +linter image in the Dockerfile's `lint` phase. The same holds for tailwindcss +(see [Stylesheet](#stylesheet)). ESLint, prettier, node and yarn are not prerequisites either, and `make lint` and `make fmt` never use a host copy of them (see [Linting](#linting)). @@ -43,8 +43,9 @@ make check # Run the server from the clone. DATA_DIR defaults to # /var/lib/webhooker in every environment, so set it (in .env or the -# shell) to a writable directory. -DATA_DIR=./data make dev +# shell) to a writable directory outside the clone: the databases hold +# the session key. +DATA_DIR=../webhooker-data make dev # Build Docker image make docker @@ -55,11 +56,11 @@ make docker ```bash make bootstrap # Install all dependencies (idempotent) make setup # Bootstrap + install git pre-commit hook -make assets # Extract Alpine.js from 3p/ (test, check, build, dev run it) +make assets # Extract Alpine.js from 3p/ (build and dev run it) make fmt # Format Go (gofmt + goimports) and Markdown (prettier, in Docker) make fmt-check # Fail if gofmt or prettier would change anything (writes nothing) make lint # Run golangci-lint and ESLint in Docker -make test # Run tests with race detection +make test # Run tests with race detection, in Docker make test-browser # Run the browser test in Docker (Dockerfile.browser) make check # test + lint + fmt-check + css-check (CI gate) make build # Build binary to bin/webhooker (version-stamped) @@ -1095,7 +1096,9 @@ field), in the UI footer, and in the startup log line (`msg=starting`, The value is stamped in at build time by the linker; it is not read from a file at runtime, so it identifies the build itself. -`script/version` produces the value and both build paths use it: +`script/version` produces the value for `make build`, and the image build runs +`make build` too; `script/docker` and `script/cibuild` run the same +`git describe --tags --always --dirty` on the host. All of them report: | Build | What it reports | | ----------------------- | --------------------------------------------------- | @@ -1110,18 +1113,19 @@ carries, so any `docker build .` of a clone, with no build arguments, stamps the commit it was built from; a shallow clone of one branch has no tags and stamps the short SHA. `.dockerignore` must therefore leave out neither `.git` nor any tracked file, which git in the build would see as deleted, marking the version -`-dirty`. It does leave `.git/config`, which can hold a remote URL carrying a -credential and which `git describe` does not need, out of a directory context. A -context sent as a tar is not filtered by `.dockerignore`, so it carries -`.git/config` unless its sender leaves it out; for upaas, that is +`-dirty`. It does leave out every git `config` (`**/.git/config`, +`**/.git/modules/**/config`), which can hold a remote URL carrying a credential +and which `git describe` does not need, from a directory context. A context sent +as a tar is not filtered by `.dockerignore`, so it carries `.git/config` unless +its sender leaves it out; for upaas, that is https://git.eeqj.de/sneak/upaas/issues/274. git in the build reads the checkout whoever owns its files, since a context sent as a tar archive keeps the sender's owners and git otherwise refuses a checkout owned by another user. A `VERSION` build arg (`--build-arg VERSION=...`) takes precedence; `script/docker` (and so -`make docker`) passes the one `script/version` resolves on the host. The image -build fails if its context carries `.git` and the version still comes out -`unknown`, which means git is missing from the build or could not read the -checkout. +`make docker`) and `script/cibuild` pass the one they resolve on the host, or +`unknown` where git gives none. The image build fails if its context carries +`.git` and the version still comes out empty, `dev` or `unknown`, which means +git is missing from the build or could not read the checkout. `unknown` is what a source tarball, or a `docker build` with no `.git` in its context and no `VERSION` build arg, reports. A build that reports `unknown` is a @@ -1135,7 +1139,9 @@ to a commit. Nothing that varies between two builds of the same commit is stamped — no timestamp, no hostname, no builder identity — so two builds of one commit still -produce a byte-identical binary. +produce a byte-identical binary. `make build` passes `-trimpath`, so the +directory it builds in is not recorded either, and `-s -w`, which leave out the +symbol table and debug information. ### Backups contain secrets @@ -1213,11 +1219,14 @@ commands with no script behind them, though `build`, `run` and `dev` first run `script/assets`, and `build` and `version` both take their value from `script/version`. -`script/test`, `make build` and `make dev` each run `script/assets` first, which -writes the ignored `static/js/alpine.min.js` (see -[Third-party browser assets](#third-party-browser-assets)), so `make test`, -`make check` and the pre-commit hook work on a fresh clone without a separate -step. +`make build` and `make dev` each run `script/assets` first, which writes the +uncommitted `static/js/alpine.min.js` (see +[Third-party browser assets](#third-party-browser-assets)), so they work on a +fresh clone without a separate step. The Docker stages that compile the code run +it themselves. + +Every `docker build` in `script/` passes `--no-cache`: a check served from the +build cache is a check that did not run. We provide: @@ -1227,10 +1236,12 @@ We provide: - `script/projectname` — output the project name ("webhooker") - `script/assets` — extract Alpine.js from its tarball in `3p/` (see [Third-party browser assets](#third-party-browser-assets)) -- `script/test` — run the test suite +- `script/test` — run the test suite: builds the Dockerfile's `test` phase, + tagged `webhooker-test` - `script/test-browser` — run the browser test in Docker (see [Third-party browser assets](#third-party-browser-assets)) -- `script/lint` — run golangci-lint and ESLint in Docker (see Linting below) +- `script/lint` — run the `gofmt` check, golangci-lint and ESLint: builds the + Dockerfile's `lint` phase, tagged `webhooker-lint` (see Linting below) - `script/fmt` — format the Go code and, in Docker, the Markdown (writes) - `script/fmt-check` — check formatting (read-only) - `script/css` — regenerate `static/css/tailwind.css` in Docker (writes; see @@ -1241,11 +1252,11 @@ We provide: - `script/version` — output the version to stamp into the binary (see [Version stamping](#version-stamping)) - `script/docker` — build the Docker image tagged via `script/projectname`, - passing `script/version`'s output in as the `VERSION` build arg -- `script/cibuild` — CI entrypoint: `docker build .` (the Dockerfile runs the - checks, so a green build implies a green repo) -- `script/ci-mark-superseded` — CI helper: mark the commits whose run a newer - push cancelled (see [CI gate honesty](#ci-gate-honesty)) + passing the version `git describe` gives on the host in as the `VERSION` build + arg +- `script/cibuild` — CI entrypoint: `script/bootstrap`, then `script/check`, + then the same image build as `script/docker`, whose gate phases run again (see + [CI gate honesty](#ci-gate-honesty)) - `script/precommit` — pre-commit checks (`go mod tidy` guard, then `script/check`) - `script/install-precommit` — install the git pre-commit hook that runs @@ -1298,12 +1309,12 @@ apply. The directory is `3p/` rather than `vendor/` because Go treats a root `script/assets` (`make assets`) extracts the browser build, `package/dist/cdn.min.js`, from the tarball to `static/js/alpine.min.js`, where -`go:embed` picks it up. `script/test`, `make build` and `make dev` run it first, -and the Dockerfile builds through `make test` and `make build`, so nothing -downloads Alpine.js. The extracted file is not committed, and `.dockerignore` -keeps any host copy out of the build context. `static/static.go` names every -file it embeds, so a build that skips the extraction, such as a bare `go build`, -fails with an error naming `js/alpine.min.js`. +`go:embed` picks it up. `make build` and `make dev` run it first, and so do the +Dockerfile's lint, test and build stages, so nothing downloads Alpine.js. The +extracted file is not committed, and `.dockerignore` keeps any host copy out of +the build context. `static/static.go` names every file it embeds, so a build +that skips the extraction, such as a bare `go build`, fails with an error naming +`js/alpine.min.js`. To move to a new version: download `https://registry.npmjs.org/@alpinejs/csp/-/csp-.tgz`, check it against @@ -2969,8 +2980,6 @@ webhooker/ ├── internal/ │ ├── banner/ │ │ └── banner.go # Ruled block for the one credential shown in the clear -│ ├── ciscript/ -│ │ └── doc.go # Tests for the CI shell scripts in script/; no runtime code │ ├── resetpw/ │ │ └── resetpw.go # `webhooker resetpw`: set an account's password, stopped deployments only │ ├── config/ @@ -3075,8 +3084,7 @@ webhooker/ │ └── 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 -├── Dockerfile # Stages: lint, stylesheet, JavaScript lint, Markdown, test+build, Alpine runtime -├── Dockerfile.lint # Lint-only image built by script/lint +├── Dockerfile # Stages: stylesheet, JavaScript lint, Markdown, lint, test, build, Alpine runtime ├── Dockerfile.browser # Browser test image built by script/test-browser ├── Makefile # 13 of 19 targets shim script/; 6 are inline ├── go.mod / go.sum @@ -3084,6 +3092,7 @@ webhooker/ ├── .yarnrc.yml # yarn settings: install into node_modules/ ├── eslint.config.mjs # ESLint configuration for static/js/ ├── .prettierrc # prettier settings for the Markdown +├── .prettierignore # Files prettier skips └── .golangci.yml # golangci-lint configuration ``` @@ -3339,47 +3348,36 @@ Two operational consequences follow from bounding the sequence: ### Linting -golangci-lint never runs on the host. `script/lint` builds `Dockerfile.lint`, -which copies the repo into the digest-pinned golangci-lint image and lints as a -build step, so a successful build is a clean lint. A host binary would share one -cache and one lock with every other checkout on the machine, which has produced -both invented findings attributed to other worktrees and unearned passes. +golangci-lint never runs on the host. `script/lint` builds the Dockerfile's +`lint` phase, which copies the repo into the digest-pinned golangci-lint image +and lints as a build step, so a successful build is a clean lint. A host binary +would share one cache and one lock with every other checkout on the machine, +which has produced both invented findings attributed to other worktrees and +unearned passes. -Three properties are load-bearing: +Two properties are load-bearing: -- `script/lint` passes `--no-cache-filter=lint`. Without it an unchanged tree - replays the lint layer from cache and the build exits 0 in under a second - having linted nothing. The `deps` stage stays cacheable, so module downloads - are not repeated. Invalidation is scoped to the one stage; never prune the - shared build cache. -- `script/lint` does not trust that flag. Docker silently ignores - `--no-cache-filter` for a stage name that does not match, so a stage rename or - a one-character typo would restore the cached false green with no warning and - a fast exit 0. The script therefore tees the build output and treats a run as - a pass only if golangci-lint's own summary line (`N issues.` / `N issues:`) - appears in it: no summary, no lint, whatever the exit code says. -- Both lint steps use `RUN --network=none`. `golangci-lint config verify` is - documented as fetching its JSON schema over HTTPS, which would be an unpinned - remote dependency; the pinned image resolves the schema without network - access, and `--network=none` enforces that instead of trusting it. Verify is - worth keeping because `golangci-lint run` silently ignores config keys it does - not recognize, so a typo would disable a setting with no warning. +- `script/lint` passes `--no-cache`. Without it an unchanged tree replays the + lint layer from cache and the build exits 0 in under a second having linted + nothing. Never prune the shared build cache instead. +- Both golangci-lint steps use `RUN --network=none`. + `golangci-lint config verify` is documented as fetching its JSON schema over + HTTPS, which would be an unpinned remote dependency; the pinned image resolves + the schema without network access, and `--network=none` enforces that instead + of trusting it. Verify is worth keeping because `golangci-lint run` silently + ignores config keys it does not recognize, so a typo would disable a setting + with no warning. ESLint never runs on the host either. It lints `static/js/` (not the extracted -Alpine.js) in the Dockerfile's `js-lint` stage, which `script/lint` builds after -`Dockerfile.lint` and the image build runs before the builder stage. Its version -is pinned in `package.json` and every package's hash in `yarn.lock`. The -`js-deps` stage before it installs ESLint with `yarn install --immutable`, which -fails rather than change `yarn.lock`. The yarn it runs is the one the -`packageManager` field in `package.json` pins by version and hash, which the -node image's own corepack fetches and checks. The stage stays cached until -`package.json`, `yarn.lock` or `.yarnrc.yml` changes, so only the lint step -re-runs and ESLint is not downloaded again. `eslint.config.mjs` turns on the -rules of the JavaScript styleguide `REPO_POLICIES.md` links to that a linter can -check: `no-var` and `prefer-const`. ESLint prints nothing on a pass, so -`script/lint` has no summary line to look for; it names the stage once for both -`--target` and `--no-cache-filter`, and `--target` fails on a name that matches -no stage. +Alpine.js) in the Dockerfile's `js-lint` stage. The `lint` phase copies a file +from it, so `make lint` and the image build both run ESLint. Its version is +pinned in `package.json` and every package's hash in `yarn.lock`. The `js-deps` +stage before it installs ESLint with `yarn install --immutable`, which fails +rather than change `yarn.lock`. The yarn it runs is the one the `packageManager` +field in `package.json` pins by version and hash, which the node image's own +corepack fetches and checks. `eslint.config.mjs` turns on the rules of the +JavaScript styleguide `REPO_POLICIES.md` links to that a linter can check: +`no-var` and `prefer-const`. prettier formats the Markdown, and it never runs on the host either. It is pinned in `package.json` and `yarn.lock` beside ESLint, installed by the same @@ -3391,103 +3389,81 @@ on any Markdown file prettier would change. ### Docker The Dockerfile uses a multi-stage build. Each stage is pinned by digest, and the -lint and builder stages are separate images so the linter's version is fixed -independently of the compiler's: +lint phase and the Go stages are separate images so the linter's version is +fixed independently of the compiler's: -1. **Lint stage** (`golangci/golangci-lint:v2.12.2`, Debian-based) — downloads - dependencies, copies the source, and runs the `gofmt` check, then - `script/assets` to extract Alpine.js from `3p/`, then - `golangci-lint config verify` and `golangci-lint run`, both with - `--network=none`. -2. **Stylesheet stages** (`debian:bookworm-slim`, with the Tailwind standalone +1. **Stylesheet stages** (`debian:bookworm-slim`, with the Tailwind standalone CLI pinned by version and sha256, one binary per architecture) — generate `static/css/tailwind.css` from `static/css/input.css` and the files its `@source` lines name. `css-check` fails when the committed file differs from the generated one, and `make css` writes the generated file out from `css-output` (see [Stylesheet](#stylesheet)). -3. **JavaScript lint stages** (`node:24.21.0-alpine`, with the yarn +2. **JavaScript lint stages** (`node:24.21.0-alpine`, with the yarn `package.json` pins, run through the image's corepack) — `js-deps` installs ESLint and prettier from `yarn.lock` and `js-lint` runs ESLint over `static/js/` (see [Linting](#linting)). -4. **Markdown stages** (on `js-deps`) — `markdown-check` runs prettier over the +3. **Markdown stages** (on `js-deps`) — `markdown-check` runs prettier over the Markdown and fails on any file it would change, and `make fmt` writes the formatted files out from `markdown-output`. -5. **Builder stage** (`golang:1.26.1-bookworm`) — depends on the lint, - `css-check`, `js-lint` and `markdown-check` stages passing (it copies a file - from each), runs `make test` and `make build` (both extract Alpine.js from - `3p/` first), and finally rebuilds the binary with `CGO_ENABLED=1` and static - linking so it runs on musl. Both builds go through `make build`, the relink - adding its `-extldflags` via `GO_LDFLAGS`, so neither can drop the `-X` that - stamps the version. The version is the `VERSION` build arg if one is given, +4. **Lint phase** (`lint`, `golangci/golangci-lint:v2.14.0`, Debian-based) — + downloads dependencies, copies the source, and runs the `gofmt` check, then + `script/assets` to extract Alpine.js from `3p/`, then + `golangci-lint config verify` and `golangci-lint run`, both with + `--network=none`, and depends on `js-lint` (it copies a file from it). + `make lint` builds this stage alone. +5. **Test phase** (`test`, `golang:1.26.1-bookworm`, whose C compiler `-race` + needs) — extracts Alpine.js, then runs `go test -race -cover` at most four + packages and eight tests at a time, with a 90-second timeout per package. On + a failure it runs only the failed tests again with `-v`, since verbose output + from the whole suite would pass the 2 MiB at which the Docker build cuts off + a step's log, and then fails. `make test` builds this stage alone. +6. **Builder stage** (`golang:1.26.1-bookworm`) — depends on the lint and test + phases and the `css-check` and `markdown-check` stages passing (it copies a + file from each), runs `make build` (which extracts Alpine.js from `3p/` + first), and then rebuilds the binary with `CGO_ENABLED=1` and static linking + so it runs on musl. Both builds go through `make build`, the relink adding + its `-extldflags` via `GO_LDFLAGS`, so neither can drop the `-X` that stamps + the version. The version is the `VERSION` build arg if one is given, otherwise derived from the `.git` in the context, and the stage fails if a - context with `.git` would stamp `unknown` (see + context with `.git` would stamp an empty version, `dev` or `unknown` (see [Version stamping](#version-stamping)). -6. **Runtime stage** (`alpine:3.21`) — copies the static binary and +7. **Runtime stage** (`alpine:3.21`) — copies the static binary and `deploy/docker-entrypoint.sh`, creates the `/var/lib/webhooker` directory for all SQLite databases, exposes port 8080, and includes a health check against `/.well-known/healthcheck`. It sets no `USER`: the `ENTRYPOINT` script starts as root, sets the data directory's owner and mode, and runs the app as the non-root `webhooker` user (UID 1000) through `su-exec`. -The lint stage invokes `gofmt` and `golangci-lint` directly rather than -`make fmt-check` and `make lint`: it is already the pinned linter image, and -both targets build docker stages, which would need a docker daemon inside this -build. +The lint and test phases invoke `gofmt`, `golangci-lint` and `go test` directly +rather than `make fmt-check`, `make lint` and `make test`: those targets build +docker stages, which would need a docker daemon inside this build. -The lint and builder stages use Debian rather than Alpine because +The lint phase and the Go stages use Debian rather than Alpine because `gorm.io/driver/sqlite` pulls in `mattn/go-sqlite3`, which needs CGO and does not compile against musl. Only the final binary is statically linked, which is what lets it run on the Alpine runtime image. -`script/cibuild` — `docker build .` — is the CI gate: the checks run inside the -image, so a build that succeeds is a repo that is formatted, linted, tested and -compiled, with a current stylesheet. `script/lint` also uses Docker -(`Dockerfile.lint` and the `js-lint` stage, see Linting above), so `make lint` -and `make check` run the same pinned linter versions the gate does; of the steps -`make check` runs, only `script/test` and the `gofmt` check in -`script/fmt-check` run on the host. +`script/cibuild` is the CI gate: it runs `script/bootstrap`, then +`script/check`, then builds the image, whose build runs the lint and test phases +and the stylesheet and Markdown checks again. A build that succeeds is a repo +that is formatted, linted, tested and compiled, with a current stylesheet. +`make check` runs the same stages the gate does; of its steps, only the `gofmt` +check in `script/fmt-check` runs on the host. #### CI gate honesty A layer cache lets `docker build .` exit 0 in seconds with the lint and test stages replayed rather than executed, which would make a green check -meaningless. The `check` workflow therefore writes `.ci-fingerprint` into the -build context before building. Its value is the hash of the commit being -checked, so every commit, docs-only ones and a squash merge whose tree matches -an already-built branch included, gets a new fingerprint, invalidates the -`COPY . .` layer of every check stage, and really runs the `gofmt` check, -`golangci-lint`, the stylesheet check, ESLint, the Markdown check, `make test`, -and `make build`. A run that reports success ran them. +meaningless. Every `docker build` in `script/` therefore passes `--no-cache`, so +on every run the `gofmt` check, `golangci-lint`, ESLint, the stylesheet check, +the Markdown check, `go test` and `make build` really execute. A run that +reports success ran them. A bare `docker build .` carries no such guarantee. -The module download layer sits above `COPY . .` and stays cached. - -A separate workflow step, run before the fingerprint is written, covers a second -way the gate lied: Gitea cancels an in-flight run when a newer commit lands on -the same branch and records that cancellation as a `failure` status, so a commit -nothing ever tested reads as a test result. Cancellation is unconditional -server-side for push events, so the superseding run calls -`script/ci-mark-superseded`, which rewrites that exact status to `failure` / -`Superseded by a newer commit; never tested`. - -The state stays `failure` on purpose: Gitea's combined status folds `skipped` -into `success`, so marking a never-tested commit `skipped` made the status API -report green for it, indistinguishable from a commit that passed. Reading a -commit's status on this repo therefore goes: - -- `success` / `Successful in ...` — the checks ran and passed. -- `failure` / `Failing after ...` — the checks ran and failed. -- `failure` / `Superseded by a newer commit; never tested` — the run was - cancelled, by a newer push or by hand, and nothing was verified about this - commit. Test the commit itself before concluding anything about it. - -Genuine failures and successes are never touched, and no status is left -`pending`, which would block the commit indefinitely. The step derives its -context string from the workflow name, the job **id** and the event. That is -deliberately not byte-identical to Gitea's own rule, which uses the job's -display `name:` where the runner exports the id, so giving the job a `name:` — -or renaming the workflow — makes the derived context stop matching. The step -fails loudly when no status on the commit carries that context, so no rename can -silently disable the rewrite. +The `check` workflow is the shared one from `REPO_POLICIES.md`: it checks out +the repository and runs `script/cibuild`, nothing else. Gitea cancels an +in-flight run when a newer commit lands on the same branch and records that as +`failure` / `Has been cancelled`: nothing was verified about that commit, so +test the commit itself before concluding anything about it. ## TODO diff --git a/REPO_POLICIES.md b/REPO_POLICIES.md index 9aba6b0..20382d1 100644 --- a/REPO_POLICIES.md +++ b/REPO_POLICIES.md @@ -1,6 +1,6 @@ --- title: Repository Policies -last_modified: 2026-08-07 +last_modified: 2026-10-04 --- This document covers repository structure, tooling, and workflow standards. Code @@ -60,17 +60,28 @@ style conventions are in separate documents: prerequisite since nvm requires bash. yarn is then pinned via `corepack prepare yarn@ --activate`. Never install "latest" or "lts"; always exact versions. `script/cibuild` runs the CI build: it changes to the - repo root and runs `docker build .`; the Gitea workflow calls it. Four further - scripts are our own extensions to the standard: `script/check` runs - `script/test`, `script/lint`, and `script/fmt-check`; `script/precommit` is - what the git pre-commit hook runs, and it calls `script/check`; - `script/install-precommit` installs the git pre-commit hook (the `make hooks` - target shims to it); and `script/projectname` (literally that filename) simply - outputs the project's name. Scripts that need the name call - `script/projectname` — e.g. `script/docker` assembles its image tag from it — - so those scripts stay byte-identical across all repos. Repo-type-specific - pre-commit extras (e.g. `go mod tidy` verification in Go repos) belong in - `script/precommit`, not in the hook itself. Model scripts are at + repo root, runs `script/bootstrap`, runs `script/check`, and builds the image + with the version; the Gitea workflow calls it. **`script/cibuild` runs + `script/bootstrap` first**, because the workflow checks out the repo and runs + nothing else, while `script/fmt-check` runs the formatter on the host: on a + pristine checkout with nothing installed the run dies there, after the + containerised gates have passed. **The bootstrap alone is not enough**: + `script/bootstrap` installs node and yarn under nvm and leaves neither on the + `PATH` of the shell that called it, so a bare `yarn` still exits 127. The host + entrypoints that need yarn — `script/fmt` and `script/fmt-check` — therefore + source nvm for the pinned node version before invoking it, exactly as + `script/bootstrap`'s own install step does. A runner carrying nothing but + docker and git then gets through `script/check`. Four further scripts are our + own extensions to the standard: `script/check` runs `script/test`, + `script/lint` and `script/fmt-check`; `script/precommit` is what the git + pre-commit hook runs, and it calls `script/check`; `script/install-precommit` + installs the git pre-commit hook (the `make hooks` target shims to it); and + `script/projectname` (literally that filename) simply outputs the project's + name. Scripts that need the name call `script/projectname` — e.g. + `script/docker` assembles its image tag from it — so those scripts stay + byte-identical across all repos. Repo-type-specific pre-commit extras (e.g. + `go mod tidy` verification in Go repos) belong in `script/precommit`, not in + the hook itself. Model scripts are at `https://git.eeqj.de/sneak/prompts/raw/branch/main/script/`. The README must document the provided scripts in an **Entrypoints** section (see the README requirements below). @@ -89,87 +100,198 @@ style conventions are in separate documents: contributor should be able to understand the entire development workflow by reading the Makefile. -- Every repo should have a `Dockerfile`. All Dockerfiles must run `make check` - as a build step so the build fails if the branch is not green. For non-server - repos, the Dockerfile should bring up a development environment and run - `make check`. For server repos, `make check` should run as an early build - stage before the final image is assembled. Dockerfiles install development - prerequisites by running `script/bootstrap` rather than duplicating installs - inline; COPY `script/` and the dependency manifests (`package.json` + - `yarn.lock`, `go.mod` + `go.sum`, etc.) before running it so the bootstrap - layer stays cached until dependencies change. +- Every repo should have a `Dockerfile`, and it carries the repo's gates: a + `lint` phase and a `test` phase, with the final stage depending on both so the + image cannot be built unless they pass. For non-server repos the final stage + brings up a development environment; for server repos it is the runtime image. + The gate phases and the build stage start from their pinned base images and + install what those images lack either inline, as the canonical Go `Dockerfile` + below does for `git`, or by running `script/bootstrap`, as the `prompts` + repo's own `Dockerfile` does for its yarn packages. The development + environment stage installs development prerequisites by running + `script/bootstrap` rather than duplicating its installs inline. A stage that + runs `script/bootstrap` COPYs `script/` and the dependency manifests + (`package.json` + `yarn.lock`, `go.mod` + `go.sum`, etc.) before running it. -- **Dockerfiles must use a separate lint stage for fail-fast feedback.** Go - repos use a multistage build where linting runs in an independent stage based - on the `golangci/golangci-lint` image (pinned by hash). This stage runs - `make fmt-check` and `make lint` before the full build begins. The build stage - then declares an explicit dependency on the lint stage via - `COPY --from=lint /src/go.sum /dev/null`, which forces BuildKit to complete - linting before proceeding to compilation and tests. This ensures lint failures - surface in seconds rather than minutes, without blocking on dependency - download or compilation in the build stage. +- **Linting and testing run in Docker, as phases of the `Dockerfile`.** There is + no separate lint file. `script/lint` and `script/test` each build one phase + and nothing else: - The standard pattern for a Go repo Dockerfile is: + ```sh + docker build --no-cache --target lint -t "$(script/projectname)-lint" . + docker build --no-cache --target test -t "$(script/projectname)-test" . + ``` + + **A stage that is not the last one in the file is built only when the final + stage's chain depends on it, or when `--target` names it.** That is why the + two gates are always invoked by name here, and why the final stage carries a + `COPY --from=` of a harmless file from each of them: without that edge a + plain `docker build .` builds the last stage alone and exits 0 having linted + and tested nothing. + + **Every `docker build` in `script/` is tagged**, here and in + `script/cibuild` and `script/docker`. An untagged build leaves a dangling + image behind on every invocation, on every developer host and every CI + runner; a tagged one replaces the previous image. + + Inside a phase the tool is invoked directly — `golangci-lint`, `go test`, + `eslint`, `prettier` — never through `make lint` or `script/test`, which are + themselves a `docker build` and would recurse into a daemon that does not + exist in a build step. Formatting is the exception and stays on the host: + `script/fmt` writes the working tree, and `script/fmt-check` is its + read-only twin. + + **No lint verdict may come from a host invocation of the linter.** On a + shared host golangci-lint reads a result cache keyed on file content rather + than location, so a second checkout of the same content is served the first + one's findings, and a host-global lock in `$TMPDIR` makes concurrent runs + exit non-zero with `parallel golangci-lint is running` — a status a caller + cannot tell from real findings. Both have produced wrong verdicts in this + org, in both directions. A container has its own cache, its own `TMPDIR` and + a digest-pinned binary, so neither is reachable. + +- **Any build that runs checks is built with `--no-cache`.** Docker invalidates + a `COPY` layer only when the copied content changes, so on an unchanged tree + the check `RUN` is served from cache, nothing executes, and the build still + exits 0. Every `docker build` in `script/` therefore passes `--no-cache`: + `script/lint`, `script/test`, `script/cibuild` and `script/docker` are the + four, and there is no fifth — `script/check` runs the two gate phases and + `script/fmt-check`, and builds no image of its own. A bare `docker build .` is + not evidence that anything ran: a sub-second build reporting success is a + cache hit, not a result. Never invalidate by pruning — `docker builder prune` + and friends destroy a build cache shared with every other build on the host. + When a check is added or changed, prove it works by planting a defect it must + catch and watching the run fail on it, then revert the defect. A green run + alone shows neither that the check ran nor that it covers what it should. + +- **The gate phases are separate stages, and the build stage depends on both.** + The lint phase is based on the `golangci/golangci-lint` image (pinned by + hash), so lint failures surface in seconds rather than after a full compile, + and the test phase is based on the Debian Go image. The canonical Go repo + `Dockerfile`: ```dockerfile - # Lint stage — fast feedback on formatting and lint issues + # Lint phase # golangci/golangci-lint:v2.x.x, YYYY-MM-DD FROM golangci/golangci-lint@sha256:... AS lint WORKDIR /src COPY go.mod go.sum ./ RUN go mod download COPY . . - RUN make fmt-check - RUN make lint + RUN golangci-lint run --config .golangci.yml ./... - # Build stage - # golang:1.x-alpine, YYYY-MM-DD - FROM golang@sha256:... AS builder + # Test phase. -race needs cgo and so a C compiler, which the Debian Go + # image ships and the alpine one does not. + # golang:1.x, YYYY-MM-DD + FROM golang@sha256:... AS test WORKDIR /src - - # Force BuildKit to run the lint stage before proceeding - COPY --from=lint /src/go.sum /dev/null - COPY go.mod go.sum ./ RUN go mod download COPY . . - RUN make test + RUN go test -timeout 90s -race -cover ./... || \ + { echo "--- Rerunning with -v for details ---"; \ + go test -timeout 90s -race -v ./...; exit 1; } - ARG VERSION=dev - RUN CGO_ENABLED=0 go build -trimpath \ - -ldflags="-s -w -X main.Version=${VERSION}" \ - -o /app ./cmd/app/ + # Build stage. Nothing is wanted from either phase above; the copies + # are what make BuildKit build them first, so this stage cannot run + # unless lint and test passed. + # golang:1.x-alpine, YYYY-MM-DD + FROM golang@sha256:... AS builder + COPY --from=lint /src/go.sum /dev/null + COPY --from=test /src/go.sum /dev/null + RUN apk add --no-cache git + # A tar-stream context keeps the sender's file owners, which git refuses. + RUN git config --system --add safe.directory /src + WORKDIR /src + COPY go.mod go.sum ./ + RUN go mod download + COPY . . - # Runtime stage + # The VERSION build arg when one is given, otherwise + # `git describe --tags --always` on the .git in the build context. With + # .git present, a version that is still empty, dev or unknown fails the + # build: git is missing or could not read the checkout. + ARG VERSION + RUN VERSION="${VERSION:-$(git describe --tags --always)}"; \ + if [ -e .git ]; then \ + case "$VERSION" in ""|dev|unknown) \ + echo "version is '$VERSION' although .git is present" >&2; \ + exit 1 ;; \ + esac; \ + fi; \ + CGO_ENABLED=0 go build -trimpath \ + -ldflags="-s -w -X main.Version=${VERSION}" \ + -o /app ./cmd/app/ + + # Runtime stage, and the last one FROM alpine@sha256:... COPY --from=builder /app /usr/local/bin/app ENTRYPOINT ["app"] ``` Key points: - - The lint stage uses the `golangci/golangci-lint` image directly (it - includes both Go and the linter), so there is no need to install the - linter separately. - - `COPY --from=lint /src/go.sum /dev/null` is a no-op file copy that creates - a stage dependency. BuildKit runs stages in parallel by default; without - this line, the build stage would not wait for lint to finish and a lint - failure might not fail the overall build. + - The lint phase uses the `golangci/golangci-lint` image directly (it has + both Go and the linter), so nothing needs installing. + - `COPY --from= /src/go.sum /dev/null` is a no-op copy whose only + purpose is the ordering edge. BuildKit runs stages in parallel by default, + and a stage nothing depends on is not built at all, so without these two + lines a red gate would not fail the build. + - Keep the runtime stage last, and if you add a stage after it, give it the + same two copies. A plain `docker build .` builds the last stage's chain + and nothing else. - If the project uses `//go:embed` directives that reference build artifacts - (e.g. a web frontend compiled in a separate stage), the lint stage must + (e.g. a web frontend compiled in a separate stage), the lint phase must create placeholder files so the embed directives resolve. Example: `RUN mkdir -p web/dist && touch web/dist/index.html web/dist/style.css`. - The lint stage should not depend on the actual build output — it exists to - fail fast. - - If the project requires CGO or system libraries for linting (e.g. - `vips-dev`), install them in the lint stage with `apk add`. - - The build stage runs `make test` after compilation setup. Tests run in the - build stage, not the lint stage, because they may require compiled - artifacts or heavier dependencies. + - If the project requires CGO or system libraries for linting, install them + in the lint phase. The `golangci/golangci-lint` image is Debian-based and + has no `apk`, so install with `apt-get` under the Debian package name + (`libvips-dev`, where alpine says `vips-dev`), and delete the package + lists in the same `RUN`, so the layer does not keep them: + + ```dockerfile + RUN apt-get update \ + && apt-get install -y --no-install-recommends libvips-dev \ + && rm -rf /var/lib/apt/lists/* + ``` + + - `.dockerignore` lets `.git` into the build context. It keeps out every git + `config` at any depth (`**/.git/config`, `**/.git/modules/**/config`): the + repository's own, each submodule's under `.git/modules/`, and that of a + submodule keeping its own `.git` directory. `git describe` does not need + them, and each can hold a credential: a password in a remote URL, or the + token the CI checkout step stores there. A submodule whose name has a + `config` segment (`config`, `deploy/config`, `config/lib`) loses its whole + git directory to `**/.git/modules/**/config`, and Go's version stamping + then fails the build: give it a name without that segment + (`git submodule add --name`). The stage that compiles has `git` (the + Debian Go image has it; an alpine one needs `apk add --no-cache git`) and + takes the version from the `VERSION` build argument when one is given, + otherwise from `git describe --tags --always`. That gives the tag on a + tagged commit; on a later commit, the tag, the number of commits since it + and the short commit (`v1.2.3-4-gabc1234`); and the short commit when no + tag is reachable. The stage that compiles also marks its working directory + safe for git (`git config --system --add safe.directory /src`): a context + sent as a tar stream keeps the sender's file owners, and git refuses a + checkout owned by another user, so the version would come out empty. + `ARG VERSION` has no default, and the build fails if the context carries + `.git` and the version still comes out empty, `dev` or `unknown`. A plain + `docker build .` with no build arguments must succeed; a Dockerfile that + refuses an empty build argument drops that refusal and keeps the argument. - Every repo should have a Gitea Actions workflow (`.gitea/workflows/`) that - runs `script/cibuild` (which runs `docker build .`) on push. Since the - Dockerfile already runs `make check`, a successful build implies all checks - pass. + runs `script/cibuild` on push, and checks out the repo as its only other step. + That script bootstraps, runs the gate phases, and then builds the image, so a + successful run means every check passed; a bare `docker build .` does not + carry the same guarantee, because its gate phases may come from the cache. The + image build is uncached and so runs the gate phases a second time. That is the + price of the rule above, and it is worth paying: the image that ships is built + from a run of its own gates rather than from a cache entry. A separate + workflow limited to `main` by a `branches` list under `on: push` cannot be + checked by review: to try a change to it, add the feature branch to that list + and push, then remove the branch from the list again before merging. Keep any + job in it that publishes behind `if: github.ref_name == 'main'`, so the run + from the feature branch publishes nothing. - Use platform-standard formatters: `black` for Python, `prettier` for JS/CSS/Markdown/HTML, `go fmt` for Go. Always use default configuration with @@ -193,15 +315,17 @@ style conventions are in separate documents: suite that exceeds it fails. Under 20 seconds is the target. A suite between 20 and 60 seconds is still green, but the overage must be filed as an improvement bug against that repo. Add a 90-second timeout to the test - invocation in the Makefile (`go test -timeout 90s`). The backstop deliberately - sits above the hard cap so that it catches a genuinely hung test rather than a - merely slow one. + invocation (`go test -timeout 90s`). The backstop deliberately sits above the + hard cap so that it catches a genuinely hung test rather than a merely slow + one. -- **`make test` should use the conditional verbose rerun pattern.** Run tests - without `-v` (verbose) first. If tests fail, automatically rerun with `-v` to - show full output. This keeps CI logs and `docker build` output clean on - success (just package/suite summaries) while providing full diagnostic detail - on failure (every test case, every assertion). The general shell pattern: +- **The test command should use the conditional verbose rerun pattern.** Run + tests without `-v` (verbose) first. If tests fail, automatically rerun with + `-v` to show full output. This keeps CI logs and `docker build` output clean + on success (just package/suite summaries) while providing full diagnostic + detail on failure (every test case, every assertion). The command lives in the + `test` phase of the `Dockerfile`, since `script/test` builds that phase; the + Makefile form below is the same pattern for any repo-local invocation: ```makefile test: @@ -214,11 +338,26 @@ style conventions are in separate documents: ```makefile test: - @go test -timeout 90s -race -cover ./... || \ + @go test -count=1 -timeout 90s -race -cover ./... || \ { echo "--- Rerunning with -v for details ---"; \ - go test -timeout 90s -race -v ./...; exit 1; } + go test -count=1 -timeout 90s -race -v ./...; exit 1; } ``` + `-count=1` is required on both invocations: it defeats Go's test _result_ + cache, so neither run can report a stored pass in place of running the + tests. It leaves the build cache alone, so it costs the runtime of the suite + and no recompilation. + + That cache is Go's own, separate from Docker's layer cache. Go stores a + passing result in its cache directory (`GOCACHE`), and when the same tests + run again on unchanged code it prints that result, marked `(cached)`, + without running them. That matters on a developer's machine, where this + target runs and the directory lasts from one run to the next. The `test` + phase of the `Dockerfile` needs no `-count=1`: its base image holds no + result for this repo's tests and nothing before its `go test` step runs a + test, so there is nothing to replay. `--no-cache` (above) is what makes that + step run on an unchanged tree. + Python example: ```makefile @@ -244,10 +383,84 @@ style conventions are in separate documents: must be in `.gitignore`. No exceptions. - `.gitignore` should be comprehensive from the start: OS files (`.DS_Store`), - editor files (`.swp`, `*~`), language build artifacts, and `node_modules/`. - Fetch the standard `.gitignore` from - `https://git.eeqj.de/sneak/prompts/raw/branch/main/.gitignore` when setting up - a new repo. + editor files (`.swp`, `*~`), in-repo agent scratch directories (`.claude/`), + language build artifacts, and `node_modules/`. Fetch the standard `.gitignore` + from `https://git.eeqj.de/sneak/prompts/raw/branch/main/.gitignore` when + setting up a new repo. These patterns are written to `.gitignore`'s own + semantics, in which an unanchored pattern already matches at every depth; they + are not a `.dockerignore` and must not be transplanted into one unmodified. + +- **`.dockerignore` does not use `.gitignore` semantics, and copying patterns + across unmodified leaves secrets in the build context.** Docker matches with + `moby/patternmatcher`: `filepath.Match` semantics plus a `**` extension, so + `*` does not cross `/` and a pattern without a leading `**/` is anchored at + the build-context root. A `.dockerignore` listing `.env`, `*.pem` and `*.key` + therefore excludes only the copies at the repository root, while `config/.env` + and `certs/server.key` still reach the context and can land in an image layer + — which is more dangerous than a short file with no secret patterns at all, + because it reads as solved and stops anyone looking. Give every + depth-independent pattern the `**/` prefix and leave only genuinely + root-anchored entries unprefixed: `.claude`, and the repo's own host-built + binary, written `/myapp` and never `**/myapp`, which would also match + `cmd/myapp/` and delete the package directory from the context. Matching is + case-sensitive, and an ALL-CAPS twin per pattern still misses `Server.Key`, so + secret names use character ranges — `**/*.[kK][eE][yY]`, `**/*.[pP][eE][mM]`, + and likewise for `.envrc` and the extensionless SSH keys. Where such a pattern + also catches something the build needs, re-include it with a negation + (`!docs/example.env`); deleting the pattern reopens the exposure for every + other file it covers. Fetch the standard `.dockerignore` from + `https://git.eeqj.de/sneak/prompts/raw/branch/main/.dockerignore` and extend + it with the repo's own artifacts. + +- **In-repo agent scratch belongs in both files, written to each file's own + semantics.** `.claude/` holds one worktree per in-flight agent — an entire + additional checkout of the repo — so under `COPY . .` the build context + inflates by a multiple of the repo and another session's unreviewed work can + be copied into an image layer. In `.gitignore` the entry is `.claude/`, + unanchored. In `.dockerignore` it is `.claude`, anchored and with **no** `**/` + prefix, because the prefixed form would also delete any nested directory of + that name from the build. Anchoring carries a known gap that the canonical + `.dockerignore` states in its own comment, since consuming repos receive the + file and not the tracker: the directory is created in the agent's working + directory, so a repo running agents in subdirectories still ships + `services/api/.claude/` and must add its own anchored entry there. + +- **A plain `docker build .` of a clone stamps the version that + `git describe --tags --always` gives**, derived from the `.git` in the build + context as the canonical `Dockerfile` above shows. Without its failure check, + a missing `git` or an unreadable checkout would leave `-X main.Version=` empty + and the build would still exit 0. `script/docker` and `script/cibuild` pass + the version they compute on the host; it takes precedence. They do this + byte-identically across repos: + + ```sh + # Own line: a failing command substitution inside an argument does not + # trip `set -e`, so the inline form degrades to an empty constant. + version="$(git describe --tags --always --dirty 2>/dev/null || true)" + [ -n "$version" ] || version="unknown" + docker build --no-cache \ + --build-arg VERSION="$version" \ + -t "$(script/projectname)" . + ``` + + `--always` makes an untagged repo yield an abbreviated commit hash rather + than failing, and the `[ -n "$version" ]` line is the single place the + fallback is applied — a live check that fires on a build from an export with + no `.git` and on a repository with no commits yet. Do not fold it into the + substitution as `|| echo unknown`, which makes the guard unreachable. The + Dockerfile's side is `ARG VERSION` in the stage that compiles, declared + there because `ARG` is stage-scoped; passing `VERSION` to a repo whose + Dockerfile declares no such `ARG` is ignored and costs nothing, which is why + the scripts stay byte-identical. One consequence for CI: the standard + checkout action clones shallow and fetches no tags, so a repo that embeds a + tag-derived version must set `fetch-depth: 0` on its checkout step. + +- **Verify `.dockerignore` by enumerating the image, not by reading the + patterns.** Plant files at the root _and_ at least two directories deep, build + a probe image that does `COPY . .`, and list what actually landed + (`docker run --rm --entrypoint find IMAGE /app`). The `transferring context` + size is not a substitute: a nested secret is a few bytes, and BuildKit + transfers only the delta from the previous build. - **No build artifacts in version control.** Code-derived data (compiled bundles, minified output, generated assets) must never be committed to the @@ -263,12 +476,56 @@ style conventions are in separate documents: - Make all changes on a feature branch. You can do whatever you want on a feature branch. -- `.golangci.yml` is standardized and must _NEVER_ be modified by an agent, only - manually by the user. Fetch from - `https://git.eeqj.de/sneak/prompts/raw/branch/main/.golangci.yml`. The - canonical golangci-lint version is v2.12.2 (released 2026-05-06), installed - commit-pinned via - `go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@c0d3ddc9cf3faa61a4e378e879ece580256d76e5`. +- `.golangci.yml` is standardized. The vendored copy in a consuming repo must + _NEVER_ be modified by an agent: fetch it from + `https://git.eeqj.de/sneak/prompts/raw/branch/main/.golangci.yml` and keep it + byte-identical, so that no repo can quietly loosen its own linting. Linter + configuration changes are made to the canonical copy in the `prompts` repo and + reach consuming repos by re-vendoring; an agent may open a PR against + canonical, which only the user merges. One list is exempt from byte-identity, + because it cannot be written once for every repo: the `deny` list of the + `test-support` depguard rule, where a repo names its own test-support packages + by full import path. A repo adds entries there and changes nothing else, and a + re-vendor carries its entries forward. The canonical golangci-lint version is + v2.14.0 (released 2026-09-24), pinned as the digest of the lint phase's base + image + (`golangci/golangci-lint@sha256:ad862ba6b3798cbe0fd9fd7408d498fd74fbd2623a92406b2fd3898faf0bf98f`, + which reports `2.14.0 built with go1.27.0 from 114493f9`). A module's `go` + directive must not name a newer Go minor version than the one golangci-lint + was built with, or golangci-lint refuses to lint it: this release lints + `go 1.27.1` but not `go 1.28`. That digest is the only pin, since no repo + installs golangci-lint on the host. A repo sets the lint phase digest to the + one named here and re-vendors `.golangci.yml` in the same commit, whichever of + the two prompted the change: the canonical copy can name linters that an older + golangci-lint rejects, and a newer golangci-lint can add linters that + `default: all` switches on until the canonical copy disables them. + +- **`script/bootstrap` installs a pinned tool by comparing versions, never by + testing presence.** An `if ! command -v ; then install; fi` guard tests + `PATH` only, so on an already-provisioned machine the pin is inert and a + version bump is a silent no-op — while the Dockerfile, installing into a clean + image, gets the pinned version, so a local `make check` and `make docker` can + disagree about what the tool even is. The canonical form: + - compares the installed version against the pin over the **whole** version + token; a parser that stops at the first `-` reports `2.12.2` for a host + running `2.12.2-rc1` and skips the install; + - treats absent, non-zero, empty or unrecognised `--version` output as a + mismatch, so the failure direction is a redundant install and never a + skipped one; + - after installing, re-resolves the binary the way callers do — `hash -r`, + then through `PATH`, not through the directory the installer wrote to — + and fails naming the resolved path, since an install that a shadowing + binary hides succeeds while changing nothing any caller sees; + - is actually called, and prints the version on both success paths: a + function defined and never invoked has the same exit status and the same + empty output as one that worked. + + Keep it POSIX sh: no arrays, no `[[`, no `grep -P`. + + A Go tool a repo needs on the host is installed with `go install` pinned to + a commit hash (`go install @`). It is never tracked as + a `go.mod` tool dependency or through a `tools.go` file, either of which + pulls the tool's own dependencies into the repo's `go.mod` and `go.sum`. - When pinning images or packages by hash, add a comment above the reference with the version and date (YYYY-MM-DD). @@ -382,12 +639,14 @@ style conventions are in separate documents: settings. - Avoid putting files in the repo root unless necessary. Root should contain - only project-level config files (`README.md`, `Makefile`, `Dockerfile`, - `LICENSE`, `.gitignore`, `.editorconfig`, `REPO_POLICIES.md`, and - language-specific config). Everything else goes in a subdirectory. Canonical - subdirectory names: + only project-level config files (`README.md`, `AGENTS.md`, `Makefile`, + `Dockerfile`, `LICENSE`, `.gitignore`, `.editorconfig`, `REPO_POLICIES.md`, + and language-specific config). Everything else goes in a subdirectory. + Canonical subdirectory names: - `bin/` — executable scripts and tools - - `cmd/` — Go command entrypoints + - `cmd/` — Go command entrypoints; thin only: one `main.go` per binary whose + body is a single call into `internal/` or `pkg/`, no project logic in + `cmd/` - `configs/` — configuration templates and examples - `deploy/` — deployment manifests (k8s, compose, terraform) - `docs/` — documentation and markdown (README.md stays in root) @@ -414,3 +673,7 @@ style conventions are in separate documents: - Go: `go.mod`, `go.sum`, `.golangci.yml` - JS: `package.json`, `yarn.lock`, `.prettierrc`, `.prettierignore` - Python: `pyproject.toml` + +- Guidance for coding agents lives in one `AGENTS.md` at the repository root. It + is never committed under a file or directory named after one agent tool, such + as `CLAUDE.md` or `.claude/`, and never split into separate memory files. diff --git a/go.mod b/go.mod index ab6e2ef..8542d5b 100644 --- a/go.mod +++ b/go.mod @@ -22,7 +22,6 @@ require ( github.com/stretchr/testify v1.11.1 go.uber.org/fx v1.24.0 golang.org/x/crypto v0.38.0 - gopkg.in/yaml.v3 v3.0.1 gorm.io/driver/sqlite v1.5.4 gorm.io/gorm v1.25.5 modernc.org/sqlite v1.28.0 @@ -59,6 +58,7 @@ require ( golang.org/x/text v0.25.0 // indirect golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d // indirect google.golang.org/protobuf v1.31.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect lukechampine.com/uint128 v1.2.0 // indirect modernc.org/cc/v3 v3.40.0 // indirect modernc.org/ccgo/v3 v3.16.13 // indirect diff --git a/internal/ciscript/ci_mark_superseded_test.go b/internal/ciscript/ci_mark_superseded_test.go deleted file mode 100644 index 860092f..0000000 --- a/internal/ciscript/ci_mark_superseded_test.go +++ /dev/null @@ -1,387 +0,0 @@ -package ciscript_test - -import ( - "maps" - "os" - "os/exec" - "path/filepath" - "slices" - "strings" - "testing" - - "github.com/stretchr/testify/require" - "gopkg.in/yaml.v3" -) - -const ( - // supersededDesc is the description script/ci-mark-superseded - // writes, and the one an earlier revision of it wrote alongside a - // `skipped` state. - supersededDesc = "Superseded by a newer commit; never tested" - - // liveContext is the commit-status context Gitea uses for this - // repository's runs, as seen in its API. The script derives it from - // the workflow and job names rather than hardcoding it; the - // derivation is checked against this value below. - liveContext = "check / check (push)" - - scriptPath = "../../script/ci-mark-superseded" - workflow = "../../.gitea/workflows/check.yml" - - // failure is the only state that neither folds into a combined - // `success` (as `skipped` does) nor blocks the commit forever (as - // `pending` does). - failure = "failure" -) - -// repo is a throwaway git history: parent is the commit a run would be -// cancelled on, head the commit that superseded it. -type repo struct { - dir string - head string - parent string -} - -// scriptEnv is the run identity the Gitea runner exports and the script -// builds its context string from. -type scriptEnv struct { - workflow string - job string - event string -} - -func defaultEnv() scriptEnv { - return scriptEnv{workflow: "check", job: "check", event: "push"} -} - -func cancelled() commitStatus { - return commitStatus{ - Context: liveContext, - Status: failure, - Description: "Has been cancelled", - } -} - -func running() commitStatus { - return commitStatus{ - Context: liveContext, - Status: "pending", - Description: "Has started running", - } -} - -func TestMarkSuperseded(t *testing.T) { - t.Parallel() - - cases := map[string]struct { - parent commitStatus - wantMark bool - }{ - "a cancelled run is marked": { - parent: cancelled(), - wantMark: true, - }, - "a laundered skipped status is marked": { - parent: commitStatus{ - Context: liveContext, - Status: "skipped", - Description: supersededDesc, - }, - wantMark: true, - }, - "a genuine failure is left alone": { - parent: commitStatus{ - Context: liveContext, - Status: failure, - Description: "Failing after 3m1s", - }, - wantMark: false, - }, - "a passing run is left alone": { - parent: commitStatus{ - Context: liveContext, - Status: "success", - Description: "Successful in 2m52s", - }, - wantMark: false, - }, - "another context is left alone": { - parent: commitStatus{ - Context: "other / other (push)", - Status: failure, - Description: "Has been cancelled", - }, - wantMark: false, - }, - } - - for name, tc := range cases { - t.Run(name, func(t *testing.T) { - t.Parallel() - requireTools(t) - - history := newRepo(t) - fake, api := newFakeGitea(t) - fake.setStatus(history.head, running()) - fake.setStatus(history.parent, tc.parent) - - out, err := runScript(t, history, api, defaultEnv()) - require.NoError(t, err, out) - - posted := fake.postedFor(history.parent) - if !tc.wantMark { - require.Empty(t, posted) - - return - } - - require.Equal(t, []postedStatus{{ - Context: liveContext, - // Not `skipped`: Gitea's combined status folds - // that into `success`, which is what made a - // never-tested commit read green. - State: failure, - Description: supersededDesc, - }}, posted) - }) - } -} - -// A second run must not rewrite what the first one wrote, or every -// later push would post a duplicate status. -func TestMarkSupersededIsIdempotent(t *testing.T) { - t.Parallel() - requireTools(t) - - history := newRepo(t) - fake, api := newFakeGitea(t) - fake.setStatus(history.head, running()) - fake.setStatus(history.parent, cancelled()) - - for range 2 { - out, err := runScript(t, history, api, defaultEnv()) - require.NoError(t, err, out) - } - - require.Len(t, fake.postedFor(history.parent), 1) -} - -// Renaming the workflow or the job changes the context string Gitea -// uses. The script must say so instead of quietly matching nothing. -func TestMarkSupersededRejectsAnUnknownContext(t *testing.T) { - t.Parallel() - requireTools(t) - - history := newRepo(t) - fake, api := newFakeGitea(t) - fake.setStatus(history.head, running()) - fake.setStatus(history.parent, cancelled()) - - env := defaultEnv() - env.job = "renamed" - - out, err := runScript(t, history, api, env) - require.Error(t, err) - require.Contains(t, out, "renamed") - require.Contains(t, out, liveContext) - require.Empty(t, fake.postedFor(history.parent)) -} - -// ANCESTOR_LIMIT is a documented knob. A value that is set but unusable -// must abort: handing it to git and discarding the exit status left the -// walk empty and the step green, marking nothing. -func TestMarkSupersededRejectsAnUnparseableAncestorLimit(t *testing.T) { - t.Parallel() - requireTools(t) - - history := newRepo(t) - fake, api := newFakeGitea(t) - fake.setStatus(history.head, running()) - fake.setStatus(history.parent, cancelled()) - - out, err := runScript( - t, history, api, defaultEnv(), "ANCESTOR_LIMIT=twenty", - ) - require.Error(t, err) - require.Contains(t, out, "ANCESTOR_LIMIT") - require.Contains(t, out, "twenty") - require.Empty(t, fake.postedFor(history.parent)) -} - -// A status read that fails is not the same as a commit with nothing to -// do. Losing curl's exit status through a pipe made the two identical -// and left a laundered commit laundered with no signal. -func TestMarkSupersededFailsOnAnUnreadableAncestorStatus(t *testing.T) { - t.Parallel() - requireTools(t) - - history := newRepo(t) - fake, api := newFakeGitea(t) - fake.setStatus(history.head, running()) - fake.setStatus(history.parent, cancelled()) - fake.failStatusRead(history.parent) - - out, err := runScript(t, history, api, defaultEnv()) - require.Error(t, err) - require.Contains(t, out, history.parent) - require.Contains(t, out, "cannot read commit statuses") - require.Empty(t, fake.postedFor(history.parent)) -} - -// A shallow clone cannot resolve the parent, so it is indistinguishable -// from a root commit to rev-parse and the walk would exit 0 having -// marked nothing. It must abort instead: dropping `fetch-depth: 0` from -// the checkout step is one edit, and a silent no-op there restores the -// false-green bug this script exists to prevent. -func TestMarkSupersededRejectsAShallowRepository(t *testing.T) { - t.Parallel() - requireTools(t) - - history := shallowClone(t, newRepo(t)) - fake, api := newFakeGitea(t) - fake.setStatus(history.head, running()) - fake.setStatus(history.parent, cancelled()) - - out, err := runScript(t, history, api, defaultEnv()) - require.Error(t, err) - require.Contains(t, out, "shallow repository") - require.Empty(t, fake.postedFor(history.parent)) - require.Empty(t, fake.postedFor(history.head)) -} - -// shallowClone returns the same history as a depth-1 clone. The `file://` -// URL is required: git ignores --depth for a plain local path. -func shallowClone(t *testing.T, history repo) repo { - t.Helper() - - dir := t.TempDir() - - //nolint:gosec // fixed argv, arguments are test-local paths - cmd := exec.CommandContext(t.Context(), "git", "clone", "-q", - "--depth=1", "file://"+history.dir, dir) - - out, err := cmd.CombinedOutput() - require.NoError(t, err, string(out)) - - return repo{dir: dir, head: history.head, parent: history.parent} -} - -// The derived context must equal the one Gitea actually uses, which is -// built from the same workflow and job names. -func TestDerivedContextMatchesGitea(t *testing.T) { - t.Parallel() - requireTools(t) - - name, job := workflowIdentity(t) - - history := newRepo(t) - fake, api := newFakeGitea(t) - fake.setStatus(history.head, running()) - fake.setStatus(history.parent, cancelled()) - - out, err := runScript(t, history, api, scriptEnv{ - workflow: name, - job: job, - event: "push", - }) - require.NoError(t, err, out) - - posted := fake.postedFor(history.parent) - require.Len(t, posted, 1) - require.Equal(t, liveContext, posted[0].Context) -} - -// workflowIdentity reads the workflow name and its single job id out of -// the checked-in workflow file. -func workflowIdentity(t *testing.T) (string, string) { - t.Helper() - - raw, err := os.ReadFile(workflow) - require.NoError(t, err) - - var parsed struct { - Name string `yaml:"name"` - Jobs map[string]any `yaml:"jobs"` - } - - require.NoError(t, yaml.Unmarshal(raw, &parsed)) - - jobs := slices.Collect(maps.Keys(parsed.Jobs)) - require.Len(t, jobs, 1) - - return parsed.Name, jobs[0] -} - -func runScript( - t *testing.T, history repo, api string, env scriptEnv, - extra ...string, -) (string, error) { - t.Helper() - - script, err := filepath.Abs(scriptPath) - require.NoError(t, err) - - //nolint:gosec // fixed argv, repo-local script under test - cmd := exec.CommandContext(t.Context(), "sh", script) - cmd.Dir = history.dir - cmd.Env = append(os.Environ(), - "GITHUB_API_URL="+api, - "GITHUB_REPOSITORY=sneak/webhooker", - "GITHUB_SHA="+history.head, - "GITHUB_WORKFLOW="+env.workflow, - "GITHUB_JOB="+env.job, - "GITHUB_EVENT_NAME="+env.event, - "GITEA_TOKEN=test-token", - ) - cmd.Env = append(cmd.Env, extra...) - - out, err := cmd.CombinedOutput() - - return string(out), err -} - -func newRepo(t *testing.T) repo { - t.Helper() - - dir := t.TempDir() - - git := func(args ...string) string { - //nolint:gosec // fixed argv, arguments are test constants - cmd := exec.CommandContext(t.Context(), "git", args...) - cmd.Dir = dir - - out, err := cmd.CombinedOutput() - require.NoError(t, err, string(out)) - - return strings.TrimSpace(string(out)) - } - - commit := func(message string) string { - git( - "-c", "user.email=ci@example.invalid", - "-c", "user.name=ci", - "-c", "commit.gpgsign=false", - "commit", "-q", "--allow-empty", "-m", message, - ) - - return git("rev-parse", "HEAD") - } - - git("init", "-q", "-b", "main") - - parent := commit("parent") - head := commit("head") - - return repo{dir: dir, head: head, parent: parent} -} - -func requireTools(t *testing.T) { - t.Helper() - - for _, tool := range []string{"sh", "git", "curl", "jq"} { - _, err := exec.LookPath(tool) - if err != nil { - t.Skipf("%s is not installed: %v", tool, err) - } - } -} diff --git a/internal/ciscript/doc.go b/internal/ciscript/doc.go deleted file mode 100644 index 3867bfd..0000000 --- a/internal/ciscript/doc.go +++ /dev/null @@ -1,10 +0,0 @@ -// Package ciscript holds the tests for the repository's CI shell -// scripts in script/. It carries no runtime code: the scripts run on -// the CI runner, not inside the binary, but their behaviour still has -// to be verified by the test suite. -// -// The scripts under test are outside the Go build graph, so `go test`'s -// result cache serves a stale PASS when only a script changed: run the -// container build, or GOFLAGS=-count=1, to trust a result here after -// editing script/. -package ciscript diff --git a/internal/ciscript/fakegitea_test.go b/internal/ciscript/fakegitea_test.go deleted file mode 100644 index a3a38e1..0000000 --- a/internal/ciscript/fakegitea_test.go +++ /dev/null @@ -1,162 +0,0 @@ -package ciscript_test - -import ( - "encoding/json" - "net/http" - "net/http/httptest" - "sync" - "testing" -) - -// commitStatus is the part of an entry in Gitea's combined-status -// response that script/ci-mark-superseded reads. -type commitStatus struct { - Context string `json:"context"` - Status string `json:"status"` - Description string `json:"description"` -} - -// postedStatus is the part of a create-status request body the script -// writes. -type postedStatus struct { - Context string `json:"context"` - State string `json:"state"` - Description string `json:"description"` -} - -// fakeGitea serves the two endpoints the script talks to. Like Gitea, -// the newest status for a context replaces the previous one, so a -// second run of the script sees what the first one wrote. -type fakeGitea struct { - mu sync.Mutex - statuses map[string][]commitStatus - posted map[string][]postedStatus - // failRead is a commit whose combined-status read answers HTTP - // 500, standing in for a status API that is down. - failRead string -} - -// newFakeGitea returns the fake and the base URL to hand the script as -// GITHUB_API_URL. -func newFakeGitea(t *testing.T) (*fakeGitea, string) { - t.Helper() - - fake := &fakeGitea{ - mu: sync.Mutex{}, - statuses: map[string][]commitStatus{}, - posted: map[string][]postedStatus{}, - failRead: "", - } - - srv := httptest.NewServer(fake.routes()) - t.Cleanup(srv.Close) - - return fake, srv.URL -} - -func (f *fakeGitea) routes() http.Handler { - mux := http.NewServeMux() - - mux.HandleFunc( - "GET /repos/{owner}/{repo}/commits/{sha}/status", - f.handleCombined, - ) - mux.HandleFunc( - "POST /repos/{owner}/{repo}/statuses/{sha}", - f.handleCreate, - ) - - return mux -} - -func (f *fakeGitea) handleCombined( - w http.ResponseWriter, r *http.Request, -) { - f.mu.Lock() - defer f.mu.Unlock() - - sha := r.PathValue("sha") - if f.failRead != "" && f.failRead == sha { - http.Error(w, "boom", http.StatusInternalServerError) - - return - } - - body := struct { - Statuses []commitStatus `json:"statuses"` - }{Statuses: f.statuses[sha]} - - payload, err := json.Marshal(body) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - - return - } - - w.Header().Set("Content-Type", "application/json") - - _, _ = w.Write(payload) -} - -func (f *fakeGitea) handleCreate(w http.ResponseWriter, r *http.Request) { - var got postedStatus - - err := json.NewDecoder(r.Body).Decode(&got) - if err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - - return - } - - sha := r.PathValue("sha") - - f.mu.Lock() - defer f.mu.Unlock() - - f.posted[sha] = append(f.posted[sha], got) - f.replaceLocked(sha, commitStatus{ - Context: got.Context, - Status: got.State, - Description: got.Description, - }) - - w.WriteHeader(http.StatusCreated) -} - -// failStatusRead makes the combined-status read for one commit answer -// HTTP 500. -func (f *fakeGitea) failStatusRead(sha string) { - f.mu.Lock() - defer f.mu.Unlock() - - f.failRead = sha -} - -// setStatus gives a commit its latest status for a context. -func (f *fakeGitea) setStatus(sha string, status commitStatus) { - f.mu.Lock() - defer f.mu.Unlock() - - f.replaceLocked(sha, status) -} - -// postedFor returns the statuses the script created for a commit. -func (f *fakeGitea) postedFor(sha string) []postedStatus { - f.mu.Lock() - defer f.mu.Unlock() - - return append([]postedStatus(nil), f.posted[sha]...) -} - -// replaceLocked requires f.mu. -func (f *fakeGitea) replaceLocked(sha string, status commitStatus) { - for i, existing := range f.statuses[sha] { - if existing.Context == status.Context { - f.statuses[sha][i] = status - - return - } - } - - f.statuses[sha] = append(f.statuses[sha], status) -} diff --git a/internal/middleware/loginguard_test.go b/internal/middleware/loginguard_test.go index fb2ac0c..faca060 100644 --- a/internal/middleware/loginguard_test.go +++ b/internal/middleware/loginguard_test.go @@ -236,9 +236,9 @@ func TestLoginGuard_SemaphoreBoundsConcurrentVerifications( // rendezvousDeadlock is the deadlock guard described below. // It is orders of magnitude longer than any scheduling delay, - // so it never decides the result, and well inside script/test's - // 30s timeout, so a wedge fails on the assertion instead of - // blowing the package timeout. + // so it never decides the result, and well inside the 90s + // package timeout of the Dockerfile's test phase, so a wedge + // fails on the assertion instead of blowing that timeout. rendezvousDeadlock = 5 * time.Second ) diff --git a/script/assets b/script/assets index 54e4185..55099d7 100755 --- a/script/assets +++ b/script/assets @@ -2,8 +2,8 @@ # script/assets: extract Alpine.js from its npm package tarball, committed # in 3p/, to static/js/alpine.min.js, where go:embed reads it. The package # is @alpinejs/csp, Alpine's build for pages whose Content-Security-Policy -# forbids eval. The extracted file is not committed. script/test, make -# build and make dev run this first. +# forbids eval. The extracted file is not committed. make build, make dev +# and the Dockerfile's lint, test and build stages run this first. set -eu ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" diff --git a/script/bootstrap b/script/bootstrap index bdd28d9..b7db190 100755 --- a/script/bootstrap +++ b/script/bootstrap @@ -60,10 +60,10 @@ main() { if missing go; then pkg_install go golang go go; fi # Not installed here: docker is platform-specific and out of scope for a - # package-manager bootstrap, but script/lint, script/fmt and script/css - # need it. + # package-manager bootstrap, but script/test, script/lint, script/fmt and + # script/css need it. if missing docker; then - echo "bootstrap: docker not found; script/lint, script/fmt and script/css require it" >&2 + echo "bootstrap: docker not found; script/test, script/lint, script/fmt and script/css require it" >&2 fi go mod download diff --git a/script/check b/script/check index 6ce8351..118f59c 100755 --- a/script/check +++ b/script/check @@ -1,7 +1,7 @@ #!/bin/sh # script/check: run all checks (test, lint, fmt-check, css-check). Our own -# extension to scripts-to-rule-them-all. -# Writes only the ignored static/js/alpine.min.js, through script/test. +# extension to scripts-to-rule-them-all. test, lint and css-check are +# Docker builds; fmt-check runs gofmt on the host. Writes nothing. # Generic, apart from css-check. set -eu diff --git a/script/ci-mark-superseded b/script/ci-mark-superseded deleted file mode 100755 index 4ed52cf..0000000 --- a/script/ci-mark-superseded +++ /dev/null @@ -1,152 +0,0 @@ -#!/bin/sh -# script/ci-mark-superseded: record an honest status on commits whose CI -# run Gitea cancelled because a newer commit landed on the same branch. -# Gitea writes `failure` / "Has been cancelled" for such a run, which -# reads as a test result on a commit nothing ever tested. Cancellation is -# unconditional server-side for push events, so the superseding run -# rewrites those statuses to `failure` with a description that says the -# commit was never tested. `skipped` cannot be used: Gitea's combined -# status folds `skipped` into `success`, so a never-tested commit would -# report green. Genuine failures and successes are never touched. -# -# Called by the Gitea Actions workflow, which supplies GITHUB_API_URL, -# GITHUB_REPOSITORY, GITHUB_SHA, GITHUB_WORKFLOW, GITHUB_JOB, -# GITHUB_EVENT_NAME and GITEA_TOKEN. ANCESTOR_LIMIT (default 20) caps how -# far back the walk looks; a value that is set but not a positive integer -# aborts rather than silently disabling the walk. -set -eu - -SUPERSEDED_DESC='Superseded by a newer commit; never tested' - -# Gitea builds the commit-status context as -# " / ()", so derive it rather than -# hardcoding the result. -# -# The derivation is deliberately not byte-exact with Gitea's own rule and -# must not be "fixed" into a silent fallback. Gitea uses the job's `name:` -# (falling back to the job id) and the workflow's `name:` (falling back to -# the workflow filename), while the runner exports GITHUB_JOB as the job -# *id* and GITHUB_WORKFLOW as the parsed workflow `name:`. So giving the -# job a display `name:`, or dropping the workflow's `name:`, makes the -# derived context stop matching --- and require_own_context below then -# turns every push red with a message. That loud failure is the point -# (https://git.eeqj.de/sneak/webhooker/issues/147 item 2); guessing at a -# fallback would restore the silent no-op it replaced. -context() { - printf '%s / %s (%s)' \ - "$GITHUB_WORKFLOW" "$GITHUB_JOB" "$GITHUB_EVENT_NAME" -} - -# ANCESTOR_LIMIT is a documented knob, so a value that is set but -# unusable must fail loudly instead of defaulting -# (https://git.eeqj.de/sneak/webhooker/issues/80). Passing it straight to -# git would print `fatal: not an integer` into a discarded exit status -# and mark nothing. -ancestor_limit() { - # `-` and not `:-`: an explicitly empty value is set-but-unusable - # config, so it aborts like any other bad value rather than silently - # running at the default. - _limit="${ANCESTOR_LIMIT-20}" - case "$_limit" in - '' | *[!0-9]* | 0*) - echo "ANCESTOR_LIMIT must be a positive integer," \ - "got '${_limit}'" >&2 - - return 1 - ;; - esac - printf '%s' "$_limit" -} - -# The status Gitea created for this very job proves which context string -# it uses. If the derived one is missing, the workflow or the job was -# renamed and the match below would silently stop firing, restoring the -# false-red bug with no signal. Fail loudly instead. -require_own_context() { - if ! _body="$(curl -sf --retry 3 --retry-delay 2 --max-time 30 \ - "${1}/commits/${GITHUB_SHA}/status")"; then - echo "cannot read commit statuses for ${GITHUB_SHA}" >&2 - return 1 - fi - _found="$(printf '%s' "$_body" | jq -r '(.statuses // [])[].context')" - if printf '%s\n' "$_found" | grep -qxF "$2"; then - return 0 - fi - echo "no commit status with context '${2}' on ${GITHUB_SHA}:" >&2 - echo "workflow or job renamed? contexts present:" >&2 - printf '%s\n' "$_found" >&2 - return 1 -} - -# Latest status for our context on a commit, as "state|description". -# The read is retried and bounded, and a read that still fails aborts the -# step: a laundered commit that cannot be read is not the same as one -# with nothing to do, and piping curl into jq would discard the -# difference. -status_of() { - if ! _sbody="$(curl -sf --retry 3 --retry-delay 2 --max-time 30 \ - "${1}/commits/${2}/status")"; then - echo "cannot read commit statuses for ${2}" >&2 - - return 1 - fi - printf '%s' "$_sbody" | jq -r --arg c "$3" \ - '[(.statuses // [])[] | select(.context == $c)][0] // empty - | "\(.status)|\(.description)"' -} - -mark_superseded() { - curl -sf -X POST "${1}/statuses/${2}" \ - -H "Authorization: token ${GITEA_TOKEN}" \ - -H 'Content-Type: application/json' \ - -d "$(jq -nc --arg c "$3" --arg d "$SUPERSEDED_DESC" \ - '{context: $c, state: "failure", description: $d}')" \ - >/dev/null -} - -main() { - _api="${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}" - _ctx="$(context)" - - _limit="$(ancestor_limit)" - - require_own_context "$_api" "$_ctx" - - # A shallow clone cannot resolve the parent, so it looks exactly like - # a root commit to rev-parse below and would exit 0 having walked - # nothing (or, at depth > 1, only the ancestors that happen to be - # present). The workflow checks out with `fetch-depth: 0`; verify - # that here rather than depend on it silently. - if [ "$(git rev-parse --is-shallow-repository)" = 'true' ]; then - echo "shallow repository: the ancestor walk needs full history" >&2 - - return 1 - fi - - # A root commit legitimately has no ancestors and is not an error. - # A SHA this repository does not have lands here too, since its - # parent is equally unresolvable, but require_own_context above has - # already aborted on the 404 for it. The walk itself carries no - # `|| true`, so a rev-list failure aborts. - if ! git rev-parse -q --verify "${GITHUB_SHA}^" >/dev/null; then - echo "no ancestor of ${GITHUB_SHA} to check" - - return 0 - fi - - _walk="$(git rev-list --max-count="$_limit" "${GITHUB_SHA}^")" - - for _sha in $_walk; do - _latest="$(status_of "$_api" "$_sha" "$_ctx")" - # A run that was cancelled, or one an earlier revision of this - # script laundered into `skipped`. Anything else stands. - case "$_latest" in - 'failure|Has been cancelled' | "skipped|${SUPERSEDED_DESC}") ;; - *) continue ;; - esac - mark_superseded "$_api" "$_sha" "$_ctx" - echo "marked superseded: ${_sha}" - done -} - -main "$@" diff --git a/script/cibuild b/script/cibuild index ae14eec..d8d3200 100755 --- a/script/cibuild +++ b/script/cibuild @@ -1,15 +1,28 @@ #!/bin/sh -# script/cibuild: run the CI build. The Dockerfile runs the checks (the -# gofmt check, golangci-lint, the stylesheet check, ESLint, the Markdown -# check, make test), so a successful build implies a green repo. Generic: -# needs no adaptation. The Gitea workflow runs this on push. +# script/cibuild: run the CI build. It bootstraps first: a CI runner +# checks out and runs this and nothing else, and script/fmt-check runs +# the formatter on the host, which a pristine checkout cannot do. +# --no-cache for the same reason as script/docker: the gate phases the +# final stage depends on are RUN steps, and a cached one is a check that +# did not run. set -eu -ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)" +ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)" main() { cd "$ROOT" - docker build . + "$SCRIPT_DIR/bootstrap" + "$SCRIPT_DIR/check" + # Own line: a failing command substitution inside an argument does + # not trip `set -e`, so the inline form degrades silently to an + # empty constant. The VERSION build argument takes precedence over + # the version a build stage derives from the .git in the context. + version="$(git describe --tags --always --dirty 2>/dev/null || true)" + [ -n "$version" ] || version="unknown" + docker build --no-cache \ + --build-arg VERSION="$version" \ + -t "$("$SCRIPT_DIR/projectname")" . } main "$@" diff --git a/script/css b/script/css index 48117df..eee38cd 100755 --- a/script/css +++ b/script/css @@ -2,14 +2,16 @@ # script/css: regenerate static/css/tailwind.css (writes). tailwindcss is # never installed locally: it runs in docker, at the version and sha256 # pinned in the Dockerfile's stylesheet stages, which also say what the -# stylesheet is generated from. +# stylesheet is generated from. --no-cache, as on every docker build in +# script/, so the stylesheet is generated rather than taken from the cache. set -eu ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" main() { cd "$ROOT" - docker build --target css-output --output type=local,dest=static/css . + docker build --no-cache \ + --target css-output --output type=local,dest=static/css . } main "$@" diff --git a/script/css-check b/script/css-check index 3b80f63..15469f5 100755 --- a/script/css-check +++ b/script/css-check @@ -1,14 +1,15 @@ #!/bin/sh # script/css-check: fail when static/css/tailwind.css differs from what # script/css would generate (read-only). The comparison is the Dockerfile's -# css-check stage, which the image build runs too. +# css-check stage, which the image build runs too. --no-cache because a +# cached check is a check that did not run. set -eu ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" main() { cd "$ROOT" - docker build --target css-check --output type=cacheonly . + docker build --no-cache --target css-check --output type=cacheonly . } main "$@" diff --git a/script/docker b/script/docker index ba8a836..07b626c 100755 --- a/script/docker +++ b/script/docker @@ -1,10 +1,8 @@ #!/bin/sh # script/docker: build the Docker image tagged with the project name. -# The tag comes from script/projectname. -# -# The version script/version resolves here goes in as the VERSION build -# arg, which takes precedence over what the build would derive from the -# .git in its context. +# Identical in all repos; the tag comes from script/projectname. +# --no-cache because the gate phases the final stage depends on are RUN +# steps, and a cached one is a check that did not run. set -eu SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)" @@ -12,8 +10,14 @@ ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)" main() { cd "$ROOT" - docker build \ - --build-arg VERSION="$("$SCRIPT_DIR/version")" \ + # Own line: a failing command substitution inside an argument does + # not trip `set -e`, so the inline form degrades silently to an + # empty constant. The VERSION build argument takes precedence over + # the version a build stage derives from the .git in the context. + version="$(git describe --tags --always --dirty 2>/dev/null || true)" + [ -n "$version" ] || version="unknown" + docker build --no-cache \ + --build-arg VERSION="$version" \ -t "$("$SCRIPT_DIR/projectname")" . } diff --git a/script/fmt b/script/fmt index 450cce6..488082f 100755 --- a/script/fmt +++ b/script/fmt @@ -1,7 +1,8 @@ #!/bin/sh # script/fmt: format all files (writes): the Go code with gofmt and # goimports, the Markdown with prettier. prettier is never installed -# locally: it runs in docker, in the Dockerfile's Markdown stages. +# locally: it runs in docker, in the Dockerfile's Markdown stages, built +# with --no-cache like every docker build in script/. set -eu ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" @@ -12,7 +13,8 @@ main() { if command -v goimports >/dev/null 2>&1; then goimports -w . fi - docker build --target markdown-output --output type=local,dest=. . + docker build --no-cache \ + --target markdown-output --output type=local,dest=. . } main "$@" diff --git a/script/fmt-check b/script/fmt-check index 1a0fa50..5cc6684 100755 --- a/script/fmt-check +++ b/script/fmt-check @@ -1,6 +1,7 @@ #!/bin/sh # script/fmt-check: check formatting (read-only). Same scope as -# script/fmt, but fails instead of writing. +# script/fmt, but fails instead of writing. --no-cache because a cached +# check is a check that did not run. set -eu ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" @@ -12,7 +13,7 @@ main() { gofmt -s -l . exit 1 fi - docker build --target markdown-check --output type=cacheonly . + docker build --no-cache --target markdown-check --output type=cacheonly . } main "$@" diff --git a/script/lint b/script/lint index f5d46f8..2d8b075 100755 --- a/script/lint +++ b/script/lint @@ -1,71 +1,23 @@ #!/bin/sh -# script/lint: run the linters, golangci-lint over the Go code and then -# ESLint over static/js/. Neither is ever installed locally. +# script/lint: run the linter. Linting is a phase of the Dockerfile and +# this builds that phase alone; the linter is never installed or run on +# a developer host, where a shared result cache and a host-global lock +# make its answer untrustworthy. # -# golangci-lint runs via docker only, one way, everywhere — script/lint builds -# Dockerfile.lint, which COPYs the repo into the pinned golangci-lint image -# and lints as a build step. This works even when the docker daemon is remote -# and bind mounts are impossible, and it removes the host linter's shared -# cache, which has attributed other checkouts' findings to this one. -# -# --no-cache-filter=lint forces the lint stage to re-execute on every run; a -# cached lint stage exits 0 in under a second having linted nothing. The deps -# stage keeps its cache, so module downloads are not repeated. -# --progress=plain keeps the linter's own output visible on success, so a -# passing run shows the issue count rather than nothing. -# --output=type=cacheonly leaves no image behind to clean up. -# -# docker silently ignores --no-cache-filter for a stage name that does not -# match, so a rename or a typo would restore the cached false green with no -# warning and a fast exit 0. The flag is therefore not trusted: the build -# output is teed to a log and a run is only a pass if golangci-lint's own -# summary line ("N issues." / "N issues:") is in it. No summary, no lint, -# whatever the exit code says. +# The phase is not the last stage in the file, so it is built only when +# --target names it. --no-cache because a cached lint layer is a lint +# that did not run. The tag makes each build replace the previous image +# instead of leaving a dangling one behind. set -eu -ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)" +ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)" main() { cd "$ROOT" - - log="$(mktemp -t webhooker-lint.XXXXXXXX)" - rcfile="$(mktemp -t webhooker-lint-rc.XXXXXXXX)" - trap 'rm -f "$log" "$rcfile"' EXIT INT TERM - - # The pipeline's status is tee's, and POSIX sh has no pipefail, so the - # build's status travels via a file. Output still streams live. - { - docker build \ - -f Dockerfile.lint \ - --no-cache-filter=lint \ - --progress=plain \ - --output=type=cacheonly \ - . 2>&1 && echo 0 >"$rcfile" || echo $? >"$rcfile" - } | tee "$log" >&2 - - rc="$(cat "$rcfile")" - [ "$rc" -eq 0 ] || exit "$rc" - - if ! grep -qE '[0-9]+ issues[.:]' "$log"; then - echo "script/lint: golangci-lint printed no summary line; the linter" >&2 - echo " did not run. Check that the stage named in --no-cache-filter" >&2 - echo " still matches a stage in Dockerfile.lint." >&2 - exit 1 - fi - - # ESLint runs in the Dockerfile's js-lint stage, which the image build - # runs too. It prints nothing on a pass, so there is no summary to look - # for. Instead the stage is named once, for both flags: --target fails - # on a name that matches no stage, so a rename cannot leave - # --no-cache-filter silently ignored. The js-deps stage, which installs - # ESLint, keeps its cache, so ESLint is not downloaded again. - js_stage=js-lint - docker build \ - --target "$js_stage" \ - --no-cache-filter="$js_stage" \ - --progress=plain \ - --output=type=cacheonly \ - . + docker build --no-cache \ + --target lint \ + -t "$("$SCRIPT_DIR/projectname")-lint" . } main "$@" diff --git a/script/test b/script/test index 9ba4a8c..cd239f2 100755 --- a/script/test +++ b/script/test @@ -1,84 +1,19 @@ #!/bin/sh -# script/test: run the test suite. -# -# -timeout is applied by `go test` per package, not to the run as a whole, so -# it only has to clear the slowest single package. When this budget was set -# that was internal/handlers, measured in a cache-defeated builder stage on the -# 48-core shared build host (2026-08-18); load- and host-dependent, not -# invariants: -# -# 16.9s host load 5-20, GOMAXPROCS 48 -# 45.9s / 47.3s / 49.0s three runs at deliberate host load 31-73 -# 30.6s / 39.7s host load 5-20, GOMAXPROCS 6 / 4 -# 67.3s / 97.5s host load 5-20, GOMAXPROCS 2 / 1 -# 67.3s GOMAXPROCS 4 at deliberate host load 52-68 -# -# The old 30s budget was breached by every loaded run and by every GOMAXPROCS -# at or below 6; at GOMAXPROCS 4 it failed outright ("panic: test timed out -# after 30s"), reproduced on 33e4fa4 with no other change. -# -# 90s matches the org-wide backstop in REPO_POLICIES.md and is sized here -# against the figures above: the worst case under native parallelism is 49.0s, -# and the compound GOMAXPROCS-4-under-load case at 67.3s sits at 75% of it. -# The one figure above 90s is GOMAXPROCS 1, a synthetic core floor rather than -# a condition CI runs under. If a CPU-limited runner ever puts a real run near -# 67s, that is the datum to revisit the org figure with. -# -# Those figures predate tests hashing the admin password at 1 MB instead of -# 64 MB (https://git.eeqj.de/sneak/webhooker/pulls/404). After that change, in -# a cache-defeated build at host load 44-109 (2026-10-02), internal/handlers -# took 8.5s and the slowest package was internal/database at 15.8s. Once its -# retention tests seeded 50 rows per insert instead of 500 -# (https://git.eeqj.de/sneak/webhooker/issues/198), internal/database took -# 7.3s and the slowest package was internal/handlers at 8.1s to 10.0s, at host -# load 25-48 (2026-10-02). -# -# -p 4 -parallel 8 keep the run under 2 GB of memory: at most four test -# binaries build or run at once, each with at most eight parallel tests. Under -# -race every test binary and every link costs a few hundred MB, so the -# defaults (one per core) add up to several GB on a many-core host. -# -# The first run has no -v: go test then prints one result line per package, -# with its coverage, and for a package that fails, everything its tests wrote, -# application log lines included. Verbose output from the whole suite passes -# the 2 MiB at which the Docker build cuts off each step's log, so on a failure -# only the tests that failed run again, with -v. The script exits 1 after that -# rerun whatever its result: the first run already showed the suite is broken. +# script/test: run the test suite. Testing is a phase of the Dockerfile +# and this builds that phase alone, on the same terms as script/lint: +# --target because a phase that is not the last stage is built only when +# named, --no-cache because a cached test layer is a test that did not +# run, and a tag so each build replaces the previous image. set -eu -ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)" +ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)" main() { cd "$ROOT" - "$ROOT/script/assets" - - log="$(mktemp -t webhooker-test.XXXXXXXX)" - rcfile="$(mktemp -t webhooker-test-rc.XXXXXXXX)" - trap 'rm -f "$log" "$rcfile"' EXIT INT TERM - - # The pipeline's status is tee's, and POSIX sh has no pipefail, so go - # test's status travels via a file. Output still streams live. - { - go test -race -cover -p 4 -parallel 8 -timeout 90s ./... 2>&1 \ - && echo 0 >"$rcfile" || echo $? >"$rcfile" - } | tee "$log" - if [ "$(cat "$rcfile")" -eq 0 ]; then - return - fi - - # go test reports a failed test as a line starting "--- FAIL: TestName" - # (a failed subtest's line is indented, and reruns with its parent), and - # a failed package as "FAILpackage/path...". A failure that - # names no test, such as a build error or a timeout, is already shown in - # full above, so there is nothing to rerun. - tests="$(awk '/^--- FAIL: / { print $3 }' "$log" | paste -s -d '|' -)" - packages="$(awk '/^FAIL\t/ { print $2 }' "$log")" - if [ -n "$tests" ]; then - echo "--- Rerunning the failed tests with -v for details ---" - go test -race -v -p 4 -parallel 8 -timeout 90s \ - -run "^($tests)\$" $packages || true - fi - exit 1 + docker build --no-cache \ + --target test \ + -t "$("$SCRIPT_DIR/projectname")-test" . } main "$@" diff --git a/script/test-browser b/script/test-browser index 08bb621..cde17f4 100755 --- a/script/test-browser +++ b/script/test-browser @@ -3,8 +3,7 @@ # Docker: Dockerfile.browser builds the test and runs it in a digest-pinned # headless browser image, so the host needs no browser. # -# --no-cache-filter=browser runs the test again even when nothing changed; -# it must name the stage in Dockerfile.browser that runs it. +# --no-cache because a cached test layer is a test that did not run. # --output=type=cacheonly leaves no image behind to clean up. set -eu @@ -14,7 +13,7 @@ main() { cd "$ROOT" docker build \ -f Dockerfile.browser \ - --no-cache-filter=browser \ + --no-cache \ --progress=plain \ --output=type=cacheonly \ . diff --git a/script/version b/script/version index 9fbce61..1fe8092 100755 --- a/script/version +++ b/script/version @@ -1,9 +1,11 @@ #!/bin/sh # script/version: output the version string the binary is stamped with. # Our own extension to scripts-to-rule-them-all. The Makefile's build -# target and script/docker both take the value from here, so a `make -# build` binary and a `make docker` image built from the same checkout -# report the same thing. +# and version targets take the value from here, and the Dockerfile's +# build stage calls them. script/docker and script/cibuild run the same +# `git describe` on the host and pass the result in as $VERSION, so a +# `make build` binary and a `make docker` image built from the same +# checkout report the same thing. # # Order of precedence: # -- 2.54.0 From faf3da9a75fa40545c8e13b19f6a9d945182f4b9 Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Tue, 6 Oct 2026 07:21:30 +0200 Subject: [PATCH 099/126] Refresh the package lists before installing in script/bootstrap (closes #508) The CI runner's image starts with empty package lists, so `script/bootstrap`, which `script/cibuild` now runs first, failed to install Go and every CI run stopped there. The apt branch of `pkg_install` now runs `apt-get update` once per run, before its first `apt-get install`, with the same code as the pending shared fix in https://git.eeqj.de/sneak/prompts/pulls/116. Deviation: `script/bootstrap` differs from the vendored copy until the next re-vendor. Unverified: proven by running the CI job in the runner's image; the branch's own CI run waits for the runner outage to clear. Model: opus-5-5 --- script/bootstrap | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/script/bootstrap b/script/bootstrap index b7db190..529b99c 100755 --- a/script/bootstrap +++ b/script/bootstrap @@ -11,6 +11,7 @@ ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" PKGMGR="" SUDO="" +APT_UPDATED="" detect_pkgmgr() { [ -n "$PKGMGR" ] && return 0 @@ -39,7 +40,14 @@ pkg_install() { detect_pkgmgr case "$PKGMGR" in nix) nix-env -iA "nixpkgs.$1" ;; - apt) $SUDO env DEBIAN_FRONTEND=noninteractive apt-get install -y "$2" ;; + apt) + # Package lists may be empty (fresh images); refresh once per run. + if [ -z "$APT_UPDATED" ]; then + $SUDO env DEBIAN_FRONTEND=noninteractive apt-get update + APT_UPDATED=1 + fi + $SUDO env DEBIAN_FRONTEND=noninteractive apt-get install -y "$2" + ;; brew) brew install "$3" ;; apk) apk add --no-cache "$4" ;; esac -- 2.54.0 From a9d77e20d7959822fbf32663a9beb1dfcd7c2e56 Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Tue, 6 Oct 2026 08:51:35 +0200 Subject: [PATCH 100/126] Make the load-sensitive tests wait for what they check (closes #507) Three tests failed at random on a busy host. The browser test now waits for each page a click opens to load, and for Alpine.js to start on it, before reading it. The delivery tests' drain takes what is already queued instead of racing a 25 ms timer, since both dispatch paths queue before they return. The test phase keeps the tests' temporary directories on a tmpfs, because SQLite waiting for the disk made `internal/handlers` slow under load; the timeout and the parallel cap are unchanged. Deviation: the delivery tests do not wait with a deadline; dispatch has finished when they read. Unverified: `internal/handlers` at a host load of 260 to 290. Model: opus-5-5 --- Dockerfile | 9 ++++++- internal/delivery/inflight_test.go | 7 +++--- internal/server/alpine_browser_test.go | 34 +++++++++++++++++++------- 3 files changed, 37 insertions(+), 13 deletions(-) diff --git a/Dockerfile b/Dockerfile index 7a61e9a..c52265d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -147,9 +147,16 @@ RUN script/assets # shown in full, so there is nothing to rerun. The step fails after the rerun # whatever its result: the first run already showed the suite is broken. # +# TMPDIR, where the tests keep their SQLite databases, is a tmpfs: SQLite +# waits for the disk at every commit, and on a busy host that waiting was +# about 40% of the slowest package's run time. GOTMPDIR keeps go's own +# build files, the test binaries among them, on disk. +# # bash with pipefail, so that the first run's status is go test's, not tee's. SHELL ["/bin/bash", "-o", "pipefail", "-c"] -RUN go test -race -cover -p 4 -parallel 8 -timeout 90s ./... 2>&1 | tee /tmp/go-test.log && exit 0; \ +RUN --mount=type=tmpfs,target=/tmp/tests,size=512m \ + export TMPDIR=/tmp/tests GOTMPDIR=/tmp; \ + go test -race -cover -p 4 -parallel 8 -timeout 90s ./... 2>&1 | tee /tmp/go-test.log && exit 0; \ tests="$(awk '/^--- FAIL: / { print $3 }' /tmp/go-test.log | paste -s -d '|' -)"; \ packages="$(awk '/^FAIL\t/ { print $2 }' /tmp/go-test.log)"; \ if [ -n "$tests" ]; then \ diff --git a/internal/delivery/inflight_test.go b/internal/delivery/inflight_test.go index 08a0f69..81ad665 100644 --- a/internal/delivery/inflight_test.go +++ b/internal/delivery/inflight_test.go @@ -48,8 +48,9 @@ func fSweepSetup( // // Every caller drives the dispatch paths synchronously and has already // waited for them to return, so anything they queued is in the channel -// by now. The short grace covers nothing but scheduler jitter, and is -// kept small because one of these tests runs the drain forty times. +// by now, and nothing is waited for. A timer here would race the queued +// tasks: on a busy host it can be due by the time select looks, and +// select picks at random among the cases that are ready. func fDrain(e *delivery.Engine) []delivery.Task { var out []delivery.Task @@ -59,7 +60,7 @@ func fDrain(e *delivery.Engine) []delivery.Task { out = append(out, task) case task := <-e.ExportRetryCh(): out = append(out, task) - case <-time.After(25 * time.Millisecond): + default: return out } } diff --git a/internal/server/alpine_browser_test.go b/internal/server/alpine_browser_test.go index 367e0f9..b865083 100644 --- a/internal/server/alpine_browser_test.go +++ b/internal/server/alpine_browser_test.go @@ -280,6 +280,23 @@ func click(ctx context.Context, t *testing.T, xpath string) { )) } +// clickAndLoad clicks the link or button matching an XPath expression +// and waits, as loadPage does, for the page the click opens to load and +// for Alpine.js to start on it. Reading earlier, a check can find an +// element of the page being left, gone by the time its value is read; +// and the wait in shown is too short for a page load on a busy host. +func clickAndLoad(ctx context.Context, t *testing.T, xpath string) { + t.Helper() + + _, err := chromedp.RunResponse( + ctx, chromedp.Click(xpath, chromedp.BySearch), + ) + require.NoError(t, err) + require.NoError(t, chromedp.Run( + ctx, chromedp.WaitNotPresent("[x-cloak]", chromedp.ByQuery), + )) +} + // checkAddEntrypoint loads a webhook page and checks that the add // entrypoint form stays hidden until the Add button beside its heading // is clicked. The click looks for a button element there, so it also @@ -422,7 +439,7 @@ func checkAddTarget( ))) } - click(ctx, t, saveButton) + clickAndLoad(ctx, t, saveButton) assert.Truef(t, shown(ctx, `//span[text()="`+name+ `"]/following-sibling::div/span[text()="`+badge+`"]`), "%s: the added target is not listed as %s", targetType, badge) @@ -485,10 +502,9 @@ func checkArchiveChoices(ctx context.Context, t *testing.T, url string) { `/following-sibling::span[text()="daily"]`), "a database target added with daily is not listed as daily") - click(ctx, t, row+`//a[text()="Edit"]`) + clickAndLoad(ctx, t, row+`//a[text()="Edit"]`) require.NoError(t, chromedp.Run( ctx, - chromedp.WaitReady("#expiry", chromedp.ByQuery), chromedp.Value("#expiry", &editedExpiry, chromedp.ByQuery), chromedp.Value("#rotation", &editedRotation, chromedp.ByQuery), )) @@ -523,7 +539,7 @@ func checkRefusedTarget(ctx context.Context, t *testing.T, url string) { chromedp.Click(forwardQuery, chromedp.ByQuery), )) - click(ctx, t, saveButton) + clickAndLoad(ctx, t, saveButton) assert.True(t, shown(ctx, reason), "a refused target does not show the reason") @@ -636,7 +652,7 @@ func checkRefusedEdits( )) } - click(ctx, t, `//button[text()="Save Changes"]`) + clickAndLoad(ctx, t, `//button[text()="Save Changes"]`) assert.Truef(t, shown(ctx, reason), "%s: a refused save does not show the reason", edit.url) @@ -760,7 +776,7 @@ func checkEntrypointEdit( require.NoError(t, chromedp.Run( ctx, chromedp.SendKeys(input, "Billing sender", chromedp.ByQuery), )) - click(ctx, t, saveEdit) + clickAndLoad(ctx, t, saveEdit) assert.True(t, shown(ctx, `//span[text()="Billing sender"]`), "saving the edit form does not change the description") @@ -798,7 +814,7 @@ func checkRecentEvents(ctx context.Context, t *testing.T, url string) { "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"]`) + clickAndLoad(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") @@ -1199,7 +1215,7 @@ func checkNewWebhookTargets( `","rotation":"none"}` } - click(ctx, t, createButton) + clickAndLoad(ctx, t, createButton) require.Truef(t, shown(ctx, `//h1[text()="`+name+`"]`), "%s: the new webhook's page does not open", name) @@ -1260,7 +1276,7 @@ func checkRefusedNewWebhook(ctx context.Context, t *testing.T, url string) { chromedp.SetValue(pruningChoice, "2160h", chromedp.BySearch), chromedp.SetValue("#archive_rotation", "monthly", chromedp.ByQuery), )) - click(ctx, t, createButton) + clickAndLoad(ctx, t, createButton) assert.True(t, shown(ctx, `//div[@class="alert-error"]`), "a refused webhook does not show the reason") -- 2.54.0 From 3de345fe6f3b923f4dbb454b0cd12b7a84dddb2a Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Tue, 6 Oct 2026 10:56:50 +0200 Subject: [PATCH 101/126] Read the export test's heap only after pools drop their caches (closes #511) `TestArchiveExport_Streams` read the heap after one garbage collection, but the libraries the export calls (`regexp` under GORM's table names, `encoding/json`, GORM's row scanning) keep spare buffers in a `sync.Pool`, which keeps them through one collection. Each reading therefore counted however many happened to be cached, which varied by about as much as the test's limit and failed `go test` on `next`. The test now collects twice before each reading, so a reading is the memory the export holds. The limit, the row counts and the claim are unchanged, and an export that does not stream still fails it. Unverified: the branch's CI run waits for the runner outage to clear. Model: opus-5-5 --- internal/delivery/target_database_export_test.go | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/internal/delivery/target_database_export_test.go b/internal/delivery/target_database_export_test.go index 6515b7d..fc6dd61 100644 --- a/internal/delivery/target_database_export_test.go +++ b/internal/delivery/target_database_export_test.go @@ -460,8 +460,13 @@ func TestArchiveExport_OneFileOpenAtATime(t *testing.T) { } // heapPeak is an io.Writer that discards what it is given and records -// the largest heap it saw at a write. It collects garbage before each -// reading, so the heap it reads is what is still held. +// the largest heap it saw at a write. It collects garbage twice before +// each reading, so the heap it reads is what is still held. Once is not +// enough: the libraries the export calls (regexp, under GORM's table +// names, and encoding/json among them) cache buffers in a sync.Pool, +// which keeps them through one collection, so after one the reading +// counts however many happen to be cached. That varies from run to run +// by about as much as the limit in TestArchiveExport_Streams. type heapPeak struct { max uint64 } @@ -469,6 +474,7 @@ type heapPeak struct { func (p *heapPeak) Write(b []byte) (int, error) { var m runtime.MemStats + runtime.GC() runtime.GC() runtime.ReadMemStats(&m) p.max = max(p.max, m.HeapAlloc) @@ -498,6 +504,8 @@ func exportHeapGrowth(t *testing.T, rows, bodySize int) uint64 { export := listExport(t, path) + // Twice, for the reason heapPeak gives. + runtime.GC() runtime.GC() var start runtime.MemStats -- 2.54.0 From 128eb1b644a0cc29dd97f3d21435a6feda1b8b1a Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Tue, 6 Oct 2026 14:29:47 +0200 Subject: [PATCH 102/126] Keep test helpers out of the shipped binary (closes #506) The test helpers lived in ordinary `testing.go` files inside the config, database, middleware and session packages, so they were built into the binary and the shared `test-support` lint rule could not see them. The four files are gone: the session's helpers move into its own `_test.go` file, and the rest into `configtest`, `databasetest` and `middlewaretest`, which the `depguard` deny list now names, so a non-test file importing them fails lint. The test-support packages build through the production constructors. Judgement call: the session, the middleware and the webhook database manager now take the plain logger they log through, which the application wiring provides. Judgement call: two idle-expiry tests move the stored timestamps back instead of advancing a fake clock. Model: opus-5-5 --- .golangci.yml | 6 + README.md | 11 +- cmd/webhooker/main.go | 4 + cmd/webhooker/main_test.go | 6 +- .../{testing_test.go => clearenv_test.go} | 11 +- internal/config/config_test.go | 21 +-- .../{testing.go => configtest/configtest.go} | 8 +- internal/config/dotenv_test.go | 15 +- internal/config/env_test.go | 11 +- internal/config/sentry_test.go | 3 +- .../database/databasetest/databasetest.go | 55 ++++++ internal/database/event_db_isolation_test.go | 23 +-- internal/database/retention_test.go | 2 +- internal/database/testing.go | 47 ------ internal/database/webhook_db_manager.go | 5 +- internal/database/webhook_db_manager_test.go | 9 +- internal/delivery/archive_sweeper_test.go | 22 +-- internal/delivery/engine_integration_test.go | 59 +++---- internal/delivery/queue_depth_gormlog_test.go | 35 +--- .../delivery/target_database_rotation_test.go | 3 +- internal/gormlog/firstboot_test.go | 5 +- internal/handlers/handlers_test.go | 2 + internal/handlers/profile_test.go | 4 +- internal/handlers/target_create_query_test.go | 5 +- internal/handlers/target_download_test.go | 5 +- internal/middleware/accesslog_test.go | 5 +- internal/middleware/clientip_test.go | 5 +- internal/middleware/logbound_test.go | 5 +- internal/middleware/metrics.go | 2 +- internal/middleware/metrics_test.go | 17 +- internal/middleware/middleware.go | 15 +- internal/middleware/middleware_test.go | 158 ++++++++++-------- .../middlewaretest/middlewaretest.go | 42 +++++ internal/middleware/ratelimit_test.go | 3 +- internal/middleware/testing.go | 32 ---- internal/resetpw/resetpw_test.go | 1 + internal/server/routes_test.go | 2 + .../session/{testing.go => export_test.go} | 3 +- internal/session/session.go | 5 +- 39 files changed, 342 insertions(+), 330 deletions(-) rename internal/config/{testing_test.go => clearenv_test.go} (75%) rename internal/config/{testing.go => configtest/configtest.go} (85%) create mode 100644 internal/database/databasetest/databasetest.go delete mode 100644 internal/database/testing.go create mode 100644 internal/middleware/middlewaretest/middlewaretest.go delete mode 100644 internal/middleware/testing.go rename internal/session/{testing.go => export_test.go} (92%) diff --git a/.golangci.yml b/.golangci.yml index 1b73eb9..80801b5 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -61,6 +61,12 @@ linters: desc: >- Test-support code belongs in test files and in packages whose directory name ends in test, not in the shipped binary. + - pkg: sneak.berlin/go/webhooker/internal/config/configtest + desc: test support; a file that is not a test must not import it + - pkg: sneak.berlin/go/webhooker/internal/database/databasetest + desc: test support; a file that is not a test must not import it + - pkg: sneak.berlin/go/webhooker/internal/middleware/middlewaretest + desc: test support; a file that is not a test must not import it # Only decisions already recorded in the Go package defaults are # listed here. Every entry matches the module path exactly. gomodguard_v2: diff --git a/README.md b/README.md index 2f9bd66..0e53c78 100644 --- a/README.md +++ b/README.md @@ -2984,7 +2984,7 @@ webhooker/ │ │ └── resetpw.go # `webhooker resetpw`: set an account's password, stopped deployments only │ ├── config/ │ │ ├── config.go # Configuration loading from environment variables -│ │ └── testing.go # ClearEnvForTest: an empty environment for one test +│ │ └── configtest/ # Test support: ClearEnv, an empty environment for one test │ ├── database/ │ │ ├── base_model.go # BaseModel with UUID primary keys │ │ ├── database.go # GORM connection, migrations, admin seed @@ -3001,8 +3001,8 @@ webhooker/ │ │ ├── model_apikey.go # APIKey entity │ │ ├── password.go # Argon2id hashing and verification │ │ ├── retention.go # Retention reaper (per-webhook event expiry) -│ │ ├── testing.go # NewTestDatabase: wrapper for tests, no fx lifecycle -│ │ └── webhook_db_manager.go # Per-webhook DB lifecycle manager +│ │ ├── webhook_db_manager.go # Per-webhook DB lifecycle manager +│ │ └── databasetest/ # Test support: a WebhookDBManager for tests in other packages │ ├── datadir/ │ │ └── lock.go # Exclusive advisory lock on DATA_DIR (one instance) │ ├── globals/ @@ -3063,7 +3063,7 @@ webhooker/ │ │ ├── csrf.go # CSRF protection middleware (gorilla/csrf) │ │ ├── ratelimit.go # Per-IP rate limiting middleware (go-chi/httprate) │ │ ├── loginguard.go # Login failure counters and the Argon2id verification semaphore -│ │ └── testing.go # NewForTest: Middleware without the fx lifecycle +│ │ └── middlewaretest/ # Test support: a Middleware for tests in other packages │ ├── reqtls/ │ │ └── reqtls.go # IsTLS: the one TLS predicate, r.TLS or X-Forwarded-Proto │ ├── server/ @@ -3071,8 +3071,7 @@ webhooker/ │ │ ├── http.go # HTTP server setup with timeouts │ │ └── routes.go # All route definitions │ ├── session/ -│ │ ├── session.go # Cookie-based session management -│ │ └── testing.go # NewForTest: Session without the fx lifecycle +│ │ └── session.go # Cookie-based session management │ └── versionscript/ │ └── doc.go # Tests for script/version and the build files that use it ├── static/ diff --git a/cmd/webhooker/main.go b/cmd/webhooker/main.go index 9269e99..5990801 100644 --- a/cmd/webhooker/main.go +++ b/cmd/webhooker/main.go @@ -4,6 +4,7 @@ package main import ( "fmt" "io" + "log/slog" "os" "time" @@ -187,6 +188,9 @@ func newApp() *fx.App { fx.Provide( globals.New, logger.New, + // The plain logger the session, the middleware and the + // webhook database manager take. + func(l *logger.Logger) *slog.Logger { return l.Get() }, config.New, database.New, database.NewWebhookDBManager, diff --git a/cmd/webhooker/main_test.go b/cmd/webhooker/main_test.go index b225db6..160cb1d 100644 --- a/cmd/webhooker/main_test.go +++ b/cmd/webhooker/main_test.go @@ -14,7 +14,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "sneak.berlin/go/webhooker/internal/config" + "sneak.berlin/go/webhooker/internal/config/configtest" "sneak.berlin/go/webhooker/internal/datadir" "sneak.berlin/go/webhooker/internal/resetpw" "sneak.berlin/go/webhooker/internal/server" @@ -37,7 +37,7 @@ const dockerStopGrace = 10 * time.Second // fx.New applies options before it executes invokes, so the timeout // is set whether or not the graph itself can be constructed here. func TestNewApp_StopTimeout(t *testing.T) { - config.ClearEnvForTest(t) + configtest.ClearEnv(t) t.Setenv("DATA_DIR", t.TempDir()) got := newApp().StopTimeout() @@ -75,7 +75,7 @@ func freePort(t *testing.T) int { // anything is built, and the run of logger.New, which happens before // the configuration sets the level. func TestNewApp_SendsFxEventsToTheLogger(t *testing.T) { - config.ClearEnvForTest(t) + configtest.ClearEnv(t) t.Setenv("DATA_DIR", t.TempDir()) t.Setenv("PORT", strconv.Itoa(freePort(t))) t.Setenv("DEBUG", "true") diff --git a/internal/config/testing_test.go b/internal/config/clearenv_test.go similarity index 75% rename from internal/config/testing_test.go rename to internal/config/clearenv_test.go index 4c2e315..17f8757 100644 --- a/internal/config/testing_test.go +++ b/internal/config/clearenv_test.go @@ -7,21 +7,22 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "sneak.berlin/go/webhooker/internal/config" + "sneak.berlin/go/webhooker/internal/config/configtest" ) -// TestClearEnvForTest_RemovesAddedVariables pins that a variable set +// TestClearEnv_RemovesAddedVariables pins that a variable set // after the clear other than through t.Setenv, as a test's .env file // sets one, is gone once the test ends, so it cannot reach the tests // that run after it. // -//nolint:paralleltest // ClearEnvForTest uses t.Setenv. -func TestClearEnvForTest_RemovesAddedVariables(t *testing.T) { +//nolint:paralleltest // ClearEnv uses t.Setenv. +func TestClearEnv_RemovesAddedVariables(t *testing.T) { // The outer clear keeps a value of the key exported in the shell // from making it a variable the inner clear has to put back. - config.ClearEnvForTest(t) + configtest.ClearEnv(t) t.Run("loads a .env file after the clear", func(t *testing.T) { - config.ClearEnvForTest(t) + configtest.ClearEnv(t) path := writeDotEnv(t, dotEnvKey+"=from-dot-env\n") require.NoError(t, config.LoadDotEnvFileForTest(path)) diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 8b11f5f..717377b 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -11,6 +11,7 @@ import ( "go.uber.org/fx" "go.uber.org/fx/fxtest" "sneak.berlin/go/webhooker/internal/config" + "sneak.berlin/go/webhooker/internal/config/configtest" "sneak.berlin/go/webhooker/internal/globals" "sneak.berlin/go/webhooker/internal/logger" ) @@ -70,7 +71,7 @@ func TestEnvironmentConfig(t *testing.T) { t.Run(tt.name, func(t *testing.T) { // Cannot use t.Parallel() here because t.Setenv // is incompatible with parallel subtests. - config.ClearEnvForTest(t) + configtest.ClearEnv(t) if tt.envValue != "" { t.Setenv( @@ -196,7 +197,7 @@ func TestRetentionSweepInterval(t *testing.T) { t.Run(tt.name, func(t *testing.T) { // Cannot use t.Parallel() here because t.Setenv // is incompatible with parallel subtests. - config.ClearEnvForTest(t) + configtest.ClearEnv(t) t.Setenv("WEBHOOKER_ENVIRONMENT", "dev") if tt.set { @@ -335,7 +336,7 @@ func TestSessionIdleTimeout(t *testing.T) { t.Run(tt.name, func(t *testing.T) { // Cannot use t.Parallel() here because t.Setenv // is incompatible with parallel subtests. - config.ClearEnvForTest(t) + configtest.ClearEnv(t) t.Setenv("WEBHOOKER_ENVIRONMENT", "dev") if tt.set { @@ -388,7 +389,7 @@ func TestDefaultDataDir(t *testing.T) { t.Run("env="+name, func(t *testing.T) { // Cannot use t.Parallel() here because t.Setenv // is incompatible with parallel subtests. - config.ClearEnvForTest(t) + configtest.ClearEnv(t) if env != "" { t.Setenv("WEBHOOKER_ENVIRONMENT", env) @@ -433,7 +434,7 @@ func TestDataDirHelper(t *testing.T) { t.Run(name, func(t *testing.T) { // Cannot use t.Parallel() here because t.Setenv // is incompatible with parallel subtests. - config.ClearEnvForTest(t) + configtest.ClearEnv(t) if set != "" { t.Setenv("DATA_DIR", set) @@ -498,7 +499,7 @@ func TestReceiverRateLimit(t *testing.T) { t.Run(tt.name, func(t *testing.T) { // Cannot use t.Parallel() here because t.Setenv // is incompatible with parallel subtests. - config.ClearEnvForTest(t) + configtest.ClearEnv(t) t.Setenv("WEBHOOKER_ENVIRONMENT", "dev") if tt.set { @@ -614,7 +615,7 @@ func TestTrustedProxies(t *testing.T) { t.Run(tt.name, func(t *testing.T) { // Cannot use t.Parallel() here because t.Setenv // is incompatible with parallel subtests. - config.ClearEnvForTest(t) + configtest.ClearEnv(t) t.Setenv("WEBHOOKER_ENVIRONMENT", "dev") if tt.set { @@ -725,7 +726,7 @@ func TestAllowedEgressCIDRs(t *testing.T) { t.Run(tt.name, func(t *testing.T) { // Cannot use t.Parallel() here because t.Setenv // is incompatible with parallel subtests. - config.ClearEnvForTest(t) + configtest.ClearEnv(t) t.Setenv("WEBHOOKER_ENVIRONMENT", "dev") if tt.set { @@ -797,7 +798,7 @@ func TestEgressAllowlistWarning(t *testing.T) { t.Run(tt.name, func(t *testing.T) { // Cannot use t.Parallel() here because t.Setenv // is incompatible with parallel subtests. - config.ClearEnvForTest(t) + configtest.ClearEnv(t) t.Setenv("WEBHOOKER_ENVIRONMENT", config.EnvironmentDev) if tt.allowed != "" { @@ -933,7 +934,7 @@ func TestMetricsAuthConfig(t *testing.T) { t.Run(tt.name, func(t *testing.T) { // Cannot use t.Parallel() here because t.Setenv // is incompatible with parallel subtests. - config.ClearEnvForTest(t) + configtest.ClearEnv(t) if tt.username.set { t.Setenv("METRICS_USERNAME", tt.username.value) diff --git a/internal/config/testing.go b/internal/config/configtest/configtest.go similarity index 85% rename from internal/config/testing.go rename to internal/config/configtest/configtest.go index 3f0b2f0..abaefac 100644 --- a/internal/config/testing.go +++ b/internal/config/configtest/configtest.go @@ -1,4 +1,6 @@ -package config +// Package configtest holds test support for code that reads the +// process environment. +package configtest import ( "os" @@ -6,12 +8,12 @@ import ( "testing" ) -// ClearEnvForTest unsets every variable in the process environment +// ClearEnv unsets every variable in the process environment // for the rest of the test, so a test sees only the variables it sets // itself, not whatever the developer's shell exports. When the test // ends it leaves the environment exactly as it found it: each variable // it unset is put back, and any variable added since is removed. -func ClearEnvForTest(t *testing.T) { +func ClearEnv(t *testing.T) { t.Helper() present := make(map[string]bool) diff --git a/internal/config/dotenv_test.go b/internal/config/dotenv_test.go index b2ccbfa..9a9f073 100644 --- a/internal/config/dotenv_test.go +++ b/internal/config/dotenv_test.go @@ -8,6 +8,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "sneak.berlin/go/webhooker/internal/config" + "sneak.berlin/go/webhooker/internal/config/configtest" ) // dotEnvKey is a throwaway variable name the .env tests write and @@ -39,9 +40,9 @@ func writeDotEnv(t *testing.T, contents string) string { // normally rather than be refused for a file it was never meant to // have. // -//nolint:paralleltest // ClearEnvForTest uses t.Setenv. +//nolint:paralleltest // ClearEnv uses t.Setenv. func TestLoadDotEnv_MissingFileIsFine(t *testing.T) { - config.ClearEnvForTest(t) + configtest.ClearEnv(t) absent := filepath.Join(t.TempDir(), config.DotEnvPath) require.NoError(t, config.LoadDotEnvFileForTest(absent)) @@ -54,9 +55,9 @@ func TestLoadDotEnv_MissingFileIsFine(t *testing.T) { // reaches the environment, which is the whole reason the file is read // at all. // -//nolint:paralleltest // ClearEnvForTest uses t.Setenv. +//nolint:paralleltest // ClearEnv uses t.Setenv. func TestLoadDotEnv_AppliesValues(t *testing.T) { - config.ClearEnvForTest(t) + configtest.ClearEnv(t) path := writeDotEnv(t, "# a comment\n"+dotEnvKey+"=from-dot-env\n") @@ -82,9 +83,9 @@ func TestLoadDotEnv_RealEnvironmentWins(t *testing.T) { // reverts to its default; the process used to start that way with no // log line naming the file at all. // -//nolint:paralleltest // ClearEnvForTest uses t.Setenv. +//nolint:paralleltest // ClearEnv uses t.Setenv. func TestLoadDotEnv_MalformedFileAborts(t *testing.T) { - config.ClearEnvForTest(t) + configtest.ClearEnv(t) path := writeDotEnv( t, malformedDotEnv+dotEnvKey+"=from-dot-env\n", @@ -132,7 +133,7 @@ func TestLoadDotEnv_UnreadableFileAborts(t *testing.T) { // //nolint:paralleltest // t.Chdir moves the whole process. func TestLoadDotEnv_ReadsTheWorkingDirectory(t *testing.T) { - config.ClearEnvForTest(t) + configtest.ClearEnv(t) dir := t.TempDir() require.NoError(t, os.WriteFile( diff --git a/internal/config/env_test.go b/internal/config/env_test.go index 81448c3..067d677 100644 --- a/internal/config/env_test.go +++ b/internal/config/env_test.go @@ -7,6 +7,7 @@ import ( "github.com/stretchr/testify/require" "go.uber.org/fx" "sneak.berlin/go/webhooker/internal/config" + "sneak.berlin/go/webhooker/internal/config/configtest" "sneak.berlin/go/webhooker/internal/globals" "sneak.berlin/go/webhooker/internal/logger" ) @@ -120,7 +121,7 @@ func TestEnvBool(t *testing.T) { t.Run(tt.name, func(t *testing.T) { // Cannot use t.Parallel() here because t.Setenv // is incompatible with parallel subtests. - config.ClearEnvForTest(t) + configtest.ClearEnv(t) if tt.set { t.Setenv(testEnvKey, tt.value) @@ -169,7 +170,7 @@ func runEnvIntCases( t.Run(tt.name, func(t *testing.T) { // Cannot use t.Parallel() here because t.Setenv // is incompatible with parallel subtests. - config.ClearEnvForTest(t) + configtest.ClearEnv(t) if tt.set { t.Setenv(testEnvKey, tt.value) @@ -310,7 +311,7 @@ func TestEnvBindAddress(t *testing.T) { t.Run(tt.name, func(t *testing.T) { // Cannot use t.Parallel() here because t.Setenv // is incompatible with parallel subtests. - config.ClearEnvForTest(t) + configtest.ClearEnv(t) if tt.set { t.Setenv(testEnvKey, tt.value) @@ -476,7 +477,7 @@ func TestNewRejectsBadEnvValues(t *testing.T) { t.Run(tt.name, func(t *testing.T) { // Cannot use t.Parallel() here because t.Setenv // is incompatible with parallel subtests. - config.ClearEnvForTest(t) + configtest.ClearEnv(t) t.Setenv("WEBHOOKER_ENVIRONMENT", "dev") t.Setenv(tt.key, tt.value) @@ -638,7 +639,7 @@ func sentryEnvValueCases() []badEnvValueCase { // break the legitimate unset case: absent variables still get their // documented defaults. func TestNewUsesDefaultsWhenUnset(t *testing.T) { - config.ClearEnvForTest(t) + configtest.ClearEnv(t) t.Setenv("WEBHOOKER_ENVIRONMENT", "dev") cfg, err := buildConfig(t) diff --git a/internal/config/sentry_test.go b/internal/config/sentry_test.go index 871ecb6..d713f62 100644 --- a/internal/config/sentry_test.go +++ b/internal/config/sentry_test.go @@ -6,6 +6,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "sneak.berlin/go/webhooker/internal/config" + "sneak.berlin/go/webhooker/internal/config/configtest" ) // envKeySentryDSN is the variable envSentryDSN reads in production. @@ -100,7 +101,7 @@ func TestEnvSentryDSN(t *testing.T) { t.Run(tt.name, func(t *testing.T) { // Cannot use t.Parallel() here because t.Setenv // is incompatible with parallel subtests. - config.ClearEnvForTest(t) + configtest.ClearEnv(t) if tt.set { t.Setenv(envKeySentryDSN, tt.value) diff --git a/internal/database/databasetest/databasetest.go b/internal/database/databasetest/databasetest.go new file mode 100644 index 0000000..f999355 --- /dev/null +++ b/internal/database/databasetest/databasetest.go @@ -0,0 +1,55 @@ +// Package databasetest builds a WebhookDBManager for tests in other +// packages. +package databasetest + +import ( + "log/slog" + "os" + "testing" + + "github.com/stretchr/testify/require" + "go.uber.org/fx/fxtest" + "sneak.berlin/go/webhooker/internal/config" + "sneak.berlin/go/webhooker/internal/database" +) + +// NewWebhookDBManager creates a WebhookDBManager backed by the given +// data directory, logging at DEBUG to standard error. +func NewWebhookDBManager( + t *testing.T, dataDir string, +) *database.WebhookDBManager { + t.Helper() + + return NewWebhookDBManagerWithLogger( + t, + dataDir, + slog.New(slog.NewTextHandler( + os.Stderr, + &slog.HandlerOptions{Level: slog.LevelDebug}, + )), + ) +} + +// NewWebhookDBManagerWithLogger is NewWebhookDBManager with the +// logger supplied by the caller. The per-webhook databases this manager +// opens hand that logger to gormlog, so a test that needs to see the SQL +// the service emits can capture it. +// +// It is built through database.NewWebhookDBManager on a lifecycle that +// is never started, so nothing closes its databases but the caller. +func NewWebhookDBManagerWithLogger( + t *testing.T, dataDir string, log *slog.Logger, +) *database.WebhookDBManager { + t.Helper() + + mgr, err := database.NewWebhookDBManager( + fxtest.NewLifecycle(t), + database.WebhookDBManagerParams{ + Config: &config.Config{DataDir: dataDir}, + Logger: log, + }, + ) + require.NoError(t, err) + + return mgr +} diff --git a/internal/database/event_db_isolation_test.go b/internal/database/event_db_isolation_test.go index 66c28e0..04d6fb4 100644 --- a/internal/database/event_db_isolation_test.go +++ b/internal/database/event_db_isolation_test.go @@ -13,6 +13,7 @@ import ( "github.com/stretchr/testify/require" _ "modernc.org/sqlite" "sneak.berlin/go/webhooker/internal/database" + "sneak.berlin/go/webhooker/internal/database/databasetest" ) // testDataDirPerm is the mode the test data directory is created @@ -133,7 +134,7 @@ func TestOpenPurgesLeakedTargetRows(t *testing.T) { // Create the file the way the application does, so the targets // table has exactly the shape AutoMigrate gives it, then write // a leaked row into it the way the association upsert did. - initial := database.NewTestWebhookDBManager(dataDir) + initial := databasetest.NewWebhookDBManager(t, dataDir) _, err := initial.GetDB(webhookID) require.NoError(t, err) @@ -156,7 +157,7 @@ func TestOpenPurgesLeakedTargetRows(t *testing.T) { clearEventDBSweptMarker(t, seed) require.NoError(t, seed.Close()) - mgr := database.NewTestWebhookDBManager(dataDir) + mgr := databasetest.NewWebhookDBManager(t, dataDir) _, err = mgr.GetDB(webhookID) require.NoError(t, err) @@ -172,7 +173,7 @@ func TestOpenPurgesLeakedTargetRows(t *testing.T) { // Idempotent: a second open leaves it at zero and does not // error. - again := database.NewTestWebhookDBManager(dataDir) + again := databasetest.NewWebhookDBManager(t, dataDir) _, err = again.GetDB(webhookID) require.NoError(t, err) @@ -195,7 +196,7 @@ func TestOpenPurgeRemovesCredentialBytes(t *testing.T) { webhookID := uuid.New().String() credential := "T00000000/B00000000/" + uuid.New().String() - initial := database.NewTestWebhookDBManager(dataDir) + initial := databasetest.NewWebhookDBManager(t, dataDir) _, err := initial.GetDB(webhookID) require.NoError(t, err) @@ -230,7 +231,7 @@ func TestOpenPurgeRemovesCredentialBytes(t *testing.T) { "seeded credential is not in the file, so this test proves nothing", ) - mgr := database.NewTestWebhookDBManager(dataDir) + mgr := databasetest.NewWebhookDBManager(t, dataDir) _, err = mgr.GetDB(webhookID) require.NoError(t, err) @@ -258,7 +259,7 @@ func TestOpenRevacuumsAfterIncompleteSweep(t *testing.T) { webhookID := uuid.New().String() credential := "T00000000/B00000000/" + uuid.New().String() - initial := database.NewTestWebhookDBManager(dataDir) + initial := databasetest.NewWebhookDBManager(t, dataDir) _, err := initial.GetDB(webhookID) require.NoError(t, err) @@ -298,7 +299,7 @@ func TestOpenRevacuumsAfterIncompleteSweep(t *testing.T) { "test proves nothing", ) - mgr := database.NewTestWebhookDBManager(dataDir) + mgr := databasetest.NewWebhookDBManager(t, dataDir) _, err = mgr.GetDB(webhookID) require.NoError(t, err) @@ -325,7 +326,7 @@ func TestOpenSkipsSweptDatabase(t *testing.T) { dataDir := eventDBDataDir(t) webhookID := uuid.New().String() - mgr := database.NewTestWebhookDBManager(dataDir) + mgr := databasetest.NewWebhookDBManager(t, dataDir) _, err := mgr.GetDB(webhookID) require.NoError(t, err) @@ -347,7 +348,7 @@ func TestOpenSkipsSweptDatabase(t *testing.T) { require.NoError(t, err) require.NoError(t, marked.Close()) - again := database.NewTestWebhookDBManager(dataDir) + again := databasetest.NewWebhookDBManager(t, dataDir) _, err = again.GetDB(webhookID) require.NoError(t, err) @@ -378,7 +379,7 @@ func TestOpenSucceedsWithoutTargetsTable(t *testing.T) { require.NoError(t, err) require.NoError(t, seed.Close()) - mgr := database.NewTestWebhookDBManager(dataDir) + mgr := databasetest.NewWebhookDBManager(t, dataDir) db, err := mgr.GetDB(webhookID) require.NoError(t, err) @@ -396,7 +397,7 @@ func TestEventDBCreateOmitsAssociations(t *testing.T) { dataDir := eventDBDataDir(t) webhookID := uuid.New().String() - mgr := database.NewTestWebhookDBManager(dataDir) + mgr := databasetest.NewWebhookDBManager(t, dataDir) db, err := mgr.GetDB(webhookID) require.NoError(t, err) diff --git a/internal/database/retention_test.go b/internal/database/retention_test.go index 7097677..710ffc2 100644 --- a/internal/database/retention_test.go +++ b/internal/database/retention_test.go @@ -51,7 +51,7 @@ func setupRetentionTest(t *testing.T) *retentionTestEnv { mgr, err := database.NewWebhookDBManager( lc, - database.WebhookDBManagerParams{Config: cfg, Logger: l}, + database.WebhookDBManagerParams{Config: cfg, Logger: l.Get()}, ) require.NoError(t, err) diff --git a/internal/database/testing.go b/internal/database/testing.go deleted file mode 100644 index 72c8816..0000000 --- a/internal/database/testing.go +++ /dev/null @@ -1,47 +0,0 @@ -package database - -import ( - "log/slog" - "os" - - "gorm.io/gorm" -) - -// NewTestDatabase creates a Database wrapper around a pre-opened *gorm.DB. -// Intended for use in tests that need a *database.Database without the -// full fx lifecycle. The caller is responsible for closing the underlying -// sql.DB connection. -func NewTestDatabase(db *gorm.DB) *Database { - return &Database{ - db: db, - log: slog.New(slog.NewTextHandler( - os.Stderr, - &slog.HandlerOptions{Level: slog.LevelDebug}, - )), - } -} - -// NewTestWebhookDBManager creates a WebhookDBManager backed by the given -// data directory. Intended for use in tests without the fx lifecycle. -func NewTestWebhookDBManager(dataDir string) *WebhookDBManager { - return NewTestWebhookDBManagerWithLogger( - dataDir, - slog.New(slog.NewTextHandler( - os.Stderr, - &slog.HandlerOptions{Level: slog.LevelDebug}, - )), - ) -} - -// NewTestWebhookDBManagerWithLogger is NewTestWebhookDBManager with the -// logger supplied by the caller. The per-webhook databases this manager -// opens hand that logger to gormlog, so a test that needs to see the SQL -// the service emits can capture it. -func NewTestWebhookDBManagerWithLogger( - dataDir string, log *slog.Logger, -) *WebhookDBManager { - return &WebhookDBManager{ - dataDir: dataDir, - log: log, - } -} diff --git a/internal/database/webhook_db_manager.go b/internal/database/webhook_db_manager.go index 023e997..6dc17a9 100644 --- a/internal/database/webhook_db_manager.go +++ b/internal/database/webhook_db_manager.go @@ -15,7 +15,6 @@ import ( "sneak.berlin/go/webhooker/internal/config" "sneak.berlin/go/webhooker/internal/datadir" "sneak.berlin/go/webhooker/internal/gormlog" - "sneak.berlin/go/webhooker/internal/logger" ) // WebhookDBManagerParams holds the fx dependencies for @@ -24,7 +23,7 @@ type WebhookDBManagerParams struct { fx.In Config *config.Config - Logger *logger.Logger + Logger *slog.Logger } // errInvalidCachedDBType indicates a type assertion failure @@ -70,7 +69,7 @@ func NewWebhookDBManager( ) (*WebhookDBManager, error) { m := &WebhookDBManager{ dataDir: params.Config.DataDir, - log: params.Logger.Get(), + log: params.Logger, } // Create data directory if it doesn't exist. datadir.DirPerm is the diff --git a/internal/database/webhook_db_manager_test.go b/internal/database/webhook_db_manager_test.go index b5954be..3c914d1 100644 --- a/internal/database/webhook_db_manager_test.go +++ b/internal/database/webhook_db_manager_test.go @@ -18,6 +18,7 @@ import ( "gorm.io/gorm" "sneak.berlin/go/webhooker/internal/config" "sneak.berlin/go/webhooker/internal/database" + "sneak.berlin/go/webhooker/internal/database/databasetest" "sneak.berlin/go/webhooker/internal/globals" "sneak.berlin/go/webhooker/internal/logger" ) @@ -50,7 +51,7 @@ func setupTestWebhookDBManager( lc, database.WebhookDBManagerParams{ Config: cfg, - Logger: l, + Logger: l.Get(), }, ) require.NoError(t, err) @@ -117,7 +118,8 @@ func TestWebhookDBManager_ConcurrentFirstTouchOpensOnce(t *testing.T) { var logs bytes.Buffer - mgr := database.NewTestWebhookDBManagerWithLogger( + mgr := databasetest.NewWebhookDBManagerWithLogger( + t, t.TempDir(), slog.New(slog.NewTextHandler(&logs, nil)), ) @@ -307,7 +309,8 @@ func TestWebhookDBManager_LostDatabaseIsLogged(t *testing.T) { var logs bytes.Buffer - mgr := database.NewTestWebhookDBManagerWithLogger( + mgr := databasetest.NewWebhookDBManagerWithLogger( + t, t.TempDir(), slog.New(slog.NewTextHandler(&logs, nil)), ) diff --git a/internal/delivery/archive_sweeper_test.go b/internal/delivery/archive_sweeper_test.go index 5563349..1630ae5 100644 --- a/internal/delivery/archive_sweeper_test.go +++ b/internal/delivery/archive_sweeper_test.go @@ -21,6 +21,7 @@ import ( "gorm.io/gorm/clause" _ "modernc.org/sqlite" // Pure Go SQLite driver. "sneak.berlin/go/webhooker/internal/database" + "sneak.berlin/go/webhooker/internal/database/databasetest" "sneak.berlin/go/webhooker/internal/delivery" "sneak.berlin/go/webhooker/internal/gormlog" ) @@ -59,29 +60,14 @@ func setupArchiveTest(t *testing.T) *archiveEnv { dataDir := t.TempDir() log := archiveTestLogger() - sqlDB, err := sql.Open( - "sqlite", - fmt.Sprintf( - "file:%s?mode=rwc", - filepath.Join(dataDir, "main.db"), - ), - ) + mainDB, err := database.Open(dataDir, slog.New(slog.DiscardHandler)) require.NoError(t, err) - t.Cleanup(func() { _ = sqlDB.Close() }) - - gdb, err := gorm.Open( - sqlite.Dialector{Conn: sqlDB}, - &gorm.Config{Logger: gormlog.New(slog.New(slog.DiscardHandler))}, - ) - require.NoError(t, err) - - mainDB := database.NewTestDatabase(gdb) - require.NoError(t, mainDB.Migrate()) + t.Cleanup(func() { _ = mainDB.Close() }) eng := delivery.NewTestEngineWithDB( mainDB, - database.NewTestWebhookDBManager(dataDir), + databasetest.NewWebhookDBManager(t, dataDir), log, &http.Client{Timeout: 5 * time.Second}, 1, diff --git a/internal/delivery/engine_integration_test.go b/internal/delivery/engine_integration_test.go index cc08753..115576f 100644 --- a/internal/delivery/engine_integration_test.go +++ b/internal/delivery/engine_integration_test.go @@ -10,7 +10,6 @@ import ( "net/http" "net/http/httptest" "os" - "path/filepath" "strings" "sync/atomic" "testing" @@ -19,12 +18,11 @@ import ( "github.com/google/uuid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "gorm.io/driver/sqlite" "gorm.io/gorm" _ "modernc.org/sqlite" "sneak.berlin/go/webhooker/internal/database" + "sneak.berlin/go/webhooker/internal/database/databasetest" "sneak.berlin/go/webhooker/internal/delivery" - "sneak.berlin/go/webhooker/internal/gormlog" ) // iSetup holds common integration test dependencies. @@ -45,12 +43,12 @@ func newISetup(t *testing.T) iSetup { wDB := iSeedWebhookDB(t, dbMgr, wID) return iSetup{ - MainDB: mainDB, + MainDB: mainDB.DB(), DBMgr: dbMgr, WebhookID: wID, WebhookDB: wDB, Engine: delivery.NewTestEngineWithDB( - database.NewTestDatabase(mainDB), + mainDB, dbMgr, slog.New(slog.NewTextHandler( os.Stderr, @@ -64,35 +62,16 @@ func newISetup(t *testing.T) iSetup { } } -func iMainDB(t *testing.T) *gorm.DB { +// iMainDB opens a main database through database.Open, the way the +// service opens it, so these tests cannot pass against journal and +// locking settings production does not use. +func iMainDB(t *testing.T) *database.Database { t.Helper() - dbPath := filepath.Join( - t.TempDir(), "main-test.db", - ) - - // Opened the way the service opens the main database, so these - // tests cannot pass against journal and locking settings - // production does not use. - sqlDB, err := database.OpenSQLite( - dbPath, database.SQLiteModeCreate, - ) + db, err := database.Open(t.TempDir(), slog.New(slog.DiscardHandler)) require.NoError(t, err) - t.Cleanup(func() { _ = sqlDB.Close() }) - - db, err := gorm.Open( - sqlite.Dialector{Conn: sqlDB}, - &gorm.Config{Logger: gormlog.New(slog.New(slog.DiscardHandler))}, - ) - require.NoError(t, err) - - require.NoError(t, db.AutoMigrate( - &database.Webhook{}, - &database.Target{}, - &database.User{}, - &database.Setting{}, - )) + t.Cleanup(func() { _ = db.Close() }) return db } @@ -102,7 +81,7 @@ func iDBManager( ) *database.WebhookDBManager { t.Helper() - return database.NewTestWebhookDBManager(t.TempDir()) + return databasetest.NewWebhookDBManager(t, t.TempDir()) } func iSeedWebhookDB( @@ -1154,17 +1133,17 @@ func TestRecoverInFlight_ReportsAMissingWebhookDatabase(t *testing.T) { mainDB := iMainDB(t) webhookID := uuid.New().String() - iCreateWebhook(t, mainDB, webhookID, "lost-database") + iCreateWebhook(t, mainDB.DB(), webhookID, "lost-database") var logs bytes.Buffer - dbMgr := database.NewTestWebhookDBManagerWithLogger( - t.TempDir(), slog.New(slog.NewTextHandler(&logs, nil)), + dbMgr := databasetest.NewWebhookDBManagerWithLogger( + t, t.TempDir(), slog.New(slog.NewTextHandler(&logs, nil)), ) t.Cleanup(func() { _ = dbMgr.CloseAll() }) engine := delivery.NewTestEngineWithDB( - database.NewTestDatabase(mainDB), dbMgr, + mainDB, dbMgr, slog.New(slog.DiscardHandler), &http.Client{Timeout: 5 * time.Second}, 1, ) @@ -1188,14 +1167,14 @@ func TestRecoverInFlight_SkipsAWebhookDeletedAfterTheListIsRead( mainDB := iMainDB(t) webhookID := uuid.New().String() - iCreateWebhook(t, mainDB, webhookID, "deleted-during-recovery") + iCreateWebhook(t, mainDB.DB(), webhookID, "deleted-during-recovery") // The first query to return is recovery's read of the list of // webhooks. Deleting the webhook right after it puts the delete // between that read and the opening of the webhook's database. deleted := false - require.NoError(t, mainDB.Callback().Query().After("gorm:query"). + require.NoError(t, mainDB.DB().Callback().Query().After("gorm:query"). Register("delete-after-list", func(*gorm.DB) { if deleted { return @@ -1203,16 +1182,16 @@ func TestRecoverInFlight_SkipsAWebhookDeletedAfterTheListIsRead( deleted = true - require.NoError(t, mainDB.Delete( + require.NoError(t, mainDB.DB().Delete( &database.Webhook{}, "id = ?", webhookID, ).Error) })) - dbMgr := database.NewTestWebhookDBManager(t.TempDir()) + dbMgr := databasetest.NewWebhookDBManager(t, t.TempDir()) t.Cleanup(func() { _ = dbMgr.CloseAll() }) engine := delivery.NewTestEngineWithDB( - database.NewTestDatabase(mainDB), dbMgr, + mainDB, dbMgr, slog.New(slog.DiscardHandler), &http.Client{Timeout: 5 * time.Second}, 1, ) diff --git a/internal/delivery/queue_depth_gormlog_test.go b/internal/delivery/queue_depth_gormlog_test.go index 8c89734..51ccd75 100644 --- a/internal/delivery/queue_depth_gormlog_test.go +++ b/internal/delivery/queue_depth_gormlog_test.go @@ -5,7 +5,6 @@ import ( "context" "log/slog" "net/http" - "path/filepath" "strings" "sync" "testing" @@ -14,11 +13,9 @@ import ( "github.com/google/uuid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "gorm.io/driver/sqlite" - "gorm.io/gorm" "sneak.berlin/go/webhooker/internal/database" + "sneak.berlin/go/webhooker/internal/database/databasetest" "sneak.berlin/go/webhooker/internal/delivery" - "sneak.berlin/go/webhooker/internal/gormlog" ) // qdAggregateMarker identifies the queue-depth aggregate in the @@ -49,27 +46,13 @@ func (q *qdSyncBuf) String() string { // qdMainDB opens a main database whose GORM logger is the service's // adapter, writing through log. -func qdMainDB(t *testing.T, log *slog.Logger) *gorm.DB { +func qdMainDB(t *testing.T, log *slog.Logger) *database.Database { t.Helper() - sqlDB, err := database.OpenSQLite( - filepath.Join(t.TempDir(), "main-gormlog.db"), - database.SQLiteModeCreate, - ) + db, err := database.Open(t.TempDir(), log) require.NoError(t, err) - t.Cleanup(func() { _ = sqlDB.Close() }) - - db, err := gorm.Open( - sqlite.Dialector{Conn: sqlDB}, - &gorm.Config{Logger: gormlog.New(log)}, - ) - require.NoError(t, err) - - require.NoError(t, db.AutoMigrate( - &database.Webhook{}, - &database.Target{}, - )) + t.Cleanup(func() { _ = db.Close() }) return db } @@ -106,18 +89,18 @@ func TestQueueDepthSample_LogsNoBoundValue(t *testing.T) { )) mainDB := qdMainDB(t, log) - dbMgr := database.NewTestWebhookDBManagerWithLogger( - t.TempDir(), log, + dbMgr := databasetest.NewWebhookDBManagerWithLogger( + t, t.TempDir(), log, ) webhookID := uuid.New().String() webhookDB := iSeedWebhookDB(t, dbMgr, webhookID) - iCreateWebhook(t, mainDB, webhookID, "queue-depth-gormlog") + iCreateWebhook(t, mainDB.DB(), webhookID, "queue-depth-gormlog") targetID := uuid.New().String() - iCreateTarget(t, mainDB, targetID, webhookID, + iCreateTarget(t, mainDB.DB(), targetID, webhookID, "queue-depth-gormlog-target", database.TargetTypeHTTP, iHTTPConfig("https://example.com/hook"), 3, ) @@ -136,7 +119,7 @@ func TestQueueDepthSample_LogsNoBoundValue(t *testing.T) { ) engine := delivery.NewTestEngineWithDB( - database.NewTestDatabase(mainDB), + mainDB, dbMgr, log, &http.Client{Timeout: 5 * time.Second}, diff --git a/internal/delivery/target_database_rotation_test.go b/internal/delivery/target_database_rotation_test.go index 8bfb7c3..667a23b 100644 --- a/internal/delivery/target_database_rotation_test.go +++ b/internal/delivery/target_database_rotation_test.go @@ -10,6 +10,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "sneak.berlin/go/webhooker/internal/database" + "sneak.berlin/go/webhooker/internal/database/databasetest" "sneak.berlin/go/webhooker/internal/delivery" ) @@ -335,7 +336,7 @@ func TestArchivePathAt(t *testing.T) { t.Parallel() dataDir := t.TempDir() - dbMgr := database.NewTestWebhookDBManager(dataDir) + dbMgr := databasetest.NewWebhookDBManager(t, dataDir) webhook := &database.Webhook{ BaseModel: database.BaseModel{ID: "wh-id"}, Name: "Orders", } diff --git a/internal/gormlog/firstboot_test.go b/internal/gormlog/firstboot_test.go index c511eb4..337aed5 100644 --- a/internal/gormlog/firstboot_test.go +++ b/internal/gormlog/firstboot_test.go @@ -3,6 +3,7 @@ package gormlog_test import ( "context" "database/sql" + "log/slog" "os" "path/filepath" "testing" @@ -13,6 +14,7 @@ import ( "go.uber.org/fx/fxtest" _ "modernc.org/sqlite" // Pure Go SQLite driver. "sneak.berlin/go/webhooker/internal/config" + "sneak.berlin/go/webhooker/internal/config/configtest" "sneak.berlin/go/webhooker/internal/database" "sneak.berlin/go/webhooker/internal/globals" "sneak.berlin/go/webhooker/internal/logger" @@ -128,7 +130,7 @@ func readFirstBootSecrets( func bootAtDebug(t *testing.T, dataDir string) string { t.Helper() - config.ClearEnvForTest(t) + configtest.ClearEnv(t) t.Setenv("DEBUG", "true") t.Setenv("DATA_DIR", dataDir) @@ -145,6 +147,7 @@ func bootAtDebug(t *testing.T, dataDir string) string { fx.Provide( globals.New, logger.New, + func(l *logger.Logger) *slog.Logger { return l.Get() }, config.New, database.New, session.New, diff --git a/internal/handlers/handlers_test.go b/internal/handlers/handlers_test.go index a834342..2cbeaf1 100644 --- a/internal/handlers/handlers_test.go +++ b/internal/handlers/handlers_test.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "html/template" + "log/slog" "net/http" "net/http/httptest" "sync" @@ -249,6 +250,7 @@ func newTestAppWithConfig( fx.Provide( globals.New, logger.New, + func(l *logger.Logger) *slog.Logger { return l.Get() }, func() *config.Config { return cfg }, database.New, database.NewWebhookDBManager, diff --git a/internal/handlers/profile_test.go b/internal/handlers/profile_test.go index 8b8be2d..8468358 100644 --- a/internal/handlers/profile_test.go +++ b/internal/handlers/profile_test.go @@ -15,7 +15,7 @@ import ( "sneak.berlin/go/webhooker/internal/database" "sneak.berlin/go/webhooker/internal/handlers" "sneak.berlin/go/webhooker/internal/logger" - "sneak.berlin/go/webhooker/internal/middleware" + "sneak.berlin/go/webhooker/internal/middleware/middlewaretest" "sneak.berlin/go/webhooker/internal/session" ) @@ -135,7 +135,7 @@ func TestUserRoute_Unauthenticated_RedirectedByMiddleware(t *testing.T) { t.Cleanup(app.RequireStop) - mw := middleware.NewForTest(log.Get(), cfg, sess) + mw := middlewaretest.New(t, log.Get(), cfg, sess) var handlerReached bool diff --git a/internal/handlers/target_create_query_test.go b/internal/handlers/target_create_query_test.go index d8c542c..3eb3914 100644 --- a/internal/handlers/target_create_query_test.go +++ b/internal/handlers/target_create_query_test.go @@ -15,7 +15,7 @@ import ( "github.com/stretchr/testify/require" "sneak.berlin/go/webhooker/internal/config" "sneak.berlin/go/webhooker/internal/database" - "sneak.berlin/go/webhooker/internal/middleware" + "sneak.berlin/go/webhooker/internal/middleware/middlewaretest" ) // targetSecretSegments are the path segments of an incoming-webhook @@ -65,7 +65,8 @@ func postTargetCreate( t.Helper() logBuf := new(bytes.Buffer) - mw := middleware.NewForTest( + mw := middlewaretest.New( + t, slog.New(slog.NewJSONHandler( logBuf, &slog.HandlerOptions{Level: slog.LevelInfo}, )), diff --git a/internal/handlers/target_download_test.go b/internal/handlers/target_download_test.go index 755e6bc..f52b6c7 100644 --- a/internal/handlers/target_download_test.go +++ b/internal/handlers/target_download_test.go @@ -24,7 +24,7 @@ import ( "sneak.berlin/go/webhooker/internal/config" "sneak.berlin/go/webhooker/internal/database" "sneak.berlin/go/webhooker/internal/delivery" - "sneak.berlin/go/webhooker/internal/middleware" + "sneak.berlin/go/webhooker/internal/middleware/middlewaretest" ) // errClientGone is the write failure of a client that has gone away. @@ -310,7 +310,8 @@ func limitedServer( const sendBuffer = 4 << 10 logBuf := new(bytes.Buffer) - mw := middleware.NewForTest( + mw := middlewaretest.New( + t, slog.New(slog.NewJSONHandler(logBuf, nil)), &config.Config{Environment: config.EnvironmentDev}, nil, diff --git a/internal/middleware/accesslog_test.go b/internal/middleware/accesslog_test.go index f4b8639..4a6cb4a 100644 --- a/internal/middleware/accesslog_test.go +++ b/internal/middleware/accesslog_test.go @@ -16,6 +16,7 @@ import ( "github.com/stretchr/testify/require" "sneak.berlin/go/webhooker/internal/config" "sneak.berlin/go/webhooker/internal/middleware" + "sneak.berlin/go/webhooker/internal/middleware/middlewaretest" ) // floodRequests is the number of distinct invented paths each flood @@ -83,7 +84,7 @@ func capturingMiddleware(t *testing.T) (*middleware.Middleware, *bytes.Buffer) { TrustedProxies: trustedProxies("192.0.2.1/32"), } - return middleware.NewForTest(log, cfg, nil), buf + return middlewaretest.New(t, log, cfg, nil), buf } // capturingTextMiddleware is capturingMiddleware for the other handler @@ -107,7 +108,7 @@ func capturingTextMiddleware( TrustedProxies: trustedProxies("192.0.2.1/32"), } - return middleware.NewForTest(log, cfg, nil), buf + return middlewaretest.New(t, log, cfg, nil), buf } // accessLogRouter mirrors the production route shapes that an diff --git a/internal/middleware/clientip_test.go b/internal/middleware/clientip_test.go index 82450a7..06ab297 100644 --- a/internal/middleware/clientip_test.go +++ b/internal/middleware/clientip_test.go @@ -12,6 +12,7 @@ import ( "github.com/stretchr/testify/require" "sneak.berlin/go/webhooker/internal/config" "sneak.berlin/go/webhooker/internal/middleware" + "sneak.berlin/go/webhooker/internal/middleware/middlewaretest" ) const ( @@ -133,8 +134,8 @@ func clientLogLines( TrustedProxies: trustedProxies(trustedProxyCIDR), } - m := middleware.NewForTest( - log, cfg, newTestSessionManager(cfg, log, nil), + m := middlewaretest.New( + t, log, cfg, newTestSessionManager(t, cfg), ) handler := m.Logging()(site.build(m)) diff --git a/internal/middleware/logbound_test.go b/internal/middleware/logbound_test.go index 47b8416..1690564 100644 --- a/internal/middleware/logbound_test.go +++ b/internal/middleware/logbound_test.go @@ -41,6 +41,7 @@ import ( "github.com/stretchr/testify/require" "sneak.berlin/go/webhooker/internal/config" "sneak.berlin/go/webhooker/internal/middleware" + "sneak.berlin/go/webhooker/internal/middleware/middlewaretest" ) // bodyLimitBytes is the MaxBodySize cap these tests install. Any @@ -155,9 +156,9 @@ func capturingBoundMiddleware( ReceiverRateLimit: receiverLimitPerMinute, } - sess := newTestSessionManager(cfg, log, nil) + sess := newTestSessionManager(t, cfg) - return middleware.NewForTest(log, cfg, sess), buf + return middlewaretest.New(t, log, cfg, sess), buf } // unreachable is a next-handler that fails the test if the middleware diff --git a/internal/middleware/metrics.go b/internal/middleware/metrics.go index af092e0..1479590 100644 --- a/internal/middleware/metrics.go +++ b/internal/middleware/metrics.go @@ -151,7 +151,7 @@ var _ httpmetrics.Recorder = boundedLabelRecorder{} // Metrics returns middleware that records Prometheus HTTP metrics // with the Middleware's one recorder, which New builds on the registry -// the /metrics route serves and NewForTest on a registry of its own. +// it is given: in the application, the one the /metrics route serves. // Every call reuses that recorder, so any number of routers can // install it. func (s *Middleware) Metrics() func(http.Handler) http.Handler { diff --git a/internal/middleware/metrics_test.go b/internal/middleware/metrics_test.go index 3cea24a..169be6b 100644 --- a/internal/middleware/metrics_test.go +++ b/internal/middleware/metrics_test.go @@ -16,6 +16,7 @@ import ( "github.com/stretchr/testify/require" "sneak.berlin/go/webhooker/internal/config" "sneak.berlin/go/webhooker/internal/middleware" + "sneak.berlin/go/webhooker/internal/middleware/middlewaretest" ) const ( @@ -70,8 +71,8 @@ func metricsTestRouter( Environment: "prod", ReceiverRateLimit: receiverLimit, } - m := middleware.NewForTest( - log, cfg, newTestSessionManager(cfg, log, nil), + m := middlewaretest.New( + t, log, cfg, newTestSessionManager(t, cfg), ) reg := prometheus.NewRegistry() @@ -455,11 +456,11 @@ func TestMetrics_StatusAndSizeStillRecorded(t *testing.T) { ) } -// TestMetrics_WorksOnNewForTestMiddleware pins that a Middleware built -// by NewForTest has a recorder of its own: its Metrics() serves a -// request instead of panicking, and a second one does not collide -// with the first. -func TestMetrics_WorksOnNewForTestMiddleware(t *testing.T) { +// TestMetrics_WorksOnMiddlewaretestNew pins that a Middleware built +// by middlewaretest.New has a recorder of its own: its Metrics() +// serves a request instead of panicking, and a second one does not +// collide with the first. +func TestMetrics_WorksOnMiddlewaretestNew(t *testing.T) { t.Parallel() log := slog.New(slog.DiscardHandler) @@ -469,7 +470,7 @@ func TestMetrics_WorksOnNewForTestMiddleware(t *testing.T) { }) for range 2 { - h := middleware.NewForTest(log, cfg, nil).Metrics()(ok) + h := middlewaretest.New(t, log, cfg, nil).Metrics()(ok) req := httptest.NewRequestWithContext( t.Context(), http.MethodGet, okRoute, nil, diff --git a/internal/middleware/middleware.go b/internal/middleware/middleware.go index 930abbf..e70bfdd 100644 --- a/internal/middleware/middleware.go +++ b/internal/middleware/middleware.go @@ -22,7 +22,6 @@ import ( "sneak.berlin/go/webhooker/internal/config" "sneak.berlin/go/webhooker/internal/globals" "sneak.berlin/go/webhooker/internal/logfield" - "sneak.berlin/go/webhooker/internal/logger" "sneak.berlin/go/webhooker/internal/session" ) @@ -155,7 +154,7 @@ const ( type MiddlewareParams struct { fx.In - Logger *logger.Logger + Logger *slog.Logger Globals *globals.Globals Config *config.Config Session *session.Session @@ -169,12 +168,10 @@ type Middleware struct { params *MiddlewareParams session *session.Session - // metricsRecorder records the inbound HTTP metrics. New builds - // it on the registry /metrics serves, NewForTest on a registry - // of its own. Either way it is built once per Middleware and - // Metrics reuses it, because building it registers its - // collectors, and a second registration on the same registry - // panics. + // metricsRecorder records the inbound HTTP metrics on + // params.Registry. It is built once per Middleware and Metrics + // reuses it, because building it registers its collectors, and a + // second registration on the same registry panics. metricsRecorder httpmetrics.Recorder // loginGuard counts failed credential verifications and bounds @@ -193,7 +190,7 @@ func New( ) (*Middleware, error) { s := new(Middleware) s.params = ¶ms - s.log = params.Logger.Get() + s.log = params.Logger s.session = params.Session s.metricsRecorder = prommetrics.NewRecorder( prommetrics.Config{Registry: params.Registry}, diff --git a/internal/middleware/middleware_test.go b/internal/middleware/middleware_test.go index 4482232..717f3d4 100644 --- a/internal/middleware/middleware_test.go +++ b/internal/middleware/middleware_test.go @@ -14,36 +14,34 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "go.uber.org/fx/fxtest" "sneak.berlin/go/webhooker/internal/config" + "sneak.berlin/go/webhooker/internal/database" "sneak.berlin/go/webhooker/internal/middleware" + "sneak.berlin/go/webhooker/internal/middleware/middlewaretest" "sneak.berlin/go/webhooker/internal/session" ) const testKeySize = 32 // testMiddleware creates a Middleware with minimal dependencies -// for testing. It uses a real session.Session backed by an -// in-memory cookie store. +// for testing. It uses a real session.Session. func testMiddleware( t *testing.T, env string, ) (*middleware.Middleware, *session.Session) { t.Helper() - m, s, _ := testMiddlewareWithSessionClock(t, env, 0, nil) - - return m, s + return testMiddlewareWithIdleTimeout(t, env, 0) } -// testMiddlewareWithSessionClock is testMiddleware with a -// configurable session idle timeout and a manually advanced clock, -// for the session-expiry tests. A nil clock uses the real one. -func testMiddlewareWithSessionClock( +// testMiddlewareWithIdleTimeout is testMiddleware with a +// configurable session idle timeout, for the session-expiry tests. +func testMiddlewareWithIdleTimeout( t *testing.T, env string, idleTimeout time.Duration, - clock *fakeClock, -) (*middleware.Middleware, *session.Session, *fakeClock) { +) (*middleware.Middleware, *session.Session) { t.Helper() log := slog.New(slog.NewTextHandler( @@ -56,59 +54,44 @@ func testMiddlewareWithSessionClock( SessionIdleTimeout: idleTimeout, } - sessManager := newTestSessionManager(cfg, log, clock) + sessManager := newTestSessionManager(t, cfg) - m := middleware.NewForTest(log, cfg, sessManager) + m := middlewaretest.New(t, log, cfg, sessManager) - return m, sessManager, clock + return m, sessManager } // newTestSessionManager builds the real session.Session the -// middleware tests run against: an in-memory cookie store with a -// known key, and optionally a manually advanced clock. +// middleware tests run against, through session.New, with its key +// in a main database of its own. func newTestSessionManager( + t *testing.T, cfg *config.Config, - log *slog.Logger, - clock *fakeClock, ) *session.Session { - key := make([]byte, testKeySize) + t.Helper() - for i := range key { - key[i] = byte(i) - } + discard := slog.New(slog.DiscardHandler) - store := session.NewStore(key) + db, err := database.Open(t.TempDir(), discard) + require.NoError(t, err) - var now func() time.Time + t.Cleanup(func() { _ = db.Close() }) - if clock != nil { - now = clock.Now - } + lc := fxtest.NewLifecycle(t) - return session.NewForTest(store, cfg, log, key, now) -} + sessManager, err := session.New(lc, session.Params{ + Config: cfg, + Database: db, + Logger: discard, + }) + require.NoError(t, err) -// fakeClock is a manually advanced clock, so session expiry can be -// tested without sleeping. -type fakeClock struct { - t time.Time -} + // The start hook reads the key from db and builds the cookie + // store. + lc.RequireStart() + t.Cleanup(lc.RequireStop) -func (c *fakeClock) Now() time.Time { - return c.t -} - -func (c *fakeClock) Advance(d time.Duration) { - c.t = c.t.Add(d) -} - -// newFakeClock returns a clock started at a fixed instant. -func newFakeClock() *fakeClock { - return &fakeClock{ - t: time.Date( - 2026, time.January, 2, 3, 4, 5, 0, time.UTC, - ), - } + return sessManager } // --- Logging Middleware Tests --- @@ -583,6 +566,40 @@ func sessionCookies( return out } +// aged re-issues the session cookie in cookies with both of its +// timestamps moved back by d: the cookie as it stands once d has +// passed, so session expiry can be tested without sleeping. +func aged( + t *testing.T, + sessManager *session.Session, + cookies []*http.Cookie, + d time.Duration, +) []*http.Cookie { + t.Helper() + + req := httptest.NewRequestWithContext( + context.Background(), http.MethodGet, "/", nil) + + for _, c := range cookies { + req.AddCookie(c) + } + + sess, err := sessManager.Get(req) + require.NoError(t, err) + + for _, key := range []string{session.CreatedAtKey, session.LastSeenKey} { + at, ok := sess.Values[key].(int64) + require.True(t, ok, "the session has no %s", key) + + sess.Values[key] = at - int64(d/time.Second) + } + + w := httptest.NewRecorder() + require.NoError(t, sessManager.Save(req, w, sess)) + + return sessionCookies(w) +} + func TestRequireAuth_IdleExpiredSession_RedirectsToLogin( t *testing.T, ) { @@ -590,13 +607,11 @@ func TestRequireAuth_IdleExpiredSession_RedirectsToLogin( idle := time.Hour - m, sessManager, clock := testMiddlewareWithSessionClock( - t, config.EnvironmentDev, idle, newFakeClock(), + m, sessManager := testMiddlewareWithIdleTimeout( + t, config.EnvironmentDev, idle, ) - cookies := loginCookies(t, sessManager) - - clock.Advance(idle) + cookies := aged(t, sessManager, loginCookies(t, sessManager), idle) called, w := runAuthed(t, m, cookies) @@ -621,14 +636,12 @@ func TestRequireAuth_RefreshesIdleDeadlineOnActivity( idle := time.Hour - m, sessManager, clock := testMiddlewareWithSessionClock( - t, config.EnvironmentDev, idle, newFakeClock(), + m, sessManager := testMiddlewareWithIdleTimeout( + t, config.EnvironmentDev, idle, ) - cookies := loginCookies(t, sessManager) - // Activity halfway through the idle window. - clock.Advance(idle / 2) + cookies := aged(t, sessManager, loginCookies(t, sessManager), idle/2) called, w := runAuthed(t, m, cookies) require.True(t, called, "handler should run while valid") @@ -640,16 +653,22 @@ func TestRequireAuth_RefreshesIdleDeadlineOnActivity( ) // Past the original deadline. The refreshed cookie is still - // good; the original one is not. - clock.Advance(idle - time.Second) + // good; the original one is not. A minute short of the idle + // window leaves room for the real clock, which the session + // reads, to tick on while the test runs. + later := idle - time.Minute - calledRefreshed, _ := runAuthed(t, m, refreshed) + calledRefreshed, _ := runAuthed( + t, m, aged(t, sessManager, refreshed, later), + ) assert.True( t, calledRefreshed, "refreshed session should outlive the original deadline", ) - calledStale, staleW := runAuthed(t, m, cookies) + calledStale, staleW := runAuthed( + t, m, aged(t, sessManager, cookies, later), + ) assert.False( t, calledStale, "the pre-refresh cookie carries the old idle deadline", @@ -662,8 +681,8 @@ func TestRequireAuth_UnauthenticatedRequestDoesNotRefresh( ) { t.Parallel() - m, sessManager, _ := testMiddlewareWithSessionClock( - t, config.EnvironmentDev, time.Hour, newFakeClock(), + m, sessManager := testMiddlewareWithIdleTimeout( + t, config.EnvironmentDev, time.Hour, ) // A session cookie that exists but was never authenticated. @@ -924,12 +943,9 @@ func metricsAuthMiddleware( MetricsPassword: "secret", } - key := make([]byte, testKeySize) - store := session.NewStore(key) - - sessManager := session.NewForTest(store, cfg, log, key, nil) - - return middleware.NewForTest(log, cfg, sessManager) + return middlewaretest.New( + t, log, cfg, newTestSessionManager(t, cfg), + ) } // runMetricsAuthRequest sends a GET /metrics request with the diff --git a/internal/middleware/middlewaretest/middlewaretest.go b/internal/middleware/middlewaretest/middlewaretest.go new file mode 100644 index 0000000..73dd976 --- /dev/null +++ b/internal/middleware/middlewaretest/middlewaretest.go @@ -0,0 +1,42 @@ +// Package middlewaretest builds a Middleware for tests in other +// packages. +package middlewaretest + +import ( + "log/slog" + "testing" + + "github.com/prometheus/client_golang/prometheus" + "github.com/stretchr/testify/require" + "go.uber.org/fx/fxtest" + "sneak.berlin/go/webhooker/internal/config" + "sneak.berlin/go/webhooker/internal/middleware" + "sneak.berlin/go/webhooker/internal/session" +) + +// New builds a Middleware through middleware.New, on a +// lifecycle that is never started. +// +// Its metrics recorder writes to a fresh registry of its own, so +// Metrics() works on it and two of them never collide. +func New( + t *testing.T, + log *slog.Logger, + cfg *config.Config, + sess *session.Session, +) *middleware.Middleware { + t.Helper() + + m, err := middleware.New( + fxtest.NewLifecycle(t), + middleware.MiddlewareParams{ + Logger: log, + Config: cfg, + Session: sess, + Registry: prometheus.NewRegistry(), + }, + ) + require.NoError(t, err) + + return m +} diff --git a/internal/middleware/ratelimit_test.go b/internal/middleware/ratelimit_test.go index 373ca32..082641f 100644 --- a/internal/middleware/ratelimit_test.go +++ b/internal/middleware/ratelimit_test.go @@ -18,6 +18,7 @@ import ( "github.com/stretchr/testify/require" "sneak.berlin/go/webhooker/internal/config" "sneak.berlin/go/webhooker/internal/middleware" + "sneak.berlin/go/webhooker/internal/middleware/middlewaretest" ) func TestPostRateLimit_AllowsGET(t *testing.T) { @@ -198,7 +199,7 @@ func rateLimitMiddleware( &slog.HandlerOptions{Level: slog.LevelDebug}, )) - return middleware.NewForTest(log, cfg, nil) + return middlewaretest.New(t, log, cfg, nil) } // trustedProxies parses CIDR strings for a test Config. diff --git a/internal/middleware/testing.go b/internal/middleware/testing.go deleted file mode 100644 index cc862b2..0000000 --- a/internal/middleware/testing.go +++ /dev/null @@ -1,32 +0,0 @@ -package middleware - -import ( - "log/slog" - - "github.com/prometheus/client_golang/prometheus" - prommetrics "github.com/slok/go-http-metrics/metrics/prometheus" - "sneak.berlin/go/webhooker/internal/config" - "sneak.berlin/go/webhooker/internal/session" -) - -// NewForTest creates a Middleware with the minimum dependencies -// needed for testing. This bypasses the fx lifecycle. -// -// Its metrics recorder writes to a fresh registry of its own, so -// Metrics() works on it and two of them never collide. -func NewForTest( - log *slog.Logger, - cfg *config.Config, - sess *session.Session, -) *Middleware { - return &Middleware{ - log: log, - params: &MiddlewareParams{ - Config: cfg, - }, - session: sess, - metricsRecorder: prommetrics.NewRecorder( - prommetrics.Config{Registry: prometheus.NewRegistry()}, - ), - } -} diff --git a/internal/resetpw/resetpw_test.go b/internal/resetpw/resetpw_test.go index 99ba5ed..fe4fdce 100644 --- a/internal/resetpw/resetpw_test.go +++ b/internal/resetpw/resetpw_test.go @@ -174,6 +174,7 @@ func newServerApp( fx.Provide( globals.New, logger.New, + func(l *logger.Logger) *slog.Logger { return l.Get() }, func() *config.Config { return &config.Config{DataDir: dir} }, diff --git a/internal/server/routes_test.go b/internal/server/routes_test.go index 27e8cc9..a74be16 100644 --- a/internal/server/routes_test.go +++ b/internal/server/routes_test.go @@ -3,6 +3,7 @@ package server_test import ( "context" "html" + "log/slog" "net/http" "net/http/httptest" "net/url" @@ -130,6 +131,7 @@ func newTestEnvWithConfig( fx.Provide( globals.New, logger.New, + func(l *logger.Logger) *slog.Logger { return l.Get() }, func() *config.Config { return cfg }, database.New, database.NewWebhookDBManager, diff --git a/internal/session/testing.go b/internal/session/export_test.go similarity index 92% rename from internal/session/testing.go rename to internal/session/export_test.go index 18ce298..46eb119 100644 --- a/internal/session/testing.go +++ b/internal/session/export_test.go @@ -16,8 +16,7 @@ func NewStore(key []byte) *sessions.CookieStore { } // NewForTest creates a Session with a pre-configured cookie store for use -// in tests. This bypasses the fx lifecycle and database dependency, allowing -// middleware and handler tests to use real session functionality. The key +// in tests. This bypasses the fx lifecycle and database dependency. The key // parameter is the raw 32-byte authentication key used for session encryption // and CSRF cookie signing. // diff --git a/internal/session/session.go b/internal/session/session.go index 8e97734..5335000 100644 --- a/internal/session/session.go +++ b/internal/session/session.go @@ -16,7 +16,6 @@ import ( "go.uber.org/fx" "sneak.berlin/go/webhooker/internal/config" "sneak.berlin/go/webhooker/internal/database" - "sneak.berlin/go/webhooker/internal/logger" "sneak.berlin/go/webhooker/internal/reqtls" ) @@ -80,7 +79,7 @@ type Params struct { Config *config.Config Database *database.Database - Logger *logger.Logger + Logger *slog.Logger } // Session manages encrypted session storage. @@ -180,7 +179,7 @@ func New( params Params, ) (*Session, error) { s := &Session{ - log: params.Logger.Get(), + log: params.Logger, idleTimeout: params.Config.SessionIdleTimeout, now: time.Now, } -- 2.54.0 From 9199544999c850b5975b5331ca9c77b5e4a7aa99 Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Tue, 13 Oct 2026 01:29:42 +0200 Subject: [PATCH 103/126] Document that DATA_DIR must be on a local filesystem (closes #295) SQLite in WAL mode maps each database's -shm file into memory, and network filesystems do not support that reliably, so the README now says DATA_DIR must be local and names NFS and SMB/CIFS as unsupported. It also says what a disk I/O error with code 522 points at and what to try first. ZFS is not called unsupported: the one failure seen there was never reproduced. Model: opus-5-5 --- README.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/README.md b/README.md index 0e53c78..2ce5c4c 100644 --- a/README.md +++ b/README.md @@ -941,6 +941,13 @@ with an error naming it: for `webhooker.db` the server and `webhooker resetpw` stop, and an event or archive database fails as a damaged one does (see [Database Architecture](#database-architecture)). +**`DATA_DIR` must be on a local filesystem.** Under WAL, SQLite maps each +database's `-shm` file into memory, which network filesystems (NFS, SMB/CIFS) do +not support reliably, so they are not supported. webhooker does not check which +filesystem `DATA_DIR` is on. An error reading `disk I/O error (522)` +(`SQLITE_IOERR_SHORT_READ`) points at the storage under `DATA_DIR`, not at +locking; moving `DATA_DIR` to a plain local disk is the first thing to try. + Configuration is **not** in `DATA_DIR` — it comes from the environment and from a `.env` file read out of the process working directory. Back that up with your deployment config, separately. -- 2.54.0 From 957de7f99f39b6a878fbae4e5cab64f13eb56266 Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Tue, 13 Oct 2026 03:27:24 +0200 Subject: [PATCH 104/126] Make the HSTS max-age configurable with HSTS_MAX_AGE (closes #271) HSTS_MAX_AGE, a Go duration, sets the max-age of the Strict-Transport-Security header. Unset, it is ten years (87600h); 0 sends no header at all. A negative value, or one that is not a whole number of seconds, stops startup like an unparseable one. includeSubDomains and preload stay in the header. The value is shown on /settings and in the startup log, and the README's HSTS section now says what each part of the header commits the operator to. Model: opus-5-5 --- README.md | 60 ++++++++++++++------ internal/config/config.go | 40 +++++++++++++ internal/config/config_test.go | 77 ++++++++++++++++++++++++++ internal/handlers/settings.go | 7 +++ internal/handlers/settings_test.go | 2 + internal/middleware/middleware.go | 18 ++++-- internal/middleware/middleware_test.go | 64 +++++++++++++++++++++ 7 files changed, 244 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index 2ce5c4c..7a2a892 100644 --- a/README.md +++ b/README.md @@ -141,6 +141,7 @@ security headers are always applied. | `SENTRY_DSN` | Sentry error reporting DSN. Unset leaves error reporting off; a value the Sentry SDK cannot parse fails startup rather than serving with reporting silently off | `""` | | `RETENTION_SWEEP_INTERVAL` | How often the retention reaper and archive sweeper run (Go duration, must be positive). A value that does not parse, or is zero or negative, fails startup | `1h` | | `SESSION_IDLE_TIMEOUT` | Idle session timeout (Go duration) | `24h` | +| `HSTS_MAX_AGE` | `max-age` of the `Strict-Transport-Security` header, which also carries `includeSubDomains` and `preload` (Go duration, whole seconds). `0` sends no header. Read [HSTS](#hsts) before changing it | `87600h` (ten years) | | `RECEIVER_RATE_LIMIT` | Receiver requests/minute per IP per entrypoint (10x that per IP across the route) | `120` | | `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) | @@ -483,9 +484,10 @@ crash the reaper after startup), every entry in `TRUSTED_PROXIES` and `ALLOWED_EGRESS_CIDRS` must be a CIDR block or a bare IP address, and `BIND_ADDRESS` must be an IP address literal — `localhost`, `127.0.0.1:8080` and `10.0.0.0/8` are each rejected rather than resolved, split, or narrowed to -something they do not say — and `SENTRY_DSN` must parse as a Sentry DSN. -`SESSION_IDLE_TIMEOUT` is the exception: a non-positive value there means idle -expiry is disabled, not invalid. +something they do not say — `SENTRY_DSN` must parse as a Sentry DSN, and +`HSTS_MAX_AGE` must be a whole number of seconds, zero or more (`1.5s` and `-1h` +are rejected). `SESSION_IDLE_TIMEOUT` is the exception: a non-positive value +there means idle expiry is disabled, not invalid. `SENTRY_DSN` is checked with the Sentry SDK's own parser, the same call the SDK makes on the DSN it is later handed, so what configuration accepts is exactly @@ -882,17 +884,39 @@ If nginx runs on another host, `BIND_ADDRESS` becomes the address it connects to, `TRUSTED_PROXIES` becomes nginx's address, and the port must be firewalled to that address — the traffic between them is cleartext. -### HSTS is always sent, and is not configurable +### HSTS -Every response carries -`Strict-Transport-Security: max-age=63072000; includeSubDomains; preload`. Two -years, every subdomain, and a `preload` token. There is no setting that changes -or suppresses it. +Every response carries a `Strict-Transport-Security` header unless +`HSTS_MAX_AGE` is `0`. With the default `HSTS_MAX_AGE` of `87600h` (ten years) +it is: -This is worth knowing before the first request reaches a browser: a client that -sees it once will refuse plaintext HTTP to that hostname — and to every -subdomain of it — for two years, whatever else is served there. Terminate TLS on -a hostname you are prepared to keep on HTTPS. +``` +Strict-Transport-Security: max-age=315360000; includeSubDomains; preload +``` + +`HSTS_MAX_AGE` sets `max-age`, in seconds. `includeSubDomains` and `preload` are +always part of the header. A browser that receives the header over HTTPS refuses +plaintext HTTP to that hostname until `max-age` runs out, whatever else is +served there, so terminate TLS on a hostname you are prepared to keep on HTTPS. +Browsers ignore the header when it arrives over plain HTTP. + +`includeSubDomains` extends this to every subdomain of the hostname webhooker is +served at. Served at `hooks.example.com`, it covers every name under +`hooks.example.com`; served at `example.com`, every name under `example.com`. + +`preload` invites browsers to put the domain on their built-in HSTS preload +lists. The token alone puts nothing there. A domain is listed after someone +submits it at hstspreload.org, which accepts only a whole registered domain such +as `example.com`, not `hooks.example.com`. A listed domain is HTTPS-only in +every browser that ships the list, even for users who have never visited it, and +removal takes months to reach those browsers, so treat a listing as permanent. + +Setting `HSTS_MAX_AGE=0` stops the header being sent, but a browser that already +received it over HTTPS does not forget it: it keeps refusing plaintext HTTP +until the `max-age` it saw runs out. Each header a browser receives over HTTPS +replaces the `max-age` it stored, so to end the policy sooner, set a short +`HSTS_MAX_AGE` such as `1s` and keep it until returning browsers have picked it +up, or have a reverse proxy send `max-age=0`. ## Backup, Restore, and Upgrades @@ -3139,9 +3163,9 @@ any database — it only writes to record results. 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, - X-Content-Type-Options, X-Frame-Options, CSP, Referrer-Policy, - Permissions-Policy) +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) 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 @@ -3222,9 +3246,9 @@ credential check, see [The login endpoint](#the-login-endpoint). - Session regeneration on login to prevent session fixation attacks - Session key is a 32-byte value auto-generated on first startup and stored in the database -- Production security headers on all responses: HSTS, X-Content-Type-Options - (`nosniff`), X-Frame-Options (`DENY`), Content-Security-Policy, - Referrer-Policy, and Permissions-Policy +- 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 - 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 diff --git a/internal/config/config.go b/internal/config/config.go index b2eaf01..7ba8150 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -68,6 +68,10 @@ const ( // authenticated activity before it expires. defaultSessionIdleTimeout = 24 * time.Hour + // defaultHSTSMaxAge is the max-age of the Strict-Transport-Security + // header when HSTS_MAX_AGE is unset: ten years. + defaultHSTSMaxAge = 87600 * time.Hour + // defaultReceiverRateLimit is the default number of requests // per minute each client IP may send to a single webhook // receiver entrypoint. Generous for legitimate webhook @@ -120,6 +124,10 @@ var ErrInvalidBindAddress = errors.New("invalid bind address") // the Sentry SDK cannot parse as a DSN. var ErrInvalidSentryDSN = errors.New("invalid Sentry DSN") +// ErrInvalidHSTSMaxAge is returned when HSTS_MAX_AGE is set to a +// negative duration or one that is not a whole number of seconds. +var ErrInvalidHSTSMaxAge = errors.New("invalid HSTS max-age") + // ErrDotEnvUnreadable is returned when the optional .env file exists // but cannot be read or parsed. A file that is not there is not an // error; a file that is there and broken is. @@ -169,6 +177,10 @@ type Config struct { // which a session expires. Non-positive disables idle expiry. SessionIdleTimeout time.Duration + // HSTSMaxAge is the max-age of the Strict-Transport-Security + // header. Zero means the header is not sent at all. + HSTSMaxAge time.Duration + // ReceiverRateLimit is the number of requests per minute each // client IP may send to a single webhook receiver entrypoint. ReceiverRateLimit int @@ -624,6 +636,27 @@ func resolveEnvironment() (string, error) { return environment, nil } +// resolveHSTSMaxAge reads HSTS_MAX_AGE. Zero is allowed and turns the +// Strict-Transport-Security header off. A fraction of a second is an +// error rather than rounded, because the header carries whole seconds +// and would otherwise say something other than what was set. +func resolveHSTSMaxAge() (time.Duration, error) { + maxAge, err := envDuration("HSTS_MAX_AGE", defaultHSTSMaxAge) + if err != nil { + return 0, err + } + + if maxAge < 0 || maxAge%time.Second != 0 { + return 0, fmt.Errorf( + "%w: HSTS_MAX_AGE must be zero or a positive whole "+ + "number of seconds, got %s", + ErrInvalidHSTSMaxAge, maxAge, + ) + } + + return maxAge, nil +} + // resolveListener reads the two variables that describe the HTTP // listener: which port it claims and which address it claims it on. // They are read together because neither is meaningful alone, and @@ -684,6 +717,11 @@ func loadFromEnv() (*Config, error) { return nil, err } + hstsMaxAge, err := resolveHSTSMaxAge() + if err != nil { + return nil, err + } + receiverRateLimit, err := envPositiveInt( "RECEIVER_RATE_LIMIT", defaultReceiverRateLimit, @@ -723,6 +761,7 @@ func loadFromEnv() (*Config, error) { SentryDSN: sentryDSN, RetentionSweepInterval: retentionSweepInterval, SessionIdleTimeout: sessionIdleTimeout, + HSTSMaxAge: hstsMaxAge, ReceiverRateLimit: receiverRateLimit, TrustedProxies: trustedProxies, AllowedEgressCIDRs: allowedEgressCIDRs, @@ -807,6 +846,7 @@ func New(lc fx.Lifecycle, params ConfigParams) (*Config, error) { // disables idle expiry entirely, and that is worth showing // back to the operator. "sessionIdleTimeout", s.SessionIdleTimeout.String(), + "hstsMaxAge", s.HSTSMaxAge.String(), "receiverRateLimit", s.ReceiverRateLimit, "trustedProxies", len(s.TrustedProxies), "allowedEgressCIDRs", len(s.AllowedEgressCIDRs), diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 717377b..be65c71 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -379,6 +379,83 @@ func testSessionIdleTimeoutSuccess( assert.Equal(t, expected, cfg.SessionIdleTimeout) } +func TestHSTSMaxAge(t *testing.T) { + tests := []struct { + name string + set bool + value string + expectError bool + // sentinel, when set, must be wrapped by the startup + // error; every error case must additionally name the + // variable in its message. + sentinel error + expected time.Duration + }{ + { + name: caseUnsetUsesDefault, + set: false, + expected: 87600 * time.Hour, + }, + { + name: caseValidValueParsed, + set: true, + value: "720h", + expected: 720 * time.Hour, + }, + { + // Zero turns the header off; it is not an error. + name: "zero is accepted", + set: true, + value: "0", + expected: 0, + }, + { + name: caseUnparseableFails, + set: true, + value: "10 years", + expectError: true, + }, + { + // A whole number of seconds, so only the sign is wrong. + name: "negative whole seconds fail startup", + set: true, + value: "-1s", + expectError: true, + sentinel: config.ErrInvalidHSTSMaxAge, + }, + { + name: "fraction of a second fails startup", + set: true, + value: "1500ms", + expectError: true, + sentinel: config.ErrInvalidHSTSMaxAge, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Cannot use t.Parallel() here because t.Setenv + // is incompatible with parallel subtests. + configtest.ClearEnv(t) + t.Setenv("WEBHOOKER_ENVIRONMENT", "dev") + + if tt.set { + t.Setenv("HSTS_MAX_AGE", tt.value) + } + + if tt.expectError { + expectStartupErrorFor(t, "HSTS_MAX_AGE", tt.sentinel) + + return + } + + cfg, err := buildConfig(t) + require.NoError(t, err) + assert.Equal(t, tt.expected, cfg.HSTSMaxAge) + }) + } +} + func TestDefaultDataDir(t *testing.T) { for _, env := range []string{"", "dev", "prod"} { name := env diff --git a/internal/handlers/settings.go b/internal/handlers/settings.go index 508a657..1fa235f 100644 --- a/internal/handlers/settings.go +++ b/internal/handlers/settings.go @@ -86,6 +86,13 @@ func settingRows(cfg *config.Config) []settingRow { "Idle session timeout (Go duration)", cfg.SessionIdleTimeout.String(), }, + { + "HSTS_MAX_AGE", + "max-age of the Strict-Transport-Security header, which " + + "also carries includeSubDomains and preload (Go " + + "duration, whole seconds). 0 sends no header", + cfg.HSTSMaxAge.String(), + }, { "RECEIVER_RATE_LIMIT", "Receiver requests/minute per IP per entrypoint " + diff --git a/internal/handlers/settings_test.go b/internal/handlers/settings_test.go index f8b06a8..504bb7a 100644 --- a/internal/handlers/settings_test.go +++ b/internal/handlers/settings_test.go @@ -76,6 +76,7 @@ func TestSettingsPageShowsLoadedConfiguration(t *testing.T) { BindAddress: "192.0.2.10", RetentionSweepInterval: 17 * time.Minute, SessionIdleTimeout: 3 * time.Hour, + HSTSMaxAge: 8760 * time.Hour, ReceiverRateLimit: 77, TrustedProxies: []netip.Prefix{ netip.MustParsePrefix("10.1.0.0/16"), @@ -99,6 +100,7 @@ func TestSettingsPageShowsLoadedConfiguration(t *testing.T) { "SENTRY_DSN": "not set", "RETENTION_SWEEP_INTERVAL": "17m0s", "SESSION_IDLE_TIMEOUT": "3h0m0s", + "HSTS_MAX_AGE": "8760h0m0s", "RECEIVER_RATE_LIMIT": "77", "TRUSTED_PROXIES": "10.1.0.0/16", "ALLOWED_EGRESS_CIDRS": "192.168.5.0/24, fd00::/8", diff --git a/internal/middleware/middleware.go b/internal/middleware/middleware.go index e70bfdd..50a1a72 100644 --- a/internal/middleware/middleware.go +++ b/internal/middleware/middleware.go @@ -8,6 +8,7 @@ import ( "net" "net/http" "net/url" + "strconv" "sync" "time" @@ -535,18 +536,23 @@ func (s *Middleware) MetricsAuth() func(http.Handler) http.Handler { } // SecurityHeaders returns middleware that sets production security -// headers on every response: HSTS, X-Content-Type-Options, -// X-Frame-Options, CSP, Referrer-Policy, and Permissions-Policy. +// headers on every response: HSTS (unless HSTS_MAX_AGE is zero), +// X-Content-Type-Options, X-Frame-Options, CSP, Referrer-Policy, and +// Permissions-Policy. func (s *Middleware) SecurityHeaders() func(http.Handler) http.Handler { + maxAge := s.params.Config.HSTSMaxAge + hsts := "max-age=" + strconv.FormatInt(int64(maxAge/time.Second), 10) + + "; includeSubDomains; preload" + return func(next http.Handler) http.Handler { return http.HandlerFunc(func( w http.ResponseWriter, r *http.Request, ) { - w.Header().Set( - "Strict-Transport-Security", - "max-age=63072000; includeSubDomains; preload", - ) + if maxAge > 0 { + w.Header().Set("Strict-Transport-Security", hsts) + } + w.Header().Set( "X-Content-Type-Options", "nosniff", ) diff --git a/internal/middleware/middleware_test.go b/internal/middleware/middleware_test.go index 717f3d4..7d474ad 100644 --- a/internal/middleware/middleware_test.go +++ b/internal/middleware/middleware_test.go @@ -745,6 +745,70 @@ 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 { + t.Helper() + + cfg := &config.Config{HSTSMaxAge: maxAge} + m := middlewaretest.New( + t, slog.New(slog.DiscardHandler), cfg, + newTestSessionManager(t, cfg), + ) + + handler := m.SecurityHeaders()(http.HandlerFunc( + func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + }, + )) + + req := httptest.NewRequestWithContext( + context.Background(), http.MethodGet, "/", nil, + ) + w := httptest.NewRecorder() + + handler.ServeHTTP(w, req) + + return w.Header().Values("Strict-Transport-Security") +} + +func TestSecurityHeaders_HSTS(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + maxAge time.Duration + expected []string + }{ + { + name: "default of ten years", + maxAge: 87600 * time.Hour, + expected: []string{"max-age=315360000; includeSubDomains; preload"}, + }, + { + name: "custom value is sent in seconds", + maxAge: 90 * time.Minute, + expected: []string{"max-age=5400; includeSubDomains; preload"}, + }, + { + name: "zero sends no header", + maxAge: 0, + expected: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + assert.Equal(t, tt.expected, hstsHeaders(t, tt.maxAge)) + }) + } +} + // --- MaxBodySize Middleware Tests --- const testBodyLimit int64 = 64 -- 2.54.0 From f86ab6321df93dba97cfd34e32fb00236223d7bd Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Tue, 13 Oct 2026 05:47:26 +0200 Subject: [PATCH 105/126] Name Sentry transactions after the route they matched (closes #185) sentryhttp names each transaction after the concrete path, which the BeforeSendTransaction hook has to withhold, so with tracing on the transactions would fall into one Sentry group per method, such as POST /(redacted). A middleware installed beside every sentryhttp handler now renames the transaction to the method and chi route pattern once the request is routed, and sets its source to route. The hook keeps a route-sourced name and withholds every other name as before. A path that matches nothing at the top level keeps the withheld name; one that matches no route inside a route group, or that the group's middleware turns away, is named after the group's pattern, such as GET /pages/*. Tracing stays off. Model: opus-5-5 --- README.md | 30 ++++++++--- internal/server/export_test.go | 18 +++++++ internal/server/routes.go | 9 +++- internal/server/sentry.go | 61 +++++++++++++++++++-- internal/server/sentry_test.go | 97 +++++++++++++++++++++++++++++++--- 5 files changed, 194 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 7a2a892..124fd68 100644 --- a/README.md +++ b/README.md @@ -2551,10 +2551,23 @@ Where the pattern is out of reach — the transaction dispatch, an event capture outside the router, or a request that matched no route — the fallback is never the concrete path. The path becomes the literal `/(redacted)`, so the URL reads `http://host/(redacted)`; a URL the rewrite cannot parse into a scheme is -withheld whole. A transaction event additionally carries the SDK's own -`METHOD /path` name, built from the concrete path as well; it is rewritten on -the same terms, to `POST /h/{uuid}` where the pattern is known and -`POST /(redacted)` where it is not. +withheld whole. + +A transaction event also carries a name, which the SDK builds as `METHOD /path` +from the concrete path and gives the transaction source `url`. Sentry groups +transactions by name, so withholding it would put every request in the service +into one group per method, such as `POST /(redacted)`. Inside each Sentry +handler, a second middleware renames the transaction once the request has been +routed, to the method and the route pattern (`POST /h/{uuid}`), and sets its +source to `route`. The hook keeps a name whose source is `route` and rewrites +any other to `METHOD /(redacted)`. A path that matches nothing at the top level +has no pattern, so its transaction keeps the SDK's name and the hook withholds +it. Inside a route group, a path that matches no route in the group, and a +request the group's own middleware turns away before routing (a CSRF refusal, +the redirect to `/pages/login`), are named after the group's pattern, such as +`GET /pages/*` or `POST /hook/{sourceID}/*`, and the hook keeps that name. The +URL on a transaction event still falls back to `http://host/(redacted)`, because +the hook has no request there. The headers are an allowlist for the same reason the rules above are unconditional: the SDK's own filter removes four names and passes everything @@ -3175,7 +3188,8 @@ Applied to all routes in this order: 7. **Recoverer** — Panic recovery: one `ERROR` record through `internal/logger` and a `500` 8. **Sentry** — Error reporting to Sentry (if `SENTRY_DSN` is set; configured - with `Repanic: true` so panics still reach Recoverer) + with `Repanic: true` so panics still reach Recoverer), and inside it the + naming of the request's Sentry transaction after its route pattern Recoverer sits seventh rather than first, and both neighbours are the reason. It runs **inside** everything that observes the response, so the `500` it writes @@ -3190,9 +3204,9 @@ set a header or start a timer. Each admin page route group (`/pages`, `/user/*`, `/settings`, `/hooks`, `/hook/*`) starts with its own **Recoverer** and, if `SENTRY_DSN` is set, its -own **Sentry** error reporting. That Recoverer answers a panic with the `500` -error page in the normal layout; the global one keeps the plain-text `500` for -every other route. +own **Sentry** error reporting and transaction naming. That Recoverer answers a +panic with the `500` error page in the normal layout; the global one keeps the +plain-text `500` for every other route. Additionally, form endpoints (`/pages`, `/user/*`, `/settings`, `/hooks`, `/hook/*`) apply a **MaxBodySize** middleware that limits POST/PUT/PATCH request diff --git a/internal/server/export_test.go b/internal/server/export_test.go index 957f4dd..3a3717e 100644 --- a/internal/server/export_test.go +++ b/internal/server/export_test.go @@ -91,6 +91,24 @@ func NewRouterForTest( return s.router } +// NewRouterWithSentryForTest is NewRouterForTest with the sentryhttp +// handler registered, as a set SENTRY_DSN does in production. +func NewRouterWithSentryForTest( + t *testing.T, + log *logger.Logger, + cfg *config.Config, + mw *middleware.Middleware, + h *handlers.Handlers, +) http.Handler { + t.Helper() + + s := newServerForTest(t, log, cfg, mw, h) + s.sentryEnabled.Store(true) + s.SetupRoutes() + + return s.router +} + // ListenAddrForTest exposes the address the HTTP listener binds for // a given Config, so the rendering of host and port — IPv6 // bracketing above all — can be pinned without standing up a diff --git a/internal/server/routes.go b/internal/server/routes.go index 40e7113..d7210ab 100644 --- a/internal/server/routes.go +++ b/internal/server/routes.go @@ -90,8 +90,8 @@ func (s *Server) setupGlobalMiddleware() { // recoverPanics installs on r the recoverer, answering a panic with // page (a plain 500 when page is nil), and inside it the Sentry error -// reporting (if SENTRY_DSN is set). Repanic is true so panics still -// bubble up to the recoverer. +// reporting and transaction naming (if SENTRY_DSN is set). Repanic is +// true so panics still bubble up to the recoverer. // // Each admin page route group installs its own, with the error page, // as its first middleware. A panic there is logged, reported and @@ -105,6 +105,11 @@ func (s *Server) recoverPanics(r chi.Router, page http.Handler) { Repanic: true, }) r.Use(sentryHandler.Handle) + // Installed with every sentryhttp handler rather than once + // globally: in an admin page route group, the group's own + // handler finishes the transaction as the group returns, so + // a rename further out would come too late. + r.Use(nameSentryTransaction) } } diff --git a/internal/server/sentry.go b/internal/server/sentry.go index 086c3a6..fdd2dbd 100644 --- a/internal/server/sentry.go +++ b/internal/server/sentry.go @@ -1,6 +1,7 @@ package server import ( + "context" "net/http" "net/url" "strings" @@ -71,10 +72,15 @@ func scrubSentryRequest( pattern := sentryRoutePattern(hint) - // Only transaction events carry a Transaction name, and the SDK + // Only transaction events carry a Transaction name. sentryhttp // builds it from the concrete path too (sentryhttp.go:105 via - // tracing.go:553). Rewritten on the same terms. - if event.Transaction != "" { + // tracing.go:553) and marks it SourceURL, so it is rewritten on + // the same terms. Only a name nameSentryTransaction set from the + // route pattern, which it marks SourceRoute, is kept. + namedByRoute := event.TransactionInfo != nil && + event.TransactionInfo.Source == sentry.SourceRoute + + if event.Transaction != "" && !namedByRoute { event.Transaction = sentryTransactionName( event.Transaction, pattern, ) @@ -120,7 +126,8 @@ func scrubSentryRequest( // The transaction dispatch has no such request: Span.doFinish calls // hub.CaptureEvent (tracing.go:356), which passes a nil hint that the // client replaces with an empty one (client.go:620-622). The pattern -// is therefore always "" there, and the callers fall back. +// is therefore always "" there, and the callers fall back. A routed +// transaction is named by nameSentryTransaction instead. func sentryRoutePattern(hint *sentry.EventHint) string { if hint == nil || hint.Context == nil { return "" @@ -142,6 +149,52 @@ func sentryRoutePattern(hint *sentry.EventHint) string { return rctx.RoutePattern() } +// nameSentryTransaction renames the request's Sentry transaction to +// "METHOD ", such as "POST /h/{uuid}". sentryhttp names +// it after the concrete path, which scrubSentryRequest withholds; +// since Sentry groups transactions by name, every transaction in the +// service would otherwise land in one group per method, such as +// "POST /(redacted)". +// +// It has to run inside the sentryhttp handler, where the transaction +// is on the request context. It renames on the way out, once chi has +// routed the request, and does so in a defer so that a request whose +// handler panics is named too. The context is taken when the defer is +// set up, before routing, but the chi routing context on it is a +// pointer that chi fills in as it routes. +func nameSentryTransaction(next http.Handler) http.Handler { + return http.HandlerFunc( + func(w http.ResponseWriter, r *http.Request) { + defer setSentryTransactionName(r.Context(), r.Method) + + next.ServeHTTP(w, r) + }, + ) +} + +// setSentryTransactionName does the rename for nameSentryTransaction. +// A path that matches nothing at the top level has no pattern; its +// name is left as the SDK set it, for scrubSentryRequest to withhold. +// Inside a route group, a path that matches no route in the group, or +// a request the group's own middleware turns away before routing, has +// the group's pattern, such as "/pages/*", and is named after it. +func setSentryTransactionName(ctx context.Context, method string) { + transaction := sentry.TransactionFromContext(ctx) + rctx := chi.RouteContext(ctx) + + if transaction == nil || rctx == nil { + return + } + + pattern := rctx.RoutePattern() + if pattern == "" { + return + } + + transaction.Name = method + " " + pattern + transaction.Source = sentry.SourceRoute +} + // sentryRouteURL rebuilds an event's request URL with the route // pattern in place of the concrete path. // diff --git a/internal/server/sentry_test.go b/internal/server/sentry_test.go index 86e5cb0..cf1dcdc 100644 --- a/internal/server/sentry_test.go +++ b/internal/server/sentry_test.go @@ -16,6 +16,7 @@ import ( "github.com/go-chi/chi" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "sneak.berlin/go/webhooker/internal/config" "sneak.berlin/go/webhooker/internal/server" ) @@ -130,11 +131,15 @@ func (c sentryCase) capture(t *testing.T) []*sentry.Event { // router mirrors the one ordering these tests depend on, over the two // route patterns they need: a recovering middleware outside, then the // sentryhttp handler registered with Use and Repanic set, exactly as -// routes.go orders the two. The bare recover stands in for -// Middleware.Recoverer, which holds that outer slot in production; it -// is here only to keep panic stacks out of the test output. That the -// production one really does catch what sentryhttp re-raises is -// pinned separately, by TestSentryStillSeesAPanic. +// routes.go orders the two. It leaves out nameSentryTransaction, +// which routes.go installs inside the sentryhttp handler, so a +// transaction captured here keeps the SDK's name and shows what the +// hook does with a transaction that missed the rename. The bare +// recover stands in for Middleware.Recoverer, which holds that outer +// slot in production; it is here only to keep panic stacks out of the +// test output. That the production one really does catch what +// sentryhttp re-raises is pinned separately, by +// TestSentryStillSeesAPanic. func (c sentryCase) router() http.Handler { handler := func(_ http.ResponseWriter, r *http.Request) { // This call is what drains the body tee and fills the @@ -353,8 +358,9 @@ func TestSentryScrub_KeepsTheRoutingContext(t *testing.T) { // Span.doFinish captures with a nil hint, so BeforeSendTransaction // gets one with no context and no request: the route pattern is out of // reach and both the URL and the SDK-built transaction name have to -// fall back. Tracing is off in this service, so no transaction event -// is produced today; the hook is a floor against that changing. +// fall back. This router has no nameSentryTransaction, so this is the +// floor under a transaction that missed the rename; +// TestSentryTransaction_NamedByRoute covers the routes as they ship. func TestSentryScrub_RedactsTheTransactionDispatch(t *testing.T) { t.Parallel() @@ -376,6 +382,83 @@ func TestSentryScrub_RedactsTheTransactionDispatch(t *testing.T) { assert.Equal(t, "POST /(redacted)", event.Transaction) } +// TestSentryTransaction_NamedByRoute serves requests through the real +// route tree with tracing on, which the service leaves off. A routed +// request's transaction is named after its route pattern, so Sentry +// can group transactions by route, and the entrypoint UUID in the +// receiver path appears nowhere in the event. A path that matches +// nothing at the top level has no pattern, and its name is still +// withheld; one that matches no route inside a route group is named +// after the group's pattern. +func TestSentryTransaction_NamedByRoute(t *testing.T) { + t.Parallel() + + // newTestEnv leaves the receiver rate limit at zero, which + // refuses every request before it reaches the receiver. + env := newTestEnvWithConfig(t, &config.Config{ + DataDir: t.TempDir(), + Environment: config.EnvironmentDev, + ReceiverRateLimit: 10, + }) + + router := server.NewRouterWithSentryForTest( + t, env.log, env.cfg, env.mw, env.hnd, + ) + + for _, tc := range []struct { + method, path, want string + }{ + { + http.MethodPost, + "/h/" + sentryReceiverUUID, + "POST /h/{uuid}", + }, + // An admin page route group, which installs its own + // sentryhttp handler. + {http.MethodGet, "/pages/login", "GET /pages/login"}, + {http.MethodGet, "/pages/unrouted", "GET /pages/*"}, + { + http.MethodPost, + "/h/" + sentryReceiverUUID + "/unrouted", + "POST /(redacted)", + }, + } { + t.Run(tc.want, func(t *testing.T) { + t.Parallel() + + transport := &captureTransport{} + + opts := server.SentryClientOptionsForTest( + "https://public@sentry.invalid/1", + "webhooker-test", + ) + opts.Transport = transport + opts.EnableTracing = true + opts.TracesSampleRate = 1.0 + + client, err := sentry.NewClient(opts) + require.NoError(t, err) + + req := httptest.NewRequestWithContext( + sentry.SetHubOnContext( + context.Background(), + sentry.NewHub(client, sentry.NewScope()), + ), + tc.method, tc.path, nil, + ) + router.ServeHTTP(httptest.NewRecorder(), req) + + event := onlyEvent(t, transport.events) + require.Equal(t, "transaction", event.Type) + + assert.Equal(t, tc.want, event.Transaction) + assert.NotContains( + t, marshalEvent(t, event), sentryReceiverUUID, + ) + }) + } +} + // TestSentryScrub_TransactionDispatchIsUnscrubbedWithoutTheHook pins // that dispatch's premise the same way, since it is the one the // service does not exercise today. -- 2.54.0 From 15377f792bb846af686aee2b76b2c5f9a64cd5ae Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Tue, 13 Oct 2026 06:34:05 +0200 Subject: [PATCH 106/126] Build every database's schema from embedded SQL files (closes #56) The main database, each webhook's event database and each archive file get their schema from SQL files under internal/db/migrations/, one directory per kind. Opening a file runs, in one transaction, each file that schema_migrations does not list. GORM stays for queries. AutoMigrate is gone, tests included. An event database holds only its own tables now, so the sweep that removed target rows copied into it is deleted. The association guard stays: without it a delivery write fails on the missing targets table. AutoMigrate also had GORM parse every model before a handle was shared. That is now done on its own: GORM races when two goroutines first use models that refer to each other. Model: opus-5-5 --- README.md | 97 ++--- internal/database/database.go | 29 +- internal/database/event_db_isolation.go | 121 +----- internal/database/event_db_isolation_test.go | 387 +----------------- internal/database/event_tier_indexes_test.go | 26 +- internal/database/models.go | 17 - internal/database/schema_test.go | 168 ++++++++ internal/database/webhook_db_manager.go | 23 +- .../db/migrations/archive/000_migration.sql | 9 + internal/db/migrations/archive/001_schema.sql | 18 + .../db/migrations/events/000_migration.sql | 9 + internal/db/migrations/events/001_schema.sql | 98 +++++ internal/db/migrations/main/000_migration.sql | 9 + internal/db/migrations/main/001_schema.sql | 89 ++++ internal/db/migrations/migrations.go | 135 ++++++ internal/db/migrations/migrations_test.go | 57 +++ internal/delivery/archive_sweeper_test.go | 13 +- internal/delivery/engine_integration_test.go | 23 +- internal/delivery/engine_test.go | 37 +- internal/delivery/target_database_archive.go | 3 +- internal/delivery/terminal_state_test.go | 10 +- internal/gormlog/gormlog_test.go | 6 +- internal/gormlog/scan_guard_test.go | 17 +- internal/handlers/delivery_attempts_test.go | 5 +- internal/handlers/delivery_replay_test.go | 16 +- 25 files changed, 732 insertions(+), 690 deletions(-) delete mode 100644 internal/database/models.go create mode 100644 internal/database/schema_test.go create mode 100644 internal/db/migrations/archive/000_migration.sql create mode 100644 internal/db/migrations/archive/001_schema.sql create mode 100644 internal/db/migrations/events/000_migration.sql create mode 100644 internal/db/migrations/events/001_schema.sql create mode 100644 internal/db/migrations/main/000_migration.sql create mode 100644 internal/db/migrations/main/001_schema.sql create mode 100644 internal/db/migrations/migrations.go create mode 100644 internal/db/migrations/migrations_test.go diff --git a/README.md b/README.md index 124fd68..1e4b4b9 100644 --- a/README.md +++ b/README.md @@ -1071,23 +1071,27 @@ beside it, or wait until there are none. 4. Start the service. The container gives the directory and the restored files to the `webhooker` user before the app starts, whoever restored them (see - [Running with Docker](#running-with-docker)). `AutoMigrate` runs against each - restored database as it is opened. + [Running with Docker](#running-with-docker)). Each restored database gets any + of its SQL migrations it has not had as it is opened (see + [Upgrades](#upgrades)). ### Upgrades -**Back up before every upgrade.** Every start runs GORM `AutoMigrate` -unconditionally against whatever files it finds: +**Back up before every upgrade.** Each kind of database file has its schema in +SQL files built into the binary, one directory per kind under +`internal/db/migrations/`. In each, `000_migration.sql` creates the +`schema_migrations` table, which records the number of every file the database +has had, and `001_schema.sql` holds the whole schema. Opening a database runs, +in one transaction, every file in its directory that `schema_migrations` does +not list: -- the main database on connect — `Setting`, `User`, `APIKey`, `Webhook`, - `Entrypoint`, `Target` -- each event database when it is lazily opened — `Event`, `Delivery`, - `DeliveryResult`, `EventTotals`, `TargetTotals`, `EntrypointTotals` -- each archive database on every open and reopen +- `main/` for the main database, on connect — settings, users, API keys, + webhooks, entrypoints and targets +- `events/` for each event database, when it is lazily opened — events, + deliveries, delivery results and their running totals +- `archive/` for each archive database, on every open and reopen -There is no schema version table, no migration ledger, and no down migrations. -Nothing in the files records which version wrote them, and no code path undoes a -migration. +There are no down migrations. Upgrade procedure: @@ -1112,12 +1116,9 @@ connects to. Check the `bindAddress` field of the startup log to see what a running process bound. See [Bind address](#bind-address). **Downgrade is unsupported.** Once a newer binary has migrated the files there -is no way to move them back. `AutoMigrate` is additive — it adds tables, columns -and indexes and never drops or rewrites them — so an older binary will generally -open migrated files and appear to work while writing against a schema it does -not know about. The failure mode is silent divergence, not a startup error. The -only supported way back to an older version is restoring the pre-upgrade backup, -which discards everything received since that backup was taken. +is no way to move them back. The only supported way back to an older version is +restoring the pre-upgrade backup, which discards everything received since that +backup was taken. ### Version stamping @@ -1182,26 +1183,6 @@ at rest and restrict who can read them. - `events-{uuid}.db` and `archive-….db` hold the **full payload body and headers** of every event as received, including whatever the sending service put in them — tokens, signatures, personal data. -- Event databases written before - [issue #206](https://git.eeqj.de/sneak/webhooker/issues/206) was fixed **also - contain target credentials**: a GORM association upsert on the delivery and - retry write path copied `targets` rows, `config` included, into the - per-webhook database. For a Slack target the `webhookUrl` _is_ the bearer - credential, and an `http` target's URL can embed userinfo. This version never - writes those rows; the first time it opens such a file it deletes them and - vacuums the file, which removes the credential bytes rather than only - unlinking the rows. Deleting alone would not: the bytes stay readable in the - file's free pages until it is rewritten. The sweep is recorded in the file's - `user_version` only once the vacuum returns, so a sweep that fails or is - interrupted fails the open and is retried on the next one, and a file this - version has opened without error holds no leaked rows and no recoverable bytes - from them. On upgrade this rewrites each existing `events-{uuid}.db` once, on - its first open. Two cases still hand over live delivery destinations: a backup - taken from an older build, and a backup of a file this version has not yet - opened successfully. Copies already made stay affected — the sweep only - rewrites the file it opens, and freed blocks may persist in filesystem - snapshots and on the underlying storage. Rotate any target credential that was - in a backup you cannot account for. - `webhooker.db` stores target config **unencrypted**, tracked at [issue #212](https://git.eeqj.de/sneak/webhooker/issues/212), next to the session encryption key and the Argon2id password hashes. @@ -1919,8 +1900,9 @@ Deliveries still `pending` or `retrying` count in neither. #### Event-tier indexes -These indexes on the per-webhook event databases are declared in the model tags, -so `AutoMigrate` creates them on a fresh database: +These indexes on the per-webhook event databases are created by +`internal/db/migrations/events/001_schema.sql`, and declared in the model tags +to match: | Table | Columns | Serves | | ------------------ | ------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | @@ -1984,8 +1966,9 @@ and application state: - **Targets** — delivery destination configurations - **APIKeys** — programmatic access credentials -On first startup the main database is auto-migrated, a session encryption key is -generated and stored, and an `admin` user is created. +On first startup the main database is created from its SQL files (see +[Upgrades](#upgrades)), a session encryption key is generated and stored, and an +`admin` user is created. **Per-Webhook Event Databases** (`{DATA_DIR}/events-{webhook_uuid}.db`) — each webhook gets its own dedicated SQLite file containing: @@ -1997,9 +1980,10 @@ webhook gets its own dedicated SQLite file containing: the above, the deliveries per target, and each entrypoint's last event, kept through retention -Per-webhook databases are created automatically when a webhook is created. They -are managed by the `WebhookDBManager` component, which handles connection -pooling, lazy opening, migrations, and cleanup. +Per-webhook databases are created automatically when a webhook is created, from +the SQL files in `internal/db/migrations/events/`. They hold no copy of the main +database's tables. They are managed by the `WebhookDBManager` component, which +handles connection pooling, lazy opening, migrations, and cleanup. A per-webhook database that is missing or zero-length later means its webhook's events and pending deliveries are gone. The next time it is opened, an empty one @@ -2083,14 +2067,15 @@ the service again. After each write the archive handle is closed and reopened, debounced to at most once per second, so an operator can move the archive file away for offline archiving without stopping the service; a moved or removed archive file is -recreated automatically on the next write. A zero-length archive file is written -to as a new archive: SQLite opens it as an empty database, so it holds nothing -to lose. An optional `expiry` in the target's config JSON (e.g. -`{"expiry":"720h"}`) is validated when the target is created — the default -(unset or the literal `never`) keeps rows forever — and rows older than the -expiry are pruned each time the archive is (re)opened. An archive write failure -is never silent success: the delivery records a failed attempt with the error -and is marked failed. +recreated automatically on the next write, from the SQL files in +`internal/db/migrations/archive/`. A zero-length archive file is written to as a +new archive: SQLite opens it as an empty database, so it holds nothing to lose. +An optional `expiry` in the target's config JSON (e.g. `{"expiry":"720h"}`) is +validated when the target is created — the default (unset or the literal +`never`) keeps rows forever — and rows older than the expiry are pruned each +time the archive is (re)opened. An archive write failure is never silent +success: the delivery records a failed attempt with the error and is marked +failed. Because reopens only happen on writes, an archive whose target has stopped receiving events would never be pruned. A background **archive sweeper** closes @@ -3032,7 +3017,6 @@ webhooker/ │ ├── database/ │ │ ├── base_model.go # BaseModel with UUID primary keys │ │ ├── database.go # GORM connection, migrations, admin seed -│ │ ├── models.go # AutoMigrate for config-tier models │ │ ├── model_setting.go # Setting entity (key-value app config) │ │ ├── model_user.go # User entity │ │ ├── model_webhook.go # Webhook entity @@ -3049,6 +3033,11 @@ webhooker/ │ │ └── databasetest/ # Test support: a WebhookDBManager for tests in other packages │ ├── datadir/ │ │ └── lock.go # Exclusive advisory lock on DATA_DIR (one instance) +│ ├── db/migrations/ +│ │ ├── migrations.go # Applies a database file's SQL files when it is opened +│ │ ├── main/ # 000_migration.sql and 001_schema.sql for webhooker.db +│ │ ├── events/ # The same for each events-{uuid}.db +│ │ └── archive/ # The same for each archive file │ ├── globals/ │ │ └── globals.go # Build-time variables (appname, version, arch) │ ├── gormlog/ diff --git a/internal/database/database.go b/internal/database/database.go index 66f4353..d0beff0 100644 --- a/internal/database/database.go +++ b/internal/database/database.go @@ -18,6 +18,7 @@ import ( "sneak.berlin/go/webhooker/internal/banner" "sneak.berlin/go/webhooker/internal/config" "sneak.berlin/go/webhooker/internal/datadir" + "sneak.berlin/go/webhooker/internal/db/migrations" "sneak.berlin/go/webhooker/internal/gormlog" "sneak.berlin/go/webhooker/internal/logger" ) @@ -249,12 +250,21 @@ func (d *Database) connectTo(dataDir string) error { return fmt.Errorf("migrating %s: %w", dbPath, err) } + err = parseModels( + db, &Setting{}, &User{}, &APIKey{}, + &Webhook{}, &Entrypoint{}, &Target{}, + ) + if err != nil { + return fmt.Errorf("connecting to %s: %w", dbPath, err) + } + return nil } +// migrate applies the main database's SQL files. See +// internal/db/migrations. func (d *Database) migrate() error { - // Run GORM auto-migrations - err := d.Migrate() + err := migrations.Apply(d.db, migrations.Main) if err != nil { d.log.Error( "failed to run database migrations", @@ -269,6 +279,21 @@ func (d *Database) migrate() error { return nil } +// parseModels has GORM parse each model for db before the handle is +// shared. GORM parses a model the first time a handle uses it, and two +// goroutines doing that at once for models that refer to each other, +// as Webhook and Target do, race inside GORM. +func parseModels(db *gorm.DB, models ...any) error { + for _, model := range models { + err := (&gorm.Statement{DB: db}).Parse(model) + if err != nil { + return fmt.Errorf("parsing model %T: %w", model, err) + } + } + + return nil +} + // ensureAdminUser creates the bootstrap admin account when the // deployment has no users at all. func (d *Database) ensureAdminUser() error { diff --git a/internal/database/event_db_isolation.go b/internal/database/event_db_isolation.go index bd1abc7..202a2a4 100644 --- a/internal/database/event_db_isolation.go +++ b/internal/database/event_db_isolation.go @@ -2,7 +2,6 @@ package database import ( "fmt" - "log/slog" "gorm.io/gorm" "gorm.io/gorm/clause" @@ -18,14 +17,14 @@ const omitAssociationsCallback = "webhooker:omit_associations" // // A per-webhook database holds the event tier only, but Delivery // declares belongs-to Event and Target and the delivery engine fills -// both in memory before writing. Without this guard GORM upserts -// those parent rows here on the delivery and retry write paths, -// copying targets.config, which holds destination URLs and bearer -// credentials, into the file most likely to be backed up or handed -// to someone else. Registering the guard on the connection covers -// every write path, including writes inside a transaction and write -// paths added later. Every event-tier row this file holds is written -// explicitly, so nothing depends on the automatic save. +// both in memory before writing. Without this guard GORM upserts those +// parent rows here on the delivery and retry write paths, and the +// target, whose config holds destination URLs and bearer credentials, +// goes to a targets table this file does not have, which fails the +// write. Registering the guard on the connection +// covers every write path, including writes inside a transaction and +// write paths added later. Every event-tier row this file holds is +// written explicitly, so nothing depends on the automatic save. func omitAssociations(db *gorm.DB) error { omit := func(tx *gorm.DB) { tx.Statement.Omits = append( @@ -53,107 +52,3 @@ func omitAssociations(db *gorm.DB) error { return nil } - -// eventDBSweptVersion is the PRAGMA user_version purgeTargetRows -// stamps into a per-webhook database once it has removed any leaked -// target rows *and* the VACUUM that removes their bytes has returned. -// Nothing else in the tree uses user_version, so 0 means "not swept -// by this build". -// -// The stamp, not the DELETE, is what records that a file is done. A -// DELETE commits on its own, so a sweep that is interrupted or whose -// VACUUM fails leaves a file whose rows are gone but whose credential -// bytes are still in the free pages -- indistinguishable, by row -// count, from a file that never leaked. Both leave the stamp unset, -// so the next open sweeps again. -const eventDBSweptVersion = 1 - -// purgeTargetRows deletes target rows that an earlier build's -// association upsert wrote into a per-webhook database, and rewrites -// the file so their bytes are gone with them. AutoMigrate creates a -// targets table in every one of these files because Delivery declares -// a belongs-to Target, but nothing in the event tier may put rows in -// it. The rows it did put there are junk, not history: they carry an -// empty webhook_id, and delivery rows resolve their target against -// the main database, so nothing here refers to them. -// -// The DELETE only unlinks the rows: modernc.org/sqlite leaves -// secure_delete at SQLite's default of off, so the credential bytes -// stay readable in the file's free pages and a backup of a swept file -// would still hand them over. VACUUM rewrites the file without them. -// -// This runs before every migration and is gated on -// eventDBSweptVersion, so a file pays for the rewrite once, on the -// first open that finds it unstamped, and every open after that is a -// PRAGMA read. A file this build created is stamped before its -// targets table exists, so it never vacuums at all. A failure here -// fails the open with the stamp left unset, so the sweep is retried -// rather than skipped -- a webhook whose file cannot be swept stays -// unusable instead of quietly serving from a file that still holds -// recoverable credentials. -func purgeTargetRows( - db *gorm.DB, log *slog.Logger, webhookID string, -) error { - var version int - - // Row().Scan, not (*gorm.DB).Scan: see internal/gormlog. - err := db.Raw("PRAGMA user_version").Row().Scan(&version) - if err != nil { - return fmt.Errorf( - "reading sweep marker of webhook database %s: %w", - webhookID, err, - ) - } - - if version >= eventDBSweptVersion { - return nil - } - - var purged int64 - - if db.Migrator().HasTable("targets") { - res := db.Exec("DELETE FROM targets") - if res.Error != nil { - return fmt.Errorf( - "purging target rows from webhook database %s: %w", - webhookID, res.Error, - ) - } - - purged = res.RowsAffected - - // Unconditional: a zero row count here does not mean there is - // nothing to remove, only that no *live* row is left. See - // eventDBSweptVersion. - err = db.Exec("VACUUM").Error - if err != nil { - return fmt.Errorf( - "purged %d leaked target rows from webhook database "+ - "%s but vacuuming it failed, so the deleted "+ - "target credentials are still recoverable from "+ - "the file; it stays marked unswept and the next "+ - "open retries: %w", - purged, webhookID, err, - ) - } - } - - err = db.Exec(fmt.Sprintf( - "PRAGMA user_version = %d", eventDBSweptVersion, - )).Error - if err != nil { - return fmt.Errorf( - "marking webhook database %s swept: %w", webhookID, err, - ) - } - - if purged > 0 { - log.Warn( - "purged leaked target rows from per-webhook database", - "webhook_id", webhookID, - "rows", purged, - ) - } - - return nil -} diff --git a/internal/database/event_db_isolation_test.go b/internal/database/event_db_isolation_test.go index 04d6fb4..6f38dbb 100644 --- a/internal/database/event_db_isolation_test.go +++ b/internal/database/event_db_isolation_test.go @@ -1,392 +1,15 @@ package database_test import ( - "bytes" - "database/sql" - "fmt" - "os" - "path/filepath" "testing" "github.com/google/uuid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - _ "modernc.org/sqlite" "sneak.berlin/go/webhooker/internal/database" "sneak.berlin/go/webhooker/internal/database/databasetest" ) -// testDataDirPerm is the mode the test data directory is created -// with. -const testDataDirPerm = 0o750 - -// eventDBDataDir returns a data directory that a WebhookDBManager -// can be pointed at. -func eventDBDataDir(t *testing.T) string { - t.Helper() - - dir := filepath.Join(t.TempDir(), "events") - require.NoError(t, os.MkdirAll(dir, testDataDirPerm)) - - return dir -} - -// openRawEventDB opens the per-webhook database file directly, -// without the manager, so a test can put a file on disk in a state -// the manager has to cope with, or inspect one afterwards. -func openRawEventDB( - t *testing.T, dataDir, webhookID string, -) *sql.DB { - t.Helper() - - path := filepath.Join( - dataDir, fmt.Sprintf("events-%s.db", webhookID), - ) - - sqlDB, err := sql.Open( - "sqlite", - fmt.Sprintf("file:%s?mode=rwc", path), - ) - require.NoError(t, err) - - t.Cleanup(func() { _ = sqlDB.Close() }) - - return sqlDB -} - -// eventDBFileBytes reads a per-webhook database file off disk, so a -// test can assert on what the file itself still holds rather than on -// what a query returns. -func eventDBFileBytes(t *testing.T, dataDir, webhookID string) []byte { - t.Helper() - - //nolint:gosec // reads a file the test just created under t.TempDir() - raw, err := os.ReadFile(filepath.Join( - dataDir, fmt.Sprintf("events-%s.db", webhookID), - )) - require.NoError(t, err) - - return raw -} - -// eventDBUserVersion returns the PRAGMA user_version of a per-webhook -// database file, which is the marker purgeTargetRows stamps once it -// has swept and vacuumed. -func eventDBUserVersion(t *testing.T, sqlDB *sql.DB) int { - t.Helper() - - var version int - - require.NoError(t, sqlDB.QueryRowContext( - t.Context(), "PRAGMA user_version", - ).Scan(&version)) - - return version -} - -// clearEventDBSweptMarker resets the sweep marker to 0, which is what -// a file written by a build without the sweep looks like. Tests that -// seed a leaked row have to create the file through the manager to -// get the real targets table shape, and that stamps it. -func clearEventDBSweptMarker(t *testing.T, sqlDB *sql.DB) { - t.Helper() - - _, err := sqlDB.ExecContext(t.Context(), "PRAGMA user_version = 0") - require.NoError(t, err) -} - -// countTargetRows returns the number of rows in the targets table of -// a per-webhook database file, or -1 if the table does not exist. -func countTargetRows(t *testing.T, sqlDB *sql.DB) int { - t.Helper() - - var tables int - - require.NoError(t, sqlDB.QueryRowContext( - t.Context(), - "SELECT count(*) FROM sqlite_master "+ - "WHERE type = 'table' AND name = 'targets'", - ).Scan(&tables)) - - if tables == 0 { - return -1 - } - - var rows int - - require.NoError(t, sqlDB.QueryRowContext( - t.Context(), "SELECT count(*) FROM targets", - ).Scan(&rows)) - - return rows -} - -// TestOpenPurgesLeakedTargetRows covers the sweep for event -// databases written by a build that let GORM upsert target rows -// into them: opening the database clears them, and opening it again -// is a no-op. -func TestOpenPurgesLeakedTargetRows(t *testing.T) { - t.Parallel() - - dataDir := eventDBDataDir(t) - webhookID := uuid.New().String() - - // Create the file the way the application does, so the targets - // table has exactly the shape AutoMigrate gives it, then write - // a leaked row into it the way the association upsert did. - initial := databasetest.NewWebhookDBManager(t, dataDir) - - _, err := initial.GetDB(webhookID) - require.NoError(t, err) - require.NoError(t, initial.CloseAll()) - - seed := openRawEventDB(t, dataDir, webhookID) - - _, err = seed.ExecContext( - t.Context(), - "INSERT INTO targets "+ - "(id, webhook_id, name, type, config) "+ - "VALUES (?, '', ?, ?, ?)", - uuid.New().String(), - "leaked-target", - "slack", - `{"webhookUrl":"https://hooks.example/T000/B000/secret"}`, - ) - require.NoError(t, err) - require.Equal(t, 1, countTargetRows(t, seed)) - clearEventDBSweptMarker(t, seed) - require.NoError(t, seed.Close()) - - mgr := databasetest.NewWebhookDBManager(t, dataDir) - - _, err = mgr.GetDB(webhookID) - require.NoError(t, err) - require.NoError(t, mgr.CloseAll()) - - check := openRawEventDB(t, dataDir, webhookID) - assert.Zero(t, countTargetRows(t, check)) - assert.Equal( - t, 1, eventDBUserVersion(t, check), - "a completed sweep must mark the file so later opens skip it", - ) - require.NoError(t, check.Close()) - - // Idempotent: a second open leaves it at zero and does not - // error. - again := databasetest.NewWebhookDBManager(t, dataDir) - - _, err = again.GetDB(webhookID) - require.NoError(t, err) - require.NoError(t, again.CloseAll()) - - recheck := openRawEventDB(t, dataDir, webhookID) - assert.Zero(t, countTargetRows(t, recheck)) -} - -// TestOpenPurgeRemovesCredentialBytes covers the sweep at the level -// that matters for a backup handed to someone else: the leaked -// credential must be gone from the raw bytes of the file, not merely -// unreachable by query. A bare DELETE unlinks the row and leaves the -// bytes readable in the free pages, so this fails without the VACUUM -// in purgeTargetRows. -func TestOpenPurgeRemovesCredentialBytes(t *testing.T) { - t.Parallel() - - dataDir := eventDBDataDir(t) - webhookID := uuid.New().String() - credential := "T00000000/B00000000/" + uuid.New().String() - - initial := databasetest.NewWebhookDBManager(t, dataDir) - - _, err := initial.GetDB(webhookID) - require.NoError(t, err) - require.NoError(t, initial.CloseAll()) - - seed := openRawEventDB(t, dataDir, webhookID) - - _, err = seed.ExecContext( - t.Context(), - "INSERT INTO targets "+ - "(id, webhook_id, name, type, config) "+ - "VALUES (?, '', ?, ?, ?)", - uuid.New().String(), - "leaked-target", - "slack", - fmt.Sprintf( - `{"webhookUrl":"https://hooks.example/%s"}`, credential, - ), - ) - require.NoError(t, err) - clearEventDBSweptMarker(t, seed) - require.NoError(t, seed.Close()) - - // The seed has to be in the file for its absence later to mean - // anything. - require.True( - t, - bytes.Contains( - eventDBFileBytes(t, dataDir, webhookID), - []byte(credential), - ), - "seeded credential is not in the file, so this test proves nothing", - ) - - mgr := databasetest.NewWebhookDBManager(t, dataDir) - - _, err = mgr.GetDB(webhookID) - require.NoError(t, err) - require.NoError(t, mgr.CloseAll()) - - assert.NotContains( - t, - string(eventDBFileBytes(t, dataDir, webhookID)), - credential, - "leaked credential is still recoverable from the raw file", - ) -} - -// TestOpenRevacuumsAfterIncompleteSweep covers the case a row count -// cannot see: the rows are already deleted but the file was never -// vacuumed, because an earlier sweep died between the two or its -// VACUUM failed. The credential bytes are still recoverable, and the -// unset marker is the only thing that says so, so the next open must -// vacuum rather than conclude from the empty table that there is -// nothing to do. -func TestOpenRevacuumsAfterIncompleteSweep(t *testing.T) { - t.Parallel() - - dataDir := eventDBDataDir(t) - webhookID := uuid.New().String() - credential := "T00000000/B00000000/" + uuid.New().String() - - initial := databasetest.NewWebhookDBManager(t, dataDir) - - _, err := initial.GetDB(webhookID) - require.NoError(t, err) - require.NoError(t, initial.CloseAll()) - - seed := openRawEventDB(t, dataDir, webhookID) - - _, err = seed.ExecContext( - t.Context(), - "INSERT INTO targets "+ - "(id, webhook_id, name, type, config) "+ - "VALUES (?, '', ?, ?, ?)", - uuid.New().String(), - "leaked-target", - "slack", - fmt.Sprintf( - `{"webhookUrl":"https://hooks.example/%s"}`, credential, - ), - ) - require.NoError(t, err) - - // Exactly the state an interrupted sweep leaves: rows gone, - // marker unset, bytes still in the free pages. - _, err = seed.ExecContext(t.Context(), "DELETE FROM targets") - require.NoError(t, err) - require.Zero(t, countTargetRows(t, seed)) - clearEventDBSweptMarker(t, seed) - require.NoError(t, seed.Close()) - - require.True( - t, - bytes.Contains( - eventDBFileBytes(t, dataDir, webhookID), - []byte(credential), - ), - "the deleted row's bytes must still be in the file, or this "+ - "test proves nothing", - ) - - mgr := databasetest.NewWebhookDBManager(t, dataDir) - - _, err = mgr.GetDB(webhookID) - require.NoError(t, err) - require.NoError(t, mgr.CloseAll()) - - assert.NotContains( - t, - string(eventDBFileBytes(t, dataDir, webhookID)), - credential, - "an interrupted sweep was not retried, so the credential is "+ - "still recoverable from the raw file", - ) - - check := openRawEventDB(t, dataDir, webhookID) - assert.Equal(t, 1, eventDBUserVersion(t, check)) -} - -// TestOpenSkipsSweptDatabase covers the other half of the marker: a -// file this build created is marked without ever being vacuumed, and -// a marked file is not swept again. -func TestOpenSkipsSweptDatabase(t *testing.T) { - t.Parallel() - - dataDir := eventDBDataDir(t) - webhookID := uuid.New().String() - - mgr := databasetest.NewWebhookDBManager(t, dataDir) - - _, err := mgr.GetDB(webhookID) - require.NoError(t, err) - require.NoError(t, mgr.CloseAll()) - - marked := openRawEventDB(t, dataDir, webhookID) - assert.Equal(t, 1, eventDBUserVersion(t, marked)) - - // A marked file is left alone, so a row written into it survives - // a reopen. Nothing writes target rows any more; this stands in - // for the sweep having run. - _, err = marked.ExecContext( - t.Context(), - "INSERT INTO targets "+ - "(id, webhook_id, name, type, config) "+ - "VALUES (?, '', ?, ?, ?)", - uuid.New().String(), "sentinel", "slack", `{}`, - ) - require.NoError(t, err) - require.NoError(t, marked.Close()) - - again := databasetest.NewWebhookDBManager(t, dataDir) - - _, err = again.GetDB(webhookID) - require.NoError(t, err) - require.NoError(t, again.CloseAll()) - - check := openRawEventDB(t, dataDir, webhookID) - assert.Equal( - t, 1, countTargetRows(t, check), - "a marked file must not be swept again", - ) -} - -// TestOpenSucceedsWithoutTargetsTable covers an existing event -// database that never grew a targets table. The sweep must not fail -// startup on it. -func TestOpenSucceedsWithoutTargetsTable(t *testing.T) { - t.Parallel() - - dataDir := eventDBDataDir(t) - webhookID := uuid.New().String() - - seed := openRawEventDB(t, dataDir, webhookID) - - _, err := seed.ExecContext( - t.Context(), - "CREATE TABLE events (id text PRIMARY KEY)", - ) - require.NoError(t, err) - require.NoError(t, seed.Close()) - - mgr := databasetest.NewWebhookDBManager(t, dataDir) - - db, err := mgr.GetDB(webhookID) - require.NoError(t, err) - assert.NotNil(t, db) - require.NoError(t, mgr.CloseAll()) -} - // TestEventDBCreateOmitsAssociations covers the connection-level // guard directly: a Delivery carrying its Event and Target in // memory, written through the manager's handle, must store only the @@ -394,10 +17,9 @@ func TestOpenSucceedsWithoutTargetsTable(t *testing.T) { func TestEventDBCreateOmitsAssociations(t *testing.T) { t.Parallel() - dataDir := eventDBDataDir(t) webhookID := uuid.New().String() - mgr := databasetest.NewWebhookDBManager(t, dataDir) + mgr := databasetest.NewWebhookDBManager(t, t.TempDir()) db, err := mgr.GetDB(webhookID) require.NoError(t, err) @@ -432,8 +54,9 @@ func TestEventDBCreateOmitsAssociations(t *testing.T) { require.NoError(t, db.Model(d). Update("status", database.DeliveryStatusDelivered). Error) - require.NoError(t, mgr.CloseAll()) - check := openRawEventDB(t, dataDir, webhookID) - assert.Zero(t, countTargetRows(t, check)) + // The file has no targets table, so the create and update above + // would have failed had GORM tried to save the target. + assert.False(t, db.Migrator().HasTable(&database.Target{})) + require.NoError(t, mgr.CloseAll()) } diff --git a/internal/database/event_tier_indexes_test.go b/internal/database/event_tier_indexes_test.go index 09f97a5..65c1375 100644 --- a/internal/database/event_tier_indexes_test.go +++ b/internal/database/event_tier_indexes_test.go @@ -13,10 +13,8 @@ import ( "sneak.berlin/go/webhooker/internal/database" ) -// TestWebhookDBManager_OpenAddsEventTierIndexes verifies that opening a -// per-webhook database that predates these indexes creates them. It -// stands in for an older database file by dropping the indexes -// AutoMigrate just created, then reopening the same file. +// TestWebhookDBManager_OpenAddsEventTierIndexes verifies that a new +// per-webhook database has these indexes. func TestWebhookDBManager_OpenAddsEventTierIndexes(t *testing.T) { t.Parallel() @@ -42,27 +40,9 @@ func TestWebhookDBManager_OpenAddsEventTierIndexes(t *testing.T) { db, err := mgr.GetDB(webhookID) require.NoError(t, err) - // A fresh database has them. - for _, ix := range indexes { - require.True(t, db.Migrator().HasIndex(ix.model, ix.name)) - } - - // Stand in for a database file created before the indexes existed. - for _, ix := range indexes { - require.NoError(t, db.Migrator().DropIndex(ix.model, ix.name)) - require.False(t, db.Migrator().HasIndex(ix.model, ix.name)) - } - - // Drop the cached connection so the next open reopens the file and - // runs AutoMigrate against it, as a restart would. - require.NoError(t, mgr.CloseAll()) - - db, err = mgr.GetDB(webhookID) - require.NoError(t, err) - for _, ix := range indexes { assert.True(t, db.Migrator().HasIndex(ix.model, ix.name), - "opening the existing database should create %s", ix.name) + "a new database should have %s", ix.name) } } diff --git a/internal/database/models.go b/internal/database/models.go deleted file mode 100644 index e18fb6b..0000000 --- a/internal/database/models.go +++ /dev/null @@ -1,17 +0,0 @@ -package database - -// Migrate runs database migrations for the main application database. -// Only configuration-tier models are stored in the main database. -// Event-tier models (Event, Delivery, DeliveryResult, EventTotals, -// TargetTotals, EntrypointTotals) live in -// per-webhook dedicated databases managed by WebhookDBManager. -func (d *Database) Migrate() error { - return d.db.AutoMigrate( - &Setting{}, - &User{}, - &APIKey{}, - &Webhook{}, - &Entrypoint{}, - &Target{}, - ) -} diff --git a/internal/database/schema_test.go b/internal/database/schema_test.go new file mode 100644 index 0000000..cc378ff --- /dev/null +++ b/internal/database/schema_test.go @@ -0,0 +1,168 @@ +package database_test + +import ( + "log/slog" + "net/http" + "testing" + "time" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/gorm" + "sneak.berlin/go/webhooker/internal/database" + "sneak.berlin/go/webhooker/internal/database/databasetest" +) + +// withFixedNow returns db with GORM's clock stopped at a time in UTC, +// so that the created and updated times it writes come back from +// SQLite equal to the row written, which holds them in UTC. +func withFixedNow(db *gorm.DB) (*gorm.DB, time.Time) { + at := time.Date(2026, 10, 13, 12, 30, 45, 123456789, time.UTC) + + return db.Session(&gorm.Session{ + NowFunc: func() time.Time { return at }, + }), at +} + +// newBaseModel returns a BaseModel with a new ID. +func newBaseModel() database.BaseModel { + return database.BaseModel{ID: uuid.New().String()} +} + +// assertRoundTrip writes row, which must be the only row of its +// table, and reads back the same row. +func assertRoundTrip[T any](t *testing.T, db *gorm.DB, row *T) { + t.Helper() + + require.NoError(t, db.Create(row).Error) + + var got T + + require.NoError(t, db.First(&got).Error) + assert.Equal(t, *row, got) +} + +// TestMainSchemaHoldsEveryModel writes a row of each main database +// model, every field set, into a database made from its SQL files. +func TestMainSchemaHoldsEveryModel(t *testing.T) { + t.Parallel() + + mainDB, err := database.Open( + t.TempDir(), slog.New(slog.DiscardHandler), + ) + require.NoError(t, err) + + t.Cleanup(func() { _ = mainDB.Close() }) + + db, at := withFixedNow(mainDB.DB()) + + assertRoundTrip(t, db, &database.Setting{ + Key: "session_key", Value: "value", + }) + assertRoundTrip(t, db, &database.User{ + BaseModel: newBaseModel(), + Username: "user", + Password: "password hash", + }) + assertRoundTrip(t, db, &database.APIKey{ + BaseModel: newBaseModel(), + UserID: uuid.New().String(), + Key: "key", + Description: "API key description", + LastUsedAt: &at, + }) + assertRoundTrip(t, db, &database.Webhook{ + BaseModel: newBaseModel(), + UserID: uuid.New().String(), + Name: "webhook", + Description: "webhook description", + RetentionDays: 7, + }) + assertRoundTrip(t, db, &database.Entrypoint{ + BaseModel: newBaseModel(), + WebhookID: uuid.New().String(), + Path: uuid.New().String(), + Description: "entrypoint description", + Active: true, + }) + assertRoundTrip(t, db, &database.Target{ + BaseModel: newBaseModel(), + WebhookID: uuid.New().String(), + Name: "target", + Type: database.TargetTypeHTTP, + Active: true, + Config: `{"url":"https://example.com/"}`, + MaxRetries: 3, + }) +} + +// TestEventSchemaHoldsEveryModel does the same for a webhook's event +// database, which the manager makes from its SQL files. +func TestEventSchemaHoldsEveryModel(t *testing.T) { + t.Parallel() + + mgr := databasetest.NewWebhookDBManager(t, t.TempDir()) + + t.Cleanup(func() { _ = mgr.CloseAll() }) + + events, err := mgr.GetDB(uuid.New().String()) + require.NoError(t, err) + + db, at := withFixedNow(events) + source := uuid.New().String() + + assertRoundTrip(t, db, &database.Event{ + BaseModel: newBaseModel(), + WebhookID: uuid.New().String(), + EntrypointID: uuid.New().String(), + Method: http.MethodPost, + RawQuery: "a=1", + Headers: `{"X":"Y"}`, + Body: "body", + ContentType: "text/plain", + BodyBytes: 4, + ResubmittedFromID: &source, + }) + assertRoundTrip(t, db, &database.Delivery{ + BaseModel: newBaseModel(), + EventID: uuid.New().String(), + TargetID: uuid.New().String(), + Status: database.DeliveryStatusDelivered, + FinishedAt: &at, + Replay: true, + }) + assertRoundTrip(t, db, &database.DeliveryResult{ + BaseModel: newBaseModel(), + DeliveryID: uuid.New().String(), + AttemptNum: 2, + Success: true, + StatusCode: 200, + ResponseBody: "ok", + Error: "error", + Duration: 15, + }) + assertRoundTrip(t, db, &database.TargetTotals{ + TargetID: uuid.New().String(), + Deliveries: 5, + Delivered: 3, + Failed: 1, + DeliveriesRemoved: 2, + FailedRemoved: 1, + }) + assertRoundTrip(t, db, &database.EntrypointTotals{ + EntrypointID: uuid.New().String(), LastEventAt: at, + }) + + // A new event database has its one row of event totals already. + require.NoError(t, database.AddEventTotals(db, database.EventTotals{ + Events: 3, EventsRemoved: 1, LastEventAt: &at, + })) + + var totals []database.EventTotals + + require.NoError(t, db.Find(&totals).Error) + assert.Equal(t, []database.EventTotals{{ + ID: 1, Events: 3, EventsRemoved: 1, LastEventAt: &at, + }}, totals) +} diff --git a/internal/database/webhook_db_manager.go b/internal/database/webhook_db_manager.go index 6dc17a9..7886457 100644 --- a/internal/database/webhook_db_manager.go +++ b/internal/database/webhook_db_manager.go @@ -14,6 +14,7 @@ import ( "gorm.io/gorm" "sneak.berlin/go/webhooker/internal/config" "sneak.berlin/go/webhooker/internal/datadir" + "sneak.berlin/go/webhooker/internal/db/migrations" "sneak.berlin/go/webhooker/internal/gormlog" ) @@ -370,18 +371,7 @@ func (m *WebhookDBManager) openDB( ) } - err = purgeTargetRows(db, m.log, webhookID) - if err != nil { - _ = sqlDB.Close() - - return nil, err - } - - // Run migrations for event-tier models only - err = db.AutoMigrate( - &Event{}, &Delivery{}, &DeliveryResult{}, - &EventTotals{}, &TargetTotals{}, &EntrypointTotals{}, - ) + err = migrations.Apply(db, migrations.Events) if err != nil { _ = sqlDB.Close() @@ -391,14 +381,15 @@ func (m *WebhookDBManager) openDB( ) } - // A new database gets its row of event totals, all zero. Target - // totals rows are created by the first delivery to each target. - err = db.FirstOrCreate(&EventTotals{}).Error + err = parseModels( + db, &Event{}, &Delivery{}, &DeliveryResult{}, + &EventTotals{}, &TargetTotals{}, &EntrypointTotals{}, + ) if err != nil { _ = sqlDB.Close() return nil, fmt.Errorf( - "creating event totals for webhook database %s: %w", + "connecting to webhook database %s: %w", webhookID, err, ) } diff --git a/internal/db/migrations/archive/000_migration.sql b/internal/db/migrations/archive/000_migration.sql new file mode 100644 index 0000000..a219dc6 --- /dev/null +++ b/internal/db/migrations/archive/000_migration.sql @@ -0,0 +1,9 @@ +-- Migration 000: the table recording which migrations this file has +-- had. Applied before the others; it records itself. + +CREATE TABLE IF NOT EXISTS schema_migrations ( + version INTEGER PRIMARY KEY, + applied_at DATETIME DEFAULT CURRENT_TIMESTAMP +); + +INSERT OR IGNORE INTO schema_migrations (version) VALUES (0); diff --git a/internal/db/migrations/archive/001_schema.sql b/internal/db/migrations/archive/001_schema.sql new file mode 100644 index 0000000..ceff586 --- /dev/null +++ b/internal/db/migrations/archive/001_schema.sql @@ -0,0 +1,18 @@ +-- Migration 001: the schema of a database target's archive file. The +-- archivedEvent model in internal/delivery must agree with it. + +CREATE TABLE archived_events ( + id integer PRIMARY KEY AUTOINCREMENT, + event_id text, + webhook_id text, + entrypoint_id text, + method text, + raw_query text, + headers text, + body text, + content_type text, + archived_at datetime +); + +CREATE INDEX idx_archived_events_event_id ON archived_events (event_id); +CREATE INDEX idx_archived_events_archived_at ON archived_events (archived_at); diff --git a/internal/db/migrations/events/000_migration.sql b/internal/db/migrations/events/000_migration.sql new file mode 100644 index 0000000..a219dc6 --- /dev/null +++ b/internal/db/migrations/events/000_migration.sql @@ -0,0 +1,9 @@ +-- Migration 000: the table recording which migrations this file has +-- had. Applied before the others; it records itself. + +CREATE TABLE IF NOT EXISTS schema_migrations ( + version INTEGER PRIMARY KEY, + applied_at DATETIME DEFAULT CURRENT_TIMESTAMP +); + +INSERT OR IGNORE INTO schema_migrations (version) VALUES (0); diff --git a/internal/db/migrations/events/001_schema.sql b/internal/db/migrations/events/001_schema.sql new file mode 100644 index 0000000..20648dc --- /dev/null +++ b/internal/db/migrations/events/001_schema.sql @@ -0,0 +1,98 @@ +-- Migration 001: the schema of a webhook's event database. The models +-- in internal/database must agree with it. The webhook, entrypoint and +-- target ids here name rows in the main database, which has those +-- tables; this file does not. + +CREATE TABLE events ( + id uuid, + created_at datetime, + updated_at datetime, + deleted_at datetime, + webhook_id uuid NOT NULL, + entrypoint_id uuid NOT NULL, + method text NOT NULL, + raw_query text, + headers text, + body text, + content_type text, + body_bytes integer NOT NULL, + resubmitted_from_id uuid, + PRIMARY KEY (id) +); + +CREATE INDEX idx_events_created_at ON events (created_at); +CREATE INDEX idx_events_deleted_at ON events (deleted_at); +CREATE INDEX idx_events_deleted_at_created_at + ON events (deleted_at, created_at); +CREATE INDEX idx_events_resubmitted_from_id + ON events (resubmitted_from_id, deleted_at); +CREATE INDEX idx_events_entrypoint_id + ON events (entrypoint_id, deleted_at, resubmitted_from_id, created_at); + +CREATE TABLE deliveries ( + id uuid, + created_at datetime, + updated_at datetime, + deleted_at datetime, + event_id uuid NOT NULL, + target_id uuid NOT NULL, + status text NOT NULL DEFAULT 'pending', + finished_at datetime, + replay numeric NOT NULL DEFAULT false, + PRIMARY KEY (id), + CONSTRAINT fk_events_deliveries + FOREIGN KEY (event_id) REFERENCES events (id) +); + +CREATE INDEX idx_deliveries_deleted_at ON deliveries (deleted_at); +CREATE INDEX idx_deliveries_event_id ON deliveries (event_id, deleted_at); +CREATE INDEX idx_deliveries_status + ON deliveries (status, deleted_at, finished_at, target_id); + +CREATE TABLE delivery_results ( + id uuid, + created_at datetime, + updated_at datetime, + deleted_at datetime, + delivery_id uuid NOT NULL, + attempt_num integer NOT NULL, + success numeric, + status_code integer, + response_body text, + error text, + duration integer, + PRIMARY KEY (id), + CONSTRAINT fk_deliveries_delivery_results + FOREIGN KEY (delivery_id) REFERENCES deliveries (id) +); + +CREATE INDEX idx_delivery_results_deleted_at + ON delivery_results (deleted_at); +CREATE INDEX idx_delivery_results_delivery_id + ON delivery_results (delivery_id, deleted_at); + +CREATE TABLE event_totals ( + id integer PRIMARY KEY AUTOINCREMENT, + events integer NOT NULL, + events_removed integer NOT NULL, + last_event_at datetime +); + +-- The one row of event totals, all zero. +INSERT INTO event_totals (events, events_removed) VALUES (0, 0); + +CREATE TABLE target_totals ( + target_id uuid, + deliveries integer NOT NULL, + delivered integer NOT NULL, + failed integer NOT NULL, + deliveries_removed integer NOT NULL, + failed_removed integer NOT NULL, + PRIMARY KEY (target_id) +); + +CREATE TABLE entrypoint_totals ( + entrypoint_id uuid, + last_event_at datetime NOT NULL, + PRIMARY KEY (entrypoint_id) +); diff --git a/internal/db/migrations/main/000_migration.sql b/internal/db/migrations/main/000_migration.sql new file mode 100644 index 0000000..a219dc6 --- /dev/null +++ b/internal/db/migrations/main/000_migration.sql @@ -0,0 +1,9 @@ +-- Migration 000: the table recording which migrations this file has +-- had. Applied before the others; it records itself. + +CREATE TABLE IF NOT EXISTS schema_migrations ( + version INTEGER PRIMARY KEY, + applied_at DATETIME DEFAULT CURRENT_TIMESTAMP +); + +INSERT OR IGNORE INTO schema_migrations (version) VALUES (0); diff --git a/internal/db/migrations/main/001_schema.sql b/internal/db/migrations/main/001_schema.sql new file mode 100644 index 0000000..4911a0d --- /dev/null +++ b/internal/db/migrations/main/001_schema.sql @@ -0,0 +1,89 @@ +-- Migration 001: the main database's schema. The models in +-- internal/database must agree with it. + +CREATE TABLE settings ( + "key" text, + value text NOT NULL, + PRIMARY KEY ("key") +); + +CREATE TABLE users ( + id uuid, + created_at datetime, + updated_at datetime, + deleted_at datetime, + username text NOT NULL, + password text NOT NULL, + PRIMARY KEY (id), + CONSTRAINT chk_users_username + CHECK (length(CAST(username AS BLOB)) <= 1024) +); + +CREATE INDEX idx_users_deleted_at ON users (deleted_at); +CREATE UNIQUE INDEX idx_users_username ON users (username); + +CREATE TABLE api_keys ( + id uuid, + created_at datetime, + updated_at datetime, + deleted_at datetime, + user_id uuid NOT NULL, + "key" text NOT NULL, + description text, + last_used_at datetime, + PRIMARY KEY (id), + CONSTRAINT fk_users_api_keys FOREIGN KEY (user_id) REFERENCES users (id) +); + +CREATE INDEX idx_api_keys_deleted_at ON api_keys (deleted_at); +CREATE UNIQUE INDEX idx_api_keys_key ON api_keys ("key"); + +CREATE TABLE webhooks ( + id uuid, + created_at datetime, + updated_at datetime, + deleted_at datetime, + user_id uuid NOT NULL, + name text NOT NULL, + description text, + retention_days integer DEFAULT 30, + PRIMARY KEY (id), + CONSTRAINT fk_users_webhooks FOREIGN KEY (user_id) REFERENCES users (id) +); + +CREATE INDEX idx_webhooks_deleted_at ON webhooks (deleted_at); + +CREATE TABLE entrypoints ( + id uuid, + created_at datetime, + updated_at datetime, + deleted_at datetime, + webhook_id uuid NOT NULL, + path text NOT NULL, + description text, + active numeric DEFAULT true, + PRIMARY KEY (id), + CONSTRAINT fk_webhooks_entrypoints + FOREIGN KEY (webhook_id) REFERENCES webhooks (id) +); + +CREATE INDEX idx_entrypoints_deleted_at ON entrypoints (deleted_at); +CREATE UNIQUE INDEX idx_entrypoints_path ON entrypoints (path); + +CREATE TABLE targets ( + id uuid, + created_at datetime, + updated_at datetime, + deleted_at datetime, + webhook_id uuid NOT NULL, + name text NOT NULL, + type text NOT NULL, + active numeric DEFAULT true, + config text, + max_retries integer, + PRIMARY KEY (id), + CONSTRAINT fk_webhooks_targets + FOREIGN KEY (webhook_id) REFERENCES webhooks (id) +); + +CREATE INDEX idx_targets_deleted_at ON targets (deleted_at); diff --git a/internal/db/migrations/migrations.go b/internal/db/migrations/migrations.go new file mode 100644 index 0000000..9351d62 --- /dev/null +++ b/internal/db/migrations/migrations.go @@ -0,0 +1,135 @@ +// Package migrations holds the schema of each kind of SQLite file +// webhooker writes, as numbered SQL files embedded in the binary, and +// applies it when a file is opened. +// +// Each kind of file has its own directory. In each, 000_migration.sql +// creates only the schema_migrations table, and 001_schema.sql holds +// the whole schema. Before 1.0 a schema change is an edit to +// 001_schema.sql, never a new file. +package migrations + +import ( + "embed" + "fmt" + "io/fs" + "path" + "strconv" + "strings" + + "gorm.io/gorm" +) + +// The directories, one for each kind of database file. +const ( + // Main is the main database, webhooker.db. + Main = "main" + + // Events is a webhook's event database, events-{uuid}.db. + Events = "events" + + // Archive is a database target's archive file. + Archive = "archive" +) + +//go:embed main/*.sql events/*.sql archive/*.sql +var files embed.FS + +// Apply runs each SQL file in dir, which is Main, Events or Archive, +// that the database has not had yet, in the order of the number its +// name starts with, and records that number in schema_migrations. +// 000_migration.sql creates that table and records itself, so it runs +// first, and only when the table is missing. +// +// It all runs in one transaction, so a failure leaves the file as it +// was. +func Apply(db *gorm.DB, dir string) error { + return db.Transaction(func(tx *gorm.DB) error { + var tables int + + // Row().Scan, not (*gorm.DB).Scan: see internal/gormlog. + err := tx.Raw( + "SELECT count(*) FROM sqlite_master "+ + "WHERE type = 'table' AND name = ?", + "schema_migrations", + ).Row().Scan(&tables) + if err != nil { + return fmt.Errorf("looking for schema_migrations: %w", err) + } + + if tables == 0 { + err = run(tx, path.Join(dir, "000_migration.sql")) + if err != nil { + return err + } + } + + // fs.Glob lists the files in name order, which is number order. + names, err := fs.Glob(files, path.Join(dir, "*.sql")) + if err != nil { + return fmt.Errorf("listing migrations: %w", err) + } + + for _, name := range names { + err = applyOnce(tx, name) + if err != nil { + return err + } + } + + return nil + }) +} + +// applyOnce runs the migration file name and records its number, +// unless schema_migrations already has that number. +func applyOnce(tx *gorm.DB, name string) error { + number, _, _ := strings.Cut(path.Base(name), "_") + + version, err := strconv.Atoi(number) + if err != nil { + return fmt.Errorf("numbering migration %s: %w", name, err) + } + + var applied int + + err = tx.Raw( + "SELECT count(*) FROM schema_migrations WHERE version = ?", + version, + ).Row().Scan(&applied) + if err != nil { + return fmt.Errorf("checking migration %s: %w", name, err) + } + + if applied > 0 { + return nil + } + + err = run(tx, name) + if err != nil { + return err + } + + err = tx.Exec( + "INSERT INTO schema_migrations (version) VALUES (?)", version, + ).Error + if err != nil { + return fmt.Errorf("recording migration %s: %w", name, err) + } + + return nil +} + +// run executes every statement in the migration file name. +func run(tx *gorm.DB, name string) error { + script, err := files.ReadFile(name) + if err != nil { + return fmt.Errorf("reading migration %s: %w", name, err) + } + + err = tx.Exec(string(script)).Error + if err != nil { + return fmt.Errorf("applying migration %s: %w", name, err) + } + + return nil +} diff --git a/internal/db/migrations/migrations_test.go b/internal/db/migrations/migrations_test.go new file mode 100644 index 0000000..51065ef --- /dev/null +++ b/internal/db/migrations/migrations_test.go @@ -0,0 +1,57 @@ +package migrations_test + +import ( + "database/sql" + "log/slog" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/driver/sqlite" + "gorm.io/gorm" + _ "modernc.org/sqlite" + "sneak.berlin/go/webhooker/internal/db/migrations" + "sneak.berlin/go/webhooker/internal/gormlog" +) + +// TestApplyRunsEachFileOnce applies each kind of database file's +// migrations to a new file twice. The first time records every file. +// The second finds them all recorded and runs none, which is the only +// way it can succeed: 001_schema.sql creates tables that then exist. +func TestApplyRunsEachFileOnce(t *testing.T) { + t.Parallel() + + for _, dir := range []string{ + migrations.Main, migrations.Events, migrations.Archive, + } { + t.Run(dir, func(t *testing.T) { + t.Parallel() + + sqlDB, err := sql.Open( + "sqlite", filepath.Join(t.TempDir(), "test.db"), + ) + require.NoError(t, err) + + t.Cleanup(func() { _ = sqlDB.Close() }) + + db, err := gorm.Open( + sqlite.Dialector{Conn: sqlDB}, + &gorm.Config{ + Logger: gormlog.New(slog.New(slog.DiscardHandler)), + }, + ) + require.NoError(t, err) + + require.NoError(t, migrations.Apply(db, dir)) + require.NoError(t, migrations.Apply(db, dir)) + + var versions string + + require.NoError(t, sqlDB.QueryRowContext(t.Context(), + "SELECT group_concat(version) FROM schema_migrations", + ).Scan(&versions)) + assert.Equal(t, "0,1", versions) + }) + } +} diff --git a/internal/delivery/archive_sweeper_test.go b/internal/delivery/archive_sweeper_test.go index 1630ae5..bbd0418 100644 --- a/internal/delivery/archive_sweeper_test.go +++ b/internal/delivery/archive_sweeper_test.go @@ -22,6 +22,7 @@ import ( _ "modernc.org/sqlite" // Pure Go SQLite driver. "sneak.berlin/go/webhooker/internal/database" "sneak.berlin/go/webhooker/internal/database/databasetest" + "sneak.berlin/go/webhooker/internal/db/migrations" "sneak.berlin/go/webhooker/internal/delivery" "sneak.berlin/go/webhooker/internal/gormlog" ) @@ -172,9 +173,7 @@ func seedArchiveFile( ) require.NoError(t, err) - require.NoError( - t, gdb.AutoMigrate(&delivery.ExportArchivedEvent{}), - ) + require.NoError(t, migrations.Apply(gdb, migrations.Archive)) for i, at := range archivedAt { row := delivery.ExportArchivedEvent{ @@ -781,9 +780,9 @@ func TestArchiveSweep_NeverExpiryUntouched(t *testing.T) { // // The spec is stronger than that — a "never" archive is skipped // before any file is touched — so the archive here exists but has -// never been migrated. Opening it at all would run AutoMigrate -// and create the archive table, which is exactly what must not -// happen. +// never been migrated. Opening it at all would apply the archive +// schema and create the archive table, which is exactly what must +// not happen. func TestArchiveSweep_NeverExpirySkipsBeforeOpening( t *testing.T, ) { @@ -807,7 +806,7 @@ func TestArchiveSweep_NeverExpirySkipsBeforeOpening( // seedUnmigratedArchive creates an archive file that exists but // carries no archive schema, so any open of it is observable: the -// archive table appears only if something ran AutoMigrate. +// archive table appears only if something applied the schema. func seedUnmigratedArchive(t *testing.T, path string) { t.Helper() diff --git a/internal/delivery/engine_integration_test.go b/internal/delivery/engine_integration_test.go index 115576f..2812281 100644 --- a/internal/delivery/engine_integration_test.go +++ b/internal/delivery/engine_integration_test.go @@ -963,29 +963,12 @@ func TestFailUnretryableRetry_WritesNoTargetRow( database.DeliveryStatusFailed, ) - // The table exists in the per-webhook database because GORM - // migrates the Delivery relation's model alongside it. It must - // stay empty. - var targetRows int64 - - require.NoError(t, s.WebhookDB. - Table("targets"). - Count(&targetRows).Error) - - assert.Zero(t, targetRows, + // The per-webhook database has no targets table, so writing a + // target row there would have failed the status write above. + assert.False(t, s.WebhookDB.Migrator().HasTable("targets"), "orphaned-retry terminal failure wrote a target row "+ "into the per-webhook event database", ) - - var configs []string - - require.NoError(t, s.WebhookDB. - Table("targets"). - Pluck("config", &configs).Error) - - assert.NotContains( - t, strings.Join(configs, " "), hookURL, - ) } func TestRecoverSingleRetry_UnknownTargetType( diff --git a/internal/delivery/engine_test.go b/internal/delivery/engine_test.go index 0238e55..eca808d 100644 --- a/internal/delivery/engine_test.go +++ b/internal/delivery/engine_test.go @@ -10,7 +10,6 @@ import ( "net/http" "net/http/httptest" "os" - "path/filepath" "strconv" "strings" "sync" @@ -22,12 +21,11 @@ import ( "github.com/prometheus/client_golang/prometheus" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "gorm.io/driver/sqlite" "gorm.io/gorm" _ "modernc.org/sqlite" "sneak.berlin/go/webhooker/internal/database" + "sneak.berlin/go/webhooker/internal/database/databasetest" "sneak.berlin/go/webhooker/internal/delivery" - "sneak.berlin/go/webhooker/internal/gormlog" "sneak.berlin/go/webhooker/internal/metrics" ) @@ -37,35 +35,18 @@ const testContentType = "application/json" func testWebhookDB(t *testing.T) *gorm.DB { t.Helper() - dbPath := filepath.Join( - t.TempDir(), "events-test.db", + // Opened by the manager the service uses, so these tests cannot + // pass against a schema, journal and locking settings or write + // guard production does not use. + mgr := databasetest.NewWebhookDBManagerWithLogger( + t, t.TempDir(), slog.New(slog.DiscardHandler), ) - // Opened the way the service opens a per-webhook database, so - // these tests cannot pass against journal and locking settings - // production does not use. - sqlDB, err := database.OpenSQLite( - dbPath, database.SQLiteModeCreate, - ) + t.Cleanup(func() { _ = mgr.CloseAll() }) + + db, err := mgr.GetDB(uuid.New().String()) require.NoError(t, err) - t.Cleanup(func() { _ = sqlDB.Close() }) - - db, err := gorm.Open( - sqlite.Dialector{Conn: sqlDB}, - &gorm.Config{Logger: gormlog.New(slog.New(slog.DiscardHandler))}, - ) - require.NoError(t, err) - - require.NoError(t, db.AutoMigrate( - &database.Event{}, - &database.Delivery{}, - &database.DeliveryResult{}, - &database.EventTotals{}, - &database.TargetTotals{}, - )) - require.NoError(t, db.Create(&database.EventTotals{}).Error) - return db } diff --git a/internal/delivery/target_database_archive.go b/internal/delivery/target_database_archive.go index 08b7463..4dcff5c 100644 --- a/internal/delivery/target_database_archive.go +++ b/internal/delivery/target_database_archive.go @@ -14,6 +14,7 @@ import ( "gorm.io/driver/sqlite" "gorm.io/gorm" "sneak.berlin/go/webhooker/internal/database" + "sneak.berlin/go/webhooker/internal/db/migrations" "sneak.berlin/go/webhooker/internal/gormlog" ) @@ -335,7 +336,7 @@ func (w *archiveWriter) openMode( ) } - err = gdb.AutoMigrate(&archivedEvent{}) + err = migrations.Apply(gdb, migrations.Archive) if err != nil { _ = sqlDB.Close() diff --git a/internal/delivery/terminal_state_test.go b/internal/delivery/terminal_state_test.go index 5216f00..20ac2f3 100644 --- a/internal/delivery/terminal_state_test.go +++ b/internal/delivery/terminal_state_test.go @@ -312,13 +312,9 @@ func TestFailMissingTarget_WritesNoTargetRow( database.DeliveryStatusFailed, ) - var configs []string - - require.NoError(t, s.WebhookDB. - Table("targets"). - Pluck("config", &configs).Error) - - assert.Empty(t, configs, + // The per-webhook database has no targets table, so writing a + // target row there would have failed the status write above. + assert.False(t, s.WebhookDB.Migrator().HasTable("targets"), "the deleted-target terminal path wrote a target row "+ "into the per-webhook event database", ) diff --git a/internal/gormlog/gormlog_test.go b/internal/gormlog/gormlog_test.go index 084f98f..65b216c 100644 --- a/internal/gormlog/gormlog_test.go +++ b/internal/gormlog/gormlog_test.go @@ -135,9 +135,11 @@ func openDB( ) require.NoError(t, err) - require.NoError(t, gdb.AutoMigrate(&thing{})) + require.NoError(t, gdb.Exec( + "CREATE TABLE things (id text PRIMARY KEY, name text)", + ).Error) - // Migration chatter is not what any of these cases is about. + // Creating the table is not what any of these cases is about. buf.Reset() return gdb diff --git a/internal/gormlog/scan_guard_test.go b/internal/gormlog/scan_guard_test.go index 1f65734..6569a4f 100644 --- a/internal/gormlog/scan_guard_test.go +++ b/internal/gormlog/scan_guard_test.go @@ -115,8 +115,8 @@ func skipDir(name string) bool { } // walkNonTestGo parses every non-test .go file under root. It returns -// the directories, relative to root, it parsed a file in, along with -// every unguarded Scan it found. +// the directories, relative to root, it parsed a file in or below, +// along with every unguarded Scan it found. func walkNonTestGo(t *testing.T, root string) (map[string]bool, []string) { t.Helper() @@ -155,7 +155,12 @@ func walkNonTestGo(t *testing.T, root string) (map[string]bool, []string) { return err } - walked[dir] = true + // The directories above count too, so that one holding + // only a nested package, as internal/db does, is not + // taken for one the walk skipped. + for ; dir != "."; dir = filepath.Dir(dir) { + walked[dir] = true + } for _, pos := range unguardedScans(fset, file) { hits = append(hits, relPosition(root, pos)) @@ -205,9 +210,9 @@ func TestGormScanIsNeverCalledOutsideTests(t *testing.T) { walked, offenders := walkNonTestGo(t, root) // The module's packages are static, templates, and every directory - // directly under cmd and internal. Each holds non-test code, so one - // the walk parsed nothing in was skipped, and a Scan there would - // pass unseen. + // directly under cmd and internal. Each holds non-test code, itself + // or in a package below it, so one the walk parsed nothing in or + // below was skipped, and a Scan there would pass unseen. packages := []string{"static", "templates"} for _, parent := range []string{"cmd", "internal"} { diff --git a/internal/handlers/delivery_attempts_test.go b/internal/handlers/delivery_attempts_test.go index 5b517c3..bba56aa 100644 --- a/internal/handlers/delivery_attempts_test.go +++ b/internal/handlers/delivery_attempts_test.go @@ -42,8 +42,11 @@ func TestDeliveryAttempts_ReadInTheTargetTypesOwnTerms(t *testing.T) { {database.TargetTypeLog, true, 0, "", "written to the log", ""}, } + // Named for type and outcome, so that no two cases share a name: + // Go adds "#01" to a repeated one, and a "#" in the temporary + // directory's path cuts the path SQLiteDSN gives SQLite short. for _, tc := range cases { - t.Run(string(tc.targetType), func(t *testing.T) { + t.Run(string(tc.targetType)+" "+tc.outcome, func(t *testing.T) { t.Parallel() f := newRecentEventsFixture(t) diff --git a/internal/handlers/delivery_replay_test.go b/internal/handlers/delivery_replay_test.go index c6756a7..b9ed423 100644 --- a/internal/handlers/delivery_replay_test.go +++ b/internal/handlers/delivery_replay_test.go @@ -314,21 +314,15 @@ func assertReplayTask( ) } -// assertNoLeakedTarget proves the per-webhook database holds no target -// rows. AutoMigrate creates the table there because Delivery declares -// the relation, so it is a ROW that signals a leak: an association -// write would have upserted the whole target, plaintext config and -// all, into the event database. See +// assertNoLeakedTarget proves the per-webhook database holds no +// target: an association write would have upserted the whole target, +// plaintext config and all, into the event database. See // https://git.eeqj.de/sneak/webhooker/issues/206. func assertNoLeakedTarget(t *testing.T, webhookDB *gorm.DB) { t.Helper() - var leaked int64 - - require.NoError(t, webhookDB.Unscoped(). - Model(&database.Target{}).Count(&leaked).Error) - assert.Zero( - t, leaked, + assert.False( + t, webhookDB.Migrator().HasTable(&database.Target{}), "replay must not write the target into the event database", ) } -- 2.54.0 From c8efbd41d41ffcbd8a7c487e8213297cbcef3e8b Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Tue, 13 Oct 2026 07:18:48 +0200 Subject: [PATCH 107/126] Drop Completed Steps from TODO.md; the tracker is the record (closes #112) Every issue branch added an entry to the same list, so each branch conflicted with `next` as soon as another one landed. The tracker already records what is done, in more detail than the list did. The Workflow section now says the tracker is that record, in place of the rule that issue branches leave this file alone. Status points to the 1.0.0 milestone for what is open, and Next Step says @sneak tags `v1.0.0` after running webhooker in limited production. In Future Steps, each item that overlaps with what webhooker already does now says what exists and what is still missing, and the delivery status and retry UI item is gone because the event log, replay and resubmit cover it. Model: opus-5-5 --- TODO.md | 375 +++++--------------------------------------------------- 1 file changed, 32 insertions(+), 343 deletions(-) diff --git a/TODO.md b/TODO.md index f99594f..6929c87 100644 --- a/TODO.md +++ b/TODO.md @@ -12,24 +12,14 @@ One issue per unit of work, one branch and one PR per issue: `main` without notice. One `next` -> `main` PR accumulates the milestone; releases are cut from `main` separately. -Issue branches do NOT touch this file — the manager maintains it on `next`. -Every branch editing `TODO.md` conflicts with every other (#112). +The tracker (https://git.eeqj.de/sneak/webhooker/issues) is the record of what +is done and what is under way. This file keeps no list of either. # Status -The milestone (https://git.eeqj.de/sneak/webhooker/milestone/9) is the -authoritative list, and the only place to read a count or a state of play from. -This file records where the project is, not what is in flight: a sentence whose -truth depends on a branch being unmerged is wrong the moment it merges, and this -file has been wrong that way before. - -The durability defect that held the tag has landed -(https://git.eeqj.de/sneak/webhooker/issues/256, commit `8d64259`). Every SQLite -handle opens with WAL journaling and a busy timeout, a bookkeeping write that -fails leaves its delivery in a recoverable state rather than a lying one, and -recovery skips a delivery that already has a successful result row. Final -pre-tag verification exercised it and confirmed it holds. Whatever the milestone -still shows open is what remains before `v1.0.0`. +What is still open for 1.0.0 is in its milestone +(https://git.eeqj.de/sneak/webhooker/milestone/9). `v1.0.0` is not tagged, and +only @sneak tags it. Delivery is at-least-once by design, not by accident: a send whose result row does not land is attempted again, so a receiver can see a duplicate. That is @@ -38,338 +28,31 @@ under Rationale. It is not a defect to re-file. # Next Step -Clear the rest of the open 1.0.0 milestone -(https://git.eeqj.de/sneak/webhooker/milestone/9) and tag `v1.0.0`. Merging -`next` into `main` is a separate act from tagging and waits on neither of those: -`next` is kept mergeable at all times, which is the point of the branch. - -# Completed Steps - -- 2026-08-24 Bind the plaintext HTTP listener deliberately, via `BIND_ADDRESS` - defaulting to `127.0.0.1`, and document the reverse-proxy deployment. A - hostname, an empty value or a value carrying a port is a startup error, and - the `Dockerfile` sets `0.0.0.0` because a loopback bind inside a container is - unreachable (https://git.eeqj.de/sneak/webhooker/issues/268). The same commit - removed the shutdown race: `httpServer` is built in the constructor rather - than assigned from the serving goroutine, which orders the write before every - fx hook and rules out the nil dereference a SIGTERM arriving first would have - caused, and `sentryEnabled` is an `atomic.Bool` - (https://git.eeqj.de/sneak/webhooker/issues/226) -- 2026-08-24 Remove inbound request signature verification. The entrypoint UUID - is the authentication secret, so the per-entrypoint shared secret, the - `internal/signature` package, the receiver check, the model fields and the - forms are all gone. This reverses the feature that landed earlier in the same - milestone (https://git.eeqj.de/sneak/webhooker/issues/67, - https://git.eeqj.de/sneak/webhooker/issues/279) -- 2026-08-24 Stamp the build version into the binary and render it in the UI - footer. `script/version` is the single source — `$VERSION`, else - `git describe --tags --always --dirty`, else `unknown` — so a `make build` - binary and a `make docker` image from one checkout report the same thing, and - nothing in it varies between two builds of the same commit, which the release - gate's byte-identical assertion would catch - (https://git.eeqj.de/sneak/webhooker/issues/253) -- 2026-08-24 Derive cookie `Secure` and CSRF strictness from the request - transport rather than from `WEBHOOKER_ENVIRONMENT`. Behind a real TLS proxy - with the environment left at its `dev` default, the session cookie silently - lost `Secure` while the CSRF cookie on the same response kept it. - `X-Forwarded-Proto` is now matched case-insensitively on its first - comma-separated element, so `HTTPS` and `https, http` no longer fall to the - relaxed CSRF path (https://git.eeqj.de/sneak/webhooker/issues/269) -- 2026-08-24 Roll back a failed webhook deletion instead of committing it. A - failing delete committed whatever had already succeeded, hard-deleted the - per-webhook event database anyway, and redirected as though it had worked — - orphaned config plus permanently destroyed history, reported as success. All - three delete positions now roll back with the event database intact - (https://git.eeqj.de/sneak/webhooker/issues/262) -- 2026-08-24 Name a deleted target on its historical deliveries, marked - `(deleted)`, rather than leaving the event log unable to say where a delivery - went. A deleted target's credentials stay masked exactly as a live one's, and - it cannot become deliverable again through the receiver, resubmit, replay, the - edit form or the toggle (https://git.eeqj.de/sneak/webhooker/issues/211) -- 2026-08-24 Bound both request-controlled `/metrics` label dimensions, so the - unauthenticated receiver is no longer a memory-exhaustion vector: `handler` - carries the chi route pattern, and `method` folds anything chi cannot route - onto a single `(unmatched)` sentinel. Both were reproduced before the fix — - 300 random method tokens took the series count from 106 to 7,631, and path - flooding reached 62,532 — and a label audit across a live scrape found no - third unbounded dimension (https://git.eeqj.de/sneak/webhooker/issues/254, - https://git.eeqj.de/sneak/webhooker/issues/261) -- 2026-08-24 Validate `max_retries` on both target forms. `abc`, `2.7` and `-5` - silently became 0 — fire-and-forget — including on the edit path, where it - destroyed a working value, and `999999999` stored verbatim. The ceiling of 20 - is the `max` both templates already declared - (https://git.eeqj.de/sneak/webhooker/issues/221) -- 2026-08-24 Resubmit a stored event as a new undelivered event, so a backend - under development can be tested against real captured traffic. Per-delivery - replay cannot serve that: it re-sends one finished delivery to its own - original target, and a target created for a dev backend has no prior delivery - to replay. Resubmit re-injects the stored event at the top of the receiver - path and fans it out to whatever targets are active now - (https://git.eeqj.de/sneak/webhooker/issues/250) -- 2026-08-20 Take an exclusive lock on `DATA_DIR` at startup, so two instances - on one directory cannot both deliver - (https://git.eeqj.de/sneak/webhooker/issues/201) -- 2026-08-20 Shut down the app when the HTTP listener fails. The `OnStart` hook - returned as soon as the serving goroutine was spawned, so a failed listen left - fx reporting RUNNING and a live process with nothing bound — invisible to - systemd and Docker restart policies - (https://git.eeqj.de/sneak/webhooker/issues/200) -- 2026-08-20 Stop target credentials leaking into the per-webhook event - databases (https://git.eeqj.de/sneak/webhooker/issues/206), log SQL with - placeholders rather than bound values - (https://git.eeqj.de/sneak/webhooker/issues/207), and fail loudly on half-set - metrics auth credentials (https://git.eeqj.de/sneak/webhooker/issues/205) -- 2026-08-20 Read queue depths with `Find`, not `Scan`. `Scan` swaps GORM's own - trace recorder in for the logging adapter, and that recorder does not - implement `gorm.ParamsFilter`, so those statements logged their bound values - interpolated and bypassed the suppression above. The two units gated green - against a `next` that lacked the other, and `next` went red when both landed - (https://git.eeqj.de/sneak/webhooker/issues/234) -- 2026-08-20 Render per-attempt delivery detail in the event log - (https://git.eeqj.de/sneak/webhooker/issues/202) and add replay of a - terminally failed delivery (https://git.eeqj.de/sneak/webhooker/issues/203) -- 2026-08-20 Expose delivery metrics on `/metrics` - (https://git.eeqj.de/sneak/webhooker/issues/209) and document the backup, - restore and upgrade procedures - (https://git.eeqj.de/sneak/webhooker/issues/210) -- 2026-08-20 Add a `webhooker resetpw` subcommand and a bootstrap banner. The - admin bootstrap password was printed once among roughly 45 fx lines, and under - `docker run -d` went to container logs subject to rotation; there was no reset - path at all, so recovery meant hand-deleting the users row, documented - nowhere. The password is read from stdin or generated, never from argv where - `/proc` would publish it (https://git.eeqj.de/sneak/webhooker/issues/208) -- 2026-08-20 Add `ALLOWED_EGRESS_CIDRS`, an allowlist-only escape hatch for the - SSRF guard, so a self-hosted proxy can forward into the operator's own - network. The guard's always-blocked set cannot be reopened by configuration - (https://git.eeqj.de/sneak/webhooker/issues/204) -- 2026-08-20 Harden operator-set target headers, which were carried unsafely - across a redirect (https://git.eeqj.de/sneak/webhooker/issues/233) -- 2026-08-20 Add a target edit form with headers and timeout fields - (https://git.eeqj.de/sneak/webhooker/issues/127) -- 2026-08-18 Raise `script/test`'s per-package timeout from 30s to 90s, matching - the org-wide backstop. `go test` applies `-timeout` per package, and - `internal/handlers` had grown past the old budget: a cache-defeated build - failed outright at `GOMAXPROCS=4`, and every run under deliberate host load - breached 30s. The measurement table lives in the script (#194) -- 2026-08-18 Re-sync `REPO_POLICIES.md` from `prompts`. The local copy was stale - and still mandated a 20s test target with a 30s timeout, which the org - replaced with a 60s cap and a 90s backstop. A synced copy is not a source; - reading it as one nearly produced a PR against `prompts` proposing a change - already merged there (#196) -- 2026-08-18 Report handler panics through the logger and answer 500. chi - v1.5.5's `Recoverer` scans for a `panic(0x` frame the runtime no longer emits, - then indexes `pkg[-1:]`, so it panicked inside its own stack printer before - writing a byte: the recovery never ran, the client got a dropped connection - instead of a 500, and the original panic was lost. A local middleware replaces - it, bounded by `MaxPanicLogLineBytes` (#187) -- 2026-08-18 Route GORM's logger through `slog` and bound it. Every `gorm.Open` - left `logger.Default` in place at `Warn` with `IgnoreRecordNotFoundError` - false, so **every record-not-found printed the fully interpolated SQL to - stdout** — including the client-chosen path on `/webhook/{uuid}` and the - submitted username on the login form, at no level the operator set and outside - `internal/logger` entirely. Three call sites, not the two the issue named - (#178) -- 2026-08-18 Bound every `slog` line against client-chosen text. Eight sites - reachable unauthenticated, found by reading every `slog` call in the tree - rather than only the one reported; the budget moved to a shared - `internal/logfield` so no second truncation exists. `DEBUG` being off by - default is not a bound and is not treated as one (#176) -- 2026-08-18 Stop a slow host turning a login-guard test into a segfault. A - non-fatal `assert` on an acquire result was dereferenced on the next line, so - one timing miss killed the whole `internal/middleware` binary and reddened CI - for unrelated PRs. The fix also removed a real production race — `acquire` - could shed a request with a slot standing free, because Go picks uniformly - among ready `select` cases (#186) -- 2026-08-18 Send the chi route pattern to Sentry rather than the concrete path. - The receiver's path carries the entrypoint capability token, so every Sentry - event from `/webhook/{uuid}` shipped a live credential to a third party. - Request `Data`, `QueryString`, `Cookies` and `Env` are dropped and headers - reduced to an allowlist (#179) -- 2026-08-18 Read form fields from the POST body only. `r.FormValue` merges the - query string, so a login could be driven by URL parameters — putting the - password somewhere that lands in access logs, proxy logs and browser history - (#160) -- 2026-08-18 Verify login credentials before spending rate-limit budget, so a - flood of wrong passwords cannot lock out the account it is guessing at. The - manager took this decision rather than stall the queue; it is flagged on the - issue for reversal (#150) -- 2026-08-18 Run all linting in Docker via `Dockerfile.lint`. Host lint was - wrong in both directions from version skew and shared caches. `script/lint` - asserts the summary line, because `--no-cache-filter` silently ignores a stage - name it does not match — the flag that makes the gate meaningful fails open - (#109) -- 2026-08-18 Serve an event's full stored body over HTTP. The list query - truncates for rendering, and that truncated value was the only way to read a - body, so the full payload was unreachable (#157) -- 2026-08-18 Bound the access log line against client-chosen text. - `internal/logfield` budgets by _encoded_ bytes, not runes, so a handler's JSON - escaping cannot multiply a field past its allowance (#146) -- 2026-08-18 Mark superseded CI commits `failure` rather than `skipped`. A - skipped run rolls up green, so a commit that was never tested reported success - (#152) -- 2026-08-18 Set `fx.StopTimeout` inside the container stop grace, so shutdown - hooks are bounded by a deadline the orchestrator will actually honour rather - than being killed mid-flush (#134) -- 2026-08-17 Bucket IPv6 rate-limit keys by `/64`. A single allocation hands out - 2^64 addresses, so per-address keying let one client mint unlimited buckets. - Manager decision, recorded on the issue (#125) -- 2026-08-17 Correct release-blocking README and startup-warning inaccuracies, - including claims about behaviour the code does not have (#151) -- 2026-08-17 Fetch and verify Alpine.js at build time against - `static/vendor.sha256` instead of committing the minified blob, so the - dependency is pinned by hash rather than by trust (#145) -- 2026-08-17 Bound the event log's rendered bodies in the query itself, so a - large stored payload cannot be read into memory just to be truncated for - display (#135) -- 2026-08-17 Mask the `http` target's destination URL in the UI: it can carry a - bearer credential in its path or query, and was rendered verbatim. Manager - decision to mask unconditionally (#115) -- 2026-08-14 Bound shutdown hooks by their stop context, so a hook that hangs - cannot hold the process past its grace period (#102) -- 2026-08-14 Render templates via a buffer rather than the `ResponseWriter`, so - a template error part-way through cannot commit a 200 and then fail — the - response is written only once it is whole (#123) -- 2026-08-14 Align the session codec's max-age with the 7-day absolute cap. The - codec accepted cookies the session layer considered expired, so the cap was - enforced in one place and not the other (#108) -- 2026-08-12 Warn when `TRUSTED_PROXIES` is empty in production, where the safe - default silently discards forwarded headers and every client rate-limits as - the proxy's address (#149) -- 2026-08-12 Bound the receiver rate limit per client IP across the whole - `/webhook/*` route. The existing limiter keyed on the request path and - `/webhook/{uuid}` matches any single segment, so a client that invented a - fresh path per request minted a fresh bucket per request: the limit on the - only unauthenticated endpoint bounded nothing in aggregate, and every request - still cost an entrypoint lookup before it 404ed. An outer limiter keyed on the - client address alone now bounds that, chained in front of the unchanged - per-entrypoint limiter (#139) -- 2026-08-12 Correct release-blocking documentation inaccuracies: the README - promised manual redelivery in the present tense in three places when nothing - implements it (the same false claim also sat in the doc comment that was its - source text), the env table omitted `RETENTION_SWEEP_INTERVAL`, and `TODO.md` - itself omitted five landed units (#141) -- 2026-08-12 Make the CI gate execute the checks it reports on. The workflow now - writes a build-context fingerprint before calling `script/cibuild`, so a code - commit invalidates the `COPY` layer of the lint and builder stages while a - docs-only commit still replays from cache; a superseding run also rewrites the - `failure` status Gitea leaves on commits it cancelled and never tested. - Verified by pushing a deliberately broken test and watching CI go red (#119) -- 2026-08-12 Require a positive `RETENTION_SWEEP_INTERVAL`: a non-positive value - reached `time.NewTicker` in both the retention reaper and the archive sweeper, - panicking two goroutines with no recover after startup had already reported - success (#140) -- 2026-08-12 Bound the `X-Forwarded-For` scan's allocation to the hop cap: the - reverse walk cuts entries with `strings.LastIndexByte` instead of joining and - splitting, so a 1 MB header allocates 16 bytes rather than 1.6 MB per request - on the unauthenticated receiver. Semantics proven unchanged by differential - testing against the previous implementation (#133) -- 2026-08-12 Cap the `X-Forwarded-For` hop walk at 64 entries, so an - attacker-supplied chain cannot burn unbounded CPU in the rate-limit key - function; running off the end falls back to the peer address (#124) -- 2026-08-12 Gate forwarded-header trust behind a `TRUSTED_PROXIES` CIDR list: - all three rate limiters key on the connection's own address unless the direct - peer is a configured proxy, in which case `X-Forwarded-For` is walked right to - left for the first non-proxy hop. Default trusts nothing, and a - set-but-unparseable value aborts startup. Before this, any client could mint a - fresh bucket or drain another's by rotating a spoofed header (#88) -- 2026-08-11 Web UI cleanup: nav terminology unified on Webhooks, the Profile - settings placeholder removed, a progressive-enhancement copy button for the - entrypoint URL, and retention form copy that states the actual policy - (deletion by the reaper, 0 retains forever) (#57) -- 2026-08-11 Mask the webhook credential in delivery errors and logs: Go embeds - the request URL in `*url.Error`, so every transport failure persisted the full - Slack webhook URL into the per-webhook event database via - `DeliveryResult.Error`, a field a future REST API would have served. - `maskURLError` drops path, query and userinfo while preserving the wrapped - cause, so `errors.Is`/`As` and `Timeout()` still work and DNS, TLS and timeout - failures still read differently (#118) -- 2026-08-11 Rate-limit the public webhook receiver endpoint - (`RECEIVER_RATE_LIMIT`, default 120/min), keyed on client IP plus entrypoint - path so one entrypoint cannot exhaust another's budget; over-limit requests - get 429 with `Retry-After`. It was the one unauthenticated, internet-facing - endpoint with no limit at all (#64) -- 2026-08-11 Enforce the body size limit before CSRF parses the form: - `MaxBodySize` is now first in all four form-parsing route groups, so an - oversized request is rejected with 413 instead of being read in full by the - CSRF middleware before any cap applied (#90) -- 2026-08-11 Mask target config on the source detail page, which rendered the - stored blob verbatim and so exposed the Slack incoming-webhook URL — a bearer - credential that cannot be revoked per-holder. Config reaches the template only - as a `TargetView` of labelled fields, and header values are rendered as a - count (#113) -- 2026-08-11 Allow `retention_days` of 0 to mean retain forever, via a sentinel - written in `BeforeSave` so the GORM column default cannot win the race. Also - bounds the reaper's cutoff arithmetic: day counts above 106751 overflowed - `time.Duration` and wrapped the cutoff into the future, where every row - matched and the sweep deleted everything (#79) -- 2026-08-09 Inactivity-based session timeout: sliding idle expiry - (`SESSION_IDLE_TIMEOUT`, default `24h`) refreshed on authenticated requests, - with the 7-day absolute cap kept as an independent backstop that activity - never extends (#66) -- 2026-08-09 Restart recovery and the 60s retry sweep terminally fail an - orphaned `retrying` delivery whose target type no longer supports retries, - recording a `DeliveryResult` with the reason instead of leaving the delivery - stuck forever (#82) -- 2026-08-09 Root the delivery engine's worker pool and the retention reaper's - sweep loop at `context.Background()` rather than the fx `OnStart` hook context - (#97), which carries fx's 15s start timeout and killed both roughly fifteen - seconds after boot: the proxy silently stopped delivering webhooks entirely, - and the reaper never ran a single sweep under its default one-hour interval -- 2026-08-09 Archive writer lifecycle (#89): deleting a webhook (or its last - `database` target) evicts the cached archive writer and closes its handle - while deliberately leaving `archive-{webhookID}.db` on disk, and a new - `ArchiveSweeper` prunes idle archives on the existing - `RETENTION_SWEEP_INTERVAL` without ever creating an archive file -- 2026-08-09 Configuration parsing fails loudly on set-but-unparseable - environment values: `envInt` removed in favour of `envPositiveInt` plus a - `PORT` range check, `envBool` now parses with `strconv.ParseBool`, and - defaults apply only to unset variables (#80) -- 2026-08-07 Automatic event retention cleanup based on `retention_days`, - deleting expired events, deliveries, and delivery results from each - per-webhook event database (#63) -- 2026-08-07 Update golangci-lint to v2.12.2 (Docker image digest in - `Dockerfile`, release-archive sha256 pins in `script/bootstrap`), adopt the - canonical `.golangci.yml` (v2 `linters.settings` layout so - `lll`/`funlen`/`cyclop`/`dupl` thresholds actually apply), and fix all newly - surfaced lint findings -- 2026-07-07 Adopted scripts-to-rule-them-all: `script/` entrypoints, Makefile - shims, README Entrypoints section -- 2026-03-25 pin golangci-lint Docker image for linting (#55) -- 2026-03-18 CSRF middleware detects TLS per-request, fixing login over plain - HTTP and behind reverse proxies (#54) -- 2026-03-17 root path redirects based on auth state (#52) -- 2026-03-17 CSRF protection, SSRF prevention for HTTP delivery targets with DNS - rebinding defense, and per-IP login rate limiting (#42) -- 2026-03-17 Slack target type for incoming webhook notifications (#47) -- 2026-03-17 Dockerfile absolute paths and static linking (#49); absolute dev - DATA_DIR default and clarified env docs (#46) -- 2026-03-05 security headers middleware, session regeneration on login, request - body size limits (#41) -- 2026-03-04 tests for delivery, middleware, and session packages (#32); removed - the build-architecture global (#31) -- 2026-03-04 1.0 MVP merge: Webhook/Entrypoint/Target rename, core delivery - engine with bounded worker pool and circuit breaker, parallel fan-out, - per-webhook event databases, management UI (#16) -- 2026-03-01 repo brought to REPO_POLICIES standards; TODO.md folded into README - (#6) +@sneak runs webhooker in limited production, then tags `v1.0.0`. Merging `next` +into `main` is a separate act from tagging and waits on neither of those: `next` +is kept mergeable at all times, which is the point of the branch. # Future Steps -- Delivery status and retry management UI. Replay of a terminally failed - delivery and per-attempt detail already landed - (https://git.eeqj.de/sneak/webhooker/issues/203, - https://git.eeqj.de/sneak/webhooker/issues/202) -- Per-webhook rate limiting in the receiver handler (per-webhook config plus - handler enforcement; global limits must not apply to receiver endpoints) -- API key authentication for programmatic access (APIKey model exists; Bearer - token middleware does not) +- Per-webhook rate limits, set in the web UI and enforced in the receiver + handler, on top of the receiver's existing limits, which `RECEIVER_RATE_LIMIT` + sets for the whole server (see Rate Limiting in the README) +- API key authentication for programmatic access (the `APIKey` model exists, but + nothing creates a key and no middleware checks one) - REST API v1 - CRUD for webhooks, entrypoints, targets - event viewing and filtering endpoints - event redelivery endpoint - OpenAPI specification -- Analytics dashboard: success rates, response times, volume -- A remember-me option at login +- Analytics in the web UI: response times summarised per webhook or per target, + and figures for all webhooks together. The webhook page already shows event, + delivery and failure counts and failure percentages, the target list counts + each target's deliveries, the event log and each event's page show every + attempt's duration, and `/metrics` carries server-wide counts and delivery + durations +- A remember-me option at login. Every session already survives a browser + restart: it lasts up to 7 days, and ends sooner after `SESSION_IDLE_TIMEOUT` + (default 24 hours) without activity - Password reset flow for a forgotten password over the web. The authenticated password _change_ flow already landed, and a lost password is recoverable from the console with `webhooker resetpw` @@ -377,7 +60,13 @@ Clear the rest of the open 1.0.0 milestone - Later, nice to have - email delivery target type - SNS and S3 delivery targets - - data transformations (e.g. webhook to Slack message formatting) - - JSONL file delivery with periodic S3 upload - - webhook event search and filtering - - multi-user with role-based access control + - data transformations (e.g. a configurable Slack message format; the Slack + target sends one fixed format today) + - JSONL file delivery with periodic S3 upload (the `database` target already + archives events to SQLite files, rotated hourly, daily or monthly if set, + and the webhook page offers them as one gzipped JSON download) + - webhook event search, and filtering beyond the event log's failed and + pending views + - multi-user with role-based access control (each webhook already belongs to + one account, but there is only the account made at first boot and no way + to add another) -- 2.54.0 From e73beb20df29a9b5df55d1cb52edec0f9134cda1 Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Tue, 13 Oct 2026 08:31:51 +0200 Subject: [PATCH 108/126] Remove upgrade and older-build text from the README (closes #520) The schema comes from 001_schema.sql, edited in place before 1.0, so the README's upgrade procedure, downgrade note, BIND_ADDRESS upgrade note and remarks about what older builds wrote are removed. The description of the SQL schema files moves to a new Schema subsection under Database Architecture, the health check command moves to Version stamping, and the section is renamed Backup and Restore. The sentences on session timestamps, file modes and the retention clamp now say only what the code does. Model: opus-5-5 --- README.md | 87 +++++++++++++++++-------------------------------------- 1 file changed, 26 insertions(+), 61 deletions(-) diff --git a/README.md b/README.md index 1e4b4b9..cbdcc85 100644 --- a/README.md +++ b/README.md @@ -465,10 +465,7 @@ idle timestamp is rewritten at most once per tenth of the idle window rather than on every request, which means a session may expire up to 10% early relative to the user's true last request, but never late. -Both clocks are anchored by timestamps stored in the session cookie. Sessions -issued before this feature existed carry neither, so they are treated as -expired: upgrading to a build that has it logs every existing session out once, -and those users sign in again. +Both clocks are anchored by timestamps stored in the session cookie. #### Invalid values abort startup @@ -702,11 +699,11 @@ app as that user. `webhooker.db` holds target configuration in plaintext — bearer tokens, API keys, Slack webhook URLs — along with the session encryption key, so webhooker creates every SQLite file it owns `0600`: each database and both of its `-wal` -and `-shm` sidecars, across all three tiers. Files an earlier build left `0644` -are tightened when they are opened. The directory's `0750` is defence in depth — -it stops other local users listing the directory and learning your webhook UUIDs -from the `events-{uuid}.db` filenames — not the barrier protecting the -credentials. +and `-shm` sidecars, across all three tiers. Any of them found at another mode +is set to `0600` when the database is opened. The directory's `0750` is defence +in depth — it stops other local users listing the directory and learning your +webhook UUIDs from the `events-{uuid}.db` filenames — not the barrier protecting +the credentials. ### Running under upaas @@ -918,7 +915,7 @@ replaces the `max-age` it stored, so to end the policy sooner, set a short `HSTS_MAX_AGE` such as `1s` and keep it until returning browsers have picked it up, or have a reverse proxy send `max-age=0`. -## Backup, Restore, and Upgrades +## Backup and Restore ### What to back up @@ -1071,60 +1068,14 @@ beside it, or wait until there are none. 4. Start the service. The container gives the directory and the restored files to the `webhooker` user before the app starts, whoever restored them (see - [Running with Docker](#running-with-docker)). Each restored database gets any - of its SQL migrations it has not had as it is opened (see - [Upgrades](#upgrades)). - -### Upgrades - -**Back up before every upgrade.** Each kind of database file has its schema in -SQL files built into the binary, one directory per kind under -`internal/db/migrations/`. In each, `000_migration.sql` creates the -`schema_migrations` table, which records the number of every file the database -has had, and `001_schema.sql` holds the whole schema. Opening a database runs, -in one transaction, every file in its directory that `schema_migrations` does -not list: - -- `main/` for the main database, on connect — settings, users, API keys, - webhooks, entrypoints and targets -- `events/` for each event database, when it is lazily opened — events, - deliveries, delivery results and their running totals -- `archive/` for each archive database, on every open and reopen - -There are no down migrations. - -Upgrade procedure: - -1. Stop the service. -2. Back up `DATA_DIR` using one of the procedures above. -3. Pull the new image and start it. -4. Confirm `database migrations completed` in the logs before putting traffic - back on it. -5. Confirm the new build is the one running: - `curl -s http://host:8080/.well-known/healthcheck` reports the version it was - stamped with (see [Version stamping](#version-stamping)). - -**Upgrading past the introduction of `BIND_ADDRESS`:** earlier versions always -bound every interface. **Container deployments are unaffected** — the image -ships `ENV BIND_ADDRESS=0.0.0.0`, so a `docker run` or Compose service that -worked before still works with nothing changed. - -A **bare binary** is the case that changes: the listener now binds `127.0.0.1` -unless `BIND_ADDRESS` says otherwise, so a deployment that relied on reaching it -from another host becomes unreachable until it sets the address the proxy -connects to. Check the `bindAddress` field of the startup log to see what a -running process bound. See [Bind address](#bind-address). - -**Downgrade is unsupported.** Once a newer binary has migrated the files there -is no way to move them back. The only supported way back to an older version is -restoring the pre-upgrade backup, which discards everything received since that -backup was taken. + [Running with Docker](#running-with-docker)). ### Version stamping The binary reports its version at `/.well-known/healthcheck` (the `version` field), in the UI footer, and in the startup log line (`msg=starting`, `version=...`). It is also the Sentry release name, as `webhooker-{version}`. +`curl -s http://host:8080/.well-known/healthcheck` shows which build is running. The value is stamped in at build time by the linker; it is not read from a file at runtime, so it identifies the build itself. @@ -1583,8 +1534,7 @@ The cap is not arbitrary: the reaper computes its cutoff as a `time.Duration`, an int64 nanosecond count, and a longer period overflows it. An overflowed cutoff lands in the future, where it matches every row, so the sweep would delete every event the webhook has instead of none. The reaper also clamps the -value it is given, so a row written by an older version cannot trigger that -either. +value it reads to the cap, so no stored value can trigger that either. #### Entrypoint @@ -1967,7 +1917,7 @@ and application state: - **APIKeys** — programmatic access credentials On first startup the main database is created from its SQL files (see -[Upgrades](#upgrades)), a session encryption key is generated and stored, and an +[Schema](#schema)), a session encryption key is generated and stored, and an `admin` user is created. **Per-Webhook Event Databases** (`{DATA_DIR}/events-{webhook_uuid}.db`) — each @@ -2164,6 +2114,21 @@ The database uses the though CGO is required at build time due to the transitive `mattn/go-sqlite3` dependency from `gorm.io/driver/sqlite`. +#### Schema + +Each kind of database file has its schema in SQL files built into the binary, +one directory per kind under `internal/db/migrations/`. In each, +`000_migration.sql` creates the `schema_migrations` table, which records the +number of every file the database has had, and `001_schema.sql` holds the whole +schema. Opening a database runs, in one transaction, every file in its directory +that `schema_migrations` does not list: + +- `main/` for the main database, on connect — settings, users, API keys, + webhooks, entrypoints and targets +- `events/` for each event database, when it is lazily opened — events, + deliveries, delivery results and their running totals +- `archive/` for each archive database, on every open and reopen + ### Request Flow ``` -- 2.54.0 From 267d5b12f28f5cbb37490c6280a64a736db1d855 Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Tue, 13 Oct 2026 09:30:24 +0200 Subject: [PATCH 109/126] Escape the database path in the SQLite connection string (closes #518) SQLite reads a file: URI's path only up to the first ? or #, and decodes %XX escapes in it. With a data directory such as /srv/hooks#1, every database opened the one file /srv/hooks. The connection string is now built with url.URL, which escapes the path, and OmitHost keeps a relative path relative. Tests open databases in a directory whose name holds #, ? and %, and at a relative path, and check through PRAGMA database_list that each opened the file asked for. A handlers test comment that blamed # in the temporary directory's path is dropped, as that is no longer true. Model: opus-5-5 --- internal/database/sqlite_open.go | 14 +++- internal/database/sqlite_open_test.go | 75 +++++++++++++++++++++ internal/handlers/delivery_attempts_test.go | 3 - 3 files changed, 88 insertions(+), 4 deletions(-) diff --git a/internal/database/sqlite_open.go b/internal/database/sqlite_open.go index c162a82..e73ce57 100644 --- a/internal/database/sqlite_open.go +++ b/internal/database/sqlite_open.go @@ -242,7 +242,19 @@ func SQLiteDSN(path, mode string) string { ) q.Add("_pragma", "journal_mode(WAL)") - return "file:" + path + "?" + q.Encode() + // SQLite reads a file: URI's path only up to the first ? or #, and + // decodes %XX escapes in it, so url.URL escapes the path. OmitHost + // keeps a relative path as "file:data/x.db"; written + // "file://data/x.db", SQLite would reject "data" as the URI's + // authority. + dsn := url.URL{ + Scheme: "file", + Path: path, + OmitHost: true, + RawQuery: q.Encode(), + } + + return dsn.String() } // OpenSQLite opens the SQLite file at path with the service's diff --git a/internal/database/sqlite_open_test.go b/internal/database/sqlite_open_test.go index 448507c..b16aa12 100644 --- a/internal/database/sqlite_open_test.go +++ b/internal/database/sqlite_open_test.go @@ -2,6 +2,8 @@ package database_test import ( "context" + "database/sql" + "os" "path/filepath" "strings" "testing" @@ -112,6 +114,79 @@ func TestMainDBAppliesPragmasOnALiveHandle(t *testing.T) { assert.Equal(t, "10000", busy) } +// TestOpenSQLiteOpensThePathAskedFor uses a data directory whose name +// holds ?, # and %. SQLite reads a file: URI's path only up to a ? or +// #, and decodes %41 to A, so an unescaped path sent every database in +// that directory to one file outside it. +func TestOpenSQLiteOpensThePathAskedFor(t *testing.T) { + t.Parallel() + + dir := filepath.Join(t.TempDir(), "hooks#1?x%41") + require.NoError(t, os.Mkdir(dir, 0o700)) + + for _, name := range []string{ + database.MainDBFileName, + "events-" + uuid.New().String() + ".db", + } { + path := filepath.Join(dir, name) + + sqlDB, err := database.OpenSQLite( + path, database.SQLiteModeCreate, + ) + require.NoError(t, err) + + t.Cleanup(func() { assert.NoError(t, sqlDB.Close()) }) + + assertOpenedFile(t, sqlDB, path) + } +} + +// TestOpenSQLiteOpensARelativePath opens a database at a path relative +// to the working directory, which is what a relative DATA_DIR such as +// the README's DATA_DIR=../webhooker-data gives OpenSQLite. +// +//nolint:paralleltest // t.Chdir moves the whole process. +func TestOpenSQLiteOpensARelativePath(t *testing.T) { + t.Chdir(t.TempDir()) + require.NoError(t, os.Mkdir("data", 0o700)) + + path := filepath.Join("data", database.MainDBFileName) + + sqlDB, err := database.OpenSQLite(path, database.SQLiteModeCreate) + require.NoError(t, err) + + defer func() { require.NoError(t, sqlDB.Close()) }() + + assertOpenedFile(t, sqlDB, path) +} + +// assertOpenedFile checks that sqlDB is the SQLite file at path. +// database_list names the file SQLite actually opened, as an absolute +// path with symlinks resolved, so the files are compared, not the names. +func assertOpenedFile(t *testing.T, sqlDB *sql.DB, path string) { + t.Helper() + + var ( + seq int + schema, file string + ) + + require.NoError(t, sqlDB. + QueryRowContext(t.Context(), "pragma database_list"). + Scan(&seq, &schema, &file)) + + asked, err := os.Stat(path) + require.NoError(t, err) + + opened, err := os.Stat(file) + require.NoError(t, err) + + assert.True( + t, os.SameFile(asked, opened), + "asked for %s, SQLite opened %s", path, file, + ) +} + // TestConcurrentReaderDoesNotBlockWrites is the unit-scale form of the // reproduction in // https://git.eeqj.de/sneak/webhooker/issues/256: an operator's diff --git a/internal/handlers/delivery_attempts_test.go b/internal/handlers/delivery_attempts_test.go index bba56aa..55d82f1 100644 --- a/internal/handlers/delivery_attempts_test.go +++ b/internal/handlers/delivery_attempts_test.go @@ -42,9 +42,6 @@ func TestDeliveryAttempts_ReadInTheTargetTypesOwnTerms(t *testing.T) { {database.TargetTypeLog, true, 0, "", "written to the log", ""}, } - // Named for type and outcome, so that no two cases share a name: - // Go adds "#01" to a repeated one, and a "#" in the temporary - // directory's path cuts the path SQLiteDSN gives SQLite short. for _, tc := range cases { t.Run(string(tc.targetType)+" "+tc.outcome, func(t *testing.T) { t.Parallel() -- 2.54.0 From 373a2f828daaa68748af44d2cc7495dfcff97766 Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Tue, 13 Oct 2026 09:57:24 +0200 Subject: [PATCH 110/126] Describe code in its own terms, not by an earlier build's data (closes #522) Several comments justified code by what an earlier build would have left in a database or on disk. Each now says what the code does: reserveSQLiteFile sets the mode on every open whoever created the file, and expired treats a session lacking a deadline's timestamp as expired. The comments on retention, resubmitted events, archive pruning, target headers, the redactor and the delivery result view drop their mentions of older rows. parseMaxRetries loses its paragraph on targets saved before the ceiling existed, and neither BeforeSave nor retentionCutoff cites a migration. Comments only. Model: opus-5-5 --- internal/database/model_event.go | 3 +-- internal/database/model_webhook.go | 6 +++--- internal/database/retention.go | 2 +- internal/database/sqlite_open.go | 5 ++--- internal/delivery/archive_sweeper.go | 3 +-- internal/delivery/target_headers.go | 4 ++-- internal/delivery/target_redact.go | 6 +++--- internal/handlers/delivery_result_view.go | 14 ++++++-------- internal/handlers/target_retries.go | 7 ------- internal/session/session.go | 7 +++---- 10 files changed, 22 insertions(+), 35 deletions(-) diff --git a/internal/database/model_event.go b/internal/database/model_event.go index b50fe32..0279230 100644 --- a/internal/database/model_event.go +++ b/internal/database/model_event.go @@ -45,8 +45,7 @@ type Event struct { // ResubmittedFromID names the event this one was copied from by // an operator resubmit. It is nil for an event that arrived on - // the receiver, which is every event created before the column - // existed. It is not a foreign key: the source event can be + // the receiver. It is not a foreign key: the source event can be // reaped by retention while its copies remain, and the id is // kept as the record of where the copy came from either way. ResubmittedFromID *string `gorm:"type:uuid;index:idx_events_resubmitted_from_id,priority:1;index:idx_events_entrypoint_id,priority:3" json:"resubmittedFromId,omitempty"` diff --git a/internal/database/model_webhook.go b/internal/database/model_webhook.go index c6835e1..bc0de7f 100644 --- a/internal/database/model_webhook.go +++ b/internal/database/model_webhook.go @@ -82,7 +82,7 @@ type Webhook struct { // value while building the insert statement, which runs after // BeforeSave; rewriting any later than this loses that race and the // row lands at 30 days. Living on the model also means a future call -// site — a REST API, a fixture, a migration — cannot bypass it. +// site, such as a REST API or a fixture, cannot bypass it. func (w *Webhook) BeforeSave(_ *gorm.DB) error { if w.RetentionDays <= 0 { w.RetentionDays = RetentionForeverDays @@ -98,8 +98,8 @@ func (w *Webhook) BeforeSave(_ *gorm.DB) error { // are exempt from reaping. // // It accepts the RetentionForeverDays sentinel written by BeforeSave -// and, defensively, the non-positive values that rows written before -// the sentinel existed may still carry. +// and, defensively, any non-positive value, which BeforeSave treats +// as a request to retain forever. func retainsForever(retentionDays int) bool { return retentionDays <= 0 || retentionDays >= RetentionForeverDays diff --git a/internal/database/retention.go b/internal/database/retention.go index 7ee7d75..5265ea9 100644 --- a/internal/database/retention.go +++ b/internal/database/retention.go @@ -253,7 +253,7 @@ func (r *RetentionReaper) reapWebhook( // row in the database: the sweep then deletes every event, delivery, // and delivery result, including ones created seconds ago. Rejecting // out-of-range input at the form is the primary guard; saturating here -// means an old row, a migration, or a future call site cannot turn a +// means a value that reaches the row by any other path cannot turn a // too-large retention into total data loss. func retentionCutoff( now time.Time, diff --git a/internal/database/sqlite_open.go b/internal/database/sqlite_open.go index e73ce57..ab6d368 100644 --- a/internal/database/sqlite_open.go +++ b/internal/database/sqlite_open.go @@ -118,9 +118,8 @@ const SQLiteFilePerm fs.FileMode = 0o600 // SQLite still reports the absence rather than this function // materializing an empty database the caller asked not to create. // -// Chmod of a file that already exists is what tightens a data -// directory an earlier build left at 0644 — including a developer's -// own scratch directory — without any migration machinery. +// The mode is set on every open, whoever created the file, so a +// database or sidecar found at a wider mode is tightened too. func reserveSQLiteFile(path string, create bool) error { if create { // gosec G304: the path is the database file the caller asked diff --git a/internal/delivery/archive_sweeper.go b/internal/delivery/archive_sweeper.go index 838dddf..1a6b87a 100644 --- a/internal/delivery/archive_sweeper.go +++ b/internal/delivery/archive_sweeper.go @@ -201,8 +201,7 @@ func (s *ArchiveSweeper) sweep(ctx context.Context) { // sweepTarget prunes the archive of a single database target. // A missing, empty, or "never" expiry parses as a zero duration -// and is skipped entirely, so those archives keep exactly the -// behaviour they had before the sweep existed. +// and is skipped entirely, so those archives are never pruned. func (s *ArchiveSweeper) sweepTarget(target *database.Target) { expiry, err := parseArchiveExpiry(target.Config) if err != nil { diff --git a/internal/delivery/target_headers.go b/internal/delivery/target_headers.go index 20e3c9b..ca2b069 100644 --- a/internal/delivery/target_headers.go +++ b/internal/delivery/target_headers.go @@ -82,8 +82,8 @@ func isReservedTargetHeader(name string) bool { // one pair silently overwriting the other. // // An input with no pairs yields an empty map, which omitempty drops -// from the stored config: a target configured with no headers keeps -// the same config JSON it had before this field existed. +// from the stored config, so a target configured with no headers +// stores no headers key at all. func ParseTargetHeaders(raw string) (map[string]string, error) { headers := make(map[string]string) diff --git a/internal/delivery/target_redact.go b/internal/delivery/target_redact.go index 267e584..31d4867 100644 --- a/internal/delivery/target_redact.go +++ b/internal/delivery/target_redact.go @@ -13,9 +13,9 @@ import ( const RedactionMarker = "(redacted)" // Redactor removes one target's own credential material from -// text that target's remote peer chose: a delivery response -// body, or a delivery error stored before the delivery path -// learned to mask the URLs it embeds. +// a delivery response body, whose text the target's remote +// peer chose, and from a stored delivery error as a second +// line behind the delivery path's own URL masking. // // It removes byte-identical echoes of strings taken from the // target's stored configuration, and nothing else. Anything diff --git a/internal/handlers/delivery_result_view.go b/internal/handlers/delivery_result_view.go index f635b92..76140c5 100644 --- a/internal/handlers/delivery_result_view.go +++ b/internal/handlers/delivery_result_view.go @@ -12,13 +12,12 @@ import ( // // The bound is enforced here and in SQL, because this page's // memory profile must not depend on a constant in another -// package staying where it is, and because rows predating the -// delivery engine's own cap or restored from an archive are -// not covered by it at all. +// package staying where it is, or on every stored row having +// been written by the delivery engine under its own cap. // // It happens to equal that engine cap, so a row written by the -// current engine reaches this one exactly and is never cut -// twice. Nothing here may assume the two differ: see view. +// engine reaches this one exactly and is never cut twice. +// Nothing here may assume the two differ: see view. const maxRenderedResponseBytes = 4096 // deliveryResultColumns is the delivery attempt projection. @@ -40,9 +39,8 @@ const deliveryResultColumns = "delivery_id, attempt_num, created_at, " + // // Both Error and ResponseBody have been through the target's // Redactor. The engine already masks the URL out of the -// errors it stores, so for errors this is a second line -// covering rows written before it did; for response bodies it -// is the only line, and its reach is what +// errors it stores, so for errors this is a second line; for +// response bodies it is the only line, and its reach is what // delivery.Redactor documents. type DeliveryResultView struct { AttemptNum int diff --git a/internal/handlers/target_retries.go b/internal/handlers/target_retries.go index 15979c3..6e68916 100644 --- a/internal/handlers/target_retries.go +++ b/internal/handlers/target_retries.go @@ -49,13 +49,6 @@ var ( // working retry configuration over a typo. A default answers a // question that was not asked; it never answers one that was asked // badly. -// -// A target stored with a count above the ceiling before this -// validation existed keeps rendering and keeps delivering — nothing -// clamps the row. Re-saving it from the edit form does have to bring -// it into range, because the form submits the pre-filled value back -// and accepting it would be the ceiling not applying to the edit -// path. The 400 names the ceiling, so the fix is one field. func parseMaxRetries(raw string, fallback int) (int, error) { raw = strings.TrimSpace(raw) if raw == "" { diff --git a/internal/session/session.go b/internal/session/session.go index 5335000..1422bf2 100644 --- a/internal/session/session.go +++ b/internal/session/session.go @@ -441,10 +441,9 @@ func (s *Session) Regenerate( // // Whichever comes first ends the session. // -// A session that claims to be authenticated but carries no -// timestamps predates this check; it is treated as expired so the -// user re-authenticates rather than being granted an unbounded -// session. +// A session that claims to be authenticated but lacks the timestamp +// a deadline is measured from is treated as expired, so the user +// re-authenticates rather than being granted an unbounded session. func (s *Session) expired(sess *sessions.Session) bool { now := s.now() -- 2.54.0 From 573f7f986a19f9ffde3c637e74a09cbcea3e1d22 Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Tue, 13 Oct 2026 10:43:47 +0200 Subject: [PATCH 111/126] Build the image's static binary with netgo, osusergo and sqlite_omit_load_extension (closes #525) The image's static relink linked glibc's getaddrinfo (from Go's cgo DNS resolver) and dlopen (from SQLite extension loading). A static binary still needs glibc's shared libraries at run time for both, and the Alpine runtime has none. The relink now passes the standard build tags netgo, osusergo and sqlite_omit_load_extension through a GO_TAGS variable, which the Makefile's build target hands to go build as -tags next to GO_LDFLAGS, so the version stamp is composed as before. Model: opus-5-5 --- Dockerfile | 24 ++++++++++++------- Makefile | 10 ++++---- README.md | 14 ++++++----- internal/versionscript/version_script_test.go | 8 +++++-- 4 files changed, 35 insertions(+), 21 deletions(-) diff --git a/Dockerfile b/Dockerfile index c52265d..e72dee7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -214,16 +214,22 @@ RUN version="$(make version VERSION="$VERSION")"; \ # (Alpine.js, extracted from its tarball in 3p/) first. RUN make build VERSION="$VERSION" -# Rebuild with static linking for Alpine runtime. -# make build already verified compilation. -# The CGO binary from `make build` is dynamically linked against glibc, -# which doesn't exist on Alpine (musl). Rebuild with static linking so -# the binary runs on Alpine without glibc. +# Rebuild with static linking for the Alpine runtime. The cgo binary the +# step above builds is dynamically linked against glibc, which Alpine +# (musl) does not have. # -# The static flags go in through GO_LDFLAGS rather than a -ldflags of -# their own: the build target composes them with the -X that stamps the -# version, so this relink cannot silently drop the stamp. -RUN CGO_ENABLED=1 make build VERSION="$VERSION" GO_LDFLAGS='-extldflags "-static"' +# A statically linked glibc still loads shared libraries at run time for +# getaddrinfo and dlopen, and Alpine has none to load, so the build tags +# keep both out of the binary: netgo and osusergo select Go's own DNS +# resolver and user lookup instead of glibc's, and +# sqlite_omit_load_extension leaves out SQLite extension loading, which +# webhooker never uses. +# +# The static flags and tags go in through GO_LDFLAGS and GO_TAGS rather +# than a -ldflags of their own: the build target composes them with the +# -X that stamps the version, so this relink cannot silently drop the +# stamp. +RUN CGO_ENABLED=1 make build VERSION="$VERSION" GO_TAGS=netgo,osusergo,sqlite_omit_load_extension GO_LDFLAGS='-extldflags "-static"' # Runtime stage # alpine:3.21, 2026-03-17 diff --git a/Makefile b/Makefile index ea25515..b2f2f36 100644 --- a/Makefile +++ b/Makefile @@ -16,14 +16,16 @@ VERSION ?= $(shell script/version) # exists to correct. override VERSION := $(or $(strip $(VERSION)),$(shell script/version)) -# Extra linker flags for the build target. The static relink in the -# Dockerfile adds -extldflags here rather than passing its own -ldflags, -# so composing flags cannot drop the version stamp. +# Extra linker flags and build tags for the build target. The static +# relink in the Dockerfile passes -extldflags and its build tags through +# these rather than a -ldflags of its own, so composing flags cannot drop +# the version stamp. # # The build target itself always passes -trimpath and -s -w, as the Go # Dockerfile in REPO_POLICIES.md does: no build paths, symbol table or # debug information in the binary. GO_LDFLAGS ?= +GO_TAGS ?= bootstrap: @script/bootstrap @@ -53,7 +55,7 @@ check: @script/check build: assets - go build -trimpath -ldflags '$(strip -s -w -X main.version=$(VERSION) $(GO_LDFLAGS))' -o bin/webhooker ./cmd/webhooker + go build -trimpath -tags '$(GO_TAGS)' -ldflags '$(strip -s -w -X main.version=$(VERSION) $(GO_LDFLAGS))' -o bin/webhooker ./cmd/webhooker run: build ./bin/webhooker diff --git a/README.md b/README.md index cbdcc85..80ed3db 100644 --- a/README.md +++ b/README.md @@ -3419,12 +3419,14 @@ fixed independently of the compiler's: phases and the `css-check` and `markdown-check` stages passing (it copies a file from each), runs `make build` (which extracts Alpine.js from `3p/` first), and then rebuilds the binary with `CGO_ENABLED=1` and static linking - so it runs on musl. Both builds go through `make build`, the relink adding - its `-extldflags` via `GO_LDFLAGS`, so neither can drop the `-X` that stamps - the version. The version is the `VERSION` build arg if one is given, - otherwise derived from the `.git` in the context, and the stage fails if a - context with `.git` would stamp an empty version, `dev` or `unknown` (see - [Version stamping](#version-stamping)). + so it runs on musl, with the build tags `netgo`, `osusergo` and + `sqlite_omit_load_extension` so it needs none of glibc's shared libraries at + run time. Both builds go through `make build`, the relink adding its + `-extldflags` via `GO_LDFLAGS` and its tags via `GO_TAGS`, so neither can + drop the `-X` that stamps the version. The version is the `VERSION` build arg + if one is given, otherwise derived from the `.git` in the context, and the + stage fails if a context with `.git` would stamp an empty version, `dev` or + `unknown` (see [Version stamping](#version-stamping)). 7. **Runtime stage** (`alpine:3.21`) — copies the static binary and `deploy/docker-entrypoint.sh`, creates the `/var/lib/webhooker` directory for all SQLite databases, exposes port 8080, and includes a health check against diff --git a/internal/versionscript/version_script_test.go b/internal/versionscript/version_script_test.go index 3e59b17..68841c5 100644 --- a/internal/versionscript/version_script_test.go +++ b/internal/versionscript/version_script_test.go @@ -156,7 +156,8 @@ func TestVersion_IsStableAcrossInvocations(t *testing.T) { // The build target is the only place composing linker flags. If a // future edit drops either half, the binary silently reports "dev" // again (the defect this package exists for) or fails to link on -// Alpine. +// Alpine. If it stops passing GO_TAGS, the image's static binary links +// glibc's getaddrinfo and dlopen again. func TestMakefile_BuildComposesVersionAndExtraFlags(t *testing.T) { t.Parallel() @@ -164,6 +165,7 @@ func TestMakefile_BuildComposesVersionAndExtraFlags(t *testing.T) { require.Contains(t, makefile, "-X main.version=$(VERSION)") require.Contains(t, makefile, "$(GO_LDFLAGS)") + require.Contains(t, makefile, "-tags '$(GO_TAGS)'") require.Contains(t, makefile, "VERSION ?= $(shell script/version)") } @@ -233,7 +235,9 @@ func TestDockerfile_BuildsThroughTheMakeTarget(t *testing.T) { "a raw go build bypasses the Makefile's -X flag") require.Contains(t, dockerfile, "ARG VERSION") require.Contains(t, dockerfile, - `make build VERSION="$VERSION" GO_LDFLAGS='-extldflags "-static"'`) + `make build VERSION="$VERSION"`+ + ` GO_TAGS=netgo,osusergo,sqlite_omit_load_extension`+ + ` GO_LDFLAGS='-extldflags "-static"'`) } func read(t *testing.T, path string) string { -- 2.54.0 From f14fe0201149588ff0180a9e4be35c49649d55b2 Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Thu, 15 Oct 2026 11:15:35 +0200 Subject: [PATCH 112/126] Store delivery and archive times without the monotonic reading (closes #529) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit finished_at, archived_at and the updated_at stamp a re-dispatch writes came from a bare time.Now(). The SQLite driver stores a time as time.Time.String(), which appends the monotonic clock reading (" m=+…") to a time read from time.Now(). All three now come from GORM's own clock, NowFunc(), which stamps created_at, so they are stored in the same form. The tests for each write read the stored text back. Model: opus-5-5 --- internal/delivery/delivery_totals_test.go | 15 +++++++++++++-- internal/delivery/engine.go | 10 +++++++--- internal/delivery/engine_test.go | 12 ++++++++++++ internal/delivery/inflight_test.go | 12 +++++++++++- internal/delivery/target_database_archive.go | 6 +++++- internal/delivery/target_database_test.go | 7 +++++++ 6 files changed, 55 insertions(+), 7 deletions(-) diff --git a/internal/delivery/delivery_totals_test.go b/internal/delivery/delivery_totals_test.go index 617396d..ffa98ef 100644 --- a/internal/delivery/delivery_totals_test.go +++ b/internal/delivery/delivery_totals_test.go @@ -32,8 +32,9 @@ func targetTotals( // TestUpdateDeliveryStatus_FinishTimeAndTargetTotals pins what a status // write records for the webhook page's statistics: the time a delivery -// finished, set only when it becomes delivered or failed, and one more -// on its target's delivered or failed total. +// finished, set only when it becomes delivered or failed and stored in +// the same form as created_at, and one more on its target's delivered +// or failed total. func TestUpdateDeliveryStatus_FinishTimeAndTargetTotals(t *testing.T) { t.Parallel() @@ -75,6 +76,16 @@ func TestUpdateDeliveryStatus_FinishTimeAndTargetTotals(t *testing.T) { if tt.finished { require.NotNil(t, stored.FinishedAt) assert.False(t, stored.FinishedAt.Before(before)) + + var createdAt, finishedAt string + + require.NoError(t, db.Raw( + `SELECT CAST(created_at AS TEXT), + CAST(finished_at AS TEXT) + FROM deliveries WHERE id = ?`, d.ID, + ).Row().Scan(&createdAt, &finishedAt)) + assertStoredTime(t, createdAt) + assertStoredTime(t, finishedAt) } else { assert.Nil(t, stored.FinishedAt) } diff --git a/internal/delivery/engine.go b/internal/delivery/engine.go index fcf2bc9..be78e66 100644 --- a/internal/delivery/engine.go +++ b/internal/delivery/engine.go @@ -1701,8 +1701,11 @@ func writeDeliveryStatus( database.DeliveryStatusFailed, }). Updates(map[string]any{ - "status": status, - "finished_at": time.Now(), + "status": status, + // GORM's clock, the one that stamps created_at. A bare + // time.Now() carries a monotonic clock reading, which the + // driver would store as an " m=+…" suffix. + "finished_at": tx.NowFunc(), }) if res.Error != nil || res.RowsAffected == 0 { return res.Error @@ -1906,12 +1909,13 @@ func (e *Engine) takeForRedispatch( return false } + // GORM's clock, as writeDeliveryStatus explains. res := webhookDB. Model(&database.Delivery{}). Where( "id = ? AND status = ?", deliveryID, eligible, ). - UpdateColumn("updated_at", time.Now()) + UpdateColumn("updated_at", webhookDB.NowFunc()) if res.Error != nil { e.log.Error( diff --git a/internal/delivery/engine_test.go b/internal/delivery/engine_test.go index eca808d..7ebb016 100644 --- a/internal/delivery/engine_test.go +++ b/internal/delivery/engine_test.go @@ -117,6 +117,18 @@ func seedDelivery( return d } +// assertStoredTime asserts that text, a timestamp as SQLite holds it, +// is in the form GORM stores created_at in: the layout of +// time.Time.String, with no " m=+…" monotonic clock reading after it. +func assertStoredTime(t *testing.T, text string) { + t.Helper() + + _, err := time.Parse( + "2006-01-02 15:04:05.999999999 -0700 MST", text, + ) + assert.NoError(t, err, "stored as %q", text) +} + // httpDeliveryFixture holds the task and delivery needed // for HTTP delivery tests. type httpDeliveryFixture struct { diff --git a/internal/delivery/inflight_test.go b/internal/delivery/inflight_test.go index 81ad665..059aa5a 100644 --- a/internal/delivery/inflight_test.go +++ b/internal/delivery/inflight_test.go @@ -462,7 +462,8 @@ func TestScheduledRetryIsNotSweptDuringBackoff(t *testing.T) { // TestRedispatchStampsTheRow pins the cadence control: a stranded // delivery that has just been handed out is not selected again by the -// next tick a minute later. +// next tick a minute later. The stamp is stored in the same form as +// created_at. func TestRedispatchStampsTheRow(t *testing.T) { t.Parallel() @@ -493,4 +494,13 @@ func TestRedispatchStampsTheRow(t *testing.T) { "a re-dispatched delivery must be stamped so the "+ "next tick does not select it again", ) + + var createdAt, updatedAt string + + require.NoError(t, s.WebhookDB.Raw( + `SELECT CAST(created_at AS TEXT), CAST(updated_at AS TEXT) + FROM deliveries WHERE id = ?`, d.ID, + ).Row().Scan(&createdAt, &updatedAt)) + assertStoredTime(t, createdAt) + assertStoredTime(t, updatedAt) } diff --git a/internal/delivery/target_database_archive.go b/internal/delivery/target_database_archive.go index 4dcff5c..519af99 100644 --- a/internal/delivery/target_database_archive.go +++ b/internal/delivery/target_database_archive.go @@ -277,7 +277,11 @@ func (w *archiveWriter) write( } } - row.ArchivedAt = time.Now() + // GORM's clock, the one that stamps created_at in the other + // databases, so this is stored in the same form. A bare time.Now() + // carries a monotonic clock reading, which the driver would store + // as an " m=+…" suffix. + row.ArchivedAt = w.db.NowFunc() err := w.db.Create(&row).Error if err != nil { diff --git a/internal/delivery/target_database_test.go b/internal/delivery/target_database_test.go index 5f93849..7c96842 100644 --- a/internal/delivery/target_database_test.go +++ b/internal/delivery/target_database_test.go @@ -150,6 +150,13 @@ func TestArchiveWriter_WritesRow(t *testing.T) { assert.Equal(t, row.ContentType, got[0].ContentType) assert.JSONEq(t, `{"hello":"world"}`, got[0].Body) assert.False(t, got[0].ArchivedAt.IsZero()) + + var archivedAt string + + require.NoError(t, w.DB().Raw( + "SELECT CAST(archived_at AS TEXT) FROM archived_events", + ).Row().Scan(&archivedAt)) + assertStoredTime(t, archivedAt) } func TestArchiveWriter_RecreatesAfterRemoval( -- 2.54.0 From c0f7cc29fde5678e4930e309f6712950feaecefe Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Thu, 15 Oct 2026 12:29:17 +0200 Subject: [PATCH 113/126] Leave a delivery cut off by a stop for the next start (closes #527) Stopping the engine cancels the context every http and slack request is sent with. An attempt cut off that way was recorded as failed, which for a target with no retries, or on a last retry, ended the delivery failed and nothing sent it again. The shared retry core now records nothing for a failed attempt when the engine's context is cancelled: no result, no circuit breaker outcome, no status change, no count in the attempt metrics. The delivery stays pending or retrying and startup recovery sends it again. A target's own timeout is still a failed attempt. The README says what happens to an attempt in flight at a stop and no longer claims every attempt is recorded. Model: opus-5-5 --- README.md | 43 ++++-- internal/delivery/engine_lifecycle_test.go | 153 +++++++++++++++++++++ internal/delivery/target.go | 3 +- internal/delivery/target_http.go | 49 ++++++- internal/delivery/target_slack.go | 2 +- 5 files changed, 230 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 80ed3db..91bc88e 100644 --- a/README.md +++ b/README.md @@ -1317,20 +1317,22 @@ webhooker solves this by acting as a durable intermediary: nothing is lost if downstream targets are unavailable. 2. **Guaranteed delivery** — Events are queued for delivery to each configured - target. Failed deliveries are retried with configurable backoff. Every - delivery attempt is logged with status codes, response bodies, and timing. + target. Failed deliveries are retried with configurable backoff. Delivery + attempts are logged with status codes, response bodies, and timing. **That guarantee is at-least-once, not exactly-once.** When a send reaches its target but the write recording that outcome fails, the delivery is deliberately left in a recoverable state rather than marked done — losing a delivery is the worse failure — so the pending sweep picks it up about fifteen minutes later, or the next restart does, and the target receives a - payload it already got. webhooker adds no delivery identifier of its own to - an outbound request, so **make your receiver idempotent** against whatever - the payload itself carries. + payload it already got. A send still in flight when the service stops is + left the same way: its attempt is not recorded, and the next start sends it + again (see Shutdown). webhooker adds no delivery identifier of its own to an + outbound request, so **make your receiver idempotent** against whatever the + payload itself carries. 3. **Observability** — Full request/response logging for every webhook received - and every delivery attempted. Prometheus metrics expose volume, latency, and + and for delivery attempts. Prometheus metrics expose volume, latency, and error rates. The web UI provides real-time visibility into event flow. 4. **Fan-out** — A single incoming webhook can be delivered to multiple targets @@ -1768,7 +1770,7 @@ is what bounds it. #### DeliveryResult The result of a single delivery attempt. Every attempt (including retries) is -individually logged for full observability. +individually logged, except one cut off by a stop (see Shutdown). | Field | Type | Description | | --------------- | ------- | ----------------------------------------- | @@ -2206,12 +2208,16 @@ This means: buffer rather than spawning more goroutines. - **Independent results** — each worker records its own delivery result in the per-webhook database without coordination. -- **Graceful shutdown** — cancel the context, workers finish their current task - and exit. The stop hook waits for the pool via `lifecycle.WaitForShutdown`, - which bounds that wait by fx's stop timeout rather than blocking forever on a - wedged worker. On timeout it logs at `ERROR` and returns an error, and the - goroutines that did not finish are still running — an unclean shutdown is - reported rather than hidden. +- **Graceful shutdown** — the stop hook cancels the workers' context. A worker + may still take tasks already queued before it exits. An `http` or `slack` + request in flight is cut off, and one started after the cancel fails at once. + Neither is recorded or counted in the metrics, and the next start sends the + delivery again (see Shutdown). A `database` or `log` task runs as normal. The + stop hook waits for the pool via `lifecycle.WaitForShutdown`, which bounds + that wait by fx's stop timeout rather than blocking forever on a wedged + worker. On timeout it logs at `ERROR` and returns an error, and the goroutines + that did not finish are still running — an unclean shutdown is reported rather + than hidden. **Recovery paths:** @@ -2317,7 +2323,7 @@ getting anywhere. | Metric | Type | Meaning | | -------------------------------------- | --------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `webhooker_events_received_total` | counter | Events received and durably stored. Compare against the delivery counters on one dashboard | -| `webhooker_delivery_attempts_total` | counter | Delivery attempts actually dispatched to a target. A delivery a circuit breaker refused is not one: it is counted as a retry instead, but only when the refusal moves it into `retrying` | +| `webhooker_delivery_attempts_total` | counter | Delivery attempts actually dispatched to a target. A delivery a circuit breaker refused is not one: it is counted as a retry instead, but only when the refusal moves it into `retrying`. An attempt cut off by a stop is not counted (see Shutdown) | | `webhooker_deliveries_succeeded_total` | counter | Deliveries that reached `delivered` | | `webhooker_deliveries_failed_total` | counter | Deliveries that failed terminally and will not be retried | | `webhooker_delivery_retries_total` | counter | Deliveries put back into `retrying` | @@ -3284,7 +3290,8 @@ stop-hook log: 3. `server` — the HTTP drain, bounded by `server.ShutdownTimeout` (**3 seconds**) and by what the hooks before it left, then a Sentry flush if `SENTRY_DSN` is set -4. `delivery.Engine` — waits for its workers, then closes the archive databases +4. `delivery.Engine` — cuts off any `http` or `slack` request in flight, waits + for its workers, then closes the archive databases 5. `healthcheck` 6. `WebhookDBManager` 7. the database close @@ -3295,6 +3302,12 @@ by the stop context, so a wedge there consumes the 5 seconds before the HTTP server hook is ever entered. The hooks after the server are microsecond-scale in normal operation. +The delivery engine cancels a delivery request in flight. That attempt is not +recorded or counted in the metrics, and the delivery keeps its `pending` or +`retrying` status, so startup recovery sends it again on the next start (see +Recovery paths under Bounded Worker Pool). If the receiver already had it, it +receives it twice: the at-least-once duplicate described under Rationale. + The HTTP drain budget is deliberately **shorter** than the sequence budget. Were the two equal, a drain that used its whole budget would exhaust the sequence budget at the instant it finished, and every later hook — the delivery engine, diff --git a/internal/delivery/engine_lifecycle_test.go b/internal/delivery/engine_lifecycle_test.go index 69e0d43..7db4258 100644 --- a/internal/delivery/engine_lifecycle_test.go +++ b/internal/delivery/engine_lifecycle_test.go @@ -2,11 +2,16 @@ package delivery_test import ( "context" + "io" + "net/http" + "net/http/httptest" "path/filepath" + "sync" "testing" "time" "github.com/google/uuid" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.uber.org/fx" "gorm.io/gorm/clause" @@ -272,6 +277,154 @@ func TestEngine_StopHookHonoursStopTimeout(t *testing.T) { requireStopHookExpires(t, lc.hooks[0], "delivery engine") } +// seedHeldDelivery gives the setup's webhook an http target with +// maxRetries, pointed at a receiver that keeps every request open until +// the client gives up on it, and seeds one delivery to that target at +// status. It returns the task that sends attempt attemptNum of the +// delivery, and a channel that is closed when a request reaches the +// receiver. +func seedHeldDelivery( + t *testing.T, + s iSetup, + maxRetries int, + status database.DeliveryStatus, + attemptNum int, +) (delivery.Task, <-chan struct{}) { + t.Helper() + + arrived := make(chan struct{}) + + var once sync.Once + + ts := httptest.NewServer(http.HandlerFunc( + func(_ http.ResponseWriter, r *http.Request) { + // The server notices the client hanging up only once the + // body has been read; until then r.Context() never ends. + _, _ = io.Copy(io.Discard, r.Body) + + once.Do(func() { close(arrived) }) + + <-r.Context().Done() + }, + )) + t.Cleanup(ts.Close) + + cfg := iHTTPConfig(ts.URL) + targetID := uuid.New().String() + + iCreateWebhook(t, s.MainDB, s.WebhookID, "hook") + iCreateTarget( + t, s.MainDB, targetID, s.WebhookID, "held", + database.TargetTypeHTTP, cfg, maxRetries, + ) + + event := iSeedEvent(t, s.WebhookDB, s.WebhookID, `{"held":true}`) + d := iSeedDelivery(t, s.WebhookDB, event.ID, targetID, status) + + bodyStr := event.Body + + return iTask( + d, event, s.WebhookID, targetID, "held", cfg, + maxRetries, attemptNum, &bodyStr, + ), arrived +} + +// stopDuringAttempt waits until the receiver holds an attempt open, then +// stops the engine through its real stop hook. +func stopDuringAttempt( + t *testing.T, lc *recordingLifecycle, arrived <-chan struct{}, +) { + t.Helper() + + select { + case <-arrived: + case <-time.After(5 * time.Second): + t.Fatal("the delivery attempt never reached the receiver") + } + + require.NoError(t, lc.hooks[0].OnStop(context.Background())) +} + +// requireRequeued runs restart recovery on the stopped engine and +// requires it to queue task's attempt again on ch. +func requireRequeued( + t *testing.T, s iSetup, ch <-chan delivery.Task, task delivery.Task, +) { + t.Helper() + + s.Engine.ExportRecoverWebhookDeliveries( + context.Background(), s.WebhookID, + ) + + select { + case got := <-ch: + assert.Equal(t, task.DeliveryID, got.DeliveryID) + assert.Equal(t, task.AttemptNum, got.AttemptNum) + case <-time.After(5 * time.Second): + t.Fatal("restart recovery did not queue the delivery again") + } +} + +// TestEngine_StopLeavesFireAndForgetAttemptPending is the regression +// test for a stop that turned an attempt in flight into a terminal +// failure. Stopping the engine cancels the request; for a target with +// no retries that was the delivery's only attempt, recorded as failed +// with "context canceled", and nothing ever sent it again. The +// delivery must instead stay pending, with no result and no count in +// the attempt metrics, for restart recovery to send. +func TestEngine_StopLeavesFireAndForgetAttemptPending(t *testing.T) { + t.Parallel() + + s := newISetup(t) + reg := mIsolate(t, s) + lc := startEngineViaHook(t, s.Engine) + + task, arrived := seedHeldDelivery( + t, s, 0, database.DeliveryStatusPending, 1, + ) + + s.Engine.Notify([]delivery.Task{task}) + stopDuringAttempt(t, lc, arrived) + + iAssertStatus( + t, s.WebhookDB, task.DeliveryID, database.DeliveryStatusPending, + ) + assert.Empty(t, iResults(t, s.WebhookDB, task.DeliveryID)) + assert.Zero(t, mCounter(t, reg, mAttempts, mTypeHTTP)) + assert.Zero(t, mHTTPDurations(t, reg)) + + requireRequeued(t, s, s.Engine.ExportDeliveryCh(), task) +} + +// TestEngine_StopLeavesLastRetryAttemptRetrying is the same regression +// for a target with retries, on its last attempt: the cancelled attempt +// used to exhaust the retries and end the delivery failed. It must stay +// retrying with only its earlier attempt recorded, and restart recovery +// must send that last attempt again. +func TestEngine_StopLeavesLastRetryAttemptRetrying(t *testing.T) { + t.Parallel() + + const maxRetries = 2 + + s := newISetup(t) + lc := startEngineViaHook(t, s.Engine) + + task, arrived := seedHeldDelivery( + t, s, maxRetries, database.DeliveryStatusRetrying, maxRetries, + ) + iSeedFailedResult(t, s.WebhookDB, task.DeliveryID) + + s.Engine.ExportScheduleRetry(task, 0) + stopDuringAttempt(t, lc, arrived) + + iAssertStatus( + t, s.WebhookDB, task.DeliveryID, database.DeliveryStatusRetrying, + ) + assert.Len(t, iResults(t, s.WebhookDB, task.DeliveryID), 1) + + requireRequeued(t, s, s.Engine.ExportRetryCh(), task) +} + // deliverToArchive gives the setup's webhook a database target, // runs one delivery to it through the running engine, and returns // the target's ID and archive file path. The archive writer holds diff --git a/internal/delivery/target.go b/internal/delivery/target.go index 5ccb7d4..157565a 100644 --- a/internal/delivery/target.go +++ b/internal/delivery/target.go @@ -32,7 +32,8 @@ type Scheduler interface { // Engine.observeAttempt, alongside the DeliveryResult it records for // it. Deliver is also entered for attempts that never happen — an // open circuit breaker refuses one — so the count cannot be taken -// from around this call. +// from around this call. An attempt cut off by a stop is neither +// counted nor recorded. type Target interface { Deliver( ctx context.Context, diff --git a/internal/delivery/target_http.go b/internal/delivery/target_http.go index 8733f31..009c070 100644 --- a/internal/delivery/target_http.go +++ b/internal/delivery/target_http.go @@ -57,7 +57,11 @@ type httpCore struct { // recorded and no circuit breaker is consulted. A positive // maxRetries gates the attempt on the circuit breaker and // schedules a backed-off retry on failure. +// +// ctx is the context attempt sends its request with. Stopping the +// engine cancels it; see interruptedByStop. func (c *httpCore) deliver( + ctx context.Context, webhookDB *gorm.DB, d *database.Delivery, task *Task, @@ -66,21 +70,55 @@ func (c *httpCore) deliver( attempt func() attemptResult, ) { if maxRetries == 0 { - c.fireAndForget(webhookDB, d, attempt()) + c.fireAndForget(ctx, webhookDB, d, attempt()) return } c.withRetry( - webhookDB, d, task, sched, maxRetries, attempt, + ctx, webhookDB, d, task, sched, maxRetries, attempt, ) } +// interruptedByStop reports whether an attempt failed because the +// engine is stopping, which cancels the context its request is made +// with. Such an attempt is not recorded: no DeliveryResult, no circuit +// breaker outcome, no status change, no count in the attempt metrics. +// The delivery stays pending or retrying, and startup recovery sends +// it again on the next start. +// +// A target's own timeout leaves that context alone, so its attempt is +// still recorded as the failure it is. +func (c *httpCore) interruptedByStop( + ctx context.Context, + d *database.Delivery, + res attemptResult, +) bool { + if res.success || ctx.Err() == nil { + return false + } + + c.eng.log.Warn( + "delivery attempt interrupted by shutdown; not recorded, "+ + "it is sent again on the next start", + "delivery_id", d.ID, + "event_id", d.EventID, + "target_id", d.TargetID, + ) + + return true +} + func (c *httpCore) fireAndForget( + ctx context.Context, webhookDB *gorm.DB, d *database.Delivery, res attemptResult, ) { + if c.interruptedByStop(ctx, d, res) { + return + } + c.eng.observeAttempt(d.Target.Type, res.elapsed()) err := c.eng.recordResult( @@ -110,6 +148,7 @@ func (c *httpCore) fireAndForget( } func (c *httpCore) withRetry( + ctx context.Context, webhookDB *gorm.DB, d *database.Delivery, task *Task, @@ -131,6 +170,10 @@ func (c *httpCore) withRetry( res := attempt() + if c.interruptedByStop(ctx, d, res) { + return + } + c.eng.observeAttempt(d.Target.Type, res.elapsed()) err := c.eng.recordResult( @@ -395,7 +438,7 @@ func (t *httpTarget) Deliver( } t.deliver( - webhookDB, d, task, sched, + ctx, webhookDB, d, task, sched, d.Target.MaxRetries, attempt, ) } diff --git a/internal/delivery/target_slack.go b/internal/delivery/target_slack.go index 3703b89..553b98e 100644 --- a/internal/delivery/target_slack.go +++ b/internal/delivery/target_slack.go @@ -82,7 +82,7 @@ func (t *slackTarget) Deliver( } t.deliver( - webhookDB, d, task, sched, + ctx, webhookDB, d, task, sched, d.Target.MaxRetries, attempt, ) } -- 2.54.0 From 0d38aea12c9d75777f44808b2c664aab5bcaa1c8 Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Thu, 15 Oct 2026 14:58:35 +0200 Subject: [PATCH 114/126] Keep the entrypoint UUID out of webhooker's own logs (closes #531) On the receiver route the access log and the per-entrypoint 429 line name the route pattern whatever the status. A request chi never routed, such as a dev-mode CORS preflight to the receiver, logs (unmatched). The access log keeps only the scheme and host of a Referer. The received line names the entrypoint and webhook IDs, and the unknown-entrypoint line logs no path. The pattern is middleware.ReceiverRoute, which routes.go registers the receiver with. The README says the proxy's access log holds the UUID and must be protected like the URL, and that a log target writes the request's headers as received. Its access-log, rate-limit, Sentry and log-size passages now match the code. Model: opus-5-5 --- README.md | 90 ++++++++++------- internal/handlers/logbound_test.go | 57 ++--------- internal/handlers/webhook.go | 29 ++---- internal/handlers/webhook_test.go | 84 ++++++++++++++++ internal/middleware/accesslog_test.go | 136 +++++++++++++++++++++++--- internal/middleware/logbound_test.go | 18 ++-- internal/middleware/metrics.go | 3 +- internal/middleware/middleware.go | 104 +++++++++++++------- internal/middleware/ratelimit.go | 22 +++-- internal/server/routes.go | 7 +- 10 files changed, 380 insertions(+), 170 deletions(-) diff --git a/README.md b/README.md index 91bc88e..4f2520a 100644 --- a/README.md +++ b/README.md @@ -802,7 +802,12 @@ comes up, serves pages, and is broken in a way nothing reports. proxy's log names it regardless. nginx's default `combined` format already logs `$remote_addr`; do not replace it with one that drops the client address, and retain those logs as long as you would want to answer a question - about traffic. + about traffic. That log also records the full request path, so it holds the + entrypoint UUID of every event a sender posts. webhooker's own log does not, + apart from the line a `log` target writes. Protect the proxy's log as you + would the entrypoint URLs themselves: anyone who can read it can post events + to every entrypoint in it (see + [The entrypoint URL is the authentication secret](#the-entrypoint-url-is-the-authentication-secret)). ### nginx @@ -828,7 +833,9 @@ server { client_max_body_size 1m; # $remote_addr is the client. webhooker's own log names it, as - # clientIP, only while TRUSTED_PROXIES covers this proxy. + # clientIP, only while TRUSTED_PROXIES covers this proxy. The + # request path in this log holds the entrypoint UUID, so protect + # the file like the entrypoint URL itself. access_log /var/log/nginx/webhooker.access.log combined; location / { @@ -1160,7 +1167,16 @@ What that means for an operator: - **The URL is a capability, so treat it as a secret.** Keep it out of logs, ticket bodies, chat messages and screenshots. Anyone who reads it anywhere can - post events as that sender. + post events as that sender. Apart from a `log` target's line, webhooker's own + log lines never carry the UUID: they name a request to the receiver by the + route pattern `/h/{uuid}`, the `webhook request received` line names the + entrypoint by its `entrypoint_id`, and the access log keeps only the scheme + and host of a `Referer`. A `log` target writes the request's headers as + received, so a `Referer` naming the entrypoint URL appears in its line, as it + does when a sender has followed a redirect from `http://` to `https://`. The + reverse proxy's access log does carry the UUID, in the request path, so it + must be protected like the URL itself (see + [Deployment behind a reverse proxy](#deployment-behind-a-reverse-proxy)). - **Rotating means minting a new entrypoint, not changing a key.** There is no way to rotate the UUID in place. To retire one, delete the entrypoint (or deactivate it, which answers `410`) and create a new one, then point the @@ -2422,27 +2438,32 @@ log volume it caps rather than eliminates. A path that names no entrypoint is recorded by the handler at `DEBUG`, and the aggregate limiter logs its own rejections at `DEBUG` and without the path, so neither appears at all under the default level. The per-entrypoint limiter is the loud one: it logs every -rejection at `WARN` with the request path, which on this route is -attacker-controlled text. A client hammering a single invented path is served +rejection at `WARN`, naming the route pattern `/h/{uuid}` rather than the +request path. A client hammering a single invented path is served `RECEIVER_RATE_LIMIT` requests and has the rest of its aggregate budget rejected there, so the aggregate limit is what bounds the _number_ of those `WARN` lines — to under ten times `RECEIVER_RATE_LIMIT` per minute per client IP, 1080 at the -defaults, where before it there was no bound at all. Their _width_ is bounded by -the field budgets below, the same ones the access log spends. The access log is -bounded by neither limit: every request is recorded once at `INFO`, served or -rejected alike. +defaults, where before it there was no bound at all. The access log is bounded +by neither limit: every request is recorded once at `INFO`, served or rejected +alike. -What the access log does bound is the _content_ of those lines. A 3xx or 4xx -response logs the chi route pattern — `/h/{uuid}`, `/user/{username}//`, or the -literal `(unmatched)` when the request hit no route at all — in place of the -concrete URL. Those are the outcomes an unauthenticated client can drive for -free: 404 and 429 on any invented receiver path, a login redirect on any -invented profile path. Logging the URL there would let a flood write text of its -own choosing, at a length of its own choosing, into the log. 2xx and 5xx -responses keep the concrete path — a success resolved against a static route or -against the operator's own data (on the receiver, against a stored entrypoint -UUID), and a 5xx is a bug in this service, where the exact path is the evidence -and no client can provoke one at will. +What the access log does bound is the _content_ of those lines. On the receiver +route it logs the route pattern `/h/{uuid}` whatever the status, because the +concrete path carries the entrypoint UUID, which is the entrypoint's credential +(see +[The entrypoint URL is the authentication secret](#the-entrypoint-url-is-the-authentication-secret)). +A request chi never routed, because it matched no route or because a middleware +answered it first, logs the literal `(unmatched)` whatever the status. In the +`dev` environment the CORS middleware answers a preflight that way, so a +preflight to the receiver does not log its UUID either. On every other route, a +3xx or 4xx response logs the chi route pattern, such as `/user/{username}//`, in +place of the concrete URL. Those are the outcomes an unauthenticated client can +drive for free: a 404 on any invented path, a login redirect on any invented +profile path. Logging the URL there would let a flood write text of its own +choosing, at a length of its own choosing, into the log. 2xx and 5xx responses +keep the concrete path — a success resolved against a static route or against +the operator's own data, and a 5xx is a bug in this service, where the exact +path is the evidence and no client can provoke one at will. The query string is never logged; it is replaced by the fixed marker `?(redacted)`. It is client-chosen on every route, and @@ -2454,6 +2475,11 @@ to, `notice`, which names the line a page shows after an action, and the event log's `show`, which picks the events it lists. A query string sent to an entrypoint is not lost either: the event stores it, and the event log shows it. +The `referer` field keeps only the scheme and host of the request's `Referer`, +and logs `(redacted)` for one without them, because its path can be an +entrypoint URL: a sender that follows the `http://` to `https://` redirect in +the nginx example sends the URL it was redirected from. + Client-supplied request content does not leave the host by the other route either. The Sentry SDK attaches the request to every event it captures, independently of the access log, and `SendDefaultPII=false` does not cover all @@ -2468,9 +2494,10 @@ The same hook rewrites the request URL. The SDK builds it as `scheme://host/path` from the concrete path, which on the receiver route is `/h/` in full — and that UUID is a write capability, not an identifier: anyone holding it can post events this service accepts and its targets then -deliver. A tracker has its own retention, access control and deletion policy, so -the rule the access log follows above does not carry across that boundary. What -is sent is the chi route pattern instead: `http://host/h/{uuid}`. +deliver. What is sent is the chi route pattern instead: `http://host/h/{uuid}`. +A tracker has its own retention, access control and deletion policy, so the +pattern is sent on every route, including the 2xx and 5xx responses outside the +receiver for which the access log keeps the concrete path. The scheme and the host are kept, and everything else in the URL is discarded rather than edited, so a future SDK version that starts appending a query string @@ -2599,9 +2626,7 @@ than it: | ------------------------------------------ | ------- | ------------------- | ------------------------------------------ | | `request body exceeds limit` (413) | `WARN` | path, method | yes — `MaxBodySize` precedes `RequireAuth` | | `csrf: token validation failed` (403) | `WARN` | path, method | yes — `CSRF` precedes `RequireAuth` | -| `... 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 | @@ -2629,13 +2654,12 @@ wider of the two handlers spends more on than the client spent sending them. Every case holds each line to the 2,560-byte ceiling. That per-line ceiling is what the figure above states, and every row establishes it. -Three of the sites go further and bound the whole flood's output — the total -bytes a run of distinct invented values wrote, which is the shape an operator -sizing storage cares about. They are `request body exceeds limit` -(`TestMaxBodySize_FloodOfOversizePathsDoesNotGrowTheLog`), -`entrypoint not found` and `user not found` (the last two through -`assertBoundedFlood`). The other rows carry no aggregate assertion; the per-line -ceiling is what they establish. +Two of the sites go further and bound the whole flood's output — the total bytes +a run of distinct invented values wrote, which is the shape an operator sizing +storage cares about. They are `request body exceeds limit` +(`TestMaxBodySize_FloodOfOversizePathsDoesNotGrowTheLog`) and `user not found` +(through `assertBoundedFlood`). The other rows carry no aggregate assertion; the +per-line ceiling is what they establish. `internal/logfield/logfield_test.go` measures the per-rune charge against what the handlers really emit, over roughly 3,000 code points on each, so an @@ -2653,7 +2677,7 @@ everything else, so its lines take the level the operator set and the handler `internal/logger` selected, and every value it emits is spent through the same `internal/logfield` budget. A record-not-found is not logged as an error: it is the expected outcome on both of those paths, and each handler already records -its own miss at `DEBUG` — bounded, per the table above — without the SQL. Slow +its own miss at `DEBUG`, without the SQL and within the ceiling above. Slow statements are kept, at `WARN`, above the same 200 ms threshold GORM used and with the statement bounded, because that report is the one thing GORM's logger gave an operator that nothing else here does. The adapter orders its cases diff --git a/internal/handlers/logbound_test.go b/internal/handlers/logbound_test.go index 239bde3..9d35dac 100644 --- a/internal/handlers/logbound_test.go +++ b/internal/handlers/logbound_test.go @@ -1,19 +1,15 @@ package handlers_test -// The handler-side half of the log-field audit. Two slog calls in -// this package reach a value an UNAUTHENTICATED client picks outright -// and of a length it picks outright: +// The handler-side half of the log-field audit. The slog calls in +// this package that reach a value an UNAUTHENTICATED client picks +// outright, and of a length it picks outright, are the failed-login +// DEBUG lines, whose username is a form field. // -// - the unknown-entrypoint DEBUG line on /h/{uuid}, whose -// path segment matched no stored entrypoint and so is bounded by -// nothing; -// - the failed-login DEBUG lines, whose username is a form field. -// -// Both are at DEBUG, which is off in production by default. That is +// They are at DEBUG, which is off in production by default. That is // not a bound: an operator turning DEBUG on to diagnose a flood must -// not thereby hand the flood an unbounded write. Both spend the same -// internal/logfield budget as the access log, and both are held here -// to middleware.MaxAccessLogLineBytes. +// not thereby hand the flood an unbounded write. They spend the same +// internal/logfield budget as the access log, and are held here to +// middleware.MaxAccessLogLineBytes. // // The two login lines past the username lookup — "invalid password" // and "user logged in" — carry the same cap without needing it, since @@ -260,43 +256,6 @@ func postLoginWithPassword( return w.Code } -// TestUnknownEntrypoint_LogLineDoesNotTrackPathSize drives 8 KB of -// client-chosen path at the unauthenticated receiver's -// unknown-entrypoint DEBUG line and holds it to the same ceiling the -// access log states. -func TestUnknownEntrypoint_LogLineDoesNotTrackPathSize(t *testing.T) { - t.Parallel() - - for handlerName, newHandler := range logHandlers() { - for fillName, fill := range escapeFills() { - t.Run(handlerName+"/"+fillName, func(t *testing.T) { - t.Parallel() - - h, buf := capturingHandlers(t, newHandler) - router := receiverRouter(h) - - for i := range floodRequests { - assert.Equal( - t, - http.StatusNotFound, - postReceiver( - t, router, - oversizedFill(fill)+ - strings.Repeat("y", i), - ), - ) - } - - lines := logLines(t, buf) - require.Len(t, lines, floodRequests) - assertNoClientText(t, buf) - - assertBoundedFlood(t, buf.Len()) - }) - } - } -} - // TestFailedLogin_LogLineDoesNotTrackUsernameSize drives 8 KB of // client-chosen username at the unauthenticated login endpoint's // DEBUG line and holds it to the same ceiling. diff --git a/internal/handlers/webhook.go b/internal/handlers/webhook.go index 76bdc4c..4417de4 100644 --- a/internal/handlers/webhook.go +++ b/internal/handlers/webhook.go @@ -10,7 +10,6 @@ import ( "gorm.io/gorm" "sneak.berlin/go/webhooker/internal/database" "sneak.berlin/go/webhooker/internal/delivery" - "sneak.berlin/go/webhooker/internal/logfield" "sneak.berlin/go/webhooker/internal/middleware" ) @@ -49,14 +48,13 @@ func (h *Handlers) HandleWebhook() http.HandlerFunc { return } - // Logged only once the UUID is known to name a real - // entrypoint. The UUID comes straight out of the path on - // the one unauthenticated endpoint, so logging it before - // the lookup let a client write an INFO line per invented - // path; the request itself is already in the access log - // and a miss is already logged at DEBUG. + // Logged only once the lookup has found the entrypoint, so + // an invented path writes no INFO line; a miss is logged at + // DEBUG. The entrypoint is named by its ID: the UUID is its + // credential and is never logged. h.log.Info("webhook request received", - "entrypoint_uuid", entrypointUUID, + "entrypoint_id", entrypoint.ID, + "webhook_id", entrypoint.WebhookID, "method", r.Method, "remoteIP", middleware.RemoteIP(r), "clientIP", middleware.ClientIP(r), @@ -133,17 +131,10 @@ func (h *Handlers) lookupEntrypoint( "path = ?", entrypointUUID, ).First(&entrypoint) if result.Error != nil { - // The receiver is unauthenticated and /h/{uuid} - // matches any single segment, so this value is entirely - // client-chosen on exactly the branch where the lookup - // failed. DEBUG is off by default; the cap is what keeps - // turning it on from restoring an unbounded write. - h.log.Debug( - "entrypoint not found", - "path", logfield.Truncate( - entrypointUUID, logfield.MaxBytes, - ), - ) + // Not even an unknown UUID is logged. The receiver's log + // lines never carry the path segment, which is a credential + // whenever it names an entrypoint. + h.log.Debug("entrypoint not found") http.NotFound(w, r) return entrypoint, false diff --git a/internal/handlers/webhook_test.go b/internal/handlers/webhook_test.go index bd91e4e..c310d05 100644 --- a/internal/handlers/webhook_test.go +++ b/internal/handlers/webhook_test.go @@ -12,12 +12,15 @@ import ( "testing" "github.com/go-chi/chi" + "github.com/google/uuid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "gorm.io/gorm/clause" "sneak.berlin/go/webhooker/internal/config" "sneak.berlin/go/webhooker/internal/database" "sneak.berlin/go/webhooker/internal/handlers" "sneak.berlin/go/webhooker/internal/middleware" + "sneak.berlin/go/webhooker/internal/middleware/middlewaretest" ) // TestHandleWebhook_LogsClientNextToThePeer checks that the @@ -99,6 +102,87 @@ func TestHandleWebhook_LogsClientNextToThePeer(t *testing.T) { } } +// TestHandleWebhook_LogsNoEntrypointUUID sends an accepted event, a +// request over the receiver rate limit and a request for an unknown +// UUID through the receiver as routes.go mounts it, and checks that +// nothing logged, at any level, carries either UUID. +func TestHandleWebhook_LogsNoEntrypointUUID(t *testing.T) { + t.Parallel() + + var ( + h *handlers.Handlers + db *database.Database + ) + + cfg := &config.Config{DataDir: t.TempDir(), ReceiverRateLimit: 1} + + app := newTestAppWithConfig(t, cfg, &h, &db) + app.RequireStart() + + t.Cleanup(app.RequireStop) + + buf := new(bytes.Buffer) + log := slog.New(slog.NewJSONHandler( + buf, &slog.HandlerOptions{Level: slog.LevelDebug}, + )) + h.SetLogForTest(log) + mw := middlewaretest.New(t, log, cfg, nil) + + webhook := seedWebhook(t, db) + entrypoint := &database.Entrypoint{ + WebhookID: webhook.ID, + Path: uuid.New().String(), + Active: true, + } + require.NoError(t, db.DB().Omit(clause.Associations). + Create(entrypoint).Error) + + unknownUUID := uuid.New().String() + + router := chi.NewRouter() + router.Use(mw.Logging()) + router.With(mw.ReceiverRateLimit()).HandleFunc( + middleware.ReceiverRoute, h.HandleWebhook(), + ) + + // In order: the limit is one request per minute per path, so the + // second request for the known UUID is the rejected one. + for _, step := range []struct { + entrypointUUID string + wantStatus int + }{ + {entrypoint.Path, http.StatusOK}, + {entrypoint.Path, http.StatusTooManyRequests}, + {unknownUUID, http.StatusNotFound}, + } { + req := httptest.NewRequestWithContext( + context.Background(), http.MethodPost, + "/h/"+step.entrypointUUID, strings.NewReader("{}"), + ) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + require.Equal(t, step.wantStatus, w.Code) + } + + // The lines this test is about were all written, so the checks + // below are not passing on an empty log. + for _, msg := range []string{ + "webhook request received", + "webhook receiver rate limit exceeded", + "entrypoint not found", + } { + assert.Contains(t, buf.String(), `"msg":"`+msg+`"`) + } + + assert.NotContains(t, buf.String(), entrypoint.Path) + assert.NotContains(t, buf.String(), unknownUUID) + + line := receivedLine(t, buf) + assert.Equal(t, entrypoint.ID, line["entrypoint_id"]) + assert.Equal(t, webhook.ID, line["webhook_id"]) +} + // receivedLine returns the one "webhook request received" line in the // captured JSON log. func receivedLine(t *testing.T, buf *bytes.Buffer) map[string]any { diff --git a/internal/middleware/accesslog_test.go b/internal/middleware/accesslog_test.go index 4a6cb4a..e2971a7 100644 --- a/internal/middleware/accesslog_test.go +++ b/internal/middleware/accesslog_test.go @@ -7,6 +7,7 @@ import ( "log/slog" "net/http" "net/http/httptest" + "strconv" "strings" "testing" @@ -27,6 +28,13 @@ const floodRequests = 64 // line for a redirected or rejected request may contain it. const attackerMarker = "QQATTACKERTEXTQQ" +// knownEntrypointUUID and failingEntrypointUUID are the UUIDs the +// stand-in receiver in accessLogRouter answers 200 and 500 to. +const ( + knownEntrypointUUID = "6f1e2a9c-4b7d-4e3a-9c2f-1d8b5a7e3c60" + failingEntrypointUUID = "0b7c3e5a-2d9f-4a61-8e4b-7c1d6f2a9e35" +) + // maxLineBytes bounds a single access log line whose client-supplied // fields are of ordinary size. Well above what the fixed fields need, // well below the length of the oversized input the amplification tests @@ -132,17 +140,21 @@ func accessLogRouter(m *middleware.Middleware) *chi.Mux { ) router.HandleFunc( - "/h/{uuid}", + middleware.ReceiverRoute, func(w http.ResponseWriter, r *http.Request) { - // Stands in for the real handler: an unknown entrypoint - // UUID 404s, a known one succeeds. - if chi.URLParam(r, "uuid") != "known" { + // Stands in for the real handler: a known entrypoint + // UUID succeeds, a failing one is a server error, and + // any other 404s. + switch chi.URLParam(r, "uuid") { + case knownEntrypointUUID: + w.WriteHeader(http.StatusOK) + case failingEntrypointUUID: + http.Error( + w, "boom", http.StatusInternalServerError, + ) + default: http.Error(w, "not found", http.StatusNotFound) - - return } - - w.WriteHeader(http.StatusOK) }, ) @@ -335,11 +347,13 @@ func oversizedValue(ch string) string { } // oversizedHeaders fills every client-supplied header the access log -// reads with the same value. +// reads with the same value. The Referer carries it as the host of an +// absolute URL, because the access log keeps only a Referer's scheme +// and host. func oversizedHeaders(value string) map[string]string { return map[string]string{ "User-Agent": value, - "Referer": value, + "Referer": "http://" + value, "X-Request-Id": value, } } @@ -649,17 +663,111 @@ func TestAccessLog_SuccessKeepsConcretePathAndRedactsQuery( router := accessLogRouter(m) assert.Equal( - t, http.StatusOK, get(t, router, "/h/known?src=ci"), + t, http.StatusOK, + get(t, router, "/.well-known/healthcheck?src=ci"), ) - // The path resolved against a stored entrypoint, so it stays. The - // query never does: see TestAccessLog_UnauthenticatedSuccess... entries := accessLogEntries(t, buf) require.Len(t, entries, 1) - assert.Equal(t, "/h/known?(redacted)", entries[0]["url"]) + assert.Equal( + t, "/.well-known/healthcheck?(redacted)", entries[0]["url"], + ) assert.NotContains(t, buf.String(), "src=ci") } +// TestAccessLog_ReceiverNeverLogsTheUUID covers the two statuses that +// keep the concrete path on every other route. On the receiver that +// path carries the entrypoint UUID, so the line names the route +// pattern instead. +func TestAccessLog_ReceiverNeverLogsTheUUID(t *testing.T) { + t.Parallel() + + for entrypointUUID, wantStatus := range map[string]int{ + knownEntrypointUUID: http.StatusOK, + failingEntrypointUUID: http.StatusInternalServerError, + } { + t.Run(strconv.Itoa(wantStatus), func(t *testing.T) { + t.Parallel() + + m, buf := capturingMiddleware(t) + router := accessLogRouter(m) + + assert.Equal( + t, wantStatus, + get(t, router, "/h/"+entrypointUUID+"?src=ci"), + ) + + entries := accessLogEntries(t, buf) + require.Len(t, entries, 1) + assert.Equal(t, middleware.ReceiverRoute, entries[0]["url"]) + assert.NotContains(t, buf.String(), entrypointUUID) + }) + } +} + +// TestAccessLog_ReceiverPreflightNeverLogsTheUUID sends a CORS +// preflight to the receiver in dev, in the production middleware +// order. CORS answers it with 200 before chi routes it, so the line +// names no route and carries no UUID. +func TestAccessLog_ReceiverPreflightNeverLogsTheUUID(t *testing.T) { + t.Parallel() + + m, buf := capturingMiddleware(t) + + router := chi.NewRouter() + router.Use(m.Logging()) + router.Use(m.CORS()) + router.HandleFunc( + middleware.ReceiverRoute, + func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + }, + ) + + req := httptest.NewRequestWithContext( + context.Background(), http.MethodOptions, + "/h/"+knownEntrypointUUID, nil, + ) + req.Header.Set("Origin", "https://sender.example") + req.Header.Set("Access-Control-Request-Method", http.MethodPost) + + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + assert.Equal(t, http.StatusOK, w.Code) + + entries := accessLogEntries(t, buf) + require.Len(t, entries, 1) + assert.Equal(t, unmatchedRouteLiteral, entries[0]["url"]) + assert.NotContains(t, buf.String(), knownEntrypointUUID) +} + +// TestAccessLog_RefererNeverLogsTheUUID sends receiver requests whose +// Referer is the entrypoint URL, as a sender's is after it follows a +// redirect from http:// to https://. The referer field keeps only the +// scheme and host, and a Referer without them is logged as a marker. +func TestAccessLog_RefererNeverLogsTheUUID(t *testing.T) { + t.Parallel() + + m, buf := capturingMiddleware(t) + router := accessLogRouter(m) + + for _, referer := range []string{ + "http://webhooker.example/h/" + knownEntrypointUUID + "?src=ci", + "/h/" + knownEntrypointUUID, + } { + assert.Equal(t, http.StatusOK, getWithHeaders( + t, router, "/h/"+knownEntrypointUUID, + map[string]string{"Referer": referer}, + )) + } + + entries := accessLogEntries(t, buf) + require.Len(t, entries, 2) + assert.Equal(t, "http://webhooker.example", entries[0]["referer"]) + assert.Equal(t, "(redacted)", entries[1]["referer"]) + assert.NotContains(t, buf.String(), knownEntrypointUUID) +} + func TestAccessLog_ServerErrorKeepsConcreteURL(t *testing.T) { t.Parallel() diff --git a/internal/middleware/logbound_test.go b/internal/middleware/logbound_test.go index 1690564..980397d 100644 --- a/internal/middleware/logbound_test.go +++ b/internal/middleware/logbound_test.go @@ -10,8 +10,10 @@ package middleware_test // the client's choosing. // - CSRF's 403 rejection, at WARN, also registered ahead of // RequireAuth. -// - The rate limiters' 429 rejection, at WARN, on the -// unauthenticated receiver among others. +// - The rate limiters' 429 rejection, at WARN. On the receiver +// route it names the route pattern rather than the path, and +// the limiters that log a path sit behind RequireAuth, so its cap +// is defensive. // - RequireAuth's own unauthenticated-request line, at DEBUG. // - RecordLoginFailure's throttle rejection, at WARN. Its cap is // defensive rather than load-bearing today: chi pins the one @@ -268,10 +270,14 @@ func logSites() map[string]logSite { send: postNoToken, wantStatus: http.StatusForbidden, }, - // The per-entrypoint receiver limiter, unauthenticated. Its - // bucket is keyed on the path, so the first request through a - // fresh path is served and only the ones after it are - // rejected; sendUntilLimited absorbs that. + // The 429 line every limiter but the aggregate one shares. + // Mounted outside any route, as here, it logs the path, as + // it does for the limiters behind RequireAuth; on + // ReceiverRoute it names the pattern instead. The + // per-entrypoint receiver limiter's bucket is keyed on the + // path, so the first request through a fresh path is served + // and only the ones after it are rejected; sendUntilLimited + // absorbs that. "receiver rate limit 429": { build: func( t *testing.T, m *middleware.Middleware, diff --git a/internal/middleware/metrics.go b/internal/middleware/metrics.go index 1479590..164733a 100644 --- a/internal/middleware/metrics.go +++ b/internal/middleware/metrics.go @@ -41,7 +41,8 @@ const unmatchedMethod = unmatchedRoute // after /h/ is client-chosen, so labelling by path lets any // unauthenticated client mint permanent series at will, and publishes // the entrypoint UUID (the receiver's only credential) in the scrape -// while doing it. +// while doing it. The access log and the 429 line read it for the +// same reason: see ReceiverRoute. // // chi populates the route context during routeHTTP, so this is only // valid once routing has run. Every caller below is on the recording diff --git a/internal/middleware/middleware.go b/internal/middleware/middleware.go index 50a1a72..41e65ac 100644 --- a/internal/middleware/middleware.go +++ b/internal/middleware/middleware.go @@ -13,7 +13,6 @@ import ( "time" basicauth "github.com/99designs/basicauth-go" - "github.com/go-chi/chi" "github.com/go-chi/chi/middleware" "github.com/go-chi/cors" "github.com/prometheus/client_golang/prometheus" @@ -49,6 +48,10 @@ const ( // pick the size of the line it writes. redactedQuery = "?(redacted)" + // redactedReferer stands in for a Referer the access log cannot + // reduce to its scheme and host; see accessLogReferer. + redactedReferer = "(redacted)" + // maxLogRequestIDBytes bounds the request id, which is also // client-supplied: chi's RequestID middleware passes an inbound // X-Request-Id header through verbatim. Its generated form is an @@ -96,14 +99,14 @@ const ( // It is also the ceiling on every OTHER line this service writes // THROUGH SLOG that carries text an UNAUTHENTICATED client // supplies. Those lines — the MaxBodySize rejection, the CSRF - // rejection, the rate-limit rejection, the unauthenticated-request - // and unknown-entrypoint DEBUG lines, the failed-login DEBUG - // lines, and the two login-throttle WARN lines ("login failure - // limit exceeded" in loginguard.go and "password verification - // capacity exhausted" in internal/handlers/auth.go) — spend the - // same per-field budgets, and each carries strictly fewer - // client-supplied fields than the access log does, - // so none of them can reach a width the access log cannot. That is + // rejection, the unauthenticated-request DEBUG line, the + // failed-login DEBUG lines, and the two login-throttle WARN + // lines ("login failure limit exceeded" in loginguard.go and + // "password verification capacity exhausted" in + // internal/handlers/auth.go) — spend the same per-field budgets, + // and each carries strictly fewer client-supplied fields than the + // access log does, so none of them can reach a width the access + // log cannot. That is // asserted directly, per line and under both handlers, rather than // left to the reasoning: see logbound_test.go in this package and // in internal/handlers. @@ -151,6 +154,12 @@ const ( MaxAccessLogLineBytes = 2560 ) +// ReceiverRoute is the route pattern the webhook receiver is served +// at. Its path carries the entrypoint UUID, which is the receiver's +// only credential, so the log lines this package writes name a +// request on this route by the pattern, never by its path. +const ReceiverRoute = "/h/{uuid}" + //nolint:revive // MiddlewareParams is a standard fx naming convention. type MiddlewareParams struct { fx.In @@ -268,14 +277,14 @@ func (lrw *loggingResponseWriter) Unwrap() http.ResponseWriter { // marker. // // The path on those branches is bounded by the service's routes or by -// the operator's data — a 2xx on the receiver means the UUID named a -// stored entrypoint, a 2xx under /s means the file is in the embedded -// tree. The query is not bounded by anything: /.well-known/healthcheck -// and /s/* take no authentication and sit behind no rate limiter, and -// /pages/login behind only the login limiter, so any of them will -// answer 200 to a URL carrying an arbitrary number of arbitrary bytes -// after the '?'. Keeping the path and dropping the query is what makes -// this branch as bounded as the pattern branches below. +// the operator's data — a 2xx under /s means the file is in the +// embedded tree. The query is not bounded by anything: +// /.well-known/healthcheck and /s/* take no authentication and sit +// behind no rate limiter, and /pages/login behind only the login +// limiter, so any of them will answer 200 to a URL carrying an +// arbitrary number of arbitrary bytes after the '?'. Keeping the path +// and dropping the query is what makes this branch as bounded as the +// pattern branches below. // // Nothing debuggable is lost. The only query parameters the service // reads are the login page's `next`, the page to return to, @@ -297,36 +306,61 @@ func concreteLogURL(r *http.Request) string { // accessLogURL returns the value for the access log's url field. // -// 2xx and 5xx responses get the concrete path (see concreteLogURL). A -// success resolved against a static route or against the operator's -// own data — on the receiver, a 2xx means the UUID named a stored -// entrypoint — and a server error is our own bug, where the exact URL -// is the primary evidence and which no client can provoke at will. +// On ReceiverRoute it is the route pattern whatever the status, so +// the entrypoint UUID in the path is never logged. A request chi never +// routed gets unmatchedRoute whatever the status, for the same reason: +// in dev, CORS answers a preflight to the receiver before routing. +// +// On every other route, 2xx and 5xx responses get the concrete path +// (see concreteLogURL). A success resolved against a static route or +// against the operator's own data, and a server error is our own bug, +// where the exact URL is the primary evidence and which no client can +// provoke at will. // // 3xx and 4xx responses get the chi route pattern instead. Those are -// the outcomes an unauthenticated client drives for free: 404 or 429 -// on any invented /h/ path, 303 to the login page on any -// invented /user/ path. Logging the concrete URL there lets a flood -// write attacker-chosen text, of attacker-chosen length, into the -// operator's log at one line per request. The pattern comes from the -// router's own table, so it is bounded by the service's routes while -// still naming which class of request was rejected. +// the outcomes an unauthenticated client drives for free, such as a +// 303 to the login page on any invented /user/ path. Logging the +// concrete URL there lets a flood write attacker-chosen text, of +// attacker-chosen length, into the operator's log at one line per +// request. The pattern comes from the router's own table, so it is +// bounded by the service's routes while still naming which class of +// request was rejected. // // The pattern is only populated once routing has run, so this must be // called after the handler returns, not before. func accessLogURL(r *http.Request, status int) string { + pattern := routePatternID(r.Context()) + if pattern == ReceiverRoute || pattern == unmatchedRoute { + return pattern + } + if status < http.StatusMultipleChoices || status >= http.StatusInternalServerError { return concreteLogURL(r) } - if rc := chi.RouteContext(r.Context()); rc != nil { - if pattern := rc.RoutePattern(); pattern != "" { - return pattern - } + return pattern +} + +// accessLogReferer returns the value for the access log's referer +// field: the Referer's scheme and host, never its path or query. The +// path can be an entrypoint URL, which carries the UUID: a sender that +// follows a redirect from http:// to https:// sends the URL it was +// redirected from, and a browser sends the 404 page's URL under /h/ +// when it fetches that page's stylesheet. A Referer without both a +// scheme and a host is logged as redactedReferer. +func accessLogReferer(r *http.Request) string { + referer := r.Referer() + if referer == "" { + return "" } - return unmatchedRoute + parsed, err := url.Parse(referer) + if err != nil || parsed.Scheme == "" || parsed.Host == "" { + return redactedReferer + } + + return parsed.Scheme + "://" + parsed.Host } // Logging returns middleware that logs each HTTP request with @@ -379,7 +413,7 @@ func (s *Middleware) Logging() func(http.Handler) http.Handler { requestID, maxLogRequestIDBytes, ), "referer", logfield.Truncate( - r.Referer(), logfield.MaxBytes, + accessLogReferer(r), logfield.MaxBytes, ), "proto", r.Proto, "remoteIP", RemoteIP(r), diff --git a/internal/middleware/ratelimit.go b/internal/middleware/ratelimit.go index 93020fe..6b06b57 100644 --- a/internal/middleware/ratelimit.go +++ b/internal/middleware/ratelimit.go @@ -259,26 +259,28 @@ func (m *Middleware) clientAddr(r *http.Request) (netip.Addr, bool) { return peer, true } -// tooManyRequests returns the 429 handler used by the -// password-change and per-entrypoint receiver limiters: it logs the +// tooManyRequests returns the 429 handler used by the postRateLimit +// limiters and the per-entrypoint receiver limiter: it logs the // rejection with logMessage and answers with responseMessage. // httprate adds the Retry-After header (RFC 6585). The aggregate // receiver limiter uses floodTooManyRequests instead. // -// The path is capped against the same budget as the access log's url -// field. The per-entrypoint receiver limiter is unauthenticated and -// its path is a client-chosen segment of client-chosen length, so at -// WARN an uncapped path would let a sender pick the size of the line -// it writes — the same defect the access log capping closed. +// On ReceiverRoute the line names the route pattern, so the +// entrypoint UUID in the path is never logged. Elsewhere it names the +// path, capped against the same budget as the access log's url field; +// the limiters there sit behind RequireAuth, so the cap is defensive. func (m *Middleware) tooManyRequests( logMessage, responseMessage string, ) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { + path := r.URL.Path + if routePatternID(r.Context()) == ReceiverRoute { + path = ReceiverRoute + } + m.log.Warn( logMessage, - "path", logfield.Truncate( - r.URL.Path, logfield.MaxBytes, - ), + "path", logfield.Truncate(path, logfield.MaxBytes), "remoteIP", RemoteIP(r), "clientIP", ClientIP(r), ) diff --git a/internal/server/routes.go b/internal/server/routes.go index d7210ab..6407c97 100644 --- a/internal/server/routes.go +++ b/internal/server/routes.go @@ -6,7 +6,8 @@ import ( sentryhttp "github.com/getsentry/sentry-go/http" "github.com/go-chi/chi" - "github.com/go-chi/chi/middleware" + chimw "github.com/go-chi/chi/middleware" + "sneak.berlin/go/webhooker/internal/middleware" "sneak.berlin/go/webhooker/static" ) @@ -60,7 +61,7 @@ func (s *Server) SetupRoutes() { } func (s *Server) setupGlobalMiddleware() { - s.router.Use(middleware.RequestID) + s.router.Use(chimw.RequestID) s.router.Use(s.mw.SecurityHeaders()) s.router.Use(s.mw.Logging()) @@ -345,7 +346,7 @@ func (s *Server) setupWebhookRoutes() { // unauthenticated endpoint's body; TestReceiver_OversizeBodyRefused // pins it. s.router.With(s.mw.ReceiverRateLimit()).HandleFunc( - "/h/{uuid}", + middleware.ReceiverRoute, s.h.HandleWebhook(), ) } -- 2.54.0 From dfe8f3138ff5f8ad27ac4e2d640b9baa5218817a Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Thu, 15 Oct 2026 15:44:42 +0200 Subject: [PATCH 115/126] Bind 0.0.0.0 to IPv4 only (closes #528) Go listens on any unspecified address with one socket that answers IPv4 and IPv6, so BIND_ADDRESS=0.0.0.0 also bound every IPv6 address, which the README says it does not. The listener now uses tcp4 for an IPv4 address, written plain or IPv4-mapped; :: still binds both. The server's start hook suppresses contextcheck, as the other start hooks do. The image's health check asked for localhost, which wget reached over ::1 only. It now asks for the container's own address: its IPv4 address, or its IPv6 address on an IPv6-only network. A loopback bind answers neither. The README says such a network needs ::, drops the bindv6only condition from :: (Go sets that socket option itself) and names an ss check. Model: opus-5-5 --- Dockerfile | 11 +++- README.md | 24 +++++---- internal/server/bind_address_test.go | 77 ++++++++++++++++++++++++++++ internal/server/http.go | 31 ++++++++++- internal/server/server.go | 2 + 5 files changed, 132 insertions(+), 13 deletions(-) diff --git a/Dockerfile b/Dockerfile index e72dee7..51831be 100644 --- a/Dockerfile +++ b/Dockerfile @@ -274,8 +274,17 @@ EXPOSE 8080 # port. ENV BIND_ADDRESS=0.0.0.0 +# The probe requests the container's own address, not loopback, so a +# listener that answers only on loopback (an empty or loopback +# BIND_ADDRESS) fails it. On a network with IPv4, hostname -i prints the +# container's IPv4 address, which 0.0.0.0 and :: both answer. On an +# IPv6-only network it prints none, and the probe requests the IPv6 +# address /etc/hosts lists under the container's hostname instead, which +# only :: answers. HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ - CMD wget --no-verbose --tries=1 --spider http://localhost:8080/.well-known/healthcheck || exit 1 + CMD addr="$(hostname -i 2>/dev/null | cut -d ' ' -f 1)"; \ + [ -n "$addr" ] || addr="[$(getent hosts "$(hostname)" | cut -d ' ' -f 1)]"; \ + wget --no-verbose --tries=1 --spider "http://$addr:8080/.well-known/healthcheck" || exit 1 ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"] CMD ["/app/webhooker"] diff --git a/README.md b/README.md index 4f2520a..d5febdb 100644 --- a/README.md +++ b/README.md @@ -310,16 +310,17 @@ the namespace, whatever the process bound. Exposure is decided at the publish flag instead — `-p 127.0.0.1:8080:8080` rather than `-p 8080:8080` — which is the operator's to choose and is what [Running with Docker](#running-with-docker) shows. A loopback bind inside a container buys nothing and makes the process -unreachable through its own published port. +unreachable through its own published port. A container on an IPv6-only Docker +network has no IPv4 address for `0.0.0.0` to answer on, so there it needs +`BIND_ADDRESS=::`. The value must be an IP address literal: - `127.0.0.1` — loopback only (the binary's default). Use this with a reverse proxy on the same host. -- `0.0.0.0` — every IPv4 address. The image's default; on a bare host, only - behind a firewall on the port. -- `::` — every address, IPv6 and (on Linux, with the default - `net.ipv6.bindv6only=0`) IPv4 as well. +- `0.0.0.0` — every IPv4 address and no IPv6 address. The image's default; on a + bare host, only behind a firewall on the port. +- `::` — every IPv6 address and every IPv4 address. - A specific address such as `10.0.0.5` — that interface only. An **empty** value is treated as unset, as everywhere else here, and takes the @@ -340,7 +341,8 @@ machine that has no such interface — is a valid literal, so it reaches the listener and fails there. The process logs the bind error and exits non-zero rather than staying up with nothing listening. The effective value is in the `bindAddress` field of the startup log line, which is the way to check what a -running deployment actually bound. +running deployment actually bound. On a bare host, `ss -ltn` lists the socket +itself as `0.0.0.0:8080` for a `0.0.0.0` bind and as `*:8080` for `::`. #### Metrics credentials @@ -668,9 +670,9 @@ Either set a literal or leave the variable out entirely. Overriding `BIND_ADDRESS` to a loopback address in a container — by that route or deliberately — makes the container unreachable from outside its namespace even with `-p`. The published port answers nothing, and the health check fails -too: it requests `http://localhost:8080`, `localhost` resolves to `::1` first, -and a `127.0.0.1` bind is not listening there. The container then goes -`unhealthy` about **65 seconds** after start — from +too: it requests the container's own address (its IPv4 address, or its IPv6 +address on an IPv6-only network), where a loopback bind is not listening. The +container then goes `unhealthy` about **65 seconds** after start — from `HEALTHCHECK --start-period=5s --interval=30s --retries=3`, so failing probes at 5s, 35s and 65s, and `unhealthy` on the third. (Docker's probe cadence during the start period has changed between versions; re-derive from those three values @@ -726,8 +728,8 @@ the credentials. address (directly, or through anything that can rewrite source addresses, such as NAT or a published container port). The `remoteIP` field of the `http request` log line for a request that came through the proxy shows - that address; the health check's own lines show `::1`. See - [Trusted proxies](#trusted-proxies). + that address; the health check's own lines show the container's own + address. See [Trusted proxies](#trusted-proxies). - Leave `BIND_ADDRESS` and `DATA_DIR` unset: the image sets `BIND_ADDRESS` to `0.0.0.0`, and `DATA_DIR` defaults to `/var/lib/webhooker`. - Everything else is optional; see [Configuration](#configuration). diff --git a/internal/server/bind_address_test.go b/internal/server/bind_address_test.go index 9329180..db735b3 100644 --- a/internal/server/bind_address_test.go +++ b/internal/server/bind_address_test.go @@ -5,6 +5,7 @@ import ( "net" "net/http" "strconv" + "syscall" "testing" "time" @@ -25,6 +26,11 @@ const ( // its network namespace even with a published port. wildcardV4 = "0.0.0.0" + // loopbackV6 and wildcardV6 are the IPv6 counterparts of the two + // above. + loopbackV6 = "::1" + wildcardV6 = "::" + // unavailableAddr is a TEST-NET-1 address (RFC 5737). It is a // well-formed literal that no host is assigned, so binding it // fails with EADDRNOTAVAIL rather than succeeding somewhere @@ -116,6 +122,24 @@ func otherLocalAddr(t *testing.T) string { return "" } +// skipWithoutIPv6 skips the test when this host cannot bind +// loopbackV6. Without IPv6 there is no second family for a bind to +// claim or leave alone. +func skipWithoutIPv6(t *testing.T) { + t.Helper() + + var listenCfg net.ListenConfig + + l, err := listenCfg.Listen( + t.Context(), "tcp6", net.JoinHostPort(loopbackV6, "0"), + ) + if err != nil { + t.Skip("host has no IPv6 loopback to bind") + } + + require.NoError(t, l.Close()) +} + // startBoundServer starts the wired app with the given bind address // on a free port and returns that port. The app is stopped on // cleanup. @@ -292,6 +316,59 @@ func TestBindAddress_WildcardReachesOtherAddresses(t *testing.T) { requireDialable(t, net.JoinHostPort(other, strconv.Itoa(port))) } +// TestBindAddress_WildcardV4IsIPv4Only pins that 0.0.0.0 answers on +// IPv4 alone, as the README says, whether it is written as 0.0.0.0 or +// as the IPv4-mapped ::ffff:0.0.0.0. Go listens on 0.0.0.0 with a +// socket that answers IPv6 too unless told otherwise, which put the +// admin UI and the receiver on every IPv6 address of a host whose +// operator had chosen an IPv4 bind. +func TestBindAddress_WildcardV4IsIPv4Only(t *testing.T) { + t.Parallel() + + skipWithoutIPv6(t) + + for _, bindAddress := range []string{wildcardV4, "::ffff:0.0.0.0"} { + t.Run(bindAddress, func(t *testing.T) { + t.Parallel() + + port := startBoundServer(t, bindAddress) + + // Positive control: the service really is up, on IPv4. + requireDialable( + t, net.JoinHostPort(loopbackV4, strconv.Itoa(port)), + ) + + dialer := net.Dialer{Timeout: dialTimeout} + + conn, err := dialer.DialContext( + t.Context(), "tcp", + net.JoinHostPort(loopbackV6, strconv.Itoa(port)), + ) + if err == nil { + _ = conn.Close() + } + + require.ErrorIs( + t, err, syscall.ECONNREFUSED, + "a %s bind answered on %s", bindAddress, loopbackV6, + ) + }) + } +} + +// TestBindAddress_WildcardV6ReachesBothFamilies is the counterpart: +// :: is the value that binds IPv6, and it answers IPv4 as well. +func TestBindAddress_WildcardV6ReachesBothFamilies(t *testing.T) { + t.Parallel() + + skipWithoutIPv6(t) + + port := startBoundServer(t, wildcardV6) + + requireDialable(t, net.JoinHostPort(loopbackV6, strconv.Itoa(port))) + requireDialable(t, net.JoinHostPort(loopbackV4, strconv.Itoa(port))) +} + // TestBindAddress_ServesRequestsOnConfiguredAddress proves the bound // listener serves the application rather than merely accepting TCP, // so a bind address that is honoured cannot be mistaken for one that diff --git a/internal/server/http.go b/internal/server/http.go index 1a01999..4964c31 100644 --- a/internal/server/http.go +++ b/internal/server/http.go @@ -1,9 +1,11 @@ package server import ( + "context" "errors" "net" "net/http" + "net/netip" "strconv" "time" ) @@ -39,6 +41,23 @@ func (s *Server) listenAddr() string { ) } +// listenNetwork returns the network the HTTP listener binds on, "tcp4" +// for an IPv4 BindAddress and "tcp" for any other. +// +// On "tcp", Go binds 0.0.0.0 with a socket that answers IPv6 as well, +// exactly as it binds ::. "tcp4" keeps 0.0.0.0 to IPv4, which is what +// the README says it means. :: stays on "tcp" and answers both. An +// IPv4-mapped address such as ::ffff:0.0.0.0 is the IPv4 address it +// carries to Go's listener, so it gets "tcp4" too. +func (s *Server) listenNetwork() string { + addr, err := netip.ParseAddr(s.params.Config.BindAddress) + if err == nil && addr.Unmap().Is4() { + return "tcp4" + } + + return "tcp" +} + // newHTTPServer builds the HTTP server for this Server's // configuration. // @@ -67,7 +86,17 @@ func (s *Server) serveUntilShutdown() { s.log.Info("http begin listen", "listenaddr", s.httpServer.Addr) - err := s.httpServer.ListenAndServe() + // ListenAndServe always listens on "tcp", which would undo + // listenNetwork. + var listenCfg net.ListenConfig + + listener, err := listenCfg.Listen( + context.Background(), s.listenNetwork(), s.httpServer.Addr, + ) + if err == nil { + err = s.httpServer.Serve(listener) + } + if err != nil && !errors.Is(err, http.ErrServerClosed) { s.log.Error("listen error", "error", err) s.shutdownWithFailure() diff --git a/internal/server/server.go b/internal/server/server.go index 804b991..0d11c0f 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -135,6 +135,8 @@ func New(lc fx.Lifecycle, params ServerParams) (*Server, error) { s.httpServer = s.newHTTPServer() lc.Append(fx.Hook{ + //nolint:contextcheck // The listener outlives this hook, whose + // context fx cancels once the app has started. OnStart: func(_ context.Context) error { s.startupTime = time.Now() go s.Run() -- 2.54.0 From 872e4eb4a1740485756b6c13e93b1b2cc0331ef6 Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Thu, 15 Oct 2026 16:18:26 +0200 Subject: [PATCH 116/126] Drop 'unsafe-inline' from the Content-Security-Policy (closes #530) 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 --- README.md | 78 ++++++++++------- .../handlers/delivery_result_view_test.go | 3 +- internal/handlers/event_body.go | 9 +- internal/handlers/event_detail_test.go | 2 +- internal/handlers/source_detail_test.go | 35 ++++---- internal/middleware/middleware.go | 8 +- internal/middleware/middleware_test.go | 29 +++++-- internal/server/alpine_browser_test.go | 85 ++++++++++++++++++- static/css/style.css | 14 ++- static/css/tailwind.css | 2 +- static/js/app.js | 12 +++ templates/event_body.html | 2 +- templates/htmlheader.html | 1 - templates/settings.html | 8 +- templates/source_detail.html | 25 +++--- 15 files changed, 219 insertions(+), 94 deletions(-) diff --git a/README.md b/README.md index d5febdb..0a3894b 100644 --- a/README.md +++ b/README.md @@ -1253,34 +1253,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 @@ -1313,7 +1316,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. @@ -3108,8 +3113,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 @@ -3163,8 +3168,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 @@ -3249,6 +3255,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 ` {{block "head" .}}{{end}} {{end}} diff --git a/templates/settings.html b/templates/settings.html index e78cad3..4c66162 100644 --- a/templates/settings.html +++ b/templates/settings.html @@ -13,13 +13,11 @@
+ between its entries. wrap-anywhere breaks inside a + value only when it alone is wider than the line. -->
{{.Name}} - {{.Value}} + {{.Value}}

{{.Description}}

diff --git a/templates/source_detail.html b/templates/source_detail.html index 7e19e35..ecf3fc5 100644 --- a/templates/source_detail.html +++ b/templates/source_detail.html @@ -5,12 +5,10 @@ {{define "content"}} -
+ 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. --> +
← Back to webhooks
@@ -23,11 +21,12 @@
Full Event Log Edit - - + + @@ -90,7 +89,7 @@ {{if .Active}}Deactivate{{else}}Activate{{end}} -
+
@@ -278,7 +277,7 @@ {{if .Active}}Deactivate{{else}}Activate{{end}} -
+
-- 2.54.0 From 8c05ec4378e960f8769cb86935dface2ceb4421a Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Thu, 15 Oct 2026 17:36:35 +0200 Subject: [PATCH 117/126] Build the binary without cgo (closes #545) make build now builds with CGO_ENABLED=0, and the image builds through it. The binary's only cgo code was mattn/go-sqlite3, which gorm.io/driver/sqlite imports and webhooker never opens: every SQLite file is opened with modernc.org/sqlite and the connection handed to gorm. Without cgo, mattn's package compiles to a stub nothing calls. The Dockerfile builds the binary once. The static relink and its netgo, osusergo and sqlite_omit_load_extension tags are gone, and with them the Makefile's GO_LDFLAGS and GO_TAGS, which only the relink used. The test phase keeps cgo for -race. No README or Dockerfile sentence says the binary needs cgo any more. Model: opus-5-5 --- Dockerfile | 27 +++------------ Makefile | 17 +++------- README.md | 33 +++++++------------ internal/versionscript/version_script_test.go | 21 ++++-------- 4 files changed, 27 insertions(+), 71 deletions(-) diff --git a/Dockerfile b/Dockerfile index 51831be..3047b21 100644 --- a/Dockerfile +++ b/Dockerfile @@ -76,8 +76,6 @@ RUN --network=none node_modules/.bin/prettier --check '**/*.md' # depends on it. # # golangci/golangci-lint:v2.14.0 (Debian-based), 2026-09-24 -# Using Debian-based image because mattn/go-sqlite3 (CGO) does not -# compile on Alpine musl (off64_t is a glibc type). FROM golangci/golangci-lint:v2.14.0@sha256:ad862ba6b3798cbe0fd9fd7408d498fd74fbd2623a92406b2fd3898faf0bf98f AS lint WORKDIR /src @@ -167,9 +165,7 @@ RUN --mount=type=tmpfs,target=/tmp/tests,size=512m \ # Build stage # golang:1.26.1-bookworm (Debian-based), 2026-03-17 -# Using Debian-based image because gorm.io/driver/sqlite pulls in -# mattn/go-sqlite3 (CGO), which does not compile on Alpine musl. The image -# ships git and make, which the version step below uses. +# The image ships git and make, which the version step below uses. FROM golang:1.26.1-bookworm@sha256:4465644228bc2857a954b092167e12aa59c006a3492282a6c820bf4755fd64a4 AS builder # Nothing is wanted from the lint and test phases or from the stylesheet and @@ -211,26 +207,11 @@ RUN version="$(make version VERSION="$VERSION")"; \ fi # Builds through the Makefile's build target, which runs script/assets -# (Alpine.js, extracted from its tarball in 3p/) first. +# (Alpine.js, extracted from its tarball in 3p/) first. The target builds +# with cgo off, so the binary is statically linked and runs on the Alpine +# runtime image below as it is. RUN make build VERSION="$VERSION" -# Rebuild with static linking for the Alpine runtime. The cgo binary the -# step above builds is dynamically linked against glibc, which Alpine -# (musl) does not have. -# -# A statically linked glibc still loads shared libraries at run time for -# getaddrinfo and dlopen, and Alpine has none to load, so the build tags -# keep both out of the binary: netgo and osusergo select Go's own DNS -# resolver and user lookup instead of glibc's, and -# sqlite_omit_load_extension leaves out SQLite extension loading, which -# webhooker never uses. -# -# The static flags and tags go in through GO_LDFLAGS and GO_TAGS rather -# than a -ldflags of their own: the build target composes them with the -# -X that stamps the version, so this relink cannot silently drop the -# stamp. -RUN CGO_ENABLED=1 make build VERSION="$VERSION" GO_TAGS=netgo,osusergo,sqlite_omit_load_extension GO_LDFLAGS='-extldflags "-static"' - # Runtime stage # alpine:3.21, 2026-03-17 FROM alpine:3.21@sha256:c3f8e73fdb79deaebaa2037150150191b9dcbfba68b4a46d70103204c53f4709 diff --git a/Makefile b/Makefile index b2f2f36..e52a12b 100644 --- a/Makefile +++ b/Makefile @@ -16,17 +16,6 @@ VERSION ?= $(shell script/version) # exists to correct. override VERSION := $(or $(strip $(VERSION)),$(shell script/version)) -# Extra linker flags and build tags for the build target. The static -# relink in the Dockerfile passes -extldflags and its build tags through -# these rather than a -ldflags of its own, so composing flags cannot drop -# the version stamp. -# -# The build target itself always passes -trimpath and -s -w, as the Go -# Dockerfile in REPO_POLICIES.md does: no build paths, symbol table or -# debug information in the binary. -GO_LDFLAGS ?= -GO_TAGS ?= - bootstrap: @script/bootstrap @@ -54,8 +43,12 @@ fmt-check: check: @script/check +# Built with cgo off, -trimpath and -s -w, as the Go Dockerfile in +# REPO_POLICIES.md does: the binary carries no build paths, symbol table or +# debug information, and on Linux, the image's included, it is statically +# linked. build: assets - go build -trimpath -tags '$(GO_TAGS)' -ldflags '$(strip -s -w -X main.version=$(VERSION) $(GO_LDFLAGS))' -o bin/webhooker ./cmd/webhooker + CGO_ENABLED=0 go build -trimpath -ldflags '-s -w -X main.version=$(VERSION)' -o bin/webhooker ./cmd/webhooker run: build ./bin/webhooker diff --git a/README.md b/README.md index 0a3894b..05bc2d2 100644 --- a/README.md +++ b/README.md @@ -1393,10 +1393,10 @@ It uses: management - **[go-chi](https://github.com/go-chi/chi)** for HTTP routing - **[GORM](https://gorm.io)** for database access with - **[modernc.org/sqlite](https://pkg.go.dev/modernc.org/sqlite)** as the runtime - SQLite driver. Note: `gorm.io/driver/sqlite` transitively depends on - `mattn/go-sqlite3`, which requires CGO at build time (see [Docker](#docker) - section) + **[modernc.org/sqlite](https://pkg.go.dev/modernc.org/sqlite)**, which is pure + Go, as the SQLite driver. `gorm.io/driver/sqlite` also imports + `mattn/go-sqlite3`, whose stub for builds without cgo is linked into the + binary but never used - **[slog](https://pkg.go.dev/log/slog)** (stdlib) for structured logging with TTY detection (text for dev, JSON for prod) - **[gorilla/sessions](https://github.com/gorilla/sessions)** for encrypted @@ -2135,9 +2135,7 @@ endpoint. That is the JSON key; the error text for a missing one reads `webhook_url is required`, which is the message, not the key. The database uses the -[modernc.org/sqlite](https://pkg.go.dev/modernc.org/sqlite) driver at runtime, -though CGO is required at build time due to the transitive `mattn/go-sqlite3` -dependency from `gorm.io/driver/sqlite`. +[modernc.org/sqlite](https://pkg.go.dev/modernc.org/sqlite) driver. #### Schema @@ -3468,16 +3466,12 @@ fixed independently of the compiler's: a step's log, and then fails. `make test` builds this stage alone. 6. **Builder stage** (`golang:1.26.1-bookworm`) — depends on the lint and test phases and the `css-check` and `markdown-check` stages passing (it copies a - file from each), runs `make build` (which extracts Alpine.js from `3p/` - first), and then rebuilds the binary with `CGO_ENABLED=1` and static linking - so it runs on musl, with the build tags `netgo`, `osusergo` and - `sqlite_omit_load_extension` so it needs none of glibc's shared libraries at - run time. Both builds go through `make build`, the relink adding its - `-extldflags` via `GO_LDFLAGS` and its tags via `GO_TAGS`, so neither can - drop the `-X` that stamps the version. The version is the `VERSION` build arg - if one is given, otherwise derived from the `.git` in the context, and the - stage fails if a context with `.git` would stamp an empty version, `dev` or - `unknown` (see [Version stamping](#version-stamping)). + file from each), and runs `make build` (which extracts Alpine.js from `3p/` + first). `make build` compiles with cgo off, so the binary is statically + linked and runs on the Alpine runtime image. The version is the `VERSION` + build arg if one is given, otherwise derived from the `.git` in the context, + and the stage fails if a context with `.git` would stamp an empty version, + `dev` or `unknown` (see [Version stamping](#version-stamping)). 7. **Runtime stage** (`alpine:3.21`) — copies the static binary and `deploy/docker-entrypoint.sh`, creates the `/var/lib/webhooker` directory for all SQLite databases, exposes port 8080, and includes a health check against @@ -3489,11 +3483,6 @@ The lint and test phases invoke `gofmt`, `golangci-lint` and `go test` directly rather than `make fmt-check`, `make lint` and `make test`: those targets build docker stages, which would need a docker daemon inside this build. -The lint phase and the Go stages use Debian rather than Alpine because -`gorm.io/driver/sqlite` pulls in `mattn/go-sqlite3`, which needs CGO and does -not compile against musl. Only the final binary is statically linked, which is -what lets it run on the Alpine runtime image. - `script/cibuild` is the CI gate: it runs `script/bootstrap`, then `script/check`, then builds the image, whose build runs the lint and test phases and the stylesheet and Markdown checks again. A build that succeeds is a repo diff --git a/internal/versionscript/version_script_test.go b/internal/versionscript/version_script_test.go index 68841c5..4ec95d5 100644 --- a/internal/versionscript/version_script_test.go +++ b/internal/versionscript/version_script_test.go @@ -153,19 +153,15 @@ func TestVersion_IsStableAcrossInvocations(t *testing.T) { require.Equal(t, first, second) } -// The build target is the only place composing linker flags. If a -// future edit drops either half, the binary silently reports "dev" -// again (the defect this package exists for) or fails to link on -// Alpine. If it stops passing GO_TAGS, the image's static binary links -// glibc's getaddrinfo and dlopen again. -func TestMakefile_BuildComposesVersionAndExtraFlags(t *testing.T) { +// The build target is the only place that stamps the version. If a +// future edit drops the -X flag, the binary silently reports "dev" +// again, the defect this package exists for. +func TestMakefile_BuildStampsTheVersion(t *testing.T) { t.Parallel() makefile := read(t, makefilePath) require.Contains(t, makefile, "-X main.version=$(VERSION)") - require.Contains(t, makefile, "$(GO_LDFLAGS)") - require.Contains(t, makefile, "-tags '$(GO_TAGS)'") require.Contains(t, makefile, "VERSION ?= $(shell script/version)") } @@ -224,8 +220,8 @@ func requireMake(t *testing.T) { } } -// Every compile in the image goes through the build target, so the -// static relink cannot replace the flags that carry the stamp. +// The image compiles through the build target, so it carries the +// target's version stamp. func TestDockerfile_BuildsThroughTheMakeTarget(t *testing.T) { t.Parallel() @@ -234,10 +230,7 @@ func TestDockerfile_BuildsThroughTheMakeTarget(t *testing.T) { require.NotContains(t, dockerfile, "go build", "a raw go build bypasses the Makefile's -X flag") require.Contains(t, dockerfile, "ARG VERSION") - require.Contains(t, dockerfile, - `make build VERSION="$VERSION"`+ - ` GO_TAGS=netgo,osusergo,sqlite_omit_load_extension`+ - ` GO_LDFLAGS='-extldflags "-static"'`) + require.Contains(t, dockerfile, `RUN make build VERSION="$VERSION"`) } func read(t *testing.T, path string) string { -- 2.54.0 From c7c4ea3340b2e0e56c1fb05204a65a2592e536c0 Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Thu, 15 Oct 2026 18:07:21 +0200 Subject: [PATCH 118/126] Send only the Referer's scheme and host to Sentry (closes #548) A Sentry event kept the Referer header verbatim, and a Referer can be an entrypoint URL: a sender that follows the README's http:// to https:// redirect sends the URL it was redirected from, and a browser sends the 404 page's URL under /h/ when it fetches that page's stylesheet. The Sentry hook now keeps only the Referer's scheme and host. The access log's reduction moves, unchanged, into a new package, internal/referer, as SchemeAndHost; the access log and the Sentry hook both call it there. The comment above sentryKeepsHeader and the README's Sentry passage say so, and the README's Package Layout lists the new package. Model: opus-5-5 --- README.md | 10 ++++- internal/middleware/middleware.go | 29 ++----------- internal/referer/referer.go | 32 +++++++++++++++ internal/referer/referer_test.go | 67 +++++++++++++++++++++++++++++++ internal/server/sentry.go | 25 ++++++++---- internal/server/sentry_test.go | 31 ++++++++++++++ 6 files changed, 159 insertions(+), 35 deletions(-) create mode 100644 internal/referer/referer.go create mode 100644 internal/referer/referer_test.go diff --git a/README.md b/README.md index 05bc2d2..277d3b3 100644 --- a/README.md +++ b/README.md @@ -2494,6 +2494,9 @@ are both taken unconditionally, the body precisely because these handlers call body with `(redacted)`, drops cookies and the remote-address environment, and reduces the headers to a fixed allowlist — `Accept`, `Content-Length`, `Content-Type`, `Host`, `Origin`, `Referer`, `User-Agent` and `X-Request-Id`. +`Referer` is sent as its scheme and host only, because its path can be an +entrypoint URL; `internal/referer.SchemeAndHost` does that reduction for both +the access log's `referer` field and this hook. The same hook rewrites the request URL. The SDK builds it as `scheme://host/path` from the concrete path, which on the receiver route is @@ -2564,8 +2567,9 @@ receiver route. What survives still names the failing route — scheme, host, route pattern, method — and `X-Request-Id` ties the event to the local access log line that holds the rest. Nothing dropped is needed for the likeliest use, debugging a CSRF rejection. Its three inputs are the TLS decision, `Origin` and -`Referer`; the latter two are kept, and the first is the scheme of the retained -URL, because the SDK derives that scheme from +`Referer`; the latter two are kept (`Referer` as its scheme and host, which is +all the check compares), and the first is the scheme of the retained URL, +because the SDK derives that scheme from `r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https"`. That predicate is the SDK's own and is stricter than `internal/reqtls.IsTLS`, which this service now uses everywhere it decides transport: the SDK reports `http` for the @@ -3097,6 +3101,8 @@ webhooker/ │ │ ├── ratelimit.go # Per-IP rate limiting middleware (go-chi/httprate) │ │ ├── loginguard.go # Login failure counters and the Argon2id verification semaphore │ │ └── middlewaretest/ # Test support: a Middleware for tests in other packages +│ ├── referer/ +│ │ └── referer.go # SchemeAndHost: cuts a Referer down to its scheme and host, for the access log and the Sentry hook │ ├── reqtls/ │ │ └── reqtls.go # IsTLS: the one TLS predicate, r.TLS or X-Forwarded-Proto │ ├── server/ diff --git a/internal/middleware/middleware.go b/internal/middleware/middleware.go index 9b5b842..183fc2b 100644 --- a/internal/middleware/middleware.go +++ b/internal/middleware/middleware.go @@ -22,6 +22,7 @@ import ( "sneak.berlin/go/webhooker/internal/config" "sneak.berlin/go/webhooker/internal/globals" "sneak.berlin/go/webhooker/internal/logfield" + "sneak.berlin/go/webhooker/internal/referer" "sneak.berlin/go/webhooker/internal/session" ) @@ -48,10 +49,6 @@ const ( // pick the size of the line it writes. redactedQuery = "?(redacted)" - // redactedReferer stands in for a Referer the access log cannot - // reduce to its scheme and host; see accessLogReferer. - redactedReferer = "(redacted)" - // maxLogRequestIDBytes bounds the request id, which is also // client-supplied: chi's RequestID middleware passes an inbound // X-Request-Id header through verbatim. Its generated form is an @@ -342,27 +339,6 @@ func accessLogURL(r *http.Request, status int) string { return pattern } -// accessLogReferer returns the value for the access log's referer -// field: the Referer's scheme and host, never its path or query. The -// path can be an entrypoint URL, which carries the UUID: a sender that -// follows a redirect from http:// to https:// sends the URL it was -// redirected from, and a browser sends the 404 page's URL under /h/ -// when it fetches that page's stylesheet. A Referer without both a -// scheme and a host is logged as redactedReferer. -func accessLogReferer(r *http.Request) string { - referer := r.Referer() - if referer == "" { - return "" - } - - parsed, err := url.Parse(referer) - if err != nil || parsed.Scheme == "" || parsed.Host == "" { - return redactedReferer - } - - return parsed.Scheme + "://" + parsed.Host -} - // Logging returns middleware that logs each HTTP request with // timing and metadata. func (s *Middleware) Logging() func(http.Handler) http.Handler { @@ -413,7 +389,8 @@ func (s *Middleware) Logging() func(http.Handler) http.Handler { requestID, maxLogRequestIDBytes, ), "referer", logfield.Truncate( - accessLogReferer(r), logfield.MaxBytes, + referer.SchemeAndHost(r.Referer()), + logfield.MaxBytes, ), "proto", r.Proto, "remoteIP", RemoteIP(r), diff --git a/internal/referer/referer.go b/internal/referer/referer.go new file mode 100644 index 0000000..4aac785 --- /dev/null +++ b/internal/referer/referer.go @@ -0,0 +1,32 @@ +// Package referer reduces a Referer header to its scheme and host, so +// that a URL path, which on the receiver route carries the entrypoint +// UUID, never reaches a log line or an error report. +package referer + +import "net/url" + +// redacted stands in for a Referer that cannot be reduced to its +// scheme and host. +const redacted = "(redacted)" + +// SchemeAndHost returns a Referer's scheme and host, never its path or +// query. The path can be an entrypoint URL, which carries the UUID: a +// sender that follows a redirect from http:// to https:// sends the +// URL it was redirected from, and a browser sends the 404 page's URL +// under /h/ when it fetches that page's stylesheet. A Referer without +// both a scheme and a host comes back as "(redacted)". +// +// It is the access log's referer field, and the Referer header the +// Sentry hook sends. +func SchemeAndHost(referer string) string { + if referer == "" { + return "" + } + + parsed, err := url.Parse(referer) + if err != nil || parsed.Scheme == "" || parsed.Host == "" { + return redacted + } + + return parsed.Scheme + "://" + parsed.Host +} diff --git a/internal/referer/referer_test.go b/internal/referer/referer_test.go new file mode 100644 index 0000000..3f81cd8 --- /dev/null +++ b/internal/referer/referer_test.go @@ -0,0 +1,67 @@ +package referer_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "sneak.berlin/go/webhooker/internal/referer" +) + +// entrypointUUID is the secret part of an entrypoint URL; no case may +// return it. +const entrypointUUID = "0b6f7d3e-6a39-4e3c-9f0a-2c4d8e1b5a77" + +// redacted is what SchemeAndHost returns for a Referer it cannot +// reduce to a scheme and host. +const redacted = "(redacted)" + +func TestSchemeAndHost(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + name string + referer string + want string + }{ + { + name: "no Referer", + referer: "", + want: "", + }, + { + name: "entrypoint URL with a query", + referer: "http://webhooker.example/h/" + entrypointUUID + + "?src=ci", + want: "http://webhooker.example", + }, + { + name: "host with a port", + referer: "https://webhooker.example:8443/h/" + entrypointUUID, + want: "https://webhooker.example:8443", + }, + { + name: "path without a scheme or host", + referer: "/h/" + entrypointUUID, + want: redacted, + }, + { + name: "host without a scheme", + referer: "webhooker.example/h/" + entrypointUUID, + want: redacted, + }, + { + name: "not a URL", + referer: "http://[::1/h/" + entrypointUUID, + want: redacted, + }, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + got := referer.SchemeAndHost(tc.referer) + + assert.Equal(t, tc.want, got) + assert.NotContains(t, got, entrypointUUID) + }) + } +} diff --git a/internal/server/sentry.go b/internal/server/sentry.go index fdd2dbd..fccdd1b 100644 --- a/internal/server/sentry.go +++ b/internal/server/sentry.go @@ -8,6 +8,7 @@ import ( "github.com/getsentry/sentry-go" "github.com/go-chi/chi" + "sneak.berlin/go/webhooker/internal/referer" ) // sentryRedacted stands in for a withheld field on every event shipped @@ -253,6 +254,7 @@ func sentryTransactionName(name, pattern string) string { // gorilla/csrf accepts in place of the form field — and the shared // secrets senders put on the receiver route (X-Gitlab-Token and the // per-provider signature headers) would otherwise ship verbatim. +// The Referer it keeps is cut down to its scheme and host. func keptSentryHeaders(headers map[string]string) map[string]string { if len(headers) == 0 { return headers @@ -261,9 +263,15 @@ func keptSentryHeaders(headers map[string]string) map[string]string { kept := make(map[string]string, len(headers)) for name, value := range headers { - if sentryKeepsHeader(name) { - kept[name] = value + if !sentryKeepsHeader(name) { + continue } + + if http.CanonicalHeaderKey(name) == "Referer" { + value = referer.SchemeAndHost(value) + } + + kept[name] = value } return kept @@ -271,11 +279,14 @@ func keptSentryHeaders(headers map[string]string) map[string]string { // sentryKeepsHeader reports whether a request header is routing or // content metadata rather than client-chosen payload. Referer is kept -// on the reasoning that it is browser-set, that the only query -// parameters in this service's own URLs are the login page's `next`, -// `notice` and the event log's `show`, and that Referrer-Policy is set to -// strict-origin-when-cross-origin. X-Request-Id ties the event to the -// local access log line, which holds the rest of the detail. +// only as its scheme and host, through referer.SchemeAndHost, which +// the access log uses too: its path can be an entrypoint URL, which +// carries the UUID, as when a sender follows a redirect from http:// +// to https:// or a browser fetches the 404 page's stylesheet from +// under /h/. The scheme and host are all gorilla/csrf compares a +// Referer by, so a CSRF rejection is still readable from the event. +// X-Request-Id ties the event to the local access log line, which +// holds the rest of the detail. func sentryKeepsHeader(name string) bool { switch http.CanonicalHeaderKey(name) { case "Accept", diff --git a/internal/server/sentry_test.go b/internal/server/sentry_test.go index cf1dcdc..7b844ea 100644 --- a/internal/server/sentry_test.go +++ b/internal/server/sentry_test.go @@ -325,6 +325,37 @@ func TestSentryScrub_ReplacesTheCapabilityPathWithTheRoutePattern( ) } +// TestSentryScrub_KeepsOnlyTheRefererSchemeAndHost sends a receiver +// request whose Referer is the entrypoint URL, as a sender's is after +// it follows the README's redirect from http:// to https://. The event +// keeps the Referer's scheme and host, and carries the UUID nowhere. +func TestSentryScrub_KeepsOnlyTheRefererSchemeAndHost(t *testing.T) { + t.Parallel() + + event := onlyEvent(t, sentryCase{ + scrub: true, + panics: true, + request: func(client *sentry.Client) *http.Request { + req := sentryReceiverRequest(client) + req.Header.Set( + "Referer", + "http://webhooker.example/h/"+sentryReceiverUUID, + ) + + return req + }, + }.capture(t)) + + assert.NotContains( + t, marshalEvent(t, event), sentryReceiverUUID, + ) + assert.Equal( + t, + "http://webhooker.example", + event.Request.Headers["Referer"], + ) +} + // TestSentryScrub_KeepsTheRoutingContext checks the hook does not cost // the debugging signal: the route, its scheme and host, the method and // the metadata headers still identify what failed. On a static route -- 2.54.0 From 147d58dd96c4a8d4a7a6027460afbf247d71d75f Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Thu, 15 Oct 2026 18:57:58 +0200 Subject: [PATCH 119/126] Say the query logger drops bound values (closes #535) The comment on MaxAccessLogLineBytes said the GORM adapter logs each statement with the client's parameter written into it. Its ParamsFilter drops every bound value, so the statement is logged with its placeholders. The comment now says so and keeps the parts that still hold: the widest line spends two budgets and the adapter's test checks it against this constant. The assertBounded doc no longer claims each line was cut, and the postReceiver doc no longer explains itself by a log size test that is gone. Model: opus-5-5 --- internal/gormlog/gormlog_test.go | 2 +- internal/handlers/logbound_test.go | 7 +++---- internal/middleware/middleware.go | 19 +++++++++---------- 3 files changed, 13 insertions(+), 15 deletions(-) diff --git a/internal/gormlog/gormlog_test.go b/internal/gormlog/gormlog_test.go index 65b216c..a149ead 100644 --- a/internal/gormlog/gormlog_test.go +++ b/internal/gormlog/gormlog_test.go @@ -146,7 +146,7 @@ func openDB( } // assertBounded holds every line the adapter wrote to the stated -// ceiling and proves each was cut rather than merely short. +// ceiling and proves no line carried a whole client value. func assertBounded(t *testing.T, out string) { t.Helper() diff --git a/internal/handlers/logbound_test.go b/internal/handlers/logbound_test.go index 9d35dac..2072599 100644 --- a/internal/handlers/logbound_test.go +++ b/internal/handlers/logbound_test.go @@ -191,13 +191,12 @@ func receiverRouter(h *handlers.Handlers) *chi.Mux { return router } -// postReceiver sends one POST at /h/. +// postReceiver sends one POST with an empty body at /h/ +// through router and returns the response status. // // RawPath is cleared after parsing so chi routes on the decoded path // and the handler sees the raw bytes rather than their percent-escaped -// spelling. That is the harder case for the budget: the escaped -// spelling is plain ASCII, which costs one byte per byte, while the -// decoded bytes are what the log handler has to escape. +// spelling. func postReceiver( t *testing.T, router *chi.Mux, segment string, ) int { diff --git a/internal/middleware/middleware.go b/internal/middleware/middleware.go index 183fc2b..a1dbc69 100644 --- a/internal/middleware/middleware.go +++ b/internal/middleware/middleware.go @@ -115,16 +115,15 @@ const ( // a caller on a parameterised route would supply, which is the // only way those caps can be pinned at all. // - // One writer in that set is not a handler's own slog call. The - // GORM adapter in internal/gormlog logs the statement with the - // client-chosen parameter already interpolated into it, which on - // the receiver and login lookups is exactly the value the budgets - // above exist for. Its widest line spends two logfield.MaxBytes - // budgets — the statement and the driver error — against a fixed - // portion smaller than this one's, and - // internal/gormlog/gormlog_test.go asserts every line it emits - // against this constant directly rather than leaving it as - // arithmetic. + // The GORM adapter in internal/gormlog writes through slog as well. + // It logs each statement with its placeholders rather than the + // values bound to them (see its ParamsFilter), so the key a client + // sends to the receiver or login lookup does not reach its lines. + // Its widest line spends two logfield.MaxBytes budgets — the + // statement and the driver error — against a fixed portion smaller + // than this one's, and internal/gormlog/gormlog_test.go asserts + // every line it emits against this constant directly rather than + // leaving it as arithmetic. // // What it does NOT cover, so that the figure above is not read as // more than it is: -- 2.54.0 From 40e8ecaab40f55dcffd81c59d26395710785441b Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Thu, 15 Oct 2026 19:38:14 +0200 Subject: [PATCH 120/126] Store a target's number of delivery attempts, at least one (closes #540) Target.MaxRetries (max_retries) becomes Target.MaxAttempts (max_attempts), and the form field, parser and ceiling are renamed to match. The column is NOT NULL and a check constraint refuses a value below 1, so every target, whatever its type, is stored with at least 1. 1 is a single attempt with no circuit breaker; above 1 retries behind the breaker up to that many attempts. Both target forms refuse 0 and less; a blank field on the add target form gives 1, and on the edit form keeps the stored value. The target page shows the plain number, and the README's two paragraphs on 0 against 1 become one sentence. Model: opus-5-5 --- README.md | 48 +- internal/database/event_db_isolation_test.go | 9 +- internal/database/model_parent_test.go | 5 +- internal/database/model_secrets_test.go | 12 +- internal/database/model_target.go | 9 +- internal/database/model_target_test.go | 44 ++ internal/database/schema_test.go | 14 +- internal/db/migrations/main/001_schema.sql | 5 +- internal/delivery/archive_sweeper_test.go | 11 +- internal/delivery/engine.go | 14 +- internal/delivery/engine_integration_test.go | 51 ++- internal/delivery/engine_lifecycle_test.go | 23 +- internal/delivery/engine_test.go | 160 +++++-- internal/delivery/event_db_isolation_test.go | 2 +- internal/delivery/event_timestamp_test.go | 13 +- internal/delivery/export_test.go | 7 +- internal/delivery/inflight_test.go | 4 +- internal/delivery/metrics_test.go | 20 +- internal/delivery/recovery_durability_test.go | 26 +- internal/delivery/redirect_test.go | 2 +- internal/delivery/target_config_view.go | 23 +- internal/delivery/target_config_view_test.go | 58 ++- internal/delivery/target_headers_test.go | 12 +- internal/delivery/target_http.go | 24 +- internal/delivery/target_http_query_test.go | 7 +- internal/delivery/target_redact_test.go | 41 +- internal/delivery/target_slack.go | 7 +- internal/delivery/terminal_state_test.go | 4 +- internal/handlers/delivery_replay.go | 2 +- internal/handlers/event_log.go | 2 +- internal/handlers/export_test.go | 4 +- internal/handlers/source_delete_test.go | 9 +- internal/handlers/source_detail_test.go | 11 +- internal/handlers/target_attempts.go | 81 ++++ internal/handlers/target_attempts_test.go | 429 ++++++++++++++++++ internal/handlers/target_create.go | 38 +- internal/handlers/target_create_query_test.go | 2 +- internal/handlers/target_create_test.go | 26 +- internal/handlers/target_edit.go | 2 +- internal/handlers/target_edit_test.go | 28 +- internal/handlers/target_retries.go | 83 ---- internal/handlers/target_retries_test.go | 385 ---------------- internal/handlers/target_toggle_test.go | 2 +- internal/handlers/ui_copy_test.go | 30 +- internal/handlers/webhook.go | 2 +- internal/server/alpine_browser_test.go | 14 +- internal/server/archive_download_test.go | 9 +- internal/server/routes_test.go | 11 +- static/js/app.js | 6 +- templates/source_detail.html | 10 +- templates/target_edit.html | 6 +- 51 files changed, 1027 insertions(+), 820 deletions(-) create mode 100644 internal/database/model_target_test.go create mode 100644 internal/handlers/target_attempts.go create mode 100644 internal/handlers/target_attempts_test.go delete mode 100644 internal/handlers/target_retries.go delete mode 100644 internal/handlers/target_retries_test.go diff --git a/README.md b/README.md index 277d3b3..ca836d8 100644 --- a/README.md +++ b/README.md @@ -1591,29 +1591,27 @@ did not arrive on the URL and counts in neither. A delivery destination for events. Each target defines where and how events should be forwarded. -| Field | Type | Description | -| ------------- | ---------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `id` | UUID | Primary key | -| `webhook_id` | UUID | Foreign key → Webhook | -| `name` | string | Human-readable name | -| `type` | TargetType | One of: `http`, `slack`, `database`, `log` | -| `active` | boolean | Whether deliveries are enabled (default: true) | -| `config` | JSON text | Type-specific configuration | -| `max_retries` | integer | Total delivery attempts for `http` and `slack` targets, not retries on top of the first: 0 is a single fire-and-forget attempt with no retries and no circuit breaker, and a value of N makes N attempts in all, with exponential backoff and a per-target circuit breaker. Ignored by `database` and `log` targets. The web UI labels it Delivery attempts | +| Field | Type | Description | +| -------------- | ---------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `id` | UUID | Primary key | +| `webhook_id` | UUID | Foreign key → Webhook | +| `name` | string | Human-readable name | +| `type` | TargetType | One of: `http`, `slack`, `database`, `log` | +| `active` | boolean | Whether deliveries are enabled (default: true) | +| `config` | JSON text | Type-specific configuration | +| `max_attempts` | integer | How many times an `http` or `slack` target attempts each delivery in all: at least 1, and 1 by default. Ignored by `database` and `log` targets. The web UI labels it Delivery attempts | **Relations:** Belongs to Webhook. Has many Deliveries. **Target types:** -- **`http`** — Forward the event as an HTTP POST to a configured URL. - `max_retries` is the total number of delivery attempts, not retries on top of - the first: when `max_retries` is 0 (the default), the target operates in - fire-and-forget mode, a single attempt with no retries and no circuit breaker; - a value of N makes up to N attempts in all, retrying failed deliveries with - exponential backoff and protecting them with a per-target circuit breaker. +- **`http`** — Forward the event as an HTTP POST to a configured URL. With + `max_attempts` at 1 the target makes a single attempt; above 1 it retries a + failed delivery with exponential backoff, up to that many attempts in all, + behind a per-target circuit breaker. - **`slack`** — Post the event as a formatted message to a Slack-compatible incoming webhook URL (`webhookUrl` in `config`). It is built on the same HTTP - core as `http` and honours `max_retries` identically, circuit breaker + core as `http` and honours `max_attempts` identically, circuit breaker included. See the Slack target section under "Per-Webhook Event Databases" for the message format. - **`database`** — Archive the full event as a row into the target's own archive @@ -2186,11 +2184,11 @@ External Service ▼ ▼ ▼ ┌────────────┐ ┌────────────┐ ┌────────────┐ │ HTTP Target│ │ HTTP Target│ │ Log Target │ - │(max_retries│ │(max_retries│ │ (stdout) │ - │ == 0) │ │ > 0, │ └────────────┘ - │ fire+forget│ │ backoff + │ + │max_attempts│ │max_attempts│ │ (stdout) │ + │ == 1, │ │ > 1, │ └────────────┘ + │ one attempt│ │ backoff + │ └────────────┘ │ circuit │ - │ breaker) │ + │ breaker │ └────────────┘ ``` @@ -2267,7 +2265,7 @@ re-inject it to every currently active target. ### Circuit Breaker (HTTP and Slack Targets with Retries) -`http` and `slack` targets with `max_retries` > 0 are protected by a +`http` and `slack` targets with `max_attempts` > 1 are protected by a **per-target circuit breaker** that prevents hammering a down target with repeated failed delivery attempts. The circuit breaker is in-memory only and resets on restart (which is fine — startup recovery rescans the database @@ -2308,10 +2306,10 @@ anyway). - **Cooldown:** 30 seconds in open state before probing **Scope:** Circuit breakers apply to **`http` and `slack` targets with -`max_retries` > 0**. The Slack target is built on the same HTTP core and hands -its own `max_retries` to the same retry path, so it gets a breaker with the same -5-failure / 30-second defaults. Fire-and-forget targets of either type -(`max_retries` == 0), database targets (local operations), and log targets +`max_attempts` > 1**. The Slack target is built on the same HTTP core and hands +its own `max_attempts` to the same retry path, so it gets a breaker with the +same 5-failure / 30-second defaults. Single-attempt targets of either type +(`max_attempts` == 1), database targets (local operations), and log targets (stdout) do not use circuit breakers. When a circuit is open and a new delivery arrives, the engine marks the delivery diff --git a/internal/database/event_db_isolation_test.go b/internal/database/event_db_isolation_test.go index 6f38dbb..762a415 100644 --- a/internal/database/event_db_isolation_test.go +++ b/internal/database/event_db_isolation_test.go @@ -25,10 +25,11 @@ func TestEventDBCreateOmitsAssociations(t *testing.T) { require.NoError(t, err) target := database.Target{ - WebhookID: webhookID, - Name: "leaky-target", - Type: database.TargetTypeSlack, - Config: `{"webhookUrl":"https://hooks.example/secret"}`, + WebhookID: webhookID, + Name: "leaky-target", + Type: database.TargetTypeSlack, + Config: `{"webhookUrl":"https://hooks.example/secret"}`, + MaxAttempts: 1, } target.ID = uuid.New().String() diff --git a/internal/database/model_parent_test.go b/internal/database/model_parent_test.go index 2c9ee9e..28b8421 100644 --- a/internal/database/model_parent_test.go +++ b/internal/database/model_parent_test.go @@ -23,8 +23,9 @@ func TestPreloadedModelsMarshalWithoutTheirParent(t *testing.T) { Name: testWebhookName, Entrypoints: []database.Entrypoint{{Path: uuid.New().String()}}, Targets: []database.Target{{ - Name: "log", - Type: database.TargetTypeLog, + Name: "log", + Type: database.TargetTypeLog, + MaxAttempts: 1, }}, } require.NoError(t, db.Create(&stored).Error) diff --git a/internal/database/model_secrets_test.go b/internal/database/model_secrets_test.go index 84bd98f..37fb4d0 100644 --- a/internal/database/model_secrets_test.go +++ b/internal/database/model_secrets_test.go @@ -46,9 +46,10 @@ func TestModelsDoNotMarshalTheirSecrets(t *testing.T) { { name: "target config", model: database.Target{ - Name: keptField, - Type: database.TargetTypeSlack, - Config: `{"webhookUrl":"https://h/s/` + marker + `"}`, + Name: keptField, + Type: database.TargetTypeSlack, + Config: `{"webhookUrl":"https://h/s/` + marker + `"}`, + MaxAttempts: 1, }, }, { @@ -97,8 +98,9 @@ func TestWebhookMarshalsNoTargetConfig(t *testing.T) { encoded := marshalModel(t, database.Webhook{ Name: keptField, Targets: []database.Target{{ - Name: "slack", - Config: `{"webhookUrl":"https://h/s/` + marker + `"}`, + Name: "slack", + Config: `{"webhookUrl":"https://h/s/` + marker + `"}`, + MaxAttempts: 1, }}, }) diff --git a/internal/database/model_target.go b/internal/database/model_target.go index c10127c..c38dad5 100644 --- a/internal/database/model_target.go +++ b/internal/database/model_target.go @@ -29,9 +29,12 @@ type Target struct { // handler that marshals the model itself. Config string `gorm:"type:text" json:"-"` // JSON configuration - // For HTTP targets (max_retries=0 means fire-and-forget, - // >0 enables retries with backoff) - MaxRetries int `json:"maxRetries,omitempty"` + // MaxAttempts is how many times an HTTP or Slack target attempts + // each delivery in all, at least 1. 1 is a single attempt with no + // circuit breaker; above 1, a failed attempt is retried with + // backoff behind the breaker. The table's check constraint refuses + // a target saved with less than 1, whatever its type. + MaxAttempts int `gorm:"not null;check:max_attempts >= 1" json:"maxAttempts,omitempty"` // Relations. No model marshals the record it belongs to: // Webhook.Targets leads back here, and the JSON could loop. diff --git a/internal/database/model_target_test.go b/internal/database/model_target_test.go new file mode 100644 index 0000000..76fcd7c --- /dev/null +++ b/internal/database/model_target_test.go @@ -0,0 +1,44 @@ +package database_test + +import ( + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "sneak.berlin/go/webhooker/internal/database" +) + +// newHTTPTarget returns an http target that makes maxAttempts attempts +// at each delivery. +func newHTTPTarget(maxAttempts int) *database.Target { + return &database.Target{ + WebhookID: uuid.New().String(), + Name: "target", + Type: database.TargetTypeHTTP, + Config: `{"url":"https://example.com/"}`, + MaxAttempts: maxAttempts, + } +} + +// TestTargetCreate_RefusesZeroAttempts relies on the targets table +// alone: nothing in the model checks the number, so the check +// constraint is what keeps a target with 0 attempts out of the +// database. +func TestTargetCreate_RefusesZeroAttempts(t *testing.T) { + t.Parallel() + + db := startedTestDB(t) + + err := db.Create(newHTTPTarget(0)).Error + require.Error(t, err) + assert.Contains(t, err.Error(), "CHECK constraint failed") +} + +func TestTargetCreate_AcceptsOneAttempt(t *testing.T) { + t.Parallel() + + db := startedTestDB(t) + + require.NoError(t, db.Create(newHTTPTarget(1)).Error) +} diff --git a/internal/database/schema_test.go b/internal/database/schema_test.go index cc378ff..10e2d75 100644 --- a/internal/database/schema_test.go +++ b/internal/database/schema_test.go @@ -87,13 +87,13 @@ func TestMainSchemaHoldsEveryModel(t *testing.T) { Active: true, }) assertRoundTrip(t, db, &database.Target{ - BaseModel: newBaseModel(), - WebhookID: uuid.New().String(), - Name: "target", - Type: database.TargetTypeHTTP, - Active: true, - Config: `{"url":"https://example.com/"}`, - MaxRetries: 3, + BaseModel: newBaseModel(), + WebhookID: uuid.New().String(), + Name: "target", + Type: database.TargetTypeHTTP, + Active: true, + Config: `{"url":"https://example.com/"}`, + MaxAttempts: 3, }) } diff --git a/internal/db/migrations/main/001_schema.sql b/internal/db/migrations/main/001_schema.sql index 4911a0d..8f4cb38 100644 --- a/internal/db/migrations/main/001_schema.sql +++ b/internal/db/migrations/main/001_schema.sql @@ -80,10 +80,11 @@ CREATE TABLE targets ( type text NOT NULL, active numeric DEFAULT true, config text, - max_retries integer, + max_attempts integer NOT NULL, PRIMARY KEY (id), CONSTRAINT fk_webhooks_targets - FOREIGN KEY (webhook_id) REFERENCES webhooks (id) + FOREIGN KEY (webhook_id) REFERENCES webhooks (id), + CONSTRAINT chk_targets_max_attempts CHECK (max_attempts >= 1) ); CREATE INDEX idx_targets_deleted_at ON targets (deleted_at); diff --git a/internal/delivery/archive_sweeper_test.go b/internal/delivery/archive_sweeper_test.go index bbd0418..46a1f3a 100644 --- a/internal/delivery/archive_sweeper_test.go +++ b/internal/delivery/archive_sweeper_test.go @@ -124,11 +124,12 @@ func (env *archiveEnv) addDatabaseTarget( t.Helper() tgt := &database.Target{ - WebhookID: webhookID, - Name: "Archive", - Type: database.TargetTypeDatabase, - Active: true, - Config: configJSON, + WebhookID: webhookID, + Name: "Archive", + Type: database.TargetTypeDatabase, + Active: true, + Config: configJSON, + MaxAttempts: 1, } require.NoError( t, diff --git a/internal/delivery/engine.go b/internal/delivery/engine.go index be78e66..cde4128 100644 --- a/internal/delivery/engine.go +++ b/internal/delivery/engine.go @@ -107,7 +107,7 @@ type Task struct { TargetName string TargetType database.TargetType TargetConfig string - MaxRetries int + MaxAttempts int Method string RawQuery string @@ -1769,10 +1769,10 @@ func buildEventFromTask(task *Task) database.Event { func buildTargetFromTask(task *Task) database.Target { target := database.Target{ - Name: task.TargetName, - Type: task.TargetType, - Config: task.TargetConfig, - MaxRetries: task.MaxRetries, + Name: task.TargetName, + Type: task.TargetType, + Config: task.TargetConfig, + MaxAttempts: task.MaxAttempts, } target.ID = task.TargetID @@ -2028,7 +2028,7 @@ func (e *Engine) countAttemptsBatch( // One delivery id per recorded attempt, tallied here rather than // grouped in SQL: internal/gormlog forbids (*gorm.DB).Scan, which // a GROUP BY into a struct would need, and an attempt row per - // delivery is bounded by the target's MaxRetries. + // delivery is bounded by the target's MaxAttempts. var attemptIDs []string err := webhookDB. @@ -2106,7 +2106,7 @@ func buildRecoveryTask( TargetName: target.Name, TargetType: target.Type, TargetConfig: target.Config, - MaxRetries: target.MaxRetries, + MaxAttempts: target.MaxAttempts, Method: event.Method, RawQuery: event.RawQuery, Headers: event.Headers, diff --git a/internal/delivery/engine_integration_test.go b/internal/delivery/engine_integration_test.go index 2812281..14580c6 100644 --- a/internal/delivery/engine_integration_test.go +++ b/internal/delivery/engine_integration_test.go @@ -170,7 +170,7 @@ func iTask( d database.Delivery, event database.Event, webhookID, targetID, name, config string, - maxRetries, attemptNum int, + maxAttempts, attemptNum int, body *string, ) delivery.Task { return delivery.Task{ @@ -181,7 +181,7 @@ func iTask( TargetName: name, TargetType: database.TargetTypeHTTP, TargetConfig: config, - MaxRetries: maxRetries, + MaxAttempts: maxAttempts, Method: event.Method, Headers: event.Headers, ContentType: event.ContentType, @@ -242,7 +242,7 @@ func TestProcessNewTask_InlineBody(t *testing.T) { cfg := iHTTPConfig(ts.URL) task := iTask( d, event, s.WebhookID, targetID, - "test-target", cfg, 0, 1, &bodyStr, + "test-target", cfg, 1, 1, &bodyStr, ) s.Engine.ExportProcessNewTask( @@ -293,7 +293,7 @@ func TestProcessNewTask_LargeBody_FetchFromDB( cfg := iHTTPConfig(ts.URL) task := iTask( d, event, s.WebhookID, targetID, - "test-large", cfg, 0, 1, nil, + "test-large", cfg, 1, 1, nil, ) s.Engine.ExportProcessNewTask( @@ -320,7 +320,7 @@ func TestProcessNewTask_InvalidWebhookID(t *testing.T) { TargetName: "test", TargetType: database.TargetTypeHTTP, TargetConfig: iHTTPConfig("http://localhost:9999"), - MaxRetries: 0, + MaxAttempts: 1, Body: nil, AttemptNum: 1, } @@ -506,7 +506,7 @@ func TestWorkerLifecycle_StartStop(t *testing.T) { task := iTask( d, event, s.WebhookID, targetID, "lifecycle-test", "", - 0, 1, &bodyStr, + 1, 1, &bodyStr, ) task.TargetType = database.TargetTypeLog @@ -645,7 +645,7 @@ func TestRecoverPendingDeliveries(t *testing.T) { iCreateTarget(t, s.MainDB, targetID, s.WebhookID, "recovery-target", - database.TargetTypeLog, "", 0, + database.TargetTypeLog, "", 1, ) iSeedPendingDeliveries( @@ -686,17 +686,17 @@ func iCreateTarget( targetID, webhookID, name string, targetType database.TargetType, config string, - maxRetries int, + maxAttempts int, ) { t.Helper() target := database.Target{ - WebhookID: webhookID, - Name: name, - Type: targetType, - Active: true, - Config: config, - MaxRetries: maxRetries, + WebhookID: webhookID, + Name: name, + Type: targetType, + Active: true, + Config: config, + MaxAttempts: maxAttempts, } target.ID = targetID @@ -1077,7 +1077,7 @@ func TestRecoverInFlight_WithPendingDeliveries( iCreateTarget(t, s.MainDB, targetID, s.WebhookID, "recover-target", - database.TargetTypeLog, "", 0, + database.TargetTypeLog, "", 1, ) event := iSeedEvent( @@ -1232,7 +1232,7 @@ func TestDeliverHTTP_CustomTargetHeaders(t *testing.T) { task := iTask( d, event, s.WebhookID, targetID, "auth-target", string(cfgJSON), - 0, 1, &bodyStr, + 1, 1, &bodyStr, ) s.Engine.ExportProcessNewTask( @@ -1277,7 +1277,7 @@ func TestDeliverHTTP_TargetTimeout(t *testing.T) { task, d := iHTTPTaskAndDelivery( event, del, "timeout-target", - string(cfgJSON), 0, 1, + string(cfgJSON), 1, 1, ) e.ExportDeliverHTTP(context.TODO(), db, d, task) @@ -1339,7 +1339,7 @@ func TestDeliverHTTP_CutsStoredResponseAtMaxBodyLog( bodyStr := event.Body task := iTask( del, event, s.WebhookID, targetID, - "cut-target", cfgJSON, 0, 1, &bodyStr, + "cut-target", cfgJSON, 1, 1, &bodyStr, ) s.Engine.ExportProcessNewTask(context.TODO(), &task) @@ -1403,7 +1403,7 @@ func iHTTPTaskAndDelivery( event database.Event, del database.Delivery, name, config string, - maxRetries, attemptNum int, + maxAttempts, attemptNum int, ) (*delivery.Task, *database.Delivery) { task := &delivery.Task{ DeliveryID: del.ID, @@ -1413,7 +1413,7 @@ func iHTTPTaskAndDelivery( TargetName: name, TargetType: database.TargetTypeHTTP, TargetConfig: config, - MaxRetries: maxRetries, + MaxAttempts: maxAttempts, AttemptNum: attemptNum, } @@ -1423,9 +1423,10 @@ func iHTTPTaskAndDelivery( Status: database.DeliveryStatusPending, Event: event, Target: database.Target{ - Name: name, - Type: database.TargetTypeHTTP, - Config: config, + Name: name, + Type: database.TargetTypeHTTP, + Config: config, + MaxAttempts: maxAttempts, }, } d.ID = del.ID @@ -1466,7 +1467,7 @@ func TestDeliverHTTP_InvalidConfig(t *testing.T) { ) task, d := iHTTPTaskAndDelivery( - event, del, "bad-config", `not-json`, 0, 1, + event, del, "bad-config", `not-json`, 1, 1, ) e.ExportDeliverHTTP(context.TODO(), db, d, task) @@ -1490,7 +1491,7 @@ func TestDeliverHTTP_InvalidConfigUnrecordedStaysPending(t *testing.T) { ) task, d := iHTTPTaskAndDelivery( - event, del, "bad-config", `not-json`, 0, 1, + event, del, "bad-config", `not-json`, 1, 1, ) require.NoError(t, db.Exec("drop table delivery_results").Error) diff --git a/internal/delivery/engine_lifecycle_test.go b/internal/delivery/engine_lifecycle_test.go index 7db4258..1775fc8 100644 --- a/internal/delivery/engine_lifecycle_test.go +++ b/internal/delivery/engine_lifecycle_test.go @@ -149,7 +149,7 @@ func seedLogTask( bodyStr := event.Body task := iTask( d, event, s.WebhookID, targetID, - "hook-context-test", "", 0, 1, &bodyStr, + "hook-context-test", "", 1, 1, &bodyStr, ) task.TargetType = database.TargetTypeLog @@ -278,7 +278,7 @@ func TestEngine_StopHookHonoursStopTimeout(t *testing.T) { } // seedHeldDelivery gives the setup's webhook an http target with -// maxRetries, pointed at a receiver that keeps every request open until +// maxAttempts, pointed at a receiver that keeps every request open until // the client gives up on it, and seeds one delivery to that target at // status. It returns the task that sends attempt attemptNum of the // delivery, and a channel that is closed when a request reaches the @@ -286,7 +286,7 @@ func TestEngine_StopHookHonoursStopTimeout(t *testing.T) { func seedHeldDelivery( t *testing.T, s iSetup, - maxRetries int, + maxAttempts int, status database.DeliveryStatus, attemptNum int, ) (delivery.Task, <-chan struct{}) { @@ -315,7 +315,7 @@ func seedHeldDelivery( iCreateWebhook(t, s.MainDB, s.WebhookID, "hook") iCreateTarget( t, s.MainDB, targetID, s.WebhookID, "held", - database.TargetTypeHTTP, cfg, maxRetries, + database.TargetTypeHTTP, cfg, maxAttempts, ) event := iSeedEvent(t, s.WebhookDB, s.WebhookID, `{"held":true}`) @@ -325,7 +325,7 @@ func seedHeldDelivery( return iTask( d, event, s.WebhookID, targetID, "held", cfg, - maxRetries, attemptNum, &bodyStr, + maxAttempts, attemptNum, &bodyStr, ), arrived } @@ -380,7 +380,7 @@ func TestEngine_StopLeavesFireAndForgetAttemptPending(t *testing.T) { lc := startEngineViaHook(t, s.Engine) task, arrived := seedHeldDelivery( - t, s, 0, database.DeliveryStatusPending, 1, + t, s, 1, database.DeliveryStatusPending, 1, ) s.Engine.Notify([]delivery.Task{task}) @@ -404,13 +404,13 @@ func TestEngine_StopLeavesFireAndForgetAttemptPending(t *testing.T) { func TestEngine_StopLeavesLastRetryAttemptRetrying(t *testing.T) { t.Parallel() - const maxRetries = 2 + const maxAttempts = 2 s := newISetup(t) lc := startEngineViaHook(t, s.Engine) task, arrived := seedHeldDelivery( - t, s, maxRetries, database.DeliveryStatusRetrying, maxRetries, + t, s, maxAttempts, database.DeliveryStatusRetrying, maxAttempts, ) iSeedFailedResult(t, s.WebhookDB, task.DeliveryID) @@ -435,9 +435,10 @@ func deliverToArchive(t *testing.T, s iSetup) (string, string) { iCreateWebhook(t, s.MainDB, s.WebhookID, "hook") tgt := &database.Target{ - WebhookID: s.WebhookID, - Name: "archive", - Type: database.TargetTypeDatabase, + WebhookID: s.WebhookID, + Name: "archive", + Type: database.TargetTypeDatabase, + MaxAttempts: 1, } require.NoError( t, s.MainDB.Omit(clause.Associations).Create(tgt).Error, diff --git a/internal/delivery/engine_test.go b/internal/delivery/engine_test.go index 7ebb016..52a3d44 100644 --- a/internal/delivery/engine_test.go +++ b/internal/delivery/engine_test.go @@ -142,7 +142,7 @@ func buildHTTPFixture( dlv database.Delivery, event database.Event, targetID, name, config string, - maxRetries, attemptNum int, + maxAttempts, attemptNum int, ) httpDeliveryFixture { task := &delivery.Task{ DeliveryID: dlv.ID, @@ -152,7 +152,7 @@ func buildHTTPFixture( TargetName: name, TargetType: database.TargetTypeHTTP, TargetConfig: config, - MaxRetries: maxRetries, + MaxAttempts: maxAttempts, AttemptNum: attemptNum, } @@ -162,10 +162,10 @@ func buildHTTPFixture( Status: dlv.Status, Event: event, Target: database.Target{ - Name: name, - Type: database.TargetTypeHTTP, - Config: config, - MaxRetries: maxRetries, + Name: name, + Type: database.TargetTypeHTTP, + Config: config, + MaxAttempts: maxAttempts, }, } d.ID = dlv.ID @@ -277,7 +277,7 @@ func TestDeliverHTTP_Success(t *testing.T) { cfg := newHTTPTargetConfig(ts.URL) fix := buildHTTPFixture( - dlv, event, targetID, "test-http", cfg, 0, 1, + dlv, event, targetID, "test-http", cfg, 1, 1, ) e.ExportDeliverHTTP( @@ -324,7 +324,7 @@ func TestDeliverHTTP_Failure(t *testing.T) { cfg := newHTTPTargetConfig(ts.URL) fix := buildHTTPFixture( dlv, event, targetID, - "test-http-fail", cfg, 0, 1, + "test-http-fail", cfg, 1, 1, ) e.ExportDeliverHTTP( @@ -473,14 +473,23 @@ func TestDeliverHTTP_WithRetries_Success(t *testing.T) { ) } -func TestDeliverHTTP_MaxRetriesExhausted(t *testing.T) { +// TestDeliverHTTP_MaxAttemptsExhausted proves a target with three +// delivery attempts makes at most three: the first two failures are +// retried, the third is final, and each attempt goes through the +// target's circuit breaker. +func TestDeliverHTTP_MaxAttemptsExhausted(t *testing.T) { t.Parallel() + const maxAttempts = 3 + db := testWebhookDB(t) + var requests atomic.Int32 + ts := httptest.NewServer( http.HandlerFunc( func(w http.ResponseWriter, _ *http.Request) { + requests.Add(1) w.WriteHeader(http.StatusBadGateway) }, ), @@ -489,26 +498,103 @@ func TestDeliverHTTP_MaxRetriesExhausted(t *testing.T) { e := testEngine(t, 1) targetID := uuid.New().String() + + // It opens on the last failure only if every attempt reached it. + cb := delivery.NewTestCircuitBreaker(maxAttempts, time.Minute) + e.ExportSetCircuitBreaker(targetID, cb) + event := seedEvent(t, db, `{"retry":"exhaust"}`) dlv := seedDelivery( t, db, event.ID, targetID, - database.DeliveryStatusRetrying, + database.DeliveryStatusPending, + ) + + cfg := newHTTPTargetConfig(ts.URL) + sched := &recordingScheduler{} + + for attempt := 1; attempt <= maxAttempts; attempt++ { + fix := buildHTTPFixture( + dlv, event, targetID, + "test-http-exhaust", cfg, maxAttempts, attempt, + ) + + e.ExportDeliverHTTPWithScheduler( + context.TODO(), db, fix.Delivery, fix.Task, sched, + ) + } + + assert.Equal(t, int32(maxAttempts), requests.Load()) + assert.Len(t, sched.delays, maxAttempts-1, + "every failure but the last should be retried", + ) + assertDeliveryStatus(t, db, dlv.ID, + database.DeliveryStatusFailed, + ) + assert.Equal(t, delivery.CircuitOpen, cb.State(), + "every attempt should be counted by the circuit breaker", + ) +} + +// TestDeliverHTTP_SingleAttemptSkipsCircuitBreaker proves a target +// with one delivery attempt makes exactly that attempt, even while +// its circuit breaker is open, and that a failure is final. +func TestDeliverHTTP_SingleAttemptSkipsCircuitBreaker(t *testing.T) { + t.Parallel() + + db := testWebhookDB(t) + + var requests atomic.Int32 + + ts := httptest.NewServer( + http.HandlerFunc( + func(w http.ResponseWriter, _ *http.Request) { + requests.Add(1) + w.WriteHeader(http.StatusBadGateway) + }, + ), + ) + defer ts.Close() + + e := testEngine(t, 1) + targetID := uuid.New().String() + + cb := e.ExportGetCircuitBreaker(targetID) + + for range delivery.ExportDefaultFailureThreshold { + cb.RecordFailure() + } + + require.Equal(t, delivery.CircuitOpen, cb.State()) + + event := seedEvent(t, db, `{"retry":"none"}`) + + dlv := seedDelivery( + t, db, event.ID, targetID, + database.DeliveryStatusPending, ) cfg := newHTTPTargetConfig(ts.URL) fix := buildHTTPFixture( dlv, event, targetID, - "test-http-exhaust", cfg, 3, 3, + "test-http-single", cfg, 1, 1, + ) + sched := &recordingScheduler{} + + e.ExportDeliverHTTPWithScheduler( + context.TODO(), db, fix.Delivery, fix.Task, sched, ) - e.ExportDeliverHTTP( - context.TODO(), db, fix.Delivery, fix.Task, + assert.Equal(t, int32(1), requests.Load(), + "the open circuit breaker should not hold the attempt back", ) - + assert.Empty(t, sched.delays, "no retry should be scheduled") assertDeliveryStatus(t, db, dlv.ID, database.DeliveryStatusFailed, ) + assertDeliveryResult( + t, db, dlv.ID, false, http.StatusBadGateway, + ) } func TestDeliverHTTP_SchedulesRetryOnFailure( @@ -727,9 +813,10 @@ func seedConcurrencyTasks( Status: database.DeliveryStatusPending, Event: event, Target: database.Target{ - Name: fmt.Sprintf("task-%d", i), - Type: database.TargetTypeHTTP, - Config: targetCfg, + Name: fmt.Sprintf("task-%d", i), + Type: database.TargetTypeHTTP, + Config: targetCfg, + MaxAttempts: 1, }, } tasks[i].ID = dlv.ID @@ -741,7 +828,7 @@ func seedConcurrencyTasks( TargetName: tasks[i].Target.Name, TargetType: tasks[i].Target.Type, TargetConfig: tasks[i].Target.Config, - MaxRetries: 0, + MaxAttempts: 1, AttemptNum: 1, } } @@ -1688,9 +1775,10 @@ func buildSlackDelivery( Status: dlv.Status, Event: event, Target: database.Target{ - Name: name, - Type: database.TargetTypeSlack, - Config: slackCfg, + Name: name, + Type: database.TargetTypeSlack, + Config: slackCfg, + MaxAttempts: 1, }, } d.ID = dlv.ID @@ -1874,9 +1962,10 @@ func TestDeliverSlack_InvalidConfig(t *testing.T) { Status: database.DeliveryStatusPending, Event: event, Target: database.Target{ - Name: "test-slack-bad", - Type: database.TargetTypeSlack, - Config: `{"not_webhook_url":"missing"}`, + Name: "test-slack-bad", + Type: database.TargetTypeSlack, + Config: `{"not_webhook_url":"missing"}`, + MaxAttempts: 1, }, } d.ID = dlv.ID @@ -1933,8 +2022,9 @@ func TestProcessDelivery_RoutesToSlack(t *testing.T) { ) task := &delivery.Task{ - DeliveryID: dlv.ID, - TargetType: database.TargetTypeSlack, + DeliveryID: dlv.ID, + TargetType: database.TargetTypeSlack, + MaxAttempts: 1, } e.ExportProcessDelivery( @@ -2051,10 +2141,10 @@ func buildSlackRetryDelivery( Status: database.DeliveryStatusPending, Event: event, Target: database.Target{ - Name: "test-slack-retry", - Type: database.TargetTypeSlack, - Config: cfg, - MaxRetries: 5, + Name: "test-slack-retry", + Type: database.TargetTypeSlack, + Config: cfg, + MaxAttempts: 5, }, } d.ID = dlv.ID @@ -2089,11 +2179,11 @@ func TestDeliverSlack_WithRetries_SchedulesRetry( ) task := &delivery.Task{ - DeliveryID: dlv.ID, - TargetID: targetID, - TargetType: database.TargetTypeSlack, - MaxRetries: 5, - AttemptNum: 1, + DeliveryID: dlv.ID, + TargetID: targetID, + TargetType: database.TargetTypeSlack, + MaxAttempts: 5, + AttemptNum: 1, } e.ExportProcessDelivery(context.TODO(), db, d, task) diff --git a/internal/delivery/event_db_isolation_test.go b/internal/delivery/event_db_isolation_test.go index 4bcd183..2428b65 100644 --- a/internal/delivery/event_db_isolation_test.go +++ b/internal/delivery/event_db_isolation_test.go @@ -152,7 +152,7 @@ func TestEventDBHoldsNoTargetRowsOnFailedDelivery(t *testing.T) { ) task := iTask( d, event, s.WebhookID, targetID, - "leaky-target", cfg, 0, 1, &body, + "leaky-target", cfg, 1, 1, &body, ) s.Engine.ExportProcessNewTask(context.TODO(), &task) diff --git a/internal/delivery/event_timestamp_test.go b/internal/delivery/event_timestamp_test.go index 09e70cf..14b39a0 100644 --- a/internal/delivery/event_timestamp_test.go +++ b/internal/delivery/event_timestamp_test.go @@ -147,11 +147,12 @@ func tsSeedTarget( t.Helper() target := database.Target{ - WebhookID: webhookID, - Name: "slack-sink", - Type: database.TargetTypeSlack, - Config: config, - Active: true, + WebhookID: webhookID, + Name: "slack-sink", + Type: database.TargetTypeSlack, + Config: config, + Active: true, + MaxAttempts: 1, } require.NoError(t, mainDB.Create(&target).Error) @@ -176,7 +177,7 @@ func tsTask( TargetName: target.Name, TargetType: database.TargetTypeSlack, TargetConfig: target.Config, - MaxRetries: 0, + MaxAttempts: 1, Method: event.Method, Headers: event.Headers, ContentType: event.ContentType, diff --git a/internal/delivery/export_test.go b/internal/delivery/export_test.go index 8c9aeb4..51a3e61 100644 --- a/internal/delivery/export_test.go +++ b/internal/delivery/export_test.go @@ -142,9 +142,10 @@ func (e *Engine) ExportDeliverSlack( d *database.Delivery, ) { task := &Task{ - DeliveryID: d.ID, - TargetID: d.TargetID, - AttemptNum: 1, + DeliveryID: d.ID, + TargetID: d.TargetID, + MaxAttempts: d.Target.MaxAttempts, + AttemptNum: 1, } e.targets[database.TargetTypeSlack].Deliver( diff --git a/internal/delivery/inflight_test.go b/internal/delivery/inflight_test.go index 059aa5a..c50e3c0 100644 --- a/internal/delivery/inflight_test.go +++ b/internal/delivery/inflight_test.go @@ -32,7 +32,7 @@ func fSweepSetup( iCreateTarget(t, s.MainDB, targetID, s.WebhookID, name, - database.TargetTypeLog, "", 0, + database.TargetTypeLog, "", 1, ) require.NoError(t, s.MainDB.Create(&database.Webhook{ @@ -229,7 +229,7 @@ func TestOwnershipIsReleasedAfterDelivery(t *testing.T) { iCreateTarget(t, s.MainDB, targetID, s.WebhookID, "released", - database.TargetTypeLog, "", 0, + database.TargetTypeLog, "", 1, ) event := iSeedEvent( diff --git a/internal/delivery/metrics_test.go b/internal/delivery/metrics_test.go index e7dccba..6bd124c 100644 --- a/internal/delivery/metrics_test.go +++ b/internal/delivery/metrics_test.go @@ -291,20 +291,20 @@ func TestDeliveryMetrics_CircuitBreakerGauge(t *testing.T) { body := event.Body cfg := iHTTPConfig(ts.URL) - // A retry budget above the failure threshold, so the breaker + // An attempt budget above the failure threshold, so the breaker // rather than the budget is what stops the delivery. - maxRetries := delivery.ExportDefaultFailureThreshold + 5 + maxAttempts := delivery.ExportDefaultFailureThreshold + 5 // The retries below are only run if their target still exists; // see https://git.eeqj.de/sneak/webhooker/issues/107. iCreateTarget( t, s.MainDB, targetID, s.WebhookID, "metrics-trip", - database.TargetTypeHTTP, cfg, maxRetries, + database.TargetTypeHTTP, cfg, maxAttempts, ) first := iTask( d, event, s.WebhookID, targetID, - "metrics-trip", cfg, maxRetries, 1, &body, + "metrics-trip", cfg, maxAttempts, 1, &body, ) s.Engine.ExportProcessNewTask(context.TODO(), &first) @@ -316,7 +316,7 @@ func TestDeliveryMetrics_CircuitBreakerGauge(t *testing.T) { ExportDefaultFailureThreshold; attempt++ { task := iTask( d, event, s.WebhookID, targetID, - "metrics-trip", cfg, maxRetries, attempt, &body, + "metrics-trip", cfg, maxAttempts, attempt, &body, ) s.Engine.ExportProcessRetryTask( @@ -364,16 +364,16 @@ func TestDeliveryMetrics_BreakerBlockedIsNotAnAttempt( body := event.Body cfg := iHTTPConfig(ts.URL) - maxRetries := delivery.ExportDefaultFailureThreshold + 5 + maxAttempts := delivery.ExportDefaultFailureThreshold + 5 iCreateTarget( t, s.MainDB, targetID, s.WebhookID, "metrics-blocked", - database.TargetTypeHTTP, cfg, maxRetries, + database.TargetTypeHTTP, cfg, maxAttempts, ) first := iTask( d, event, s.WebhookID, targetID, - "metrics-blocked", cfg, maxRetries, 1, &body, + "metrics-blocked", cfg, maxAttempts, 1, &body, ) s.Engine.ExportProcessNewTask(context.TODO(), &first) @@ -382,7 +382,7 @@ func TestDeliveryMetrics_BreakerBlockedIsNotAnAttempt( ExportDefaultFailureThreshold; attempt++ { task := iTask( d, event, s.WebhookID, targetID, - "metrics-blocked", cfg, maxRetries, attempt, &body, + "metrics-blocked", cfg, maxAttempts, attempt, &body, ) s.Engine.ExportProcessRetryTask(context.TODO(), &task) @@ -405,7 +405,7 @@ func TestDeliveryMetrics_BreakerBlockedIsNotAnAttempt( blocked := iTask( d, event, s.WebhookID, targetID, - "metrics-blocked", cfg, maxRetries, + "metrics-blocked", cfg, maxAttempts, delivery.ExportDefaultFailureThreshold+1, &body, ) diff --git a/internal/delivery/recovery_durability_test.go b/internal/delivery/recovery_durability_test.go index 2dc23c8..1eabec4 100644 --- a/internal/delivery/recovery_durability_test.go +++ b/internal/delivery/recovery_durability_test.go @@ -66,7 +66,7 @@ func TestRecoverySkipsPendingWithSuccessfulResult( iCreateTarget(t, s.MainDB, targetID, s.WebhookID, "already-delivered", - database.TargetTypeLog, "", 0, + database.TargetTypeLog, "", 1, ) event := iSeedEvent( @@ -129,7 +129,7 @@ func TestRecoveryContinuesTheAttemptNumbering(t *testing.T) { iCreateTarget(t, s.MainDB, targetID, s.WebhookID, "numbering", - database.TargetTypeLog, "", 0, + database.TargetTypeLog, "", 1, ) event := iSeedEvent( @@ -354,16 +354,17 @@ func TestFailedResultWriteLeavesDeliveryRecoverable( Status: database.DeliveryStatusPending, Event: event, Target: database.Target{ - Name: "unwritable", - Type: database.TargetTypeHTTP, - Config: iHTTPConfig(ts.URL), + Name: "unwritable", + Type: database.TargetTypeHTTP, + Config: iHTTPConfig(ts.URL), + MaxAttempts: 1, }, } full.ID = d.ID s.Engine.ExportDeliverHTTP( context.Background(), s.WebhookDB, full, - &delivery.Task{DeliveryID: d.ID, AttemptNum: 1}, + &delivery.Task{DeliveryID: d.ID, MaxAttempts: 1, AttemptNum: 1}, ) assert.Equal( @@ -444,10 +445,10 @@ func TestFailedResultWriteWithRetriesLeavesDeliveryRecoverable( Status: database.DeliveryStatusPending, Event: event, Target: database.Target{ - Name: "unwritable", - Type: database.TargetTypeHTTP, - Config: iHTTPConfig(ts.URL), - MaxRetries: 3, + Name: "unwritable", + Type: database.TargetTypeHTTP, + Config: iHTTPConfig(ts.URL), + MaxAttempts: 3, }, } full.ID = d.ID @@ -457,9 +458,8 @@ func TestFailedResultWriteWithRetriesLeavesDeliveryRecoverable( s.Engine.ExportDeliverHTTPWithScheduler( context.Background(), s.WebhookDB, full, &delivery.Task{ - DeliveryID: d.ID, - TargetID: targetID, - AttemptNum: 1, + DeliveryID: d.ID, TargetID: targetID, + MaxAttempts: 3, AttemptNum: 1, }, sched, ) diff --git a/internal/delivery/redirect_test.go b/internal/delivery/redirect_test.go index 507327f..76027e7 100644 --- a/internal/delivery/redirect_test.go +++ b/internal/delivery/redirect_test.go @@ -96,7 +96,7 @@ func deliverWithProbeHeaders( body := event.Body task := iTask( d, event, s.WebhookID, targetID, - "redirect-target", string(cfg), 0, 1, &body, + "redirect-target", string(cfg), 1, 1, &body, ) s.Engine.ExportProcessNewTask(context.TODO(), &task) diff --git a/internal/delivery/target_config_view.go b/internal/delivery/target_config_view.go index 2f02285..e8b4771 100644 --- a/internal/delivery/target_config_view.go +++ b/internal/delivery/target_config_view.go @@ -120,8 +120,8 @@ func unavailableConfigFields() []ConfigField { } // slackConfigFields describes a Slack target: its masked -// webhook URL and its retry count. Only the masked URL is -// shown; the full URL is the credential. +// webhook URL and its number of delivery attempts. Only the +// masked URL is shown; the full URL is the credential. func slackConfigFields(t *database.Target) []ConfigField { cfg, err := parseSlackConfig(t.Config) if err != nil { @@ -131,7 +131,7 @@ func slackConfigFields(t *database.Target) []ConfigField { return []ConfigField{{ Label: "Webhook URL", Value: cfg.MaskedWebhookURL(), - }, maxRetriesField(t)} + }, maxAttemptsField(t)} } // httpConfigFields describes an HTTP target: its destination @@ -178,23 +178,18 @@ func httpConfigFields(t *database.Target) []ConfigField { }) } - fields = append(fields, maxRetriesField(t)) + fields = append(fields, maxAttemptsField(t)) return fields } -// 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. -func maxRetriesField(t *database.Target) ConfigField { - attempts := strconv.Itoa(t.MaxRetries) - if t.MaxRetries == 0 { - attempts = "1 (fire-and-forget: no retries, no circuit breaker)" - } - +// maxAttemptsField describes a target's number of delivery +// attempts, which lives on the target row rather than in its +// configuration blob. +func maxAttemptsField(t *database.Target) ConfigField { return ConfigField{ Label: "Delivery attempts", - Value: attempts, + Value: strconv.Itoa(t.MaxAttempts), } } diff --git a/internal/delivery/target_config_view_test.go b/internal/delivery/target_config_view_test.go index 85ffde0..8a60b24 100644 --- a/internal/delivery/target_config_view_test.go +++ b/internal/delivery/target_config_view_test.go @@ -32,7 +32,7 @@ const ( viewMaskedOrigin = viewExampleOrigin + "/..." viewUnavailable = "(unavailable)" viewExpiryNever = "never" - viewMaxRetries = "Delivery attempts" + viewMaxAttempts = "Delivery attempts" ) func TestMaskedWebhookURL(t *testing.T) { @@ -171,6 +171,7 @@ func slackTarget() database.Target { Active: true, Config: `{"webhookUrl":"` + slackWebhookURL + `"}`, + MaxAttempts: 1, } } @@ -189,28 +190,29 @@ func TestNewTargetViews_Slack(t *testing.T) { assert.Equal( t, map[string]string{ - "Webhook URL": slackMaskedURL, - viewMaxRetries: "1 (fire-and-forget: no retries, no circuit breaker)", + "Webhook URL": slackMaskedURL, + viewMaxAttempts: "1", }, fieldMap(view.Config), ) } -// TestNewTargetViews_SlackRetries proves a Slack target shows -// its retry count the same way an HTTP target does. -func TestNewTargetViews_SlackRetries(t *testing.T) { +// TestNewTargetViews_SlackAttempts proves a Slack target shows +// its number of delivery attempts the same way an HTTP target +// does. +func TestNewTargetViews_SlackAttempts(t *testing.T) { t.Parallel() target := slackTarget() - target.MaxRetries = 2 + target.MaxAttempts = 2 view := viewFor(t, target) assert.Equal( t, map[string]string{ - "Webhook URL": slackMaskedURL, - viewMaxRetries: "2", + "Webhook URL": slackMaskedURL, + viewMaxAttempts: "2", }, fieldMap(view.Config), ) @@ -225,7 +227,7 @@ func TestNewTargetViews_HTTP(t *testing.T) { `"timeout":30,` + `"headers":{"Authorization":"Bearer sekrit"},` + `"forwardQuery":true}`, - MaxRetries: 5, + MaxAttempts: 5, }) fields := fieldMap(view.Config) @@ -237,7 +239,7 @@ func TestNewTargetViews_HTTP(t *testing.T) { "Timeout": "30s", "Headers": "1 configured", "Query string": "passed on to this target", - viewMaxRetries: "5", + viewMaxAttempts: "5", }, fields, ) @@ -248,19 +250,22 @@ func TestNewTargetViews_HTTP(t *testing.T) { } } -func TestNewTargetViews_HTTPFireAndForget(t *testing.T) { +// TestNewTargetViews_HTTPSingleAttempt proves a single attempt is +// shown as the plain number, like any other. +func TestNewTargetViews_HTTPSingleAttempt(t *testing.T) { t.Parallel() view := viewFor(t, database.Target{ - Type: database.TargetTypeHTTP, - Config: `{"url":"` + viewExampleHook + `"}`, + Type: database.TargetTypeHTTP, + Config: `{"url":"` + viewExampleHook + `"}`, + MaxAttempts: 1, }) assert.Equal( t, map[string]string{ "Destination URL": viewMaskedOrigin, - viewMaxRetries: "1 (fire-and-forget: no retries, no circuit breaker)", + viewMaxAttempts: "1", }, fieldMap(view.Config), ) @@ -274,8 +279,9 @@ func TestNewTargetViews_HTTPMasksDestinationURL(t *testing.T) { t.Parallel() view := viewFor(t, database.Target{ - Type: database.TargetTypeHTTP, - Config: `{"url":"` + slackWebhookURL + `"}`, + Type: database.TargetTypeHTTP, + Config: `{"url":"` + slackWebhookURL + `"}`, + MaxAttempts: 1, }) fields := fieldMap(view.Config) @@ -398,19 +404,23 @@ func TestNewTargetViews_Unpresentable(t *testing.T) { Config: blob, }, "unparseable json": { - Type: database.TargetTypeSlack, - Config: blob, + Type: database.TargetTypeSlack, + Config: blob, + MaxAttempts: 1, }, "empty slack config": { - Type: database.TargetTypeSlack, + Type: database.TargetTypeSlack, + MaxAttempts: 1, }, "slack config without url": { - Type: database.TargetTypeSlack, - Config: `{}`, + Type: database.TargetTypeSlack, + Config: `{}`, + MaxAttempts: 1, }, "unparseable http json": { - Type: database.TargetTypeHTTP, - Config: `{"url":`, + Type: database.TargetTypeHTTP, + Config: `{"url":`, + MaxAttempts: 1, }, "unparseable archive json": { Type: database.TargetTypeDatabase, diff --git a/internal/delivery/target_headers_test.go b/internal/delivery/target_headers_test.go index 4250dee..ef765bc 100644 --- a/internal/delivery/target_headers_test.go +++ b/internal/delivery/target_headers_test.go @@ -189,6 +189,7 @@ func TestNewTargetConfigForm(t *testing.T) { Config: `{"url":"https://example.com/h",` + `"headers":{"Authorization":"Bearer abc"},` + `"timeout":9}`, + MaxAttempts: 1, }) require.NoError(t, err) assert.Equal(t, "https://example.com/h", form.URL) @@ -196,8 +197,9 @@ func TestNewTargetConfigForm(t *testing.T) { assert.Equal(t, "9", form.Timeout) form, err = delivery.NewTargetConfigForm(&database.Target{ - Type: database.TargetTypeSlack, - Config: `{"webhookUrl":"https://hooks.example/s"}`, + Type: database.TargetTypeSlack, + Config: `{"webhookUrl":"https://hooks.example/s"}`, + MaxAttempts: 1, }) require.NoError(t, err) assert.Equal(t, "https://hooks.example/s", form.URL) @@ -242,9 +244,9 @@ func TestNewTargetConfigForm_UnreadableConfigErrors(t *testing.T) { t.Parallel() cases := []*database.Target{ - {Type: database.TargetTypeHTTP, Config: "not json"}, - {Type: database.TargetTypeHTTP, Config: `{}`}, - {Type: database.TargetTypeSlack, Config: ""}, + {Type: database.TargetTypeHTTP, Config: "not json", MaxAttempts: 1}, + {Type: database.TargetTypeHTTP, Config: `{}`, MaxAttempts: 1}, + {Type: database.TargetTypeSlack, Config: "", MaxAttempts: 1}, { Type: database.TargetTypeDatabase, Config: `{"expiry":"soon"}`, diff --git a/internal/delivery/target_http.go b/internal/delivery/target_http.go index 009c070..ce18d22 100644 --- a/internal/delivery/target_http.go +++ b/internal/delivery/target_http.go @@ -53,10 +53,10 @@ type httpCore struct { } // deliver runs one delivery attempt through the retry core. -// A maxRetries of 0 is fire-and-forget: a single attempt is -// recorded and no circuit breaker is consulted. A positive -// maxRetries gates the attempt on the circuit breaker and -// schedules a backed-off retry on failure. +// A maxAttempts of 1 is a single attempt, recorded without +// consulting a circuit breaker. A higher maxAttempts gates the +// attempt on the circuit breaker and schedules a backed-off retry +// on failure, until maxAttempts attempts have been made. // // ctx is the context attempt sends its request with. Stopping the // engine cancels it; see interruptedByStop. @@ -66,17 +66,17 @@ func (c *httpCore) deliver( d *database.Delivery, task *Task, sched Scheduler, - maxRetries int, + maxAttempts int, attempt func() attemptResult, ) { - if maxRetries == 0 { + if maxAttempts == 1 { c.fireAndForget(ctx, webhookDB, d, attempt()) return } c.withRetry( - ctx, webhookDB, d, task, sched, maxRetries, attempt, + ctx, webhookDB, d, task, sched, maxAttempts, attempt, ) } @@ -153,7 +153,7 @@ func (c *httpCore) withRetry( d *database.Delivery, task *Task, sched Scheduler, - maxRetries int, + maxAttempts int, attempt func() attemptResult, ) { cb := c.getCircuitBreaker(task.TargetID) @@ -205,7 +205,7 @@ func (c *httpCore) withRetry( cb.RecordFailure() c.handleRetry( - webhookDB, d, task, sched, maxRetries, attemptNum, + webhookDB, d, task, sched, maxAttempts, attemptNum, ) } @@ -266,10 +266,10 @@ func (c *httpCore) handleRetry( d *database.Delivery, task *Task, sched Scheduler, - maxRetries int, + maxAttempts int, attemptNum int, ) { - if attemptNum >= maxRetries { + if attemptNum >= maxAttempts { c.eng.settleStatus( webhookDB, d, d.Target.Type, database.DeliveryStatusFailed, @@ -439,7 +439,7 @@ func (t *httpTarget) Deliver( t.deliver( ctx, webhookDB, d, task, sched, - d.Target.MaxRetries, attempt, + d.Target.MaxAttempts, attempt, ) } diff --git a/internal/delivery/target_http_query_test.go b/internal/delivery/target_http_query_test.go index fb3a5de..b06a398 100644 --- a/internal/delivery/target_http_query_test.go +++ b/internal/delivery/target_http_query_test.go @@ -47,7 +47,7 @@ func deliverWithQuery(t *testing.T, cfg string) database.DeliveryResult { database.DeliveryStatusPending, ) task := iTask( - d, event, s.WebhookID, d.TargetID, "query", cfg, 0, 1, &event.Body, + d, event, s.WebhookID, d.TargetID, "query", cfg, 1, 1, &event.Body, ) task.RawQuery = eventQuery @@ -144,8 +144,9 @@ func TestDeliverHTTP_ForwardedQueryKeepsTheTargetURLMasked(t *testing.T) { t.Cleanup(ts.Close) target := &database.Target{ - Type: database.TargetTypeHTTP, - Config: httpTargetConfig(t, ts.URL+"/in?token="+secret, true), + Type: database.TargetTypeHTTP, + Config: httpTargetConfig(t, ts.URL+"/in?token="+secret, true), + MaxAttempts: 1, } deliverWithQuery(t, target.Config) diff --git a/internal/delivery/target_redact_test.go b/internal/delivery/target_redact_test.go index 52613de..a4c010f 100644 --- a/internal/delivery/target_redact_test.go +++ b/internal/delivery/target_redact_test.go @@ -22,8 +22,9 @@ func TestRedactor_RemovesSlackWebhookURL(t *testing.T) { t.Parallel() r := delivery.NewRedactor(&database.Target{ - Type: database.TargetTypeSlack, - Config: `{"webhookUrl":"` + redactWebhookURL + `"}`, + Type: database.TargetTypeSlack, + Config: `{"webhookUrl":"` + redactWebhookURL + `"}`, + MaxAttempts: 1, }) got := r.Redact("no_service for " + redactWebhookURL) @@ -52,8 +53,9 @@ func TestRedactor_RemovesSecretSeveredByACut(t *testing.T) { t.Parallel() r := delivery.NewRedactor(&database.Target{ - Type: database.TargetTypeSlack, - Config: `{"webhookUrl":"` + redactWebhookURL + `"}`, + Type: database.TargetTypeSlack, + Config: `{"webhookUrl":"` + redactWebhookURL + `"}`, + MaxAttempts: 1, }) // Every cut position inside the credential, not just a @@ -96,6 +98,7 @@ func TestRedactor_RedactsCredentialShapedHeaderValues( `"X-Credential":"HHHHHHHHHHHH",` + `"Accept":"application/json",` + `"User-Agent":"webhooker/1.0"}}`, + MaxAttempts: 1, }) for _, secret := range []string{ @@ -136,6 +139,7 @@ func TestRedactor_IgnoresVeryShortHeaderValues(t *testing.T) { Type: database.TargetTypeHTTP, Config: `{"url":"https://example.com/in",` + `"headers":{"X-Api-Key":"ab"}}`, + MaxAttempts: 1, }) const response = "rabbit" @@ -150,8 +154,9 @@ func TestRedactor_RemovesBarePath(t *testing.T) { t.Parallel() r := delivery.NewRedactor(&database.Target{ - Type: database.TargetTypeSlack, - Config: `{"webhookUrl":"` + redactWebhookURL + `"}`, + Type: database.TargetTypeSlack, + Config: `{"webhookUrl":"` + redactWebhookURL + `"}`, + MaxAttempts: 1, }) got := r.Redact("POST " + redactSecretPath + " 404") @@ -183,8 +188,9 @@ func TestRedactor_RemovesHTTPURLQueryAndUserinfo(t *testing.T) { raw := dest.String() r := delivery.NewRedactor(&database.Target{ - Type: database.TargetTypeHTTP, - Config: `{"url":"` + raw + `"}`, + Type: database.TargetTypeHTTP, + Config: `{"url":"` + raw + `"}`, + MaxAttempts: 1, }) for _, echoed := range []string{ @@ -232,8 +238,9 @@ func TestRedactor_RemovesEchoedQueryOfURLWithoutPath(t *testing.T) { "https://example.com?token=" + secret, } { r := delivery.NewRedactor(&database.Target{ - Type: database.TargetTypeHTTP, - Config: `{"url":"` + dest + `"}`, + Type: database.TargetTypeHTTP, + Config: `{"url":"` + dest + `"}`, + MaxAttempts: 1, }) for echoed, want := range echoes { @@ -253,8 +260,9 @@ func TestRedactor_LeavesUnrelatedTextAlone(t *testing.T) { const response = "ok=false error=channel_not_found" r := delivery.NewRedactor(&database.Target{ - Type: database.TargetTypeSlack, - Config: `{"webhookUrl":"` + redactWebhookURL + `"}`, + Type: database.TargetTypeSlack, + Config: `{"webhookUrl":"` + redactWebhookURL + `"}`, + MaxAttempts: 1, }) assert.Equal(t, response, r.Redact(response)) @@ -279,8 +287,9 @@ func TestRedactor_EmptyUserinfoDoesNotShredTheBody(t *testing.T) { require.Empty(t, parsed.User.String()) r := delivery.NewRedactor(&database.Target{ - Type: database.TargetTypeHTTP, - Config: `{"url":"` + dest + `"}`, + Type: database.TargetTypeHTTP, + Config: `{"url":"` + dest + `"}`, + MaxAttempts: 1, }) const body = "ok=false error=channel_not_found" @@ -314,8 +323,8 @@ func TestRedactor_ZeroValueAndConfiglessTargets(t *testing.T) { for _, tgt := range []database.Target{ {Type: database.TargetTypeLog}, {Type: database.TargetTypeDatabase}, - {Type: database.TargetTypeSlack, Config: "not json"}, - {Type: database.TargetTypeHTTP, Config: ""}, + {Type: database.TargetTypeSlack, Config: "not json", MaxAttempts: 1}, + {Type: database.TargetTypeHTTP, Config: "", MaxAttempts: 1}, } { assert.Equal( t, text, diff --git a/internal/delivery/target_slack.go b/internal/delivery/target_slack.go index 553b98e..2b9129d 100644 --- a/internal/delivery/target_slack.go +++ b/internal/delivery/target_slack.go @@ -30,9 +30,8 @@ type SlackTargetConfig struct { // slackTarget delivers events to Slack incoming webhooks. It // formats the event into a Slack message and posts it as // JSON. It shares the retry core with the HTTP target: a -// MaxRetries of 0 stays single-attempt fire-and-forget -// (preserving existing Slack targets), while a positive -// MaxRetries adds backoff and circuit breaking. +// MaxAttempts of 1 is a single attempt, while a higher one +// adds backoff and circuit breaking. type slackTarget struct { *httpCore @@ -83,7 +82,7 @@ func (t *slackTarget) Deliver( t.deliver( ctx, webhookDB, d, task, sched, - d.Target.MaxRetries, attempt, + d.Target.MaxAttempts, attempt, ) } diff --git a/internal/delivery/terminal_state_test.go b/internal/delivery/terminal_state_test.go index 20ac2f3..ef2b0cf 100644 --- a/internal/delivery/terminal_state_test.go +++ b/internal/delivery/terminal_state_test.go @@ -743,7 +743,7 @@ func TestSendRecoveredDeliveries_TargetMissingFromMap( iCreateTarget( t, s.MainDB, targetID, s.WebhookID, "found-on-lookup", - database.TargetTypeLog, "", 0, + database.TargetTypeLog, "", 1, ) event := iSeedEvent( @@ -790,7 +790,7 @@ func TestRecoverPending_TargetUnreadable_LeavesDeliveryAlone( iCreateTarget( t, s.MainDB, targetID, s.WebhookID, "healthy", - database.TargetTypeLog, "", 0, + database.TargetTypeLog, "", 1, ) event := iSeedEvent( diff --git a/internal/handlers/delivery_replay.go b/internal/handlers/delivery_replay.go index 96a8cfc..091ba4f 100644 --- a/internal/handlers/delivery_replay.go +++ b/internal/handlers/delivery_replay.go @@ -308,7 +308,7 @@ func createReplayDelivery( TargetName: target.Name, TargetType: target.Type, TargetConfig: target.Config, - MaxRetries: target.MaxRetries, + MaxAttempts: target.MaxAttempts, Method: event.Method, RawQuery: event.RawQuery, Headers: event.Headers, diff --git a/internal/handlers/event_log.go b/internal/handlers/event_log.go index 63a5d7c..52f17bf 100644 --- a/internal/handlers/event_log.go +++ b/internal/handlers/event_log.go @@ -496,7 +496,7 @@ const deliveryIDChunkSize = 500 // // Each response body is cut by SQLite rather than in Go, for // the reason deliveryResultColumns gives. How many attempts a -// delivery has is the target's MaxRetries, which the +// delivery has is bounded by the target's MaxAttempts, which the // authenticated operator sets; how many of them reach the page // is bounded again by maxRenderedAttempts. func (h *Handlers) loadDeliveryResults( diff --git a/internal/handlers/export_test.go b/internal/handlers/export_test.go index 852f79a..6357e46 100644 --- a/internal/handlers/export_test.go +++ b/internal/handlers/export_test.go @@ -37,10 +37,10 @@ const MaxRenderedResponseBytesForTest = maxRenderedResponseBytes // per-delivery attempt ceiling to the handlers_test package. const MaxRenderedAttemptsForTest = maxRenderedAttempts -// MaxTargetRetriesForTest exposes the target max_retries ceiling to +// MaxTargetAttemptsForTest exposes the target max_attempts ceiling to // the handlers_test package, so the tests assert against the constant // the handlers enforce rather than a number copied beside it. -const MaxTargetRetriesForTest = maxTargetRetries +const MaxTargetAttemptsForTest = maxTargetAttempts // EventDBLeftMsgForTest and SidecarLeftMsgForTest expose the two // messages the webhook delete handler logs when a file of the event diff --git a/internal/handlers/source_delete_test.go b/internal/handlers/source_delete_test.go index 211e891..4ef4143 100644 --- a/internal/handlers/source_delete_test.go +++ b/internal/handlers/source_delete_test.go @@ -64,10 +64,11 @@ func seedTarget( t.Helper() tgt := &database.Target{ - WebhookID: webhookID, - Name: "t-" + string(targetType), - Type: targetType, - Active: true, + WebhookID: webhookID, + Name: "t-" + string(targetType), + Type: targetType, + Active: true, + MaxAttempts: 1, } require.NoError( diff --git a/internal/handlers/source_detail_test.go b/internal/handlers/source_detail_test.go index 84ddefb..6ccfc13 100644 --- a/internal/handlers/source_detail_test.go +++ b/internal/handlers/source_detail_test.go @@ -37,11 +37,12 @@ func seedConfiguredTarget( t.Helper() tgt := &database.Target{ - WebhookID: webhookID, - Name: "t-" + string(targetType), - Type: targetType, - Active: true, - Config: config, + WebhookID: webhookID, + Name: "t-" + string(targetType), + Type: targetType, + Active: true, + Config: config, + MaxAttempts: 1, } require.NoError( diff --git a/internal/handlers/target_attempts.go b/internal/handlers/target_attempts.go new file mode 100644 index 0000000..c98712e --- /dev/null +++ b/internal/handlers/target_attempts.go @@ -0,0 +1,81 @@ +package handlers + +import ( + "errors" + "strconv" + "strings" +) + +// maxTargetAttempts bounds a target's max_attempts. +// +// Both target forms already declare max="20" on the input, so this +// enforces server-side what the UI has always advertised rather than +// introducing a new limit. +// +// The number is not cosmetic. Every attempt writes a delivery_results +// row that the event log then loads and renders, and the engine backs +// off by 2^(n-1) seconds, so attempt 20 is already about six days +// after the first. A value beyond this buys no additional durability +// and only costs rows. +const maxTargetAttempts = 20 + +// Errors returned when a max_attempts form value cannot be turned into +// a number of attempts. +var ( + // errAttemptsInvalid signals a max_attempts form value that is not + // a whole number of at least 1. + errAttemptsInvalid = errors.New( + "must be a whole number, at least 1", + ) + + // errAttemptsTooLarge signals a max_attempts form value that is a + // whole number but above maxTargetAttempts. It is distinguished + // from errAttemptsInvalid so the message can name the ceiling + // instead of implying the input was not a number. + errAttemptsTooLarge = errors.New("out of range") +) + +// parseMaxAttempts interprets a max_attempts form value. +// +// An ABSENT value — the field empty or not submitted — yields +// fallback, which lets the create path apply its default and the edit +// path leave the stored value alone. A value that is SET BUT INVALID +// is an error: unparseable, below 1, or above maxTargetAttempts. +// +// The distinction is the whole point of this function. Returning a +// default for input the operator typed but that did not parse would +// silently turn retries off on a store-and-forward proxy — and on the +// edit path destroy a working configuration over a typo. A default +// answers a question that was not asked; it never answers one that +// was asked badly. +func parseMaxAttempts(raw string, fallback int) (int, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return fallback, nil + } + + v, err := strconv.Atoi(raw) + if err != nil || v < 1 { + return 0, errAttemptsInvalid + } + + if v > maxTargetAttempts { + return 0, errAttemptsTooLarge + } + + return v, nil +} + +// attemptsErrorMessage returns the message the create and edit forms +// show for a rejected max_attempts value. Any error other than +// errAttemptsTooLarge falls back to the generic wording, so an +// unrecognised parse failure still produces a sensible 400. +func attemptsErrorMessage(err error) string { + if errors.Is(err, errAttemptsTooLarge) { + return errAttemptsTooLarge.Error() + + ": at most " + strconv.Itoa(maxTargetAttempts) + + " attempts" + } + + return errAttemptsInvalid.Error() +} diff --git a/internal/handlers/target_attempts_test.go b/internal/handlers/target_attempts_test.go new file mode 100644 index 0000000..8dfa859 --- /dev/null +++ b/internal/handlers/target_attempts_test.go @@ -0,0 +1,429 @@ +package handlers_test + +import ( + "net/http" + "net/url" + "strconv" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "sneak.berlin/go/webhooker/internal/database" + "sneak.berlin/go/webhooker/internal/handlers" +) + +// attemptsTargetURL is the destination the attempts-validation targets +// point at. It is a literal public address rather than a hostname so +// the SSRF check resolves nothing and a sandbox without DNS cannot +// make these cases pass or fail for the wrong reason. +const attemptsTargetURL = "https://93.184.216.34/hooks/attempts" + +const ( + // wayAboveCeiling is the typo'd-extra-zero case from the report. + wayAboveCeiling = "999999999" + + // notANumber is the plainest garbage an operator can type, and + // the value the report submitted on the edit form. + notANumber = "abc" + + // workingAttempts is the number of attempts a seeded target is + // already delivering with, which a rejected submission must not + // disturb. + workingAttempts = 2 +) + +// aboveCeiling is the smallest rejected whole number. +func aboveCeiling() string { + return strconv.Itoa(handlers.MaxTargetAttemptsForTest + 1) +} + +// overCeilingAttempts is whole-number input past the limit, which is +// rejected with the limit named. +func overCeilingAttempts() []string { + return []string{aboveCeiling(), wayAboveCeiling} +} + +// belowOneAttempts is whole-number input under the floor of one +// attempt, which is rejected with the floor named. +func belowOneAttempts() []string { + return []string{"0", "-5"} +} + +// unparseableAttempts is input an operator can type into the field +// that is not a number of attempts. Each must be REJECTED: silently +// reading any of them as a default turns retries off on a +// store-and-forward proxy without saying so. +// +// The twenty-digit case is here because it parses as digits but +// overflows int, which is the one failure the field's own min/max +// attributes cannot describe. +func unparseableAttempts() []string { + return []string{ + notANumber, + "2.7", + "12345678901234567890", + "1e3", + } +} + +// createAttemptsForm is a complete, otherwise-valid HTTP target +// creation, so the only thing any case below varies is max_attempts. +func createAttemptsForm(attempts string) url.Values { + form := url.Values{} + form.Set("name", "attempts-target") + form.Set("type", string(database.TargetTypeHTTP)) + form.Set("url", attemptsTargetURL) + + if attempts != absentField { + form.Set("max_attempts", attempts) + } + + return form +} + +// absentField marks a field the form does not submit at all, which is +// the case that legitimately takes a default and must stay distinct +// from a field submitted with garbage in it. +const absentField = "\x00absent" + +// absentAttempts is every way of saying "the operator did not set +// this", each of which takes the default rather than a 400. Blank and +// whitespace-only count as absent here because they do in the timeout +// and retention controls on the same forms; a rule the fields do not +// share would be its own surprise. +func absentAttempts() []string { + return []string{absentField, "", " "} +} + +// createWithAttempts posts the target create form for a fresh webhook +// and returns the webhook and the response. +func createWithAttempts( + t *testing.T, + env *sourceTestEnv, + attempts string, +) (database.Webhook, int, string) { + t.Helper() + + webhook := seedWebhookWithRetention(t, env.db, 30) + + w := serveTarget( + env, http.MethodPost, + "/hook/"+webhook.ID+"/targets", + createAttemptsForm(attempts), + ) + + return webhook, w.Code, w.Body.String() +} + +// TestTargetCreate_AttemptsAboveCeilingRejected proves the create form +// enforces a ceiling at all, and that the 400 names it — a rejection +// that does not say what the limit is leaves the operator guessing. +func TestTargetCreate_AttemptsAboveCeilingRejected(t *testing.T) { + t.Parallel() + + env := setupSourceTest(t) + ceiling := strconv.Itoa(handlers.MaxTargetAttemptsForTest) + + for _, attempts := range overCeilingAttempts() { + webhook, code, body := createWithAttempts(t, env, attempts) + + assert.Equal(t, http.StatusBadRequest, code, + "max_attempts=%s should be rejected", attempts) + assert.Contains(t, body, ceiling, + "the rejection for %s should name the ceiling", + attempts) + assert.Empty(t, + targetsForWebhook(t, env.db, webhook.ID), + "no target should be created for %s", attempts) + } +} + +// TestTargetCreate_AttemptsBelowOneRejected proves the create form +// refuses 0 and less, naming the floor: every delivery is attempted +// at least once. +func TestTargetCreate_AttemptsBelowOneRejected(t *testing.T) { + t.Parallel() + + env := setupSourceTest(t) + + for _, attempts := range belowOneAttempts() { + webhook, code, body := createWithAttempts(t, env, attempts) + + assert.Equal(t, http.StatusBadRequest, code, + "max_attempts=%s should be rejected", attempts) + assert.Contains(t, body, "at least 1", + "the rejection for %s should name the floor", + attempts) + assert.Empty(t, + targetsForWebhook(t, env.db, webhook.ID), + "no target should be created for %s", attempts) + } +} + +// TestTargetCreate_UnparseableAttemptsRejected is the core of the +// defect: each of these was accepted with HTTP 200 and stored as a +// default. +func TestTargetCreate_UnparseableAttemptsRejected(t *testing.T) { + t.Parallel() + + env := setupSourceTest(t) + + for _, attempts := range unparseableAttempts() { + webhook, code, body := createWithAttempts(t, env, attempts) + + assert.Equal(t, http.StatusBadRequest, code, + "max_attempts=%q should be rejected, not coerced", + attempts) + assert.Contains(t, body, "whole number", + "the rejection for %q should say why", attempts) + assert.Empty(t, + targetsForWebhook(t, env.db, webhook.ID), + "no target should be created for %q", attempts) + } +} + +// TestTargetCreate_ValidAttemptsStored covers the accepting half, +// including both bounds: a bound that rejects its own limit would +// make the advertised range unreachable. +func TestTargetCreate_ValidAttemptsStored(t *testing.T) { + t.Parallel() + + env := setupSourceTest(t) + + for _, want := range []int{1, 3, handlers.MaxTargetAttemptsForTest} { + webhook, code, body := createWithAttempts( + t, env, strconv.Itoa(want), + ) + + require.Equal(t, http.StatusSeeOther, code, body) + + targets := targetsForWebhook(t, env.db, webhook.ID) + require.Len(t, targets, 1) + assert.Equal(t, want, targets[0].MaxAttempts) + } +} + +// TestTargetCreate_AbsentAttemptsTakesDefault keeps the two cases +// distinct. An omitted field is not an operator asking for something +// invalid, so it still gets the default of one attempt rather than a +// 400 — otherwise the fix above would make the form unusable. +func TestTargetCreate_AbsentAttemptsTakesDefault(t *testing.T) { + t.Parallel() + + env := setupSourceTest(t) + + for _, attempts := range absentAttempts() { + webhook, code, body := createWithAttempts(t, env, attempts) + + require.Equal(t, http.StatusSeeOther, code, body) + + targets := targetsForWebhook(t, env.db, webhook.ID) + require.Len(t, targets, 1) + assert.Equal(t, 1, targets[0].MaxAttempts, + "an absent max_attempts should take the default") + } +} + +// seedAttemptsTarget creates an HTTP target already delivering with +// workingAttempts attempts, through the real create handler. +func seedAttemptsTarget( + t *testing.T, + env *sourceTestEnv, +) (database.Webhook, database.Target) { + t.Helper() + + webhook, code, body := createWithAttempts( + t, env, strconv.Itoa(workingAttempts), + ) + require.Equal(t, http.StatusSeeOther, code, body) + + targets := targetsForWebhook(t, env.db, webhook.ID) + require.Len(t, targets, 1) + require.Equal(t, workingAttempts, targets[0].MaxAttempts) + + return webhook, targets[0] +} + +// editAttemptsForm is a complete edit submission that changes the +// target's name as well, so a rejected submission can be shown to +// have written nothing at all rather than merely to have left +// max_attempts alone. +func editAttemptsForm(attempts string) url.Values { + form := url.Values{} + form.Set("name", "renamed-by-edit") + form.Set("url", attemptsTargetURL) + + if attempts != absentField { + form.Set("max_attempts", attempts) + } + + return form +} + +// assertEditRejectedAndUnchanged submits an edit expected to fail and +// checks both halves of the requirement: the 400 explains itself, and +// the target it was submitted against is untouched. +func assertEditRejectedAndUnchanged( + t *testing.T, + env *sourceTestEnv, + attempts, wantReason string, +) { + t.Helper() + + webhook, target := seedAttemptsTarget(t, env) + + w := submitTargetEdit( + env, webhook.ID, target.ID, editAttemptsForm(attempts), + ) + + assert.Equal(t, http.StatusBadRequest, w.Code, + "max_attempts=%q should be rejected on edit", attempts) + assert.Contains(t, w.Body.String(), wantReason, + "the rejection for %q should say why", attempts) + + stored := storedTarget(t, env, target.ID) + assert.Equal(t, workingAttempts, stored.MaxAttempts, + "a rejected edit must not destroy the working number "+ + "of attempts with %q", attempts) + assert.Equal(t, "attempts-target", stored.Name, + "a rejected edit must write nothing at all") +} + +// TestTargetEdit_UnparseableAttemptsRejected is the damaging half of +// the defect. A target delivering with retries, re-saved with a typo +// in the field, returned 200 and was left with retries disabled and +// nothing said. +func TestTargetEdit_UnparseableAttemptsRejected(t *testing.T) { + t.Parallel() + + env := setupSourceTest(t) + + for _, attempts := range unparseableAttempts() { + assertEditRejectedAndUnchanged( + t, env, attempts, "whole number", + ) + } +} + +// TestTargetEdit_AttemptsBelowOneRejected proves the floor applies +// to the edit path too. +func TestTargetEdit_AttemptsBelowOneRejected(t *testing.T) { + t.Parallel() + + env := setupSourceTest(t) + + for _, attempts := range belowOneAttempts() { + assertEditRejectedAndUnchanged(t, env, attempts, "at least 1") + } +} + +// TestTargetEdit_AttemptsAboveCeilingRejected proves the ceiling +// applies to the edit path too, naming itself, so the two paths +// cannot disagree about what is storable. +func TestTargetEdit_AttemptsAboveCeilingRejected(t *testing.T) { + t.Parallel() + + env := setupSourceTest(t) + ceiling := strconv.Itoa(handlers.MaxTargetAttemptsForTest) + + for _, attempts := range overCeilingAttempts() { + assertEditRejectedAndUnchanged(t, env, attempts, ceiling) + } +} + +// TestTargetEdit_ValidAttemptsStored covers the accepting half of the +// edit path, so the bounds cannot be enforced by simply refusing +// every submission. +func TestTargetEdit_ValidAttemptsStored(t *testing.T) { + t.Parallel() + + env := setupSourceTest(t) + + for _, want := range []int{1, 9, handlers.MaxTargetAttemptsForTest} { + webhook, target := seedAttemptsTarget(t, env) + + w := submitTargetEdit( + env, webhook.ID, target.ID, + editAttemptsForm(strconv.Itoa(want)), + ) + require.Equal(t, + http.StatusSeeOther, w.Code, w.Body.String(), + ) + + assert.Equal(t, want, + storedTarget(t, env, target.ID).MaxAttempts) + } +} + +// TestTargetEdit_AbsentAttemptsLeavesStoredValue is the edit path's +// absent-versus-invalid case. The field is only offered by the forms +// for types that retry, so a submission without it must leave the +// stored count alone rather than be rejected or reset. +func TestTargetEdit_AbsentAttemptsLeavesStoredValue(t *testing.T) { + t.Parallel() + + env := setupSourceTest(t) + + for _, attempts := range absentAttempts() { + webhook, target := seedAttemptsTarget(t, env) + + w := submitTargetEdit( + env, webhook.ID, target.ID, + editAttemptsForm(attempts), + ) + require.Equal(t, + http.StatusSeeOther, w.Code, w.Body.String(), + ) + + assert.Equal(t, workingAttempts, + storedTarget(t, env, target.ID).MaxAttempts, + "an absent max_attempts must leave the stored "+ + "count alone (%q)", attempts) + } +} + +// TestTargetAttempts_CreateAndEditAgreeOnEveryCase proves the two +// paths cannot disagree, which is what let the create form and the +// edit form drift apart in the first place. Every input is submitted +// to both and the accept/reject verdicts are compared. +func TestTargetAttempts_CreateAndEditAgreeOnEveryCase(t *testing.T) { + t.Parallel() + + env := setupSourceTest(t) + + accepted := []string{ + "1", "2", + strconv.Itoa(handlers.MaxTargetAttemptsForTest), + } + overCeiling := overCeilingAttempts() + belowOne := belowOneAttempts() + unparseable := unparseableAttempts() + + cases := make( + []string, 0, + len(accepted)+len(overCeiling)+len(belowOne)+ + len(unparseable), + ) + cases = append(cases, accepted...) + cases = append(cases, overCeiling...) + cases = append(cases, belowOne...) + cases = append(cases, unparseable...) + + for _, attempts := range cases { + _, createCode, _ := createWithAttempts(t, env, attempts) + + webhook, target := seedAttemptsTarget(t, env) + editCode := submitTargetEdit( + env, webhook.ID, target.ID, + editAttemptsForm(attempts), + ).Code + + assert.Equal(t, + createCode == http.StatusBadRequest, + editCode == http.StatusBadRequest, + "create and edit must agree on max_attempts=%q "+ + "(create %d, edit %d)", + attempts, createCode, editCode, + ) + } +} diff --git a/internal/handlers/target_create.go b/internal/handlers/target_create.go index 224cb3f..5410f47 100644 --- a/internal/handlers/target_create.go +++ b/internal/handlers/target_create.go @@ -102,9 +102,10 @@ func (h *Handlers) newTarget( in targetFormInput, ) (*database.Target, string, error) { target := &database.Target{ - WebhookID: webhookID, - Type: in.Type, - Active: true, + WebhookID: webhookID, + Type: in.Type, + Active: true, + MaxAttempts: 1, } errMsg, err := h.setTargetFromForm(ctx, target, in) @@ -117,9 +118,10 @@ func (h *Handlers) newTarget( // setTargetFromForm validates a target form against the target's type // and, when it accepts it, sets the target's name, configuration and -// retry count from it. It returns the message the form shows for -// anything it refuses, an unknown type among them, and then leaves the -// target unchanged; an error is the server's fault, as for newTarget. +// number of delivery attempts from it. It returns the message the form +// shows for anything it refuses, an unknown type among them, and then +// leaves the target unchanged; an error is the server's fault, as for +// newTarget. // The add target form and the target edit form both go through here, // so the two cannot come to disagree about what a target may be. func (h *Handlers) setTargetFromForm( @@ -136,20 +138,20 @@ func (h *Handlers) setTargetFromForm( return errMsg, err } - // An empty max_retries keeps the target's count: the - // fire-and-forget default of 0 for a new target, and the stored - // count for an edited one, since the forms for target types that - // do not retry have no such field. A value that is filled in but - // invalid is refused rather than becoming that count, so a typo - // cannot destroy the count a target is delivering with. - maxRetries, err := parseMaxRetries(in.MaxRetries, target.MaxRetries) + // An empty max_attempts keeps the target's count: the default of + // 1 for a new target, and the stored count for an edited one, + // since the forms for target types that do not retry have no such + // field. A value that is filled in but invalid is refused rather + // than becoming that count, so a typo cannot destroy the count a + // target is delivering with. + maxAttempts, err := parseMaxAttempts(in.MaxAttempts, target.MaxAttempts) if err != nil { - return "Invalid delivery attempts: " + retriesErrorMessage(err), nil + return "Invalid delivery attempts: " + attemptsErrorMessage(err), nil } target.Name = in.Name target.Config = configJSON - target.MaxRetries = maxRetries + target.MaxAttempts = maxAttempts return "", nil } @@ -176,8 +178,8 @@ type targetFormInput struct { // ForwardQuery is an HTTP target's checkbox that passes each // event's query string on to it. ForwardQuery bool - // MaxRetries is an HTTP or Slack target's max_retries. - MaxRetries string + // MaxAttempts is an HTTP or Slack target's max_attempts. + MaxAttempts string // Expiry is a database (archive) target's row expiry. Expiry string // Rotation is a database (archive) target's rotation. @@ -204,7 +206,7 @@ func targetFormInputFrom(r *http.Request) targetFormInput { Headers: r.PostFormValue("headers"), Timeout: r.PostFormValue("timeout"), ForwardQuery: r.PostFormValue("forward_query") != "", - MaxRetries: r.PostFormValue("max_retries"), + MaxAttempts: r.PostFormValue("max_attempts"), Expiry: r.PostFormValue("expiry"), Rotation: r.PostFormValue("rotation"), } diff --git a/internal/handlers/target_create_query_test.go b/internal/handlers/target_create_query_test.go index 3eb3914..1d0603b 100644 --- a/internal/handlers/target_create_query_test.go +++ b/internal/handlers/target_create_query_test.go @@ -196,7 +196,7 @@ func TestHandleTargetCreate_QueryStringCannotSupplyNameOrType( w, _ := postTargetCreate( t, env, webhook.ID, - "name=leaky&type=slack&max_retries=9&expiry=30d&url="+ + "name=leaky&type=slack&max_attempts=9&expiry=30d&url="+ url.QueryEscape(targetSecretURL), url.Values{}, ) diff --git a/internal/handlers/target_create_test.go b/internal/handlers/target_create_test.go index 253f63e..e993d6a 100644 --- a/internal/handlers/target_create_test.go +++ b/internal/handlers/target_create_test.go @@ -21,24 +21,24 @@ func TestHandleTargetCreate_EveryType(t *testing.T) { // fields is the rest of each submission, as a query string. cases := []struct { - targetType database.TargetType - fields string - wantConfig string - wantRetries int + targetType database.TargetType + fields string + wantConfig string + wantAttempts int }{ { - database.TargetTypeHTTP, "timeout=12&max_retries=3", + database.TargetTypeHTTP, "timeout=12&max_attempts=3", `{"url":"` + editOriginalURL + `","timeout":12}`, 3, }, { - database.TargetTypeSlack, "max_retries=4", + database.TargetTypeSlack, "max_attempts=4", `{"webhookUrl":"` + editOriginalURL + `"}`, 4, }, { database.TargetTypeDatabase, "expiry=720h", - `{"expiry":"720h"}`, 0, + `{"expiry":"720h"}`, 1, }, - {database.TargetTypeLog, "", "", 0}, + {database.TargetTypeLog, "", "", 1}, } for _, tc := range cases { @@ -62,7 +62,7 @@ func TestHandleTargetCreate_EveryType(t *testing.T) { 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) + assert.Equal(t, tc.wantAttempts, targets[0].MaxAttempts) if tc.wantConfig == "" { assert.Empty(t, targets[0].Config) @@ -90,11 +90,11 @@ func TestHandleTargetCreate_RefusedFormComesBack(t *testing.T) { { database.TargetTypeHTTP, "name=private&url=" + editBlockedURL + - "&timeout=12&max_retries=3", + "&timeout=12&max_attempts=3", "Invalid target URL", }, { - database.TargetTypeSlack, "name=no-url&max_retries=4", + database.TargetTypeSlack, "name=no-url&max_attempts=4", "Webhook URL is required for Slack targets", }, { @@ -133,8 +133,8 @@ func TestHandleTargetCreate_RefusedFormComesBack(t *testing.T) { 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 + // section named after its field (max_attempts as + // data-max-attempts), except url, which comes back in // data-destination; templates/source_detail.html says why. for field := range typed { attr := "data-" + strings.ReplaceAll(field, "_", "-") diff --git a/internal/handlers/target_edit.go b/internal/handlers/target_edit.go index 9a29fb4..ce57455 100644 --- a/internal/handlers/target_edit.go +++ b/internal/handlers/target_edit.go @@ -82,7 +82,7 @@ func (h *Handlers) HandleTargetEdit() http.HandlerFunc { Headers: cfg.Headers, Timeout: cfg.Timeout, ForwardQuery: cfg.ForwardQuery, - MaxRetries: strconv.Itoa(target.MaxRetries), + MaxAttempts: strconv.Itoa(target.MaxAttempts), Expiry: cfg.Expiry, Rotation: cfg.Rotation, } diff --git a/internal/handlers/target_edit_test.go b/internal/handlers/target_edit_test.go index f73aada..57c114f 100644 --- a/internal/handlers/target_edit_test.go +++ b/internal/handlers/target_edit_test.go @@ -117,7 +117,7 @@ func seedHTTPTarget( form.Set("url", editOriginalURL) form.Set("headers", headers) form.Set("timeout", timeout) - form.Set("max_retries", "3") + form.Set("max_attempts", "3") w := serveTarget( env, http.MethodPost, @@ -179,7 +179,7 @@ func editForm(targetURL, headers, timeout string) url.Values { form.Set("url", targetURL) form.Set("headers", headers) form.Set("timeout", timeout) - form.Set("max_retries", "5") + form.Set("max_attempts", "5") return form } @@ -280,9 +280,9 @@ func TestHandleTargetEditSubmit_Saves(t *testing.T) { assertEditClearingRemovesThem(t, env) }) - t.Run("absent max_retries is not zeroed", func(t *testing.T) { + t.Run("absent max_attempts is kept", func(t *testing.T) { t.Parallel() - assertEditKeepsAbsentMaxRetries(t, env) + assertEditKeepsAbsentMaxAttempts(t, env) }) } @@ -307,7 +307,7 @@ func assertEditChangesDestination( reloaded := storedTarget(t, env, target.ID) assert.Equal(t, "edited-name", reloaded.Name) - assert.Equal(t, 5, reloaded.MaxRetries) + assert.Equal(t, 5, reloaded.MaxAttempts) assert.Equal( t, database.TargetTypeHTTP, reloaded.Type, "the edit form must not change a target's type", @@ -368,25 +368,25 @@ func assertEditClearingRemovesThem( assert.Zero(t, cfg.Timeout) } -// Retries are offered only by the forms for target types that retry. -// An absent field means the form does not edit retries, not that -// they should be turned off. -func assertEditKeepsAbsentMaxRetries( +// Delivery attempts are offered only by the forms for target types +// that retry. An absent field means the form does not edit them, not +// that retries should be turned off. +func assertEditKeepsAbsentMaxAttempts( t *testing.T, env *sourceTestEnv, ) { t.Helper() webhook, target := seedHTTPTarget(t, env, "", "") - require.Equal(t, 3, target.MaxRetries) + require.Equal(t, 3, target.MaxAttempts) form := editForm(editOriginalURL, "", "") - form.Del("max_retries") + form.Del("max_attempts") w := submitTargetEdit(env, webhook.ID, target.ID, form) require.Equal(t, http.StatusSeeOther, w.Code, w.Body.String()) assert.Equal( - t, 3, storedTarget(t, env, target.ID).MaxRetries, + t, 3, storedTarget(t, env, target.ID).MaxAttempts, ) } @@ -659,12 +659,12 @@ func TestHandleTargetEditSubmit_RefusedFormComesBack(t *testing.T) { { database.TargetTypeHTTP, "name=edited&url=" + editBlockedURL + - "&headers=X-Edited:+kept&timeout=12&max_retries=3", + "&headers=X-Edited:+kept&timeout=12&max_attempts=3", "Invalid target URL", }, { database.TargetTypeSlack, - "name=edited&url=" + editOriginalURL + "&max_retries=25", + "name=edited&url=" + editOriginalURL + "&max_attempts=25", "Invalid delivery attempts", }, { diff --git a/internal/handlers/target_retries.go b/internal/handlers/target_retries.go deleted file mode 100644 index 6e68916..0000000 --- a/internal/handlers/target_retries.go +++ /dev/null @@ -1,83 +0,0 @@ -package handlers - -import ( - "errors" - "strconv" - "strings" -) - -// maxTargetRetries bounds a target's max_retries. -// -// Both target forms already declare max="20" on the input, so this -// enforces server-side what the UI has always advertised rather than -// introducing a new limit. -// -// The number is not cosmetic. Every attempt writes a delivery_results -// row that the event log then loads and renders, and the engine backs -// off by 2^(n-1) seconds, so attempt 20 is already about six days -// after the first. A value beyond this buys no additional durability -// and only costs rows. -const maxTargetRetries = 20 - -// Errors returned when a max_retries form value cannot be turned into -// a retry count. -var ( - // errRetriesInvalid signals a max_retries form value that is not - // a non-negative whole number. - errRetriesInvalid = errors.New( - "must be a whole number", - ) - - // errRetriesTooLarge signals a max_retries form value that is a - // whole number but above maxTargetRetries. It is distinguished - // from errRetriesInvalid so the message can name the ceiling - // instead of implying the input was not a number. - errRetriesTooLarge = errors.New("out of range") -) - -// parseMaxRetries interprets a max_retries form value. -// -// An ABSENT value — the field empty or not submitted — yields -// fallback, which lets the create path apply its default and the edit -// path leave the stored value alone. A value that is SET BUT INVALID -// is an error: unparseable, negative, or above maxTargetRetries. -// -// The distinction is the whole point of this function. max_retries=0 -// means fire-and-forget, so returning 0 for input the operator typed -// but that did not parse silently disables retries on a -// store-and-forward proxy — and on the edit path it destroys a -// working retry configuration over a typo. A default answers a -// question that was not asked; it never answers one that was asked -// badly. -func parseMaxRetries(raw string, fallback int) (int, error) { - raw = strings.TrimSpace(raw) - if raw == "" { - return fallback, nil - } - - v, err := strconv.Atoi(raw) - if err != nil || v < 0 { - return 0, errRetriesInvalid - } - - if v > maxTargetRetries { - return 0, errRetriesTooLarge - } - - return v, nil -} - -// retriesErrorMessage returns the message the create and edit forms -// show for a rejected max_retries value. Any error other than -// errRetriesTooLarge falls back to the generic wording, so an -// unrecognised parse failure still produces a sensible 400. -func retriesErrorMessage(err error) string { - if errors.Is(err, errRetriesTooLarge) { - return errRetriesTooLarge.Error() + - ": at most " + strconv.Itoa(maxTargetRetries) + - " attempts" - } - - return errRetriesInvalid.Error() + - ", or 0 for fire-and-forget" -} diff --git a/internal/handlers/target_retries_test.go b/internal/handlers/target_retries_test.go deleted file mode 100644 index 686af04..0000000 --- a/internal/handlers/target_retries_test.go +++ /dev/null @@ -1,385 +0,0 @@ -package handlers_test - -import ( - "net/http" - "net/url" - "strconv" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "sneak.berlin/go/webhooker/internal/database" - "sneak.berlin/go/webhooker/internal/handlers" -) - -// retriesTargetURL is the destination the retry-validation targets -// point at. It is a literal public address rather than a hostname so -// the SSRF check resolves nothing and a sandbox without DNS cannot -// make these cases pass or fail for the wrong reason. -const retriesTargetURL = "https://93.184.216.34/hooks/retries" - -const ( - // wayAboveCeiling is the typo'd-extra-zero case from the report. - wayAboveCeiling = "999999999" - - // notANumber is the plainest garbage an operator can type, and - // the value the report submitted on the edit form. - notANumber = "abc" - - // workingRetries is the retry count a seeded target is already - // delivering with, which a rejected submission must not disturb. - workingRetries = 2 -) - -// aboveCeiling is the smallest rejected whole number. -func aboveCeiling() string { - return strconv.Itoa(handlers.MaxTargetRetriesForTest + 1) -} - -// overCeilingRetries is whole-number input past the limit, which is -// rejected with the limit named. -func overCeilingRetries() []string { - return []string{aboveCeiling(), wayAboveCeiling} -} - -// unparseableRetries is input an operator can type into the field -// that is not a retry count. Each must be REJECTED: silently reading -// any of them as 0 turns a store-and-forward proxy into -// fire-and-forget without saying so. -// -// The twenty-digit case is here because it parses as digits but -// overflows int, which is the one failure the field's own min/max -// attributes cannot describe. -func unparseableRetries() []string { - return []string{ - notANumber, - "2.7", - "-5", - "12345678901234567890", - "1e3", - } -} - -// createRetriesForm is a complete, otherwise-valid HTTP target -// creation, so the only thing any case below varies is max_retries. -func createRetriesForm(retries string) url.Values { - form := url.Values{} - form.Set("name", "retries-target") - form.Set("type", string(database.TargetTypeHTTP)) - form.Set("url", retriesTargetURL) - - if retries != absentField { - form.Set("max_retries", retries) - } - - return form -} - -// absentField marks a field the form does not submit at all, which is -// the case that legitimately takes a default and must stay distinct -// from a field submitted with garbage in it. -const absentField = "\x00absent" - -// absentRetries is every way of saying "the operator did not set -// this", each of which takes the default rather than a 400. Blank and -// whitespace-only count as absent here because they do in the timeout -// and retention controls on the same forms; a rule the fields do not -// share would be its own surprise. -func absentRetries() []string { - return []string{absentField, "", " "} -} - -// createWithRetries posts the target create form for a fresh webhook -// and returns the webhook and the response. -func createWithRetries( - t *testing.T, - env *sourceTestEnv, - retries string, -) (database.Webhook, int, string) { - t.Helper() - - webhook := seedWebhookWithRetention(t, env.db, 30) - - w := serveTarget( - env, http.MethodPost, - "/hook/"+webhook.ID+"/targets", - createRetriesForm(retries), - ) - - return webhook, w.Code, w.Body.String() -} - -// TestTargetCreate_RetriesAboveCeilingRejected proves the create form -// enforces a ceiling at all, and that the 400 names it — a rejection -// that does not say what the limit is leaves the operator guessing. -func TestTargetCreate_RetriesAboveCeilingRejected(t *testing.T) { - t.Parallel() - - env := setupSourceTest(t) - ceiling := strconv.Itoa(handlers.MaxTargetRetriesForTest) - - for _, retries := range overCeilingRetries() { - webhook, code, body := createWithRetries(t, env, retries) - - assert.Equal(t, http.StatusBadRequest, code, - "max_retries=%s should be rejected", retries) - assert.Contains(t, body, ceiling, - "the rejection for %s should name the ceiling", - retries) - assert.Empty(t, - targetsForWebhook(t, env.db, webhook.ID), - "no target should be created for %s", retries) - } -} - -// TestTargetCreate_UnparseableRetriesRejected is the core of the -// defect: each of these was accepted with HTTP 200 and stored as 0. -func TestTargetCreate_UnparseableRetriesRejected(t *testing.T) { - t.Parallel() - - env := setupSourceTest(t) - - for _, retries := range unparseableRetries() { - webhook, code, body := createWithRetries(t, env, retries) - - assert.Equal(t, http.StatusBadRequest, code, - "max_retries=%q should be rejected, not coerced", - retries) - assert.Contains(t, body, "whole number", - "the rejection for %q should say why", retries) - assert.Empty(t, - targetsForWebhook(t, env.db, webhook.ID), - "no target should be created for %q", retries) - } -} - -// TestTargetCreate_ValidRetriesStored covers the accepting half, -// including the ceiling itself: a bound that rejects its own limit -// would make the advertised maximum unreachable. -func TestTargetCreate_ValidRetriesStored(t *testing.T) { - t.Parallel() - - env := setupSourceTest(t) - - for _, want := range []int{0, 3, handlers.MaxTargetRetriesForTest} { - webhook, code, body := createWithRetries( - t, env, strconv.Itoa(want), - ) - - require.Equal(t, http.StatusSeeOther, code, body) - - targets := targetsForWebhook(t, env.db, webhook.ID) - require.Len(t, targets, 1) - assert.Equal(t, want, targets[0].MaxRetries) - } -} - -// TestTargetCreate_AbsentRetriesTakesDefault keeps the two cases -// distinct. An omitted field is not an operator asking for something -// invalid, so it still gets the fire-and-forget default rather than a -// 400 — otherwise the fix above would make the form unusable. -func TestTargetCreate_AbsentRetriesTakesDefault(t *testing.T) { - t.Parallel() - - env := setupSourceTest(t) - - for _, retries := range absentRetries() { - webhook, code, body := createWithRetries(t, env, retries) - - require.Equal(t, http.StatusSeeOther, code, body) - - targets := targetsForWebhook(t, env.db, webhook.ID) - require.Len(t, targets, 1) - assert.Equal(t, 0, targets[0].MaxRetries, - "an absent max_retries should take the default") - } -} - -// seedRetriesTarget creates an HTTP target already delivering with -// workingRetries retries, through the real create handler. -func seedRetriesTarget( - t *testing.T, - env *sourceTestEnv, -) (database.Webhook, database.Target) { - t.Helper() - - webhook, code, body := createWithRetries( - t, env, strconv.Itoa(workingRetries), - ) - require.Equal(t, http.StatusSeeOther, code, body) - - targets := targetsForWebhook(t, env.db, webhook.ID) - require.Len(t, targets, 1) - require.Equal(t, workingRetries, targets[0].MaxRetries) - - return webhook, targets[0] -} - -// editRetriesForm is a complete edit submission that changes the -// target's name as well, so a rejected submission can be shown to -// have written nothing at all rather than merely to have left -// max_retries alone. -func editRetriesForm(retries string) url.Values { - form := url.Values{} - form.Set("name", "renamed-by-edit") - form.Set("url", retriesTargetURL) - - if retries != absentField { - form.Set("max_retries", retries) - } - - return form -} - -// assertEditRejectedAndUnchanged submits an edit expected to fail and -// checks both halves of the requirement: the 400 explains itself, and -// the target it was submitted against is untouched. -func assertEditRejectedAndUnchanged( - t *testing.T, - env *sourceTestEnv, - retries, wantReason string, -) { - t.Helper() - - webhook, target := seedRetriesTarget(t, env) - - w := submitTargetEdit( - env, webhook.ID, target.ID, editRetriesForm(retries), - ) - - assert.Equal(t, http.StatusBadRequest, w.Code, - "max_retries=%q should be rejected on edit", retries) - assert.Contains(t, w.Body.String(), wantReason, - "the rejection for %q should say why", retries) - - stored := storedTarget(t, env, target.ID) - assert.Equal(t, workingRetries, stored.MaxRetries, - "a rejected edit must not destroy the working retry "+ - "count with %q", retries) - assert.Equal(t, "retries-target", stored.Name, - "a rejected edit must write nothing at all") -} - -// TestTargetEdit_UnparseableRetriesRejected is the damaging half of -// the defect. A target delivering with two retries, re-saved with a -// typo in the field, returned 200 and was left with retries disabled -// and nothing said. -func TestTargetEdit_UnparseableRetriesRejected(t *testing.T) { - t.Parallel() - - env := setupSourceTest(t) - - for _, retries := range unparseableRetries() { - assertEditRejectedAndUnchanged( - t, env, retries, "whole number", - ) - } -} - -// TestTargetEdit_RetriesAboveCeilingRejected proves the ceiling -// applies to the edit path too, naming itself, so the two paths -// cannot disagree about what is storable. -func TestTargetEdit_RetriesAboveCeilingRejected(t *testing.T) { - t.Parallel() - - env := setupSourceTest(t) - ceiling := strconv.Itoa(handlers.MaxTargetRetriesForTest) - - for _, retries := range overCeilingRetries() { - assertEditRejectedAndUnchanged(t, env, retries, ceiling) - } -} - -// TestTargetEdit_ValidRetriesStored covers the accepting half of the -// edit path, so the ceiling cannot be enforced by simply refusing -// every submission. -func TestTargetEdit_ValidRetriesStored(t *testing.T) { - t.Parallel() - - env := setupSourceTest(t) - - for _, want := range []int{0, 9, handlers.MaxTargetRetriesForTest} { - webhook, target := seedRetriesTarget(t, env) - - w := submitTargetEdit( - env, webhook.ID, target.ID, - editRetriesForm(strconv.Itoa(want)), - ) - require.Equal(t, - http.StatusSeeOther, w.Code, w.Body.String(), - ) - - assert.Equal(t, want, - storedTarget(t, env, target.ID).MaxRetries) - } -} - -// TestTargetEdit_AbsentRetriesLeavesStoredValue is the edit path's -// absent-versus-invalid case. Retries are only offered by the forms -// for types that retry, so a submission without the field must leave -// the stored count alone rather than be rejected or zeroed. -func TestTargetEdit_AbsentRetriesLeavesStoredValue(t *testing.T) { - t.Parallel() - - env := setupSourceTest(t) - - for _, retries := range absentRetries() { - webhook, target := seedRetriesTarget(t, env) - - w := submitTargetEdit( - env, webhook.ID, target.ID, - editRetriesForm(retries), - ) - require.Equal(t, - http.StatusSeeOther, w.Code, w.Body.String(), - ) - - assert.Equal(t, workingRetries, - storedTarget(t, env, target.ID).MaxRetries, - "an absent max_retries must leave the stored "+ - "count alone (%q)", retries) - } -} - -// TestTargetRetries_CreateAndEditAgreeOnEveryCase proves the two -// paths cannot disagree, which is what let the create form and the -// edit form drift apart in the first place. Every input is submitted -// to both and the accept/reject verdicts are compared. -func TestTargetRetries_CreateAndEditAgreeOnEveryCase(t *testing.T) { - t.Parallel() - - env := setupSourceTest(t) - - accepted := []string{ - "0", "1", - strconv.Itoa(handlers.MaxTargetRetriesForTest), - } - overCeiling := overCeilingRetries() - unparseable := unparseableRetries() - - cases := make( - []string, 0, - len(accepted)+len(overCeiling)+len(unparseable), - ) - cases = append(cases, accepted...) - cases = append(cases, overCeiling...) - cases = append(cases, unparseable...) - - for _, retries := range cases { - _, createCode, _ := createWithRetries(t, env, retries) - - webhook, target := seedRetriesTarget(t, env) - editCode := submitTargetEdit( - env, webhook.ID, target.ID, - editRetriesForm(retries), - ).Code - - assert.Equal(t, - createCode == http.StatusBadRequest, - editCode == http.StatusBadRequest, - "create and edit must agree on max_retries=%q "+ - "(create %d, edit %d)", - retries, createCode, editCode, - ) - } -} diff --git a/internal/handlers/target_toggle_test.go b/internal/handlers/target_toggle_test.go index 05a9695..196e54d 100644 --- a/internal/handlers/target_toggle_test.go +++ b/internal/handlers/target_toggle_test.go @@ -59,7 +59,7 @@ func TestHandleTargetToggle_DoesNotUndoAnEdit(t *testing.T) { stored := storedTarget(t, env, tgt.ID) assert.False(t, stored.Active) assert.Equal(t, "edited-name", stored.Name) - assert.Equal(t, 5, stored.MaxRetries) + assert.Equal(t, 5, stored.MaxAttempts) assert.Equal( t, editReplacedURL, storedHTTPConfig(t, env, tgt.ID).URL, ) diff --git a/internal/handlers/ui_copy_test.go b/internal/handlers/ui_copy_test.go index 8b9e655..8b5cf69 100644 --- a/internal/handlers/ui_copy_test.go +++ b/internal/handlers/ui_copy_test.go @@ -348,21 +348,19 @@ func TestEntrypointCopyButtonIsProgressiveEnhancement(t *testing.T) { ) } -// maxRetriesHelp is the wording both target forms must carry. The -// delivery core makes max_retries attempts in total, not that many +// maxAttemptsHelp is the wording both target forms must carry. The +// delivery core makes max_attempts attempts in total, not that many // retries on top of a first try (a fresh delivery starts at attempt 1 // and target_http gives up once the attempt number reaches -// max_retries), and 0 is special-cased to a single fire-and-forget -// attempt with no circuit breaker. -const maxRetriesHelp = "How many times each delivery is attempted in " + - "all, the first attempt included. 0 means a single attempt with no " + - "retries and no circuit breaker." +// max_attempts). +const maxAttemptsHelp = "How many times each delivery is attempted in " + + "all, the first attempt included." -// TestTargetFormMaxRetriesCopyMatchesBehaviour pins the max_retries +// TestTargetFormMaxAttemptsCopyMatchesBehaviour pins the max_attempts // label, "Delivery attempts", and help text on both the create form // (the add-target form on the webhook detail page) and the edit form, // so the copy cannot drift back to calling the number a retry count. -func TestTargetFormMaxRetriesCopyMatchesBehaviour(t *testing.T) { +func TestTargetFormMaxAttemptsCopyMatchesBehaviour(t *testing.T) { t.Parallel() var h *handlers.Handlers @@ -394,11 +392,11 @@ func TestTargetFormMaxRetriesCopyMatchesBehaviour(t *testing.T) { assert.Contains(t, createBody, "Delivery attempts:") assert.Contains( - t, createBody, maxRetriesHelp, - "the add-target form must explain max_retries as total attempts", + t, createBody, maxAttemptsHelp, + "the add-target form must explain max_attempts as total attempts", ) - // A slack target exercises the same max_retries field while needing + // A slack target exercises the same max_attempts field while needing // only a URL from the edit template, so the test data stays // minimal. The Target and TargetForm keys mirror the field names // the template reads off the handler's values. @@ -412,8 +410,8 @@ func TestTargetFormMaxRetriesCopyMatchesBehaviour(t *testing.T) { "Active": true, }, "TargetForm": map[string]any{ - "URL": "https://hooks.slack.com/services/x", - "MaxRetries": "3", + "URL": "https://hooks.slack.com/services/x", + "MaxAttempts": "3", }, dataKeyError: "", }, @@ -421,8 +419,8 @@ func TestTargetFormMaxRetriesCopyMatchesBehaviour(t *testing.T) { assert.Contains(t, editBody, `class="label">Delivery attempts`) assert.Contains( - t, editBody, maxRetriesHelp, - "the target edit form must explain max_retries as total attempts", + t, editBody, maxAttemptsHelp, + "the target edit form must explain max_attempts as total attempts", ) } diff --git a/internal/handlers/webhook.go b/internal/handlers/webhook.go index 4417de4..6358134 100644 --- a/internal/handlers/webhook.go +++ b/internal/handlers/webhook.go @@ -433,7 +433,7 @@ func buildDeliveryTasks( TargetName: targets[i].Name, TargetType: targets[i].Type, TargetConfig: targets[i].Config, - MaxRetries: targets[i].MaxRetries, + MaxAttempts: targets[i].MaxAttempts, Method: event.Method, RawQuery: event.RawQuery, Headers: event.Headers, diff --git a/internal/server/alpine_browser_test.go b/internal/server/alpine_browser_test.go index d7b3a71..6587a59 100644 --- a/internal/server/alpine_browser_test.go +++ b/internal/server/alpine_browser_test.go @@ -363,11 +363,11 @@ func checkAddEachTargetType(ctx context.Context, t *testing.T, url string) { }{ { "http", "http", - "csrf_token name type url headers timeout max_retries", + "csrf_token name type url headers timeout max_attempts", map[string]string{"url": publicTargetURL}, }, { - "slack", "slack", "csrf_token name type url max_retries", + "slack", "slack", "csrf_token name type url max_attempts", map[string]string{"url": publicTargetURL}, }, { @@ -639,11 +639,11 @@ func checkRefusedEdits( values map[string]string }{ {page + "/targets/" + targetID + "/edit", map[string]string{ - "#name": "edited-target", - "#url": "http://127.0.0.1/hook", - "#headers": "X-Edited: kept", - "#timeout": "12", - "#max_retries": "3", + "#name": "edited-target", + "#url": "http://127.0.0.1/hook", + "#headers": "X-Edited: kept", + "#timeout": "12", + "#max_attempts": "3", }}, {page + "/edit", map[string]string{ "#name": "edited-webhook", diff --git a/internal/server/archive_download_test.go b/internal/server/archive_download_test.go index 6e2239a..71da33d 100644 --- a/internal/server/archive_download_test.go +++ b/internal/server/archive_download_test.go @@ -27,10 +27,11 @@ func TestHook_DownloadArchive(t *testing.T) { env.seedTarget(t, wh.ID) archive := &database.Target{ - WebhookID: wh.ID, - Name: "kept", - Type: database.TargetTypeDatabase, - Active: true, + WebhookID: wh.ID, + Name: "kept", + Type: database.TargetTypeDatabase, + Active: true, + MaxAttempts: 1, } require.NoError(t, env.db.DB().Omit(clause.Associations).Create(archive).Error, diff --git a/internal/server/routes_test.go b/internal/server/routes_test.go index a74be16..f7d9b57 100644 --- a/internal/server/routes_test.go +++ b/internal/server/routes_test.go @@ -402,11 +402,12 @@ func (e *testEnv) seedTarget( t.Helper() tgt := &database.Target{ - WebhookID: webhookID, - Name: "routed-target", - Type: database.TargetTypeHTTP, - Active: true, - Config: `{"url":"http://93.184.216.34/hook"}`, + WebhookID: webhookID, + Name: "routed-target", + Type: database.TargetTypeHTTP, + Active: true, + Config: `{"url":"http://93.184.216.34/hook"}`, + MaxAttempts: 1, } require.NoError( diff --git a/static/js/app.js b/static/js/app.js index 3d91446..7ae079e 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -151,7 +151,7 @@ document.addEventListener("alpine:init", function () { headers: "", timeout: "", forwardQuery: false, - maxRetries: "", + maxAttempts: "", expiry: "", rotation: "", init() { @@ -165,7 +165,7 @@ document.addEventListener("alpine:init", function () { this.timeout = refused.timeout; this.forwardQuery = this.$root.hasAttribute("data-forward-query"); - this.maxRetries = refused.maxRetries; + this.maxAttempts = refused.maxAttempts; this.expiry = refused.expiry; this.rotation = refused.rotation; }, @@ -185,7 +185,7 @@ document.addEventListener("alpine:init", function () { this.headers = ""; this.timeout = ""; this.forwardQuery = false; - this.maxRetries = ""; + this.maxAttempts = ""; this.expiry = ""; this.rotation = ""; this.$refs.form.reset(); diff --git a/templates/source_detail.html b/templates/source_detail.html index ecf3fc5..a47c5df 100644 --- a/templates/source_detail.html +++ b/templates/source_detail.html @@ -135,7 +135,7 @@ data-headers="{{.TargetForm.Headers}}" data-timeout="{{.TargetForm.Timeout}}" {{if .TargetForm.ForwardQuery}}data-forward-query{{end}} - data-max-retries="{{.TargetForm.MaxRetries}}" + data-max-attempts="{{.TargetForm.MaxAttempts}}" data-expiry="{{.TargetForm.Expiry}}" data-rotation="{{.TargetForm.Rotation}}">
@@ -193,9 +193,9 @@
- +
-

How many times each delivery is attempted in all, the first attempt included. 0 means a single attempt with no retries and no circuit breaker.

+

How many times each delivery is attempted in all, the first attempt included.

@@ -209,9 +209,9 @@
- +
-

How many times each delivery is attempted in all, the first attempt included. 0 means a single attempt with no retries and no circuit breaker.

+

How many times each delivery is attempted in all, the first attempt included.

diff --git a/templates/target_edit.html b/templates/target_edit.html index 42f6d79..82b159b 100644 --- a/templates/target_edit.html +++ b/templates/target_edit.html @@ -89,9 +89,9 @@ {{if or (eq .Target.Type "http") (eq .Target.Type "slack")}}
- - -

How many times each delivery is attempted in all, the first attempt included. 0 means a single attempt with no retries and no circuit breaker.

+ + +

How many times each delivery is attempted in all, the first attempt included.

{{end}} -- 2.54.0 From f10440b7cca0fabd9ab38214529b27ba62fe28a5 Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Thu, 15 Oct 2026 20:03:33 +0200 Subject: [PATCH 121/126] Store a webhook's retention as a plain day count, 0 meaning forever (closes #539) retention_days loses its column default of 30, and Webhook.RetentionDays its default tag, so a 0 is stored as 0. The 365000-day stand-in RetentionForeverDays and the BeforeSave hook that wrote it are gone. The reaper and the "forever" label read 0 as forever, and the forms accept 0 or 1 to MaxFiniteRetentionDays; a blank field still gives 30 on the new webhook form and keeps the stored value on the edit form. A negative value, which the forms refuse, is still read as forever so it cannot put the reaper's cutoff in the future. Model: opus-5-5 --- README.md | 46 ++---- internal/database/model_webhook.go | 75 +++------ internal/database/model_webhook_test.go | 82 ++-------- internal/database/retention.go | 3 +- internal/database/retention_test.go | 84 +++------- internal/db/migrations/main/001_schema.sql | 2 +- internal/handlers/shared.go | 21 +-- internal/handlers/source_management_test.go | 167 ++++++++------------ internal/handlers/ui_copy_test.go | 8 +- internal/handlers/webhook_stats_test.go | 2 +- 10 files changed, 141 insertions(+), 349 deletions(-) diff --git a/README.md b/README.md index ca836d8..8a5b339 100644 --- a/README.md +++ b/README.md @@ -1519,47 +1519,35 @@ the rotation chosen below that (none, monthly, daily or hourly). Both are validated as on the add target form, and the webhook and its targets are created together or not at all. -| Field | Type | Description | -| ---------------- | ------- | ----------------------------------------------------------- | -| `id` | UUID | Primary key | -| `user_id` | UUID | Foreign key → User | -| `name` | string | Human-readable name | -| `description` | string | Optional description | -| `retention_days` | integer | Days to retain events (default: 30; 0 means retain forever) | +| Field | Type | Description | +| ---------------- | ------- | --------------------------------------------- | +| `id` | UUID | Primary key | +| `user_id` | UUID | Foreign key → User | +| `name` | string | Human-readable name | +| `description` | string | Optional description | +| `retention_days` | integer | Days to retain events; 0 means retain forever | **Relations:** Belongs to User. Has many Entrypoints. Has many Targets. The `retention_days` field controls how long event data is kept in the webhook's dedicated database before automatic cleanup. -Setting `retention_days` to `0` means "retain events forever". Because the -column carries a default of 30, a literal zero cannot survive an insert, so a -zero is rewritten on save to a sentinel of `365 * 1000` days -(`database.RetentionForeverDays`). The retention reaper recognises that sentinel -and skips the webhook entirely, and the web UI displays such a webhook's -retention as "forever" rather than as a day count. +The value is stored as entered. Setting `retention_days` to `0` means "retain +events forever": the retention reaper skips the webhook entirely, and the web UI +displays its retention as "forever" rather than as a day count. -Submitted `retention_days` values therefore fall into three bands, not two: - -- `1` up to `database.MaxFiniteRetentionDays` (106751 days, about 292 years) is - accepted as a finite retention. -- Above that ceiling but below the retain-forever sentinel of 365000 - (`database.RetentionForeverDays`) is rejected with a 400. This is the band the - cap exists for. -- `0`, and `365000` or above, are accepted and mean retain forever, collapsing - to the sentinel — `0` in `Webhook.BeforeSave`, the large values in - `parseRetentionDays`. The large values are not out of range: the edit form - pre-fills the sentinel for a retain-forever webhook, so submitting that form - back unchanged has to keep meaning "forever". - -A negative value is in none of the three: `parseRetentionDays` rejects it with a -400 before `BeforeSave` ever sees it. +The new webhook and edit forms accept `0`, or a whole number of days from `1` up +to `database.MaxFiniteRetentionDays` (106751 days, about 292 years). Anything +else, a negative value or one above that cap included, is rejected with a 400. A +blank field gives the default of 30 days (`database.DefaultRetentionDays`) on +the new webhook form, and leaves the stored value unchanged on the edit form. The cap is not arbitrary: the reaper computes its cutoff as a `time.Duration`, an int64 nanosecond count, and a longer period overflows it. An overflowed cutoff lands in the future, where it matches every row, so the sweep would delete every event the webhook has instead of none. The reaper also clamps the -value it reads to the cap, so no stored value can trigger that either. +value it reads to the cap, and reads a negative value as forever, so no stored +value can trigger that either. #### Entrypoint diff --git a/internal/database/model_webhook.go b/internal/database/model_webhook.go index bc0de7f..2be1415 100644 --- a/internal/database/model_webhook.go +++ b/internal/database/model_webhook.go @@ -4,27 +4,13 @@ import ( "math" "strconv" "time" - - "gorm.io/gorm" ) const ( - // DefaultRetentionDays is the event retention period applied to a - // webhook created without an explicit retention value. It is the - // single source of truth for that policy and must stay in sync - // with the `gorm:"default:30"` column default on - // Webhook.RetentionDays below; a struct tag cannot reference a - // constant, so a test asserts the two agree. + // DefaultRetentionDays is the event retention period the new + // webhook form applies when its retention field is left blank. DefaultRetentionDays = 30 - // RetentionForeverDays is the sentinel RetentionDays value meaning - // "retain events forever". Users express that intent as 0, which - // Webhook.BeforeSave rewrites to this value: the column default - // substitutes DefaultRetentionDays for a zero value at insert - // time, so a zero can never survive a round trip to the database. - // Nothing outside this file may hardcode the number. - RetentionForeverDays = 365 * 1000 - // MaxFiniteRetentionDays is the largest finite retention period the // reaper's cutoff arithmetic can represent, and therefore the // largest one a caller may request. It is derived from that @@ -38,9 +24,7 @@ const ( // turns the cutoff into a timestamp in the far future that matches // every row in the webhook's database. That is why this bound is // enforced on input and why retentionCutoff saturates underneath - // it. Note that RetentionForeverDays deliberately sits above this - // ceiling: such webhooks are skipped before any cutoff is - // computed, and never reach the arithmetic at all. + // it. MaxFiniteRetentionDays = int( math.MaxInt64 / int64(time.Hour) / hoursPerDay, ) @@ -48,12 +32,10 @@ const ( // Webhook represents a webhook processing unit that groups entrypoints and targets // -// Every method below takes a pointer receiver. BeforeSave has to, -// because it mutates the record and GORM only invokes hooks declared -// that way; the display helpers follow suit so the receiver kinds do -// not mix. Handlers therefore put a *Webhook into template data: -// html/template cannot call a pointer method on a value held in a map, -// because a map element is not addressable. +// Every method below takes a pointer receiver, so handlers put a +// *Webhook into template data: html/template cannot call a pointer +// method on a value held in a map, because a map element is not +// addressable. type Webhook struct { BaseModel @@ -61,10 +43,9 @@ type Webhook struct { Name string `gorm:"not null" json:"name"` Description string `json:"description"` - // RetentionDays is the number of days to retain events. A value of - // RetentionForeverDays means retain forever. The column default - // must equal DefaultRetentionDays. - RetentionDays int `gorm:"default:30" json:"retentionDays"` + // RetentionDays is the number of days to retain events, and 0 + // means retain them forever. + RetentionDays int `json:"retentionDays"` // Relations. No model marshals the record it belongs to: // User.Webhooks leads back here, and the JSON could loop. @@ -73,36 +54,17 @@ type Webhook struct { Targets []Target `json:"targets,omitempty"` } -// BeforeSave normalises RetentionDays on every insert and update. A -// non-positive value is the user's way of asking for "retain forever", -// which is stored as the RetentionForeverDays sentinel. -// -// This has to happen in a hook rather than at the call sites. GORM -// substitutes the column default (DefaultRetentionDays) for a zero -// value while building the insert statement, which runs after -// BeforeSave; rewriting any later than this loses that race and the -// row lands at 30 days. Living on the model also means a future call -// site, such as a REST API or a fixture, cannot bypass it. -func (w *Webhook) BeforeSave(_ *gorm.DB) error { - if w.RetentionDays <= 0 { - w.RetentionDays = RetentionForeverDays - } - - return nil -} - -// retainsForever reports whether a stored RetentionDays value means -// "keep events indefinitely". It is the single definition of that -// question, shared by Webhook.RetainsForever and by the reaper's +// retainsForever reports whether a RetentionDays value means "keep +// events indefinitely", which 0 does. It is the single definition of +// that question, shared by Webhook.RetainsForever and by the reaper's // cutoff computation so the two cannot disagree about which webhooks // are exempt from reaping. // -// It accepts the RetentionForeverDays sentinel written by BeforeSave -// and, defensively, any non-positive value, which BeforeSave treats -// as a request to retain forever. +// A negative value, which the forms refuse, is read the same way: as a +// day count it would put the cutoff in the future, where it matches +// every event the webhook has. func retainsForever(retentionDays int) bool { - return retentionDays <= 0 || - retentionDays >= RetentionForeverDays + return retentionDays <= 0 } // RetainsForever reports whether this webhook's events are kept @@ -119,7 +81,8 @@ func (w *Webhook) RetentionCutoff(now time.Time) (time.Time, bool) { } // RetentionLabel returns the webhook's retention policy as display -// text, so that no template has to know about the sentinel value. +// text: "forever" rather than "0 days" for a webhook that keeps its +// events. func (w *Webhook) RetentionLabel() string { if w.RetainsForever() { return "forever" diff --git a/internal/database/model_webhook_test.go b/internal/database/model_webhook_test.go index d8be7fa..55444c5 100644 --- a/internal/database/model_webhook_test.go +++ b/internal/database/model_webhook_test.go @@ -2,8 +2,6 @@ package database_test import ( "context" - "reflect" - "strconv" "testing" "time" @@ -47,8 +45,7 @@ func storedRetention(t *testing.T, db *gorm.DB, id string) int { } // newWebhookWithRetention creates a webhook through the ordinary Create -// path, so the BeforeSave hook and the GORM column default both apply -// exactly as they do in production. +// path, as the new webhook form does. func newWebhookWithRetention( t *testing.T, db *gorm.DB, @@ -67,7 +64,7 @@ func newWebhookWithRetention( return wh.ID } -func TestWebhookBeforeSave_ZeroBecomesForeverSentinel(t *testing.T) { +func TestWebhookRetentionDays_ZeroIsStoredAsZero(t *testing.T) { t.Parallel() db := startedTestDB(t) @@ -75,31 +72,10 @@ func TestWebhookBeforeSave_ZeroBecomesForeverSentinel(t *testing.T) { wh := &database.Webhook{RetentionDays: 0} id := newWebhookWithRetention(t, db, wh) - assert.Equal( - t, - database.RetentionForeverDays, - storedRetention(t, db, id), - "a zero retention must be stored as the sentinel, "+ - "not replaced by the column default", - ) + assert.Equal(t, 0, storedRetention(t, db, id)) } -func TestWebhookBeforeSave_NegativeBecomesForeverSentinel(t *testing.T) { - t.Parallel() - - db := startedTestDB(t) - - wh := &database.Webhook{RetentionDays: -5} - id := newWebhookWithRetention(t, db, wh) - - assert.Equal( - t, - database.RetentionForeverDays, - storedRetention(t, db, id), - ) -} - -func TestWebhookBeforeSave_PositiveIsPreserved(t *testing.T) { +func TestWebhookRetentionDays_DayCountIsStored(t *testing.T) { t.Parallel() db := startedTestDB(t) @@ -110,10 +86,9 @@ func TestWebhookBeforeSave_PositiveIsPreserved(t *testing.T) { assert.Equal(t, 7, storedRetention(t, db, id)) } -// TestWebhookBeforeSave_UpdateToZeroBecomesSentinel proves the hook -// fires on update as well as insert, via the same Save call the edit -// handler makes. -func TestWebhookBeforeSave_UpdateToZeroBecomesSentinel(t *testing.T) { +// TestWebhookRetentionDays_UpdateToZeroIsStoredAsZero changes a day +// count to 0 through the same Save call the edit handler makes. +func TestWebhookRetentionDays_UpdateToZeroIsStoredAsZero(t *testing.T) { t.Parallel() db := startedTestDB(t) @@ -125,28 +100,7 @@ func TestWebhookBeforeSave_UpdateToZeroBecomesSentinel(t *testing.T) { wh.RetentionDays = 0 require.NoError(t, db.Omit(clause.Associations).Save(wh).Error) - assert.Equal( - t, - database.RetentionForeverDays, - storedRetention(t, db, id), - ) -} - -// TestWebhookRetentionColumnDefaultMatchesConstant guards the one place -// the default lives twice: a struct tag cannot reference a constant, so -// this asserts the tag and DefaultRetentionDays agree. -func TestWebhookRetentionColumnDefaultMatchesConstant(t *testing.T) { - t.Parallel() - - field, ok := reflect.TypeFor[database.Webhook](). - FieldByName("RetentionDays") - require.True(t, ok, "Webhook.RetentionDays must exist") - - assert.Equal( - t, - "default:"+strconv.Itoa(database.DefaultRetentionDays), - field.Tag.Get("gorm"), - ) + assert.Equal(t, 0, storedRetention(t, db, id)) } // TestMaxFiniteRetentionDaysIsTheOverflowCeiling asserts that the @@ -176,14 +130,6 @@ func TestMaxFiniteRetentionDaysIsTheOverflowCeiling(t *testing.T) { time.Duration(overCeiling*hoursPerDay)*time.Hour, "one day past the ceiling must overflow", ) - - assert.Less( - t, - database.MaxFiniteRetentionDays, - database.RetentionForeverDays, - "the sentinel sits above the ceiling and is only safe "+ - "because retain-forever webhooks skip the arithmetic", - ) } func TestWebhookRetainsForeverAndLabel(t *testing.T) { @@ -195,16 +141,8 @@ func TestWebhookRetainsForeverAndLabel(t *testing.T) { forever bool label string }{ - { - "sentinel", - database.RetentionForeverDays, true, testForeverLabel, - }, - { - "above sentinel", - database.RetentionForeverDays + 1, true, testForeverLabel, - }, - {"legacy zero", 0, true, testForeverLabel}, - {"legacy negative", -1, true, testForeverLabel}, + {"zero", 0, true, testForeverLabel}, + {"negative", -1, true, testForeverLabel}, {"default", database.DefaultRetentionDays, false, "30 days"}, {"one day", 1, false, "1 day"}, } diff --git a/internal/database/retention.go b/internal/database/retention.go index 5265ea9..a57bc95 100644 --- a/internal/database/retention.go +++ b/internal/database/retention.go @@ -243,7 +243,8 @@ func (r *RetentionReaper) reapWebhook( // retentionCutoff returns the timestamp before which a webhook's // events have expired, and whether any cutoff applies at all. It -// reports false for a retain-forever policy, so no DELETE is issued. +// reports false for a retention of 0, which means retain forever, so +// no DELETE is issued. // // The day count is clamped to MaxFiniteRetentionDays first. This is // defense in depth rather than decoration: a time.Duration is an int64 diff --git a/internal/database/retention_test.go b/internal/database/retention_test.go index 710ffc2..5659eda 100644 --- a/internal/database/retention_test.go +++ b/internal/database/retention_test.go @@ -85,41 +85,6 @@ func createWebhook( db.Omit(clause.Associations).Create(wh).Error, ) - // Webhook.BeforeSave rewrites a non-positive RetentionDays to the - // retain-forever sentinel, and the column's GORM default would - // otherwise substitute 30. Force the requested value with a - // column-level update so tests can plant legacy rows that predate - // the sentinel and still carry a literal 0 or negative value. - require.NoError( - t, - db.Model(wh). - Update("retention_days", retentionDays).Error, - ) - - return wh.ID -} - -// createWebhookNormally inserts a webhook through the ordinary Create -// path, with no column-level forcing, so Webhook.BeforeSave applies -// exactly as it does in production. Passing 0 therefore yields a row -// holding the RetentionForeverDays sentinel. -func createWebhookNormally( - t *testing.T, - db *gorm.DB, - retentionDays int, -) string { - t.Helper() - - wh := &database.Webhook{ - UserID: uuid.New().String(), - Name: testWebhookName, - RetentionDays: retentionDays, - } - require.NoError( - t, - db.Omit(clause.Associations).Create(wh).Error, - ) - return wh.ID } @@ -281,19 +246,19 @@ func TestRetentionReaper_ReapsExpiredKeepsRecent(t *testing.T) { assertChainPresent(t, db, recent) } -// TestRetentionReaper_SkipsSentinelReapsFiniteInSameSweep covers the -// end-to-end retain-forever path: a webhook created the normal way with -// a requested retention of 0 lands on the RetentionForeverDays -// sentinel, and the reaper leaves its ancient events alone while still -// reaping a finite-retention webhook in the very same sweep. -func TestRetentionReaper_SkipsSentinelReapsFiniteInSameSweep( +// TestRetentionReaper_SkipsZeroReapsFiniteInSameSweep covers the +// end-to-end retain-forever path: a webhook created with a retention +// of 0 reads back 0, and the reaper leaves its ancient events alone +// while still reaping a finite-retention webhook in the very same +// sweep. +func TestRetentionReaper_SkipsZeroReapsFiniteInSameSweep( t *testing.T, ) { t.Parallel() env := setupRetentionTest(t) - foreverID := createWebhookNormally(t, env.mainDB.DB(), 0) + foreverID := createWebhook(t, env.mainDB.DB(), 0) var stored database.Webhook @@ -302,14 +267,12 @@ func TestRetentionReaper_SkipsSentinelReapsFiniteInSameSweep( env.mainDB.DB().Where("id = ?", foreverID). First(&stored).Error, ) - require.Equal( - t, - database.RetentionForeverDays, - stored.RetentionDays, - "a requested retention of 0 must persist as the sentinel", + require.Zero( + t, stored.RetentionDays, + "a retention of 0 must be stored as 0", ) - finiteID := createWebhookNormally(t, env.mainDB.DB(), 30) + finiteID := createWebhook(t, env.mainDB.DB(), 30) foreverDB, err := env.mgr.GetDB(foreverID) require.NoError(t, err) @@ -335,14 +298,10 @@ func TestRetentionReaper_SkipsSentinelReapsFiniteInSameSweep( // negative, so subtracting it moves the cutoff into the far future, // where "created_at < cutoff" matches every row: an event created a // moment ago, and its delivery and delivery result, were all deleted -// on the first sweep. 200000 is inside that band and below the -// retain-forever sentinel, so it is treated as a finite policy and -// really does reach the arithmetic. +// on the first sweep. 200000 is inside that band. // -// The row is planted at the column level because such a value can no -// longer be submitted through the form; the point of the test is that -// a row from an older version, or a future call site, still cannot -// trigger the wipe. +// The form refuses such a value; the point of the test is that a row +// written by any other path still cannot trigger the wipe. func TestRetentionReaper_HugeFiniteRetentionRetainsRecentEvents( t *testing.T, ) { @@ -358,12 +317,6 @@ func TestRetentionReaper_HugeFiniteRetentionRetainsRecentEvents( database.MaxFiniteRetentionDays, "the test value must exceed what the cutoff can represent", ) - require.Less( - t, - overflowingRetentionDays, - database.RetentionForeverDays, - "the test value must not be treated as retain-forever", - ) webhookID := createWebhook( t, env.mainDB.DB(), overflowingRetentionDays, @@ -379,14 +332,15 @@ func TestRetentionReaper_HugeFiniteRetentionRetainsRecentEvents( assertChainPresent(t, db, fresh) } -func TestRetentionReaper_RetainsForeverWhenNonPositive(t *testing.T) { +// TestRetentionReaper_RetainsForeverWhenNegative checks that a negative +// retention, which the forms refuse, keeps every event rather than +// putting the cutoff in the future, where it would match them all. +func TestRetentionReaper_RetainsForeverWhenNegative(t *testing.T) { t.Parallel() env := setupRetentionTest(t) - // A legacy row written before the sentinel existed still carries a - // literal 0; the <= 0 guard must keep honouring it. - webhookID := createWebhook(t, env.mainDB.DB(), 0) + webhookID := createWebhook(t, env.mainDB.DB(), -1) db, err := env.mgr.GetDB(webhookID) require.NoError(t, err) diff --git a/internal/db/migrations/main/001_schema.sql b/internal/db/migrations/main/001_schema.sql index 8f4cb38..a03edc8 100644 --- a/internal/db/migrations/main/001_schema.sql +++ b/internal/db/migrations/main/001_schema.sql @@ -46,7 +46,7 @@ CREATE TABLE webhooks ( user_id uuid NOT NULL, name text NOT NULL, description text, - retention_days integer DEFAULT 30, + retention_days integer, PRIMARY KEY (id), CONSTRAINT fk_users_webhooks FOREIGN KEY (user_id) REFERENCES users (id) ); diff --git a/internal/handlers/shared.go b/internal/handlers/shared.go index 43e13fc..536e49b 100644 --- a/internal/handlers/shared.go +++ b/internal/handlers/shared.go @@ -16,21 +16,16 @@ import ( // // An empty value yields fallback, which lets the create path apply the // default and the edit path leave the stored value unchanged. A value -// of 0 is returned as 0 and is rewritten to the retain-forever -// sentinel by database.Webhook's BeforeSave hook. Anything unparseable -// or negative is refused rather than silently given a default. +// of 0 means retain events forever and is returned as it is. Anything +// unparseable or negative is refused rather than silently given a +// default. // // The upper bound is not cosmetic. The reaper computes its cutoff as a // time.Duration, an int64 nanosecond count, so a day count above // database.MaxFiniteRetentionDays overflows, puts the cutoff in the -// future, and deletes every event the webhook has. A finite value -// above that ceiling is therefore refused, and the message names the -// ceiling rather than implying the input was not a number. -// -// A value at or above the retain-forever sentinel is not out of range: -// it is what the edit form pre-fills for a retain-forever webhook, so -// submitting the form back unchanged has to keep meaning "forever" -// rather than being rejected. +// future, and deletes every event the webhook has. A value above that +// ceiling is therefore refused, and the message names the ceiling +// rather than implying the input was not a number. func parseRetentionDays(raw string, fallback int) (int, string) { raw = strings.TrimSpace(raw) if raw == "" { @@ -43,10 +38,6 @@ func parseRetentionDays(raw string, fallback int) (int, string) { "retain events forever." } - if v >= database.RetentionForeverDays { - return database.RetentionForeverDays, "" - } - if v > database.MaxFiniteRetentionDays { return 0, "Retention must be at most " + strconv.Itoa(database.MaxFiniteRetentionDays) + diff --git a/internal/handlers/source_management_test.go b/internal/handlers/source_management_test.go index 1021df4..924684f 100644 --- a/internal/handlers/source_management_test.go +++ b/internal/handlers/source_management_test.go @@ -137,8 +137,7 @@ func onlyWebhook( } // seedWebhookWithRetention inserts a webhook owned by the test user -// with an exact stored retention value, bypassing Webhook.BeforeSave -// via a column-level update so that legacy rows can be planted too. +// with the given retention. func seedWebhookWithRetention( t *testing.T, db *database.Database, @@ -155,13 +154,6 @@ func seedWebhookWithRetention( t, db.DB().Omit(clause.Associations).Create(wh).Error, ) - require.NoError( - t, - db.DB().Model(wh). - Update("retention_days", retentionDays).Error, - ) - - wh.RetentionDays = retentionDays return *wh } @@ -225,10 +217,9 @@ func setupSourceTest(t *testing.T) *sourceTestEnv { } } -// TestHandleSourceCreateSubmit_ZeroRetentionPersistsForever is the core -// regression test for the bug: the create form's 0 must reach the -// database as the retain-forever sentinel rather than being replaced by -// the column's default of 30. +// TestHandleSourceCreateSubmit_ZeroRetentionPersistsForever checks +// that the create form's 0 reaches the database as 0, which means +// retain forever. func TestHandleSourceCreateSubmit_ZeroRetentionPersistsForever( t *testing.T, ) { @@ -241,35 +232,60 @@ func TestHandleSourceCreateSubmit_ZeroRetentionPersistsForever( require.Equal(t, http.StatusSeeOther, w.Code) wh := onlyWebhook(t, env.db) - assert.Equal( - t, - database.RetentionForeverDays, - storedRetentionDays(t, env.db, wh.ID), - ) + assert.Equal(t, 0, storedRetentionDays(t, env.db, wh.ID)) assert.True(t, wh.RetainsForever()) } -func TestHandleSourceCreateSubmit_OmittedRetentionUsesDefault( +func TestHandleSourceCreateSubmit_DayCountIsStored(t *testing.T) { + t.Parallel() + + env := setupSourceTest(t) + days := "45" + + w := submitCreate(t, env.handlers, env.cookies, "finite", &days) + require.Equal(t, http.StatusSeeOther, w.Code) + + wh := onlyWebhook(t, env.db) + assert.Equal(t, 45, storedRetentionDays(t, env.db, wh.ID)) +} + +// TestHandleSourceCreateSubmit_BlankRetentionUsesDefault covers a +// retention field left empty and one missing from the form: both +// give the default, not forever. +func TestHandleSourceCreateSubmit_BlankRetentionUsesDefault( t *testing.T, ) { t.Parallel() - env := setupSourceTest(t) + blank := "" - w := submitCreate(t, env.handlers, env.cookies, "defaulted", nil) - require.Equal(t, http.StatusSeeOther, w.Code) + for name, retention := range map[string]*string{ + "blank": &blank, + "omitted": nil, + } { + t.Run(name, func(t *testing.T) { + t.Parallel() - wh := onlyWebhook(t, env.db) - assert.Equal( - t, - database.DefaultRetentionDays, - storedRetentionDays(t, env.db, wh.ID), - ) + env := setupSourceTest(t) + + w := submitCreate( + t, env.handlers, env.cookies, "defaulted", retention, + ) + require.Equal(t, http.StatusSeeOther, w.Code) + + wh := onlyWebhook(t, env.db) + assert.Equal( + t, + database.DefaultRetentionDays, + storedRetentionDays(t, env.db, wh.ID), + ) + }) + } } -// TestHandleSourceCreate_PrefillsDefaultFromConstant keeps the create -// form's pre-filled retention from becoming a third hardcoded copy of -// the 30-day policy. +// TestHandleSourceCreate_PrefillsDefaultFromConstant checks that the +// create form pre-fills its retention from database.DefaultRetentionDays +// rather than from a hardcoded 30. func TestHandleSourceCreate_PrefillsDefaultFromConstant(t *testing.T) { t.Parallel() @@ -290,7 +306,7 @@ func TestHandleSourceCreate_PrefillsDefaultFromConstant(t *testing.T) { ) assert.NotContains( t, body, `max="365"`, - "a max below the sentinel would block retain-forever", + "a max of 365 would block any longer retention", ) assert.Contains(t, body, `min="0"`) } @@ -368,40 +384,6 @@ func TestHandleSourceCreateSubmit_OverflowingRetentionIsRejected( ) } -// TestHandleSourceCreateSubmit_SentinelIsAcceptedAsForever guards the -// boundary between "too large to represent" and "retain forever": the -// sentinel is above MaxFiniteRetentionDays, but it is the value the -// edit form pre-fills, so it must be accepted rather than rejected as -// out of range. A value above the sentinel is stored as the sentinel. -func TestHandleSourceCreateSubmit_SentinelIsAcceptedAsForever( - t *testing.T, -) { - t.Parallel() - - for _, days := range []int{ - database.RetentionForeverDays, - database.RetentionForeverDays + 1, - } { - raw := strconv.Itoa(days) - - t.Run(raw, func(t *testing.T) { - t.Parallel() - - env := setupSourceTest(t) - - w := submitCreate(t, env.handlers, env.cookies, "forever", &raw) - require.Equal(t, http.StatusSeeOther, w.Code) - - wh := onlyWebhook(t, env.db) - assert.Equal( - t, - database.RetentionForeverDays, - storedRetentionDays(t, env.db, wh.ID), - ) - }) - } -} - // TestHandleSourceCreateSubmit_RejectedFormKeepsUserInput checks that a // validation failure hands the user's typing back, matching what the // edit form already does. Losing a long description to a mistyped @@ -480,11 +462,7 @@ func TestHandleSourceEditSubmit_ZeroRetentionPersistsForever( w := submitEdit(t, env, wh, "0") require.Equal(t, http.StatusSeeOther, w.Code) - assert.Equal( - t, - database.RetentionForeverDays, - storedRetentionDays(t, env.db, wh.ID), - ) + assert.Equal(t, 0, storedRetentionDays(t, env.db, wh.ID)) } func TestHandleSourceEditSubmit_InvalidRetentionIsRejected( @@ -867,18 +845,15 @@ func TestHandleSourceEditSubmit_ArchiveNameTaken(t *testing.T) { assert.Equal(t, oldName, stored.Name) } -// TestSourceEditForm_ForeverWebhookRoundTrips walks the exact path that -// the removed max="365" cap used to break: render the edit form for a -// retain-forever webhook, confirm the pre-filled sentinel is not capped -// by browser validation, then submit that pre-filled value straight -// back and confirm the retention policy survives untouched. +// TestSourceEditForm_ForeverWebhookRoundTrips renders the edit form for +// a retain-forever webhook, confirms it pre-fills 0, then submits that +// pre-filled value straight back and confirms the retention policy +// survives untouched. func TestSourceEditForm_ForeverWebhookRoundTrips(t *testing.T) { t.Parallel() env := setupSourceTest(t) - wh := seedWebhookWithRetention( - t, env.db, database.RetentionForeverDays, - ) + wh := seedWebhookWithRetention(t, env.db, 0) req := getRequest( t, "/hook/"+wh.ID+"/edit", env.cookies, @@ -889,17 +864,12 @@ func TestSourceEditForm_ForeverWebhookRoundTrips(t *testing.T) { require.Equal(t, http.StatusOK, w.Code) - sentinel := strconv.Itoa(database.RetentionForeverDays) body := w.Body.String() assert.Contains( - t, body, `value="`+sentinel+`"`, + t, body, `name="retention_days" value="0"`, "the edit form pre-fills the stored retention", ) - assert.NotContains( - t, body, `max="365"`, - "a max below the sentinel would block saving any edit", - ) // "Currently forever." is the rendered RetentionLabel, not the // static hint below the input, which says "Enter 0 to retain events // forever." A bare Contains of "forever" would pass for any @@ -910,29 +880,20 @@ func TestSourceEditForm_ForeverWebhookRoundTrips(t *testing.T) { ) // Submit the pre-filled value back, exactly as a browser would. - post := submitEdit(t, env, wh, sentinel) + post := submitEdit(t, env, wh, "0") require.Equal(t, http.StatusSeeOther, post.Code) - assert.Equal( - t, - database.RetentionForeverDays, - storedRetentionDays(t, env.db, wh.ID), - ) + assert.Equal(t, 0, storedRetentionDays(t, env.db, wh.ID)) } -// TestSourceListAndDetail_ShowForeverNotTheSentinelNumber checks that -// the retain-forever value is never rendered to the user as a raw day -// count on either read-only view. -func TestSourceListAndDetail_ShowForeverNotTheSentinelNumber( - t *testing.T, -) { +// TestSourceListAndDetail_ShowForeverNotZeroDays checks that a +// retention of 0 is shown as forever, never as a day count, on either +// read-only view. +func TestSourceListAndDetail_ShowForeverNotZeroDays(t *testing.T) { t.Parallel() env := setupSourceTest(t) - wh := seedWebhookWithRetention( - t, env.db, database.RetentionForeverDays, - ) - sentinel := strconv.Itoa(database.RetentionForeverDays) + wh := seedWebhookWithRetention(t, env.db, 0) listW := httptest.NewRecorder() env.handlers.HandleSourceList().ServeHTTP( @@ -941,7 +902,7 @@ func TestSourceListAndDetail_ShowForeverNotTheSentinelNumber( require.Equal(t, http.StatusOK, listW.Code) assert.Contains(t, listW.Body.String(), "Retention: forever") - assert.NotContains(t, listW.Body.String(), sentinel) + assert.NotContains(t, listW.Body.String(), "Retention: 0 days") detailW := httptest.NewRecorder() env.handlers.HandleSourceDetail().ServeHTTP( @@ -954,5 +915,5 @@ func TestSourceListAndDetail_ShowForeverNotTheSentinelNumber( require.Equal(t, http.StatusOK, detailW.Code) assert.Contains(t, detailW.Body.String(), "Retention: forever") - assert.NotContains(t, detailW.Body.String(), sentinel) + assert.NotContains(t, detailW.Body.String(), "Retention: 0 days") } diff --git a/internal/handlers/ui_copy_test.go b/internal/handlers/ui_copy_test.go index 8b5cf69..b32d371 100644 --- a/internal/handlers/ui_copy_test.go +++ b/internal/handlers/ui_copy_test.go @@ -184,8 +184,7 @@ func TestEventLogPageIsCalledFullEventLog(t *testing.T) { // TestCreateFormRetentionCopyMatchesBehaviour pins the create form's // retention copy to what the code does: the reaper permanently deletes // events past the cutoff, an empty field falls back to -// DefaultRetentionDays, and 0 is rewritten to the retain-forever -// sentinel by Webhook.BeforeSave. +// DefaultRetentionDays, and 0 means retain forever. func TestCreateFormRetentionCopyMatchesBehaviour(t *testing.T) { t.Parallel() @@ -253,10 +252,7 @@ func TestEditFormRetentionCopyMatchesBehaviour(t *testing.T) { "blank means unchanged, not forever", ) - forever := &database.Webhook{ - Name: "wh", - RetentionDays: database.RetentionForeverDays, - } + forever := &database.Webhook{Name: "wh", RetentionDays: 0} forever.ID = "wh-2" foreverBody := renderPage( diff --git a/internal/handlers/webhook_stats_test.go b/internal/handlers/webhook_stats_test.go index 530072f..5b47566 100644 --- a/internal/handlers/webhook_stats_test.go +++ b/internal/handlers/webhook_stats_test.go @@ -500,7 +500,7 @@ func TestWebhookStats_PaneShowsRetentionPeriod(t *testing.T) { want string }{ {30, "30 days"}, - {database.RetentionForeverDays, "forever"}, + {0, "forever"}, } for _, tt := range tests { -- 2.54.0 From 0cdda43e6ce0ada61700aef0ef65cedccd45dc30 Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Thu, 15 Oct 2026 21:03:43 +0200 Subject: [PATCH 122/126] Move the archive files into their own package (closes #541) The SQLite archive files the database target writes are now managed by internal/archive: file names, rotation, the writer of each target, pruning, renames, the gzipped JSON export, the idle sweep, and the rename lock, which was Handlers.renameMu. The database target stays in internal/delivery and writes through archive.Writers; the engine still closes the writers when it stops, so the stop order is unchanged. The handlers take the rename lock from archive.Archives.RenameLock, and rename and evict through archive.Archives. Exported names drop their Archive prefix in the new package. The tests moved with the code; the two that check a delivery's recorded attempt stay in delivery. Model: opus-5-5 --- README.md | 46 +- cmd/webhooker/main.go | 16 +- cmd/webhooker/main_test.go | 2 +- internal/archive/archive.go | 454 ++++++++++ internal/archive/archive_test.go | 773 +++++++++++++++++ .../download.go} | 45 +- .../download_test.go} | 54 +- .../evict_test.go} | 84 +- internal/archive/export_test.go | 280 +++++++ .../rotation.go} | 28 +- .../rotation_test.go} | 50 +- .../archive_sweeper.go => archive/sweeper.go} | 46 +- .../sweeper_test.go} | 189 +++-- .../writer.go} | 62 +- .../writer_gormlog_test.go} | 12 +- internal/database/sqlite_mode_test.go | 2 +- internal/db/migrations/archive/001_schema.sql | 2 +- internal/delivery/engine.go | 79 +- internal/delivery/engine_lifecycle_test.go | 20 +- internal/delivery/export_test.go | 301 +------ internal/delivery/inflight_test.go | 29 - internal/delivery/target.go | 5 +- internal/delivery/target_config_edit.go | 9 +- internal/delivery/target_config_view.go | 7 +- internal/delivery/target_config_view_test.go | 11 + internal/delivery/target_database.go | 373 +-------- internal/delivery/target_database_test.go | 784 ++++-------------- internal/handlers/archive_rotation_test.go | 4 +- internal/handlers/gormlogbound_test.go | 2 +- internal/handlers/handlers.go | 17 +- internal/handlers/handlers_test.go | 17 +- internal/handlers/source_delete_test.go | 14 +- internal/handlers/target_create.go | 9 +- internal/handlers/target_download.go | 32 +- internal/handlers/target_download_test.go | 22 +- internal/handlers/target_edit.go | 9 +- internal/handlers/target_list.go | 7 +- internal/handlers/target_list_test.go | 6 +- internal/handlers/webhook_delete.go | 10 +- internal/handlers/webhook_edit.go | 10 +- internal/resetpw/resetpw_test.go | 12 +- internal/server/routes_test.go | 16 +- 42 files changed, 2178 insertions(+), 1772 deletions(-) create mode 100644 internal/archive/archive.go create mode 100644 internal/archive/archive_test.go rename internal/{delivery/target_database_export.go => archive/download.go} (89%) rename internal/{delivery/target_database_export_test.go => archive/download_test.go} (90%) rename internal/{delivery/target_database_evict_test.go => archive/evict_test.go} (81%) create mode 100644 internal/archive/export_test.go rename internal/{delivery/target_database_rotation.go => archive/rotation.go} (85%) rename internal/{delivery/target_database_rotation_test.go => archive/rotation_test.go} (87%) rename internal/{delivery/archive_sweeper.go => archive/sweeper.go} (85%) rename internal/{delivery/archive_sweeper_test.go => archive/sweeper_test.go} (85%) rename internal/{delivery/target_database_archive.go => archive/writer.go} (92%) rename internal/{delivery/target_database_archive_gormlog_test.go => archive/writer_gormlog_test.go} (93%) diff --git a/README.md b/README.md index 8a5b339..9e6ec4e 100644 --- a/README.md +++ b/README.md @@ -2100,15 +2100,15 @@ writes go on meanwhile, since under WAL a reader never blocks a writer. Until the open file is closed its `-wal` cannot be checkpointed past what the download reads, so a long download lets that `-wal` grow. -Deleting a webhook releases its archives: the delivery engine's cached archive -writers are dropped and their file handles closed, so nothing lingers after the -webhook is gone. The archive **files themselves are deliberately left on disk**. -Unlike the event database — per-webhook working storage that is hard-deleted -with the webhook — an archive is long-term storage an operator may still want to -keep or move away for offline retention, and destroying it as a side effect of -deleting a webhook would be unrecoverable. Removing an `archive-….db` is the -operator's call. Deleting a `database` target releases its writer the same way, -and for the same reason leaves its file alone. +Deleting a webhook releases its archives: its cached archive writers are dropped +and their file handles closed, so nothing lingers after the webhook is gone. The +archive **files themselves are deliberately left on disk**. Unlike the event +database — per-webhook working storage that is hard-deleted with the webhook — +an archive is long-term storage an operator may still want to keep or move away +for offline retention, and destroying it as a side effect of deleting a webhook +would be unrecoverable. Removing an `archive-….db` is the operator's call. +Deleting a `database` target releases its writer the same way, and for the same +reason leaves its file alone. The **Slack target type** sends webhook events as formatted messages to any Slack-compatible incoming webhook URL (works with Slack, Mattermost, and other @@ -2997,6 +2997,12 @@ webhooker/ ├── cmd/webhooker/ │ └── main.go # Entry point: subcommand dispatch; no args locks DATA_DIR and wires fx ├── internal/ +│ ├── archive/ +│ │ ├── archive.go # The archive writer of each database target, eviction, rename, and the rename lock +│ │ ├── writer.go # Archive file lifecycle and pruning +│ │ ├── rotation.go # Archive rotation and file names +│ │ ├── download.go # Archive download as gzipped JSON +│ │ └── sweeper.go # Periodic pruning of idle archives │ ├── banner/ │ │ └── banner.go # Ruled block for the one credential shown in the clear │ ├── resetpw/ @@ -3040,13 +3046,9 @@ webhooker/ │ │ ├── target.go # Target interface, Task, Scheduler │ │ ├── target_http.go # HTTP target (retries, circuit breaker) │ │ ├── target_slack.go # Slack/Mattermost incoming-webhook target -│ │ ├── target_database.go # Database archive target -│ │ ├── target_database_archive.go # Archive file lifecycle and pruning -│ │ ├── target_database_rotation.go # Archive rotation and file names -│ │ ├── target_database_export.go # Archive download as gzipped JSON +│ │ ├── target_database.go # Database archive target, writing through internal/archive │ │ ├── target_log.go # Log target (stdout) │ │ ├── target_config_view.go # Masked target config for templates -│ │ ├── archive_sweeper.go # Periodic pruning of idle archives │ │ ├── queue_depth.go # Periodic sampler behind the queue-depth gauges │ │ ├── url_mask.go # Strips credentials from *url.Error │ │ └── ssrf.go # SSRF prevention (IP validation, safe HTTP transport) @@ -3137,14 +3139,16 @@ Components are wired via Uber fx in this order: 11. `metrics.New` — The delivery collectors, registered on that registry 12. `middleware.New` — HTTP middleware 13. `delivery.New` — Event-driven delivery engine -14. `delivery.NewArchiveSweeper` — Periodic pruning of idle archives -15. `delivery.Engine` → `delivery.Notifier` — interface bridge -16. `delivery.Engine` → `delivery.Archives` — interface bridge so deleting or +14. `archive.NewWriters` — The database targets' archive writers, and the lock + every rename of their files runs under +15. `archive.NewSweeper` — Periodic pruning of idle archives +16. `delivery.Engine` → `delivery.Notifier` — interface bridge +17. `archive.Writers` → `archive.Archives` — interface bridge so deleting or renaming a webhook or target reaches its archive files -17. `server.New` — HTTP server and router +18. `server.New` — HTTP server and router The server starts via -`fx.Invoke(func(*server.Server, *delivery.Engine, *database.RetentionReaper, *delivery.ArchiveSweeper) {})`, +`fx.Invoke(func(*server.Server, *delivery.Engine, *database.RetentionReaper, *archive.Sweeper) {})`, which triggers the fx lifecycle hooks in dependency order. The `delivery.Notifier` interface allows the webhook handler to send self-contained `delivery.Task` slices to the engine without a direct package dependency. Each @@ -3313,7 +3317,7 @@ order under a **5 second budget** (`fx.StopTimeout` in `cmd/webhooker/main.go`). That budget covers the whole sequence, not each hook. The order, read off the fx stop-hook log: -1. `ArchiveSweeper` +1. `archive.Sweeper` 2. `RetentionReaper` 3. `server` — the HTTP drain, bounded by `server.ShutdownTimeout` (**3 seconds**) and by what the hooks before it left, then a Sentry flush if @@ -3356,7 +3360,7 @@ skipped when that leaves too little to be worth attempting — so a full-length drain means Sentry events are dropped rather than the database close being skipped. -This does not make the database close unconditional. A slow `ArchiveSweeper` or +This does not make the database close unconditional. A slow `archive.Sweeper` or `RetentionReaper` is enough to cut the shutdown short, not only one that consumes the whole budget: what they spend comes out of the drain first, so after 2 seconds of theirs a request still in flight gets 1 second to finish, and diff --git a/cmd/webhooker/main.go b/cmd/webhooker/main.go index 5990801..a830af4 100644 --- a/cmd/webhooker/main.go +++ b/cmd/webhooker/main.go @@ -10,6 +10,7 @@ import ( "go.uber.org/fx" "go.uber.org/fx/fxevent" + "sneak.berlin/go/webhooker/internal/archive" "sneak.berlin/go/webhooker/internal/config" "sneak.berlin/go/webhooker/internal/database" "sneak.berlin/go/webhooker/internal/datadir" @@ -49,7 +50,7 @@ import ( // before the server may already have spent. // // This does not make the database close unconditional: the -// ArchiveSweeper and RetentionReaper hooks run before the server. +// archive.Sweeper and RetentionReaper hooks run before the server. // What they spend comes out of the drain first, but past 3s it comes // out of the reserve, and they can consume the whole budget. const stopTimeout = 5 * time.Second @@ -188,8 +189,8 @@ func newApp() *fx.App { fx.Provide( globals.New, logger.New, - // The plain logger the session, the middleware and the - // webhook database manager take. + // The plain logger the session, the middleware, the + // webhook database manager and the archive writers take. func(l *logger.Logger) *slog.Logger { return l.Get() }, config.New, database.New, @@ -208,14 +209,15 @@ func newApp() *fx.App { // disagree about a destination. delivery.NewGuard, delivery.New, - delivery.NewArchiveSweeper, + archive.NewWriters, + archive.NewSweeper, // Wire *delivery.Engine as delivery.Notifier so the // webhook handler can notify the engine of new deliveries. func(e *delivery.Engine) delivery.Notifier { return e }, - // Wire *delivery.Engine as delivery.Archives so deleting + // Wire *archive.Writers as archive.Archives so deleting // or renaming a webhook or target reaches its archive // files. - func(e *delivery.Engine) delivery.Archives { return e }, + func(w *archive.Writers) archive.Archives { return w }, // Wire *delivery.Engine as delivery.CircuitBreakers so // the pages can show a target whose deliveries are // paused. @@ -227,7 +229,7 @@ func newApp() *fx.App { *server.Server, *delivery.Engine, *database.RetentionReaper, - *delivery.ArchiveSweeper, + *archive.Sweeper, ) { }, ), diff --git a/cmd/webhooker/main_test.go b/cmd/webhooker/main_test.go index 160cb1d..dbc6836 100644 --- a/cmd/webhooker/main_test.go +++ b/cmd/webhooker/main_test.go @@ -252,7 +252,7 @@ const tailHeadroom = 2 * time.Second // can produce, since a shorter drain leaves the flush more room and // the worst case is not necessarily at either extreme. // -// Nor does the hook start on a full budget: the ArchiveSweeper and +// Nor does the hook start on a full budget: the archive.Sweeper and // RetentionReaper hooks run before it, and whatever they spent is // gone. The outer sweep walks every amount they can spend. Once they // have eaten into the headroom themselves, the hook must spend diff --git a/internal/archive/archive.go b/internal/archive/archive.go new file mode 100644 index 0000000..7177547 --- /dev/null +++ b/internal/archive/archive.go @@ -0,0 +1,454 @@ +// Package archive manages the SQLite archive files the database target +// writes: how they are named and rotated, the writers that open, write, +// close and reopen them, pruning the rows past a target's expiry, +// renaming a target's files and moving them back when that fails, the +// gzipped JSON export, the idle sweep, and the locks that keep a rename +// apart from archive writes, from other edits and from exports. +package archive + +import ( + "fmt" + "log/slog" + "strings" + "sync" + "time" + + "go.uber.org/fx" + "sneak.berlin/go/webhooker/internal/database" +) + +// archiveNameMaxLen is how many characters of a webhook or target +// name an archive file name keeps. +const archiveNameMaxLen = 40 + +// Archives is how the handlers keep the database targets' archive +// files in step with the configuration. Deleting a webhook or a +// target releases the cached archive writers, whose open file +// handles would otherwise outlive them; renaming one renames the +// archive files, which are named for the webhook and the target +// (see FileName). RenameLock returns the lock every rename runs under +// (see Writers.RenameLock). +// +// It is an interface so that the handlers' tests can stand in for +// Writers. +// +// Neither eviction deletes an archive file. Both are idempotent +// and are no-ops for a webhook or target with no writer. +type Archives interface { + RenameLock() *sync.Mutex + EvictWebhook(webhookID string) + EvictTarget(targetID string) + Rename(targetID, webhookName, targetName string) error +} + +// WritersParams holds the fx dependencies for Writers. +type WritersParams struct { + fx.In + + Database *database.Database + DBManager *database.WebhookDBManager + Logger *slog.Logger +} + +// Writers holds the archive writer of each database target, and the +// lock every rename of archive files runs under. The database target +// writes an event through it, the handlers rename and evict through +// it, the archive sweeper prunes through it, and the delivery engine +// evicts every writer when it stops. See archiveWriter for the +// close/reopen, auto-recreate, and expiry semantics. +type Writers struct { + db *database.Database + dbMgr *database.WebhookDBManager + log *slog.Logger + + // writers holds one archive writer per database target, keyed + // by target ID. + mu sync.Mutex + writers map[string]*archiveWriter + + // renameMu makes the webhook edit, the target edit and target + // creation run one at a time, each held from loading the stored + // names through the archive rename, the save and any move back. + // Interleaved, one could rename an archive between another's + // rename and save, leaving the file named for one edit and the + // stored names from the other. An archive download holds it while + // it reads the stored names and lists the files they give, and + // again for each file while it finds the file under the names + // stored then and opens it. RenameLock returns it. + renameMu sync.Mutex +} + +// NewWriters builds the archive writers. It opens no archive file: +// each target's writer is made on its first use. +func NewWriters(params WritersParams) *Writers { + return &Writers{ + db: params.Database, + dbMgr: params.DBManager, + log: params.Logger, + } +} + +// RenameLock implements Archives. It returns the lock every rename of +// archive files runs under: see renameMu for who holds it and for how +// long. Archive writes and Rename itself do not take it. +func (ws *Writers) RenameLock() *sync.Mutex { + return &ws.renameMu +} + +// FileName returns the file name of a database target's +// archive: archive-WEBHOOKNAME-TARGETNAME-TARGETID.db, with both +// names passed through archiveNamePart. The target ID keeps the +// name unique when two targets' names come out the same. +func FileName(webhookName, targetName, targetID string) string { + return "archive-" + archiveNamePart(webhookName) + "-" + + archiveNamePart(targetName) + "-" + targetID + ".db" +} + +// archiveNamePart makes a webhook or target name safe to put in a +// file name. It is lowercased; ASCII letters and digits are kept, +// every other run of characters becomes a single "-", and no "-" is +// left at either end. It is cut to archiveNameMaxLen characters, and +// a name with nothing left is "unnamed". +func archiveNamePart(name string) string { + var b strings.Builder + + dash := false + + for _, r := range strings.ToLower(name) { + if (r < 'a' || r > 'z') && (r < '0' || r > '9') { + dash = b.Len() > 0 + + continue + } + + if dash { + b.WriteByte('-') + + dash = false + } + + b.WriteRune(r) + } + + part := b.String() + if len(part) > archiveNameMaxLen { + part = strings.TrimRight(part[:archiveNameMaxLen], "-") + } + + if part == "" { + return "unnamed" + } + + return part +} + +// Write writes the full event of a delivery to a database target as +// a row into the target's archive database, honouring the optional +// per-target expiry and rotation parsed from the target config JSON. +// With rotation, the event goes to the file for the period of its +// receive time. +func (ws *Writers) Write(d *database.Delivery) error { + webhookID := d.Event.WebhookID + if webhookID == "" { + return errArchiveMissingWebhookID + } + + expiry, err := ParseExpiry(d.Target.Config) + if err != nil { + return err + } + + rotation, err := ParseRotation(d.Target.Config) + if err != nil { + return err + } + + // An event whose stored row was gone before its delivery ran has + // no receive time (see delivery's Engine.hydrateEvent), and goes + // to the file for now. + receivedAt := d.Event.CreatedAt + if receivedAt.IsZero() { + receivedAt = time.Now() + } + + w, err := ws.writerFor(d.TargetID) + if err != nil { + return err + } + + row := archivedEvent{ + EventID: d.Event.ID, + WebhookID: webhookID, + EntrypointID: d.Event.EntrypointID, + Method: d.Event.Method, + RawQuery: d.Event.RawQuery, + Headers: d.Event.Headers, + Body: d.Event.Body, + ContentType: d.Event.ContentType, + } + + return w.write(row, expiry, archivePeriod(rotation, receivedAt)) +} + +// Rename implements Archives. It renames every one of a database +// target's archive files to FileName(webhookName, targetName, +// targetID), each keeping the period in its name. It goes through the +// target's writer, so the move holds the lock that the target's +// archive writes and the idle sweep take, and later writes use the +// new name. It never replaces a file: if one already has a new name, +// the error is ErrNameTaken. +// +// The writer is created if there is none, and it stays cached. The +// handlers rename before they save the new name, so until the save +// the main database still has the old one; a delivery in that window +// must find this writer rather than build one from the old name. +func (ws *Writers) Rename( + targetID, webhookName, targetName string, +) error { + w, err := ws.writerFor(targetID) + if err != nil { + return err + } + + return w.rename(FileName(webhookName, targetName, targetID)) +} + +// EvictTarget implements Archives. It drops a database target's +// archive writer from the registry and closes its handle, so a +// deleted target does not leave a writer (and an open archive handle +// within its debounce window) alive for the process lifetime. +// +// The map entry is removed under the registry lock, which is +// then released before the handle is closed under the writer's +// own lock: that ordering keeps the registry available to other +// targets while an in-flight write on this one drains, and +// closing under the writer's lock means eviction can never race +// a write. +// +// Eviction is idempotent and silent for a target with no writer, +// which is the common case: only a database target that has +// received an event or been renamed has one. It never deletes the +// archive file. +func (ws *Writers) EvictTarget(targetID string) { + ws.mu.Lock() + + w, ok := ws.writers[targetID] + if ok { + delete(ws.writers, targetID) + } + + ws.mu.Unlock() + + if !ok { + return + } + + w.evict() + + ws.log.Info( + "evicted archive writer", + "target_id", targetID, + "path", w.path, + ) +} + +// EvictWebhook implements Archives. It evicts, exactly as EvictTarget +// does, the writer of every database target of a webhook. The archive +// files themselves are left on disk — they are long-term storage the +// operator owns. +func (ws *Writers) EvictWebhook(webhookID string) { + ws.mu.Lock() + + var gone []*archiveWriter + + for targetID, w := range ws.writers { + if w.webhookID == webhookID { + delete(ws.writers, targetID) + + gone = append(gone, w) + } + } + + ws.mu.Unlock() + + for _, w := range gone { + w.evict() + + ws.log.Info( + "evicted archive writer", + "webhook_id", webhookID, + "path", w.path, + ) + } +} + +// EvictAll evicts every cached archive writer, exactly as EvictTarget +// does for one target. The delivery engine calls it at shutdown, once +// its workers have returned. Closing the last handle on an archive +// moves the contents of its -wal into the .db and removes the +// -wal, so a clean stop leaves each archive as a single file. +func (ws *Writers) EvictAll() { + ws.mu.Lock() + + writers := ws.writers + ws.writers = nil + + ws.mu.Unlock() + + for _, w := range writers { + w.evict() + } +} + +// writerFor returns the archive writer for a database target, +// creating and caching it on first use. Each target has one writer +// so its close/reopen debounce state is shared across concurrent +// deliveries, and so a rename and the idle sweep take the same lock +// as its writes. +func (ws *Writers) writerFor( + targetID string, +) (*archiveWriter, error) { + ws.mu.Lock() + defer ws.mu.Unlock() + + w, ok := ws.writers[targetID] + if !ok { + var err error + + w, err = ws.newWriter(targetID) + if err != nil { + return nil, err + } + + if ws.writers == nil { + ws.writers = make(map[string]*archiveWriter) + } + + ws.writers[targetID] = w + } + + // A delivery claims the entry: even if the idle sweep created + // it moments ago, it now belongs to the registry proper and + // the sweep must leave it in place when it finishes. + w.sweepOwned = false + + return w, nil +} + +// sweepWriterFor returns the archive writer the idle sweep should +// prune a target's archive through, together with whether the sweep +// itself created the registry entry. +// +// The sweep must route its prune through the registered writer so +// the writer's mutex orders it against concurrent writes, but it +// must never leave a registry entry behind: a sweep that ran +// concurrently with the target's deletion would otherwise +// re-create an entry that nothing will ever evict again, which is +// exactly the leak eviction exists to prevent. An entry the sweep +// creates is therefore marked sweep-owned and handed back to +// releaseSweepWriter when the sweep is done. +func (ws *Writers) sweepWriterFor( + targetID string, +) (*archiveWriter, bool, error) { + ws.mu.Lock() + defer ws.mu.Unlock() + + w, ok := ws.writers[targetID] + if ok { + return w, false, nil + } + + w, err := ws.newWriter(targetID) + if err != nil { + return nil, false, err + } + + if ws.writers == nil { + ws.writers = make(map[string]*archiveWriter) + } + + w.sweepOwned = true + ws.writers[targetID] = w + + return w, true, nil +} + +// releaseSweepWriter drops a registry entry that the idle sweep +// created, so a sweep leaves the registry exactly as it found it. +// +// The entry is removed only if it is still the very writer the +// sweep installed and no delivery has claimed it in the meantime +// (writerFor clears sweepOwned when it hands a writer to the +// write path). Both conditions are evaluated under the registry +// lock, so an eviction that raced the sweep — which removes the +// entry outright — simply finds nothing left to do here, and a +// delivery that adopted the writer keeps a registered, evictable +// one. +func (ws *Writers) releaseSweepWriter( + targetID string, w *archiveWriter, +) { + ws.mu.Lock() + defer ws.mu.Unlock() + + cur, ok := ws.writers[targetID] + if !ok || cur != w || !cur.sweepOwned { + return + } + + delete(ws.writers, targetID) +} + +// newWriter builds the writer for a database target's archive. Its +// path is the one Path gives for the webhook and the target as +// the main database names them now; from then on only rename changes +// the name the writer uses. It does not touch the archive files. +func (ws *Writers) newWriter( + targetID string, +) (*archiveWriter, error) { + if ws.dbMgr == nil { + return nil, errArchiveNoDataDir + } + + var target database.Target + + err := ws.db.DB(). + Preload("Webhook"). + First(&target, "id = ?", targetID).Error + if err != nil { + return nil, fmt.Errorf( + "loading database target %s: %w", targetID, err, + ) + } + + w := newArchiveWriter( + Path(ws.dbMgr, &target.Webhook, &target), + ws.log, + ) + w.webhookID = target.WebhookID + + return w, nil +} + +// sweepArchive prunes one database target's archive of rows older +// than expiry, without requiring a write. A missing archive file is +// left missing (see sweepExpired), so a sweep never creates an +// archive for a target that has never received an event. +// +// It also never leaves a registry entry behind: an entry it had +// to create to reach the writer's mutex is released again once +// the prune is done, so a sweep racing a target deletion cannot +// resurrect the writer the eviction just dropped. +func (ws *Writers) sweepArchive( + targetID string, expiry time.Duration, +) error { + w, created, err := ws.sweepWriterFor(targetID) + if err != nil { + return err + } + + if created { + defer ws.releaseSweepWriter(targetID, w) + } + + return w.sweepExpired(expiry) +} diff --git a/internal/archive/archive_test.go b/internal/archive/archive_test.go new file mode 100644 index 0000000..0c0031b --- /dev/null +++ b/internal/archive/archive_test.go @@ -0,0 +1,773 @@ +package archive_test + +import ( + "database/sql" + "fmt" + "io/fs" + "log/slog" + "net/http" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/driver/sqlite" + "gorm.io/gorm" + _ "modernc.org/sqlite" // Pure Go SQLite driver. + "sneak.berlin/go/webhooker/internal/archive" + "sneak.berlin/go/webhooker/internal/database" + "sneak.berlin/go/webhooker/internal/gormlog" +) + +const ( + // testContentType is the event content type used in tests. + testContentType = "application/json" + + // eventQuery is the query string an archived event carries. + eventQuery = "a=1&b=2" +) + +func archiveTestLogger() *slog.Logger { + return slog.New(slog.NewTextHandler( + os.Stderr, + &slog.HandlerOptions{Level: slog.LevelDebug}, + )) +} + +// openArchiveDBForRead opens an archive file read-only so a +// test can inspect the rows the writer persisted. +func openArchiveDBForRead( + t *testing.T, path string, +) *gorm.DB { + t.Helper() + + sqlDB, err := sql.Open( + "sqlite", + fmt.Sprintf("file:%s?mode=ro", path), + ) + require.NoError(t, err) + + t.Cleanup(func() { _ = sqlDB.Close() }) + + gdb, err := gorm.Open( + sqlite.Dialector{Conn: sqlDB}, + &gorm.Config{Logger: gormlog.New(slog.New(slog.DiscardHandler))}, + ) + require.NoError(t, err) + + return gdb +} + +// archiveFileSuffixes returns the archive file itself and the +// SQLite sidecars that accompany an open database. A test that +// asserts no archive was created has to check all of them. +func archiveFileSuffixes() []string { + return []string{"", "-wal", "-shm"} +} + +// removeArchiveFiles simulates an operator moving the archive +// away by deleting the SQLite file and its sidecar files. +func removeArchiveFiles(t *testing.T, path string) { + t.Helper() + + for _, suffix := range []string{ + "", "-wal", "-shm", "-journal", + } { + err := os.Remove(path + suffix) + if err != nil && !os.IsNotExist(err) { + t.Fatalf("removing %s%s: %v", path, suffix, err) + } + } +} + +// assertStoredTime asserts that text, a timestamp as SQLite holds it, +// is in the form GORM stores created_at in: the layout of +// time.Time.String, with no " m=+…" monotonic clock reading after it. +func assertStoredTime(t *testing.T, text string) { + t.Helper() + + _, err := time.Parse( + "2006-01-02 15:04:05.999999999 -0700 MST", text, + ) + assert.NoError(t, err, "stored as %q", text) +} + +// newEvent returns an event received now with the given body, as the +// webhook handler stores it. +func newEvent(body string) database.Event { + return database.Event{ + BaseModel: database.BaseModel{ + ID: uuid.New().String(), + CreatedAt: time.Now(), + }, + WebhookID: uuid.New().String(), + EntrypointID: uuid.New().String(), + Method: http.MethodPost, + Headers: `{"Content-Type":["application/json"]}`, + Body: body, + ContentType: testContentType, + } +} + +// databaseTargetDelivery returns a delivery of event to a database +// target, as the database target hands it to Writers.Write. +func databaseTargetDelivery( + event database.Event, tgt *database.Target, +) *database.Delivery { + return &database.Delivery{ + EventID: event.ID, + TargetID: tgt.ID, + Event: event, + Target: database.Target{ + Name: tgt.Name, + Type: database.TargetTypeDatabase, + Config: tgt.Config, + }, + } +} + +func TestArchiveWriter_WritesRow(t *testing.T) { + t.Parallel() + + path := filepath.Join(t.TempDir(), "archive-wh.db") + w := archive.NewExportArchiveWriter( + path, archiveTestLogger(), 0, + ) + + row := archive.ExportArchivedEvent{ + EventID: "ev-1", + WebhookID: "wh-1", + EntrypointID: "ep-1", + Method: "POST", + Headers: `{"X":"Y"}`, + Body: `{"hello":"world"}`, + ContentType: "application/json", + } + + require.NoError(t, w.Write(row, 0)) + assert.FileExists(t, path) + + var got []archive.ExportArchivedEvent + + require.NoError(t, w.DB().Find(&got).Error) + require.Len(t, got, 1) + assert.Equal(t, "ev-1", got[0].EventID) + assert.Equal(t, "wh-1", got[0].WebhookID) + assert.Equal(t, "ep-1", got[0].EntrypointID) + assert.Equal(t, row.Method, got[0].Method) + assert.Equal(t, row.ContentType, got[0].ContentType) + assert.JSONEq(t, `{"hello":"world"}`, got[0].Body) + assert.False(t, got[0].ArchivedAt.IsZero()) + + var archivedAt string + + require.NoError(t, w.DB().Raw( + "SELECT CAST(archived_at AS TEXT) FROM archived_events", + ).Row().Scan(&archivedAt)) + assertStoredTime(t, archivedAt) +} + +func TestArchiveWriter_RecreatesAfterRemoval( + t *testing.T, +) { + t.Parallel() + + path := filepath.Join(t.TempDir(), "archive-wh.db") + w := archive.NewExportArchiveWriter( + path, archiveTestLogger(), 0, + ) + + require.NoError(t, w.Write( + archive.ExportArchivedEvent{EventID: "a"}, 0, + )) + assert.FileExists(t, path) + + // The operator moves the archive away while the handle is + // still open. + removeArchiveFiles(t, path) + require.NoFileExists(t, path) + + // The next write recreates the file with a fresh schema and + // only the new row. + require.NoError(t, w.Write( + archive.ExportArchivedEvent{EventID: "b"}, 0, + )) + assert.FileExists(t, path) + + var got []archive.ExportArchivedEvent + + require.NoError(t, w.DB().Find(&got).Error) + require.Len(t, got, 1) + assert.Equal(t, "b", got[0].EventID) +} + +// TestArchiveHandleIsWAL closes the last gap in the durability +// evidence: the main and per-webhook tiers each assert their journal +// mode on a live handle, and the archive tier gets its settings from +// the same code path but nothing checked the running file. +func TestArchiveHandleIsWAL(t *testing.T) { + t.Parallel() + + w := archive.NewExportArchiveWriter( + filepath.Join(t.TempDir(), "archive-wal.db"), + archiveTestLogger(), 0, + ) + + require.NoError(t, w.Open(0)) + + var mode string + + row := w.DB().Raw("pragma journal_mode").Row() + require.NoError(t, row.Scan(&mode)) + assert.Equal(t, "wal", strings.ToLower(mode)) + + var busy string + + row = w.DB().Raw("pragma busy_timeout").Row() + require.NoError(t, row.Scan(&busy)) + assert.Equal(t, "10000", busy) +} + +// TestStatArchive proves Stat finds no file before the first +// write; after a write still held in the -wal, counts the -wal in the +// size and takes its later time as the last write; and finds no file +// again once the file has been moved away. +func TestStatArchive(t *testing.T) { + t.Parallel() + + path := filepath.Join(t.TempDir(), "archive-wh.db") + + _, err := archive.Stat(path) + require.ErrorIs(t, err, fs.ErrNotExist) + + // With the clock stopped, the reopen debounce never passes, so + // the handle stays open after the write. + stopped := time.Now() + w := archive.NewExportArchiveWriter(path, archiveTestLogger(), 0) + w.SetNow(func() time.Time { return stopped }) + + require.NoError(t, w.Write(archive.ExportArchivedEvent{EventID: "a"}, 0)) + + written := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC) + earlier := written.Add(-time.Hour) + require.NoError(t, os.Chtimes(path, earlier, earlier)) + require.NoError(t, os.Chtimes(path+"-wal", written, written)) + + file, err := os.Stat(path) + require.NoError(t, err) + + wal, err := os.Stat(path + "-wal") + require.NoError(t, err) + require.Positive(t, wal.Size()) + + got, err := archive.Stat(path) + require.NoError(t, err) + assert.Equal(t, 1, got.Files) + assert.Equal(t, file.Size()+wal.Size(), got.Size) + assert.True(t, written.Equal(got.Written), got.Written) + + removeArchiveFiles(t, path) + + _, err = archive.Stat(path) + require.ErrorIs(t, err, fs.ErrNotExist) +} + +func TestArchiveWriter_ReopenDebounce(t *testing.T) { + t.Parallel() + + const debounce = 2 * time.Second + + path := filepath.Join(t.TempDir(), "archive-wh.db") + w := archive.NewExportArchiveWriter( + path, archiveTestLogger(), debounce, + ) + + // The writer measures its reopen debounce on this clock, which + // only the test moves, so how long the host takes between + // writes cannot change the result. + now := time.Now() + + w.SetNow(func() time.Time { return now }) + + require.NoError(t, w.Write( + archive.ExportArchivedEvent{EventID: "a"}, 0, + )) + require.NoError(t, w.Write( + archive.ExportArchivedEvent{EventID: "b"}, 0, + )) + + // Two writes inside the debounce window trigger only the + // initial open — no extra close/reopen. + assert.Equal(t, 1, w.Reopens()) + + now = now.Add(debounce) + + require.NoError(t, w.Write( + archive.ExportArchivedEvent{EventID: "c"}, 0, + )) + + // A write after the window elapses closes and reopens once. + assert.Equal(t, 2, w.Reopens()) +} + +func TestArchiveWriter_ExpiryPrune(t *testing.T) { + t.Parallel() + + path := filepath.Join(t.TempDir(), "archive-wh.db") + w := archive.NewExportArchiveWriter( + path, archiveTestLogger(), 0, + ) + + require.NoError(t, w.Open(0)) + + old := archive.ExportArchivedEvent{ + EventID: "old", + ArchivedAt: time.Now().Add(-2 * time.Hour), + } + fresh := archive.ExportArchivedEvent{ + EventID: "fresh", + ArchivedAt: time.Now(), + } + + require.NoError(t, w.DB().Create(&old).Error) + require.NoError(t, w.DB().Create(&fresh).Error) + + // Reopening with a one-hour expiry prunes the old row. + require.NoError(t, w.Reopen(time.Hour)) + + var got []archive.ExportArchivedEvent + + require.NoError(t, w.DB().Find(&got).Error) + require.Len(t, got, 1) + assert.Equal(t, "fresh", got[0].EventID) +} + +func TestParseArchiveExpiry(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + in string + want time.Duration + wantErr bool + }{ + {"empty config", "", 0, false}, + {"explicit never", `{"expiry":"never"}`, 0, false}, + {"empty expiry", `{"expiry":""}`, 0, false}, + {"duration", `{"expiry":"1h"}`, time.Hour, false}, + {"unparseable", `{"expiry":"nonsense"}`, 0, true}, + {"zero duration", `{"expiry":"0s"}`, 0, true}, + {"negative duration", `{"expiry":"-5h"}`, 0, true}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + got, err := archive.ParseExpiry(tc.in) + if tc.wantErr { + require.Error(t, err) + + return + } + + require.NoError(t, err) + assert.Equal(t, tc.want, got) + }) + } +} + +func TestValidateArchiveExpiry(t *testing.T) { + t.Parallel() + + valid := []string{"", "never", "1h", "720h", "30m"} + for _, in := range valid { + require.NoError(t, + archive.ValidateExpiry(in), + "expiry %q should be accepted", in, + ) + } + + invalid := []string{"nonsense", "7d", "-5h", "0s", "0"} + for _, in := range invalid { + require.Error(t, + archive.ValidateExpiry(in), + "expiry %q should be rejected", in, + ) + } +} + +// TestArchiveFileName pins the archive file name and the rules +// that make a webhook or target name safe to put in it. +func TestArchiveFileName(t *testing.T) { + t.Parallel() + + const id = "3f2a1c9e-8d4b-4c1a-9e2f-0a1b2c3d4e5f" + + cases := []struct { + name string + webhook string + target string + want string + }{ + { + "plain names", "orders", "archive", + "archive-orders-archive-" + id + ".db", + }, + { + "lowercased", "Orders", "Main Archive", + "archive-orders-main-archive-" + id + ".db", + }, + { + "a run of other characters is one dash", + `a /\..b`, "c__--d", + "archive-a-b-c-d-" + id + ".db", + }, + { + "no dash at either end", " --orders!! ", "(archive)", + "archive-orders-archive-" + id + ".db", + }, + { + "path separators", "../../etc/passwd", "a/b", + "archive-etc-passwd-a-b-" + id + ".db", + }, + { + "letters outside ASCII are dropped", + "Bestellungen Größe", "café", + "archive-bestellungen-gr-e-caf-" + id + ".db", + }, + { + "nothing left is unnamed", "", "!!!", + "archive-unnamed-unnamed-" + id + ".db", + }, + { + "cut to 40 characters", strings.Repeat("a", 50), "x", + "archive-" + strings.Repeat("a", 40) + "-x-" + id + ".db", + }, + { + "no dash left by the cut", + strings.Repeat("a", 39) + " b", "x", + "archive-" + strings.Repeat("a", 39) + "-x-" + id + ".db", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + assert.Equal( + t, tc.want, + archive.FileName(tc.webhook, tc.target, id), + ) + }) + } +} + +// TestWrite_EachTargetHasItsOwnArchive proves two +// database targets of one webhook archive into separate files. +func TestWrite_EachTargetHasItsOwnArchive(t *testing.T) { + t.Parallel() + + env := setupArchiveTest(t) + first := env.seedDatabaseTarget(t, "") + second := env.addDatabaseTarget(t, first.WebhookID, "") + + event := newEvent(`{"n":1}`) + + for _, tgt := range []*database.Target{first, second} { + require.NoError(t, env.writers.Write( + databaseTargetDelivery(event, tgt), + )) + } + + require.NotEqual( + t, env.archivePath(first), env.archivePath(second), + ) + assert.Equal( + t, []string{event.ID}, + archivedEventIDs(t, env.archivePath(first)), + ) + assert.Equal( + t, []string{event.ID}, + archivedEventIDs(t, env.archivePath(second)), + ) +} + +// TestRename_MovesTheFile proves a rename moves the archive, rows +// and all, and that later writes go to the new name. +func TestRename_MovesTheFile(t *testing.T) { + t.Parallel() + + env := setupArchiveTest(t) + tgt := env.seedDatabaseTarget(t, "") + oldPath := env.archivePath(tgt) + + first := newEvent(`{"n":1}`) + require.NoError(t, env.writers.Write(databaseTargetDelivery(first, tgt))) + require.FileExists(t, oldPath) + + require.NoError( + t, env.writers.Rename(tgt.ID, "Orders", "Long Term"), + ) + + newPath := filepath.Join( + env.dataDir, "archive-orders-long-term-"+tgt.ID+".db", + ) + + assert.NoFileExists(t, oldPath) + assert.Equal(t, []string{first.ID}, archivedEventIDs(t, newPath)) + + second := newEvent(`{"n":2}`) + require.NoError(t, env.writers.Write(databaseTargetDelivery(second, tgt))) + + assert.ElementsMatch( + t, []string{first.ID, second.ID}, + archivedEventIDs(t, newPath), + ) + assert.NoFileExists( + t, oldPath, "a write after the rename must use the new name", + ) +} + +// TestRename_NeverReplacesAFile plants a file at the new name, once +// the .db alone, once a lone -wal and once a lone -shm, and proves +// each time that the rename is refused, the planted file survives, +// and the archive keeps its name and its rows. +func TestRename_NeverReplacesAFile(t *testing.T) { + t.Parallel() + + for _, suffix := range archiveFileSuffixes() { + t.Run("planted .db"+suffix, func(t *testing.T) { + t.Parallel() + + env := setupArchiveTest(t) + tgt := env.seedDatabaseTarget(t, "") + oldPath := env.archivePath(tgt) + + first := newEvent(`{"n":1}`) + require.NoError( + t, env.writers.Write(databaseTargetDelivery(first, tgt)), + ) + + newPath := filepath.Join( + env.dataDir, "archive-orders-long-term-"+tgt.ID+".db", + ) + plantedPath := newPath + suffix + require.NoError( + t, os.WriteFile(plantedPath, []byte("planted"), 0o600), + ) + + require.ErrorIs( + t, env.writers.Rename(tgt.ID, "Orders", "Long Term"), + archive.ErrNameTaken, + ) + + //nolint:gosec // reads the file the test planted under t.TempDir() + planted, err := os.ReadFile(plantedPath) + require.NoError(t, err) + assert.Equal(t, "planted", string(planted)) + + second := newEvent(`{"n":2}`) + require.NoError( + t, env.writers.Write(databaseTargetDelivery(second, tgt)), + ) + + assert.ElementsMatch( + t, []string{first.ID, second.ID}, + archivedEventIDs(t, oldPath), + ) + }) + } +} + +// TestRename_BeforeTheNameIsSaved covers the order the handlers +// use: they rename before they save the new name, so a delivery in +// between must write under the new name although the main database +// still has the old one. It also shows that renaming an archive that +// does not exist yet is not an error. +func TestRename_BeforeTheNameIsSaved(t *testing.T) { + t.Parallel() + + env := setupArchiveTest(t) + tgt := env.seedDatabaseTarget(t, "") + + require.NoError( + t, env.writers.Rename(tgt.ID, "Orders", "Archive"), + ) + + event := newEvent(`{"n":1}`) + require.NoError(t, env.writers.Write(databaseTargetDelivery(event, tgt))) + + assert.FileExists( + t, + filepath.Join( + env.dataDir, "archive-orders-archive-"+tgt.ID+".db", + ), + ) + assert.NoFileExists(t, env.archivePath(tgt)) +} + +// TestArchiveWriter_RenameMovesSidecars proves a rename carries +// the -wal and -shm a crash can leave beside an archive no handle +// has opened since. SQLite finds them by name, so a -wal left +// behind would lose the transactions it holds. +func TestArchiveWriter_RenameMovesSidecars(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + oldPath := filepath.Join(dir, "archive-old.db") + newPath := filepath.Join(dir, "archive-new.db") + + for _, suffix := range archiveFileSuffixes() { + require.NoError( + t, os.WriteFile(oldPath+suffix, []byte(suffix), 0o600), + ) + } + + w := archive.NewExportArchiveWriter( + oldPath, archiveTestLogger(), 0, + ) + + require.NoError(t, w.Rename("archive-new.db")) + + for _, suffix := range archiveFileSuffixes() { + assert.NoFileExists(t, oldPath+suffix) + assert.FileExists(t, newPath+suffix) + } + + assert.Equal(t, newPath, w.Path()) +} + +// TestArchiveWriter_RenameMovesBackOnFailure makes the -wal fail to +// move after the .db has moved, and proves the .db is moved back, so +// the archive is never split across two names. The new name is 255 +// bytes, the longest a file name may be, so the .db can take it but +// the -wal, four bytes longer, cannot. +func TestArchiveWriter_RenameMovesBackOnFailure(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + oldPath := filepath.Join(dir, "archive-old.db") + newName := strings.Repeat("a", 252) + ".db" + + for _, suffix := range archiveFileSuffixes() { + require.NoError( + t, os.WriteFile(oldPath+suffix, []byte(suffix), 0o600), + ) + } + + w := archive.NewExportArchiveWriter( + oldPath, archiveTestLogger(), 0, + ) + + require.Error(t, w.Rename(newName)) + + for _, suffix := range archiveFileSuffixes() { + assert.FileExists(t, oldPath+suffix) + } + + assert.NoFileExists(t, filepath.Join(dir, newName)) + assert.Equal(t, oldPath, w.Path()) +} + +// TestArchiveWriter_RenameMovesBackEveryFile renames a target with a +// file without a period and a file for a month, and proves that when +// the month's file fails to move, the file already moved is moved back: +// both files are under the old name with their rows, and nothing is +// under the new name. The new name is 251 bytes, so the file without a +// period, with its -wal and -shm, can take it, but the month's file, +// eight bytes longer, cannot. +func TestArchiveWriter_RenameMovesBackEveryFile(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + oldPath := filepath.Join(dir, "archive-old.db") + monthPath := filepath.Join(dir, "archive-old-2026-03.db") + w := archive.NewExportArchiveWriter(oldPath, archiveTestLogger(), 0) + + require.NoError(t, w.WritePeriod( + archive.ExportArchivedEvent{EventID: "in-none"}, 0, "", + )) + require.NoError(t, w.WritePeriod( + archive.ExportArchivedEvent{EventID: "in-month"}, 0, "2026-03", + )) + + require.Error(t, w.Rename(strings.Repeat("a", 248)+".db")) + + assert.Equal(t, []string{"in-none"}, archivedEventIDs(t, oldPath)) + assert.Equal(t, []string{"in-month"}, archivedEventIDs(t, monthPath)) + + entries, err := os.ReadDir(dir) + require.NoError(t, err) + + for _, entry := range entries { + assert.True(t, strings.HasPrefix(entry.Name(), "archive-old"), + "%s is not under the old name", entry.Name()) + } + + assert.Equal(t, oldPath, w.Path()) +} + +// TestRenameLock_HeldByOneEditAtATime holds the rename lock, as an edit +// does from loading the stored names until it has saved them. While it +// is held, the edit's rename and an archive write still finish, since +// neither takes it, and the event ends up under the new name whichever +// ran first. A second edit waits for the lock until the first lets go. +func TestRenameLock_HeldByOneEditAtATime(t *testing.T) { + t.Parallel() + + env := setupArchiveTest(t) + tgt := env.seedDatabaseTarget(t, "") + event := newEvent(`{"n":1}`) + + env.writers.RenameLock().Lock() + + finished := make(chan error, 2) + + go func() { + finished <- env.writers.Rename(tgt.ID, "Orders", "Long Term") + }() + go func() { + finished <- env.writers.Write(databaseTargetDelivery(event, tgt)) + }() + + for range 2 { + select { + case err := <-finished: + require.NoError(t, err) + case <-time.After(10 * time.Second): + t.Fatal("a rename or a write waited for the rename lock") + } + } + + newPath := filepath.Join( + env.dataDir, "archive-orders-long-term-"+tgt.ID+".db", + ) + assert.Equal(t, []string{event.ID}, archivedEventIDs(t, newPath)) + + secondEdit := make(chan struct{}) + + go func() { + env.writers.RenameLock().Lock() + close(secondEdit) + env.writers.RenameLock().Unlock() + }() + + select { + case <-secondEdit: + t.Fatal("a second edit took the rename lock while it was held") + case <-time.After(100 * time.Millisecond): + } + + env.writers.RenameLock().Unlock() + + select { + case <-secondEdit: + case <-time.After(10 * time.Second): + t.Fatal("a second edit did not get the rename lock once released") + } +} diff --git a/internal/delivery/target_database_export.go b/internal/archive/download.go similarity index 89% rename from internal/delivery/target_database_export.go rename to internal/archive/download.go index 450c60e..39d349f 100644 --- a/internal/delivery/target_database_export.go +++ b/internal/archive/download.go @@ -1,4 +1,4 @@ -package delivery +package archive import ( "compress/gzip" @@ -28,25 +28,25 @@ import ( const archiveTableQuery = "SELECT count(*) FROM sqlite_master " + "WHERE type = 'table' AND name = 'archived_events'" -// ArchivePath returns where a database target's archive file is: in +// Path returns where a database target's archive file is: in // the data directory, beside the webhook's event database, under the -// name ArchiveFileName gives it. -func ArchivePath( +// name FileName gives it. +func Path( dbMgr *database.WebhookDBManager, webhook *database.Webhook, target *database.Target, ) string { return filepath.Join( filepath.Dir(dbMgr.DBPath(webhook.ID)), - ArchiveFileName(webhook.Name, target.Name, target.ID), + FileName(webhook.Name, target.Name, target.ID), ) } -// ArchiveExportFileName returns the name a database target's archive +// ExportFileName returns the name a database target's archive // downloads under: // archive-WEBHOOKNAME-TARGETNAME-YYYYMMDDTHHMMSSZ.json.gz, the names -// made safe as in ArchiveFileName and the time in UTC. -func ArchiveExportFileName( +// made safe as in FileName and the time in UTC. +func ExportFileName( webhookName, targetName string, at time.Time, ) string { return "archive-" + archiveNamePart(webhookName) + "-" + @@ -54,7 +54,7 @@ func ArchiveExportFileName( at.UTC().Format("20060102T150405Z") + ".json.gz" } -// ArchiveExport is a database target's archive listed for download. It +// Export is a database target's archive listed for download. It // opens one of the target's files at a time, only when its rows are // about to be written out, and closes it before it opens the next, so // an export holds at most one file open however many the target has. @@ -66,7 +66,7 @@ func ArchiveExportFileName( // is open, and the export does not see them. SQLite cannot checkpoint // a -wal past an open snapshot, so the open file's -wal grows until the // export has written that file out. -type ArchiveExport struct { +type Export struct { // periods are the periods of the target's files when the export // was listed, "" for the file without one, in the order // archiveFiles lists them. @@ -77,7 +77,7 @@ type ArchiveExport struct { // between. lock sync.Locker - // currentPath returns the path ArchivePath gives the target under + // currentPath returns the path Path gives the target under // the names stored for it now, which a rename may have changed since // the export was listed. currentPath func() (string, error) @@ -103,11 +103,12 @@ type exportedName struct { Name string `json:"name"` } -// NewArchiveExport lists a database target's archive files for export, -// given the path ArchivePath gives it (see archiveFiles). It opens none +// NewExport lists a database target's archive files for export, +// given the path Path gives it (see archiveFiles). It opens none // of them. Its caller holds lock, which every rename of the target's -// files runs under, from reading the names path is made of until it -// returns, so the files it lists are the ones those names give. +// files runs under (Writers.RenameLock returns it), from reading the names +// path is made of until it returns, so the files it lists are the ones +// those names give. // // WriteGzipJSON, called without lock held, finds each file again by its // period under the path currentPath gives, holding lock while it does @@ -115,18 +116,18 @@ type exportedName struct { // file. A file that is gone by then, emptied by the sweep or moved // away, is skipped. The export never creates a file: with no files, it // has no rows. -func NewArchiveExport( +func NewExport( path string, lock sync.Locker, currentPath func() (string, error), log *slog.Logger, -) (*ArchiveExport, error) { +) (*Export, error) { files, err := archiveFiles(path) if err != nil { return nil, err } - x := &ArchiveExport{lock: lock, currentPath: currentPath, log: log} + x := &Export{lock: lock, currentPath: currentPath, log: log} for _, file := range files { x.periods = append(x.periods, file.period) @@ -199,7 +200,7 @@ func openExportFile( // closed once its rows are written, before the next is opened. When it // returns, no file is open. After an error the gzip stream is left // unfinished, so what was written does not decompress as a whole file. -func (x *ArchiveExport) WriteGzipJSON( +func (x *Export) WriteGzipJSON( ctx context.Context, w io.Writer, webhook *database.Webhook, @@ -228,7 +229,7 @@ func (x *ArchiveExport) WriteGzipJSON( // openFile finds the target's archive file for period under the path // currentPath gives now, and opens it for the export, holding x.lock // for both. For a file that is gone, the error wraps fs.ErrNotExist. -func (x *ArchiveExport) openFile( +func (x *Export) openFile( ctx context.Context, period string, ) (*exportFile, error) { x.lock.Lock() @@ -258,7 +259,7 @@ func (f *exportFile) close() error { // writeJSON writes head with archived_events added as its last key, // the rows going into it one at a time. -func (x *ArchiveExport) writeJSON( +func (x *Export) writeJSON( ctx context.Context, w io.Writer, head []byte, ) error { // head goes out without its closing brace, so that @@ -286,7 +287,7 @@ func (x *ArchiveExport) writeJSON( // writeRows writes the archived rows of each file to w, one per line, // separated by commas, opening each file in turn and closing it once // its rows are written. -func (x *ArchiveExport) writeRows(ctx context.Context, w io.Writer) error { +func (x *Export) writeRows(ctx context.Context, w io.Writer) error { sep := "\n" for _, period := range x.periods { diff --git a/internal/delivery/target_database_export_test.go b/internal/archive/download_test.go similarity index 90% rename from internal/delivery/target_database_export_test.go rename to internal/archive/download_test.go index fc6dd61..1e1a644 100644 --- a/internal/delivery/target_database_export_test.go +++ b/internal/archive/download_test.go @@ -1,4 +1,4 @@ -package delivery_test +package archive_test import ( "bufio" @@ -19,8 +19,8 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "sneak.berlin/go/webhooker/internal/archive" "sneak.berlin/go/webhooker/internal/database" - "sneak.berlin/go/webhooker/internal/delivery" ) // The webhook and the target the export tests' archives belong to. @@ -37,7 +37,7 @@ const binaryBody = "\xff\xfe\x00\x01binary\x80" // writeExportTo writes export to w as the archive of the export tests' // webhook and target, exported at 2026-10-02T12:03:04Z. func writeExportTo( - t *testing.T, export *delivery.ArchiveExport, w io.Writer, + t *testing.T, export *archive.Export, w io.Writer, ) error { t.Helper() @@ -57,7 +57,7 @@ func writeExportTo( // listExport lists the archive at path for export, as the archive of a // target whose names do not change. -func listExport(t *testing.T, path string) *delivery.ArchiveExport { +func listExport(t *testing.T, path string) *archive.Export { t.Helper() return newExport(t, path, &sync.Mutex{}, func() (string, error) { @@ -73,10 +73,10 @@ func newExport( path string, lock sync.Locker, currentPath func() (string, error), -) *delivery.ArchiveExport { +) *archive.Export { t.Helper() - export, err := delivery.NewArchiveExport( + export, err := archive.NewExport( path, lock, currentPath, archiveTestLogger(), ) require.NoError(t, err) @@ -96,7 +96,7 @@ func exportArchive(t *testing.T, path string) map[string]any { // decompressed and parsed. Reading to the end makes the gzip reader // check that the stream was finished. func writeExport( - t *testing.T, export *delivery.ArchiveExport, + t *testing.T, export *archive.Export, ) map[string]any { t.Helper() @@ -157,11 +157,11 @@ func TestArchiveExport_MatchesStoredRows(t *testing.T) { t.Parallel() path := filepath.Join(t.TempDir(), "archive.db") - w := delivery.NewExportArchiveWriter(path, archiveTestLogger(), 0) + w := archive.NewExportArchiveWriter(path, archiveTestLogger(), 0) bodies := []string{`{"order":1}`, "plain text", "", binaryBody} for i, body := range bodies { - require.NoError(t, w.Write(delivery.ExportArchivedEvent{ + require.NoError(t, w.Write(archive.ExportArchivedEvent{ EventID: fmt.Sprintf("ev-%d", i), WebhookID: exportWebhookID, EntrypointID: "ep-1", @@ -173,7 +173,7 @@ func TestArchiveExport_MatchesStoredRows(t *testing.T) { }, 0)) } - var stored []delivery.ExportArchivedEvent + var stored []archive.ExportArchivedEvent require.NoError(t, openArchiveDBForRead(t, path). Order("id").Find(&stored).Error) @@ -201,7 +201,7 @@ func TestArchiveExport_MatchesStoredRows(t *testing.T) { // assertExportedRow checks that ev, from an export, holds every column // of the stored row. func assertExportedRow( - t *testing.T, row delivery.ExportArchivedEvent, ev map[string]any, + t *testing.T, row archive.ExportArchivedEvent, ev map[string]any, ) { t.Helper() @@ -248,7 +248,7 @@ func TestArchiveExport_Empty(t *testing.T) { require.NoError(t, os.WriteFile(noTable, nil, 0o600)) require.NoError(t, - delivery.NewExportArchiveWriter(noRows, archiveTestLogger(), 0). + archive.NewExportArchiveWriter(noRows, archiveTestLogger(), 0). Open(0), ) @@ -284,18 +284,18 @@ func TestArchiveExport_ReadsOneSnapshot(t *testing.T) { t.Parallel() path := filepath.Join(t.TempDir(), "archive.db") - w := delivery.NewExportArchiveWriter(path, archiveTestLogger(), 0) + w := archive.NewExportArchiveWriter(path, archiveTestLogger(), 0) - require.NoError(t, w.Write(delivery.ExportArchivedEvent{EventID: "listed"}, 0)) + require.NoError(t, w.Write(archive.ExportArchivedEvent{EventID: "listed"}, 0)) opened := &unlockHook{fn: func() { - require.NoError(t, w.Write(delivery.ExportArchivedEvent{EventID: "during"}, 0)) + require.NoError(t, w.Write(archive.ExportArchivedEvent{EventID: "during"}, 0)) }} export := newExport(t, path, opened, func() (string, error) { return path, nil }) - require.NoError(t, w.Write(delivery.ExportArchivedEvent{EventID: "before-open"}, 0)) + require.NoError(t, w.Write(archive.ExportArchivedEvent{EventID: "before-open"}, 0)) assert.Equal(t, []string{"listed", "before-open"}, @@ -305,7 +305,7 @@ func TestArchiveExport_ReadsOneSnapshot(t *testing.T) { var stored int64 require.NoError(t, openArchiveDBForRead(t, path). - Model(&delivery.ExportArchivedEvent{}).Count(&stored).Error) + Model(&archive.ExportArchivedEvent{}).Count(&stored).Error) assert.Equal(t, int64(3), stored) } @@ -318,11 +318,11 @@ func TestArchiveExport_FindsFilesAfterRename(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "archive-old.db") - w := delivery.NewExportArchiveWriter(path, archiveTestLogger(), 0) + w := archive.NewExportArchiveWriter(path, archiveTestLogger(), 0) for _, period := range []string{"", dayPeriod, hourPeriod} { require.NoError(t, w.WritePeriod( - delivery.ExportArchivedEvent{EventID: "in-" + period}, 0, period, + archive.ExportArchivedEvent{EventID: "in-" + period}, 0, period, )) } @@ -353,19 +353,19 @@ func TestArchiveExport_EveryFileOldestFirst(t *testing.T) { t.Parallel() path := filepath.Join(t.TempDir(), "archive-wh.db") - w := delivery.NewExportArchiveWriter(path, archiveTestLogger(), 0) + w := archive.NewExportArchiveWriter(path, archiveTestLogger(), 0) // Written in an order that is not the export's. for _, period := range []string{nextDayPeriod, "", hourPeriod, "2026-03"} { require.NoError(t, w.WritePeriod( - delivery.ExportArchivedEvent{EventID: "in-" + period}, 0, period, + archive.ExportArchivedEvent{EventID: "in-" + period}, 0, period, )) } export := listExport(t, path) require.NoError(t, w.WritePeriod( - delivery.ExportArchivedEvent{EventID: "later"}, 0, "2026-03-06", + archive.ExportArchivedEvent{EventID: "later"}, 0, "2026-03-06", )) events := exportedEvents(t, writeExport(t, export)) @@ -434,13 +434,13 @@ func TestArchiveExport_OneFileOpenAtATime(t *testing.T) { require.NoError(t, err) path := filepath.Join(dir, "archive-wh.db") - w := delivery.NewExportArchiveWriter(path, archiveTestLogger(), 0) + w := archive.NewExportArchiveWriter(path, archiveTestLogger(), 0) random := make([]byte, 36<<10) for hour := range 24 { _, _ = rand.Read(random) - require.NoError(t, w.WritePeriod(delivery.ExportArchivedEvent{ + require.NoError(t, w.WritePeriod(archive.ExportArchivedEvent{ Body: base64.StdEncoding.EncodeToString(random), }, 0, fmt.Sprintf("2026-10-01-%02d", hour))) } @@ -489,7 +489,7 @@ func exportHeapGrowth(t *testing.T, rows, bodySize int) uint64 { t.Helper() path := filepath.Join(t.TempDir(), "archive.db") - w := delivery.NewExportArchiveWriter(path, archiveTestLogger(), 0) + w := archive.NewExportArchiveWriter(path, archiveTestLogger(), 0) // Base64 makes four characters of every three bytes. random := make([]byte, bodySize/4*3) @@ -497,7 +497,7 @@ func exportHeapGrowth(t *testing.T, rows, bodySize int) uint64 { for range rows { _, _ = rand.Read(random) - require.NoError(t, w.Write(delivery.ExportArchivedEvent{ + require.NoError(t, w.Write(archive.ExportArchivedEvent{ Body: base64.StdEncoding.EncodeToString(random), }, 0)) } @@ -565,7 +565,7 @@ func TestArchiveExportFileName(t *testing.T) { assert.Equal(t, "archive-orders-eu-long-term-archive-20261002T120304Z.json.gz", - delivery.ArchiveExportFileName( + archive.ExportFileName( exportWebhookName, exportTargetName, time.Date(2026, 10, 2, 14, 3, 4, 0, cest), ), diff --git a/internal/delivery/target_database_evict_test.go b/internal/archive/evict_test.go similarity index 81% rename from internal/delivery/target_database_evict_test.go rename to internal/archive/evict_test.go index 5022ba4..5e39c85 100644 --- a/internal/delivery/target_database_evict_test.go +++ b/internal/archive/evict_test.go @@ -1,7 +1,6 @@ -package delivery_test +package archive_test import ( - "context" "errors" "fmt" "net/http" @@ -13,8 +12,8 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "sneak.berlin/go/webhooker/internal/archive" "sneak.berlin/go/webhooker/internal/database" - "sneak.berlin/go/webhooker/internal/delivery" ) // deliverTo archives one event to a database target, leaving the @@ -24,12 +23,9 @@ func deliverTo( ) { t.Helper() - webhookDB := testWebhookDB(t) - event := seedEvent(t, webhookDB, `{"archived":true}`) + event := newEvent(`{"archived":true}`) - env.eng.ExportDeliverDatabase( - webhookDB, seedDatabaseTargetDelivery(t, webhookDB, event, tgt), - ) + require.NoError(t, env.writers.Write(databaseTargetDelivery(event, tgt))) } // TestEvictWebhook_ClosesAndRemovesWriter proves that evicting @@ -49,20 +45,20 @@ func TestEvictWebhook_ClosesAndRemovesWriter(t *testing.T) { deliverTo(t, env, tgt) require.True( - t, env.eng.ExportArchiveHandleOpen(tgt.ID), + t, env.writers.ExportArchiveHandleOpen(tgt.ID), "the writer should hold an open handle after a write", ) } - env.eng.EvictWebhook(first.WebhookID) + env.writers.EvictWebhook(first.WebhookID) for _, tgt := range []*database.Target{first, second} { assert.False( - t, env.eng.ExportHasArchiveWriter(tgt.ID), + t, env.writers.ExportHasArchiveWriter(tgt.ID), "eviction should remove the registry entry", ) assert.False( - t, env.eng.ExportArchiveHandleOpen(tgt.ID), + t, env.writers.ExportArchiveHandleOpen(tgt.ID), "eviction should close the archive handle", ) assert.FileExists( @@ -72,7 +68,7 @@ func TestEvictWebhook_ClosesAndRemovesWriter(t *testing.T) { } assert.True( - t, env.eng.ExportArchiveHandleOpen(other.ID), + t, env.writers.ExportArchiveHandleOpen(other.ID), "another webhook's writer must be left alone", ) } @@ -90,15 +86,15 @@ func TestEvictTarget_LeavesOtherTargets(t *testing.T) { deliverTo(t, env, doomed) deliverTo(t, env, kept) - env.eng.EvictTarget(doomed.ID) + env.writers.EvictTarget(doomed.ID) - assert.False(t, env.eng.ExportHasArchiveWriter(doomed.ID)) + assert.False(t, env.writers.ExportHasArchiveWriter(doomed.ID)) assert.FileExists( t, env.archivePath(doomed), "eviction must not delete the archive file", ) assert.True( - t, env.eng.ExportArchiveHandleOpen(kept.ID), + t, env.writers.ExportArchiveHandleOpen(kept.ID), "the other target's writer must be left alone", ) } @@ -112,21 +108,21 @@ func TestEvictWebhook_UnknownWebhookIsNoOp(t *testing.T) { env := setupArchiveTest(t) assert.NotPanics(t, func() { - env.eng.EvictWebhook("no-such-webhook") - env.eng.EvictWebhook("no-such-webhook") - env.eng.EvictTarget("no-such-target") - env.eng.EvictTarget("no-such-target") + env.writers.EvictWebhook("no-such-webhook") + env.writers.EvictWebhook("no-such-webhook") + env.writers.EvictTarget("no-such-target") + env.writers.EvictTarget("no-such-target") }) assert.False( - t, env.eng.ExportHasArchiveWriter("no-such-target"), + t, env.writers.ExportHasArchiveWriter("no-such-target"), "eviction must not create a writer", ) } // evictTestRow builds an archive row for the eviction tests. -func evictTestRow(eventID string) delivery.ExportArchivedEvent { - return delivery.ExportArchivedEvent{ +func evictTestRow(eventID string) archive.ExportArchivedEvent { + return archive.ExportArchivedEvent{ EventID: eventID, WebhookID: "wh-evict", Method: http.MethodPost, @@ -147,7 +143,7 @@ func TestEvictedWriter_WriteDoesNotReopenFile(t *testing.T) { path := filepath.Join(t.TempDir(), "archive-evicted.db") - w := delivery.NewExportArchiveWriter( + w := archive.NewExportArchiveWriter( path, archiveTestLogger(), 0, ) @@ -164,7 +160,7 @@ func TestEvictedWriter_WriteDoesNotReopenFile(t *testing.T) { err := w.Write(evictTestRow("ev-2"), 0) require.ErrorIs( - t, err, delivery.ErrExportArchiveWriterEvicted, + t, err, archive.ErrExportArchiveWriterEvicted, "an evicted writer must refuse writes", ) assert.NoFileExists( @@ -187,7 +183,7 @@ func TestEvictedWriter_SweepDoesNotReopenFile(t *testing.T) { path := filepath.Join(t.TempDir(), "archive-evicted.db") - w := delivery.NewExportArchiveWriter( + w := archive.NewExportArchiveWriter( path, archiveTestLogger(), 0, ) @@ -199,7 +195,7 @@ func TestEvictedWriter_SweepDoesNotReopenFile(t *testing.T) { err := w.SweepExpired(time.Hour) require.ErrorIs( - t, err, delivery.ErrExportArchiveWriterEvicted, + t, err, archive.ErrExportArchiveWriterEvicted, "an evicted writer must refuse an idle sweep", ) assert.False( @@ -230,7 +226,7 @@ const racingWriteGoroutines = 4 // against a hang, not a timing assumption: the first write after // the eviction is refused. func startRacingWrites( - w *delivery.ExportArchiveWriter, + w *archive.ExportArchiveWriter, ) *racingWrites { r := &racingWrites{ started: make(chan struct{}, racingWriteGoroutines), @@ -276,7 +272,7 @@ func (r *racingWrites) record(err error) { r.mu.Lock() defer r.mu.Unlock() - if errors.Is(err, delivery.ErrExportArchiveWriterEvicted) { + if errors.Is(err, archive.ErrExportArchiveWriterEvicted) { r.sawEvicted = true return @@ -320,7 +316,7 @@ func TestEvictWebhook_RacingWriteDoesNotReopenHandle( // eviction is about to detach. deliverTo(t, env, tgt) - w := env.eng.ExportArchiveWriterFor(tgt.ID) + w := env.writers.ExportArchiveWriterFor(tgt.ID) require.NotNil(t, w) require.True(t, w.HandleOpen()) @@ -330,7 +326,7 @@ func TestEvictWebhook_RacingWriteDoesNotReopenHandle( // eviction has to contend for the writer's mutex. race.awaitFirstWrite() - env.eng.EvictWebhook(tgt.WebhookID) + env.writers.EvictWebhook(tgt.WebhookID) sawEvicted, otherErr := race.wait() @@ -345,7 +341,7 @@ func TestEvictWebhook_RacingWriteDoesNotReopenHandle( "been evicted", ) assert.False( - t, env.eng.ExportHasArchiveWriter(tgt.ID), + t, env.writers.ExportHasArchiveWriter(tgt.ID), "the registry entry must stay gone", ) } @@ -362,25 +358,25 @@ func TestEvictWebhook_LaterDeliveryRecreatesWriter(t *testing.T) { tgt := env.seedDatabaseTarget(t, "") deliverTo(t, env, tgt) - require.True(t, env.eng.ExportHasArchiveWriter(tgt.ID)) + require.True(t, env.writers.ExportHasArchiveWriter(tgt.ID)) - env.eng.EvictWebhook(tgt.WebhookID) + env.writers.EvictWebhook(tgt.WebhookID) // A fresh delivery for the same target gets a brand new // writer from the registry, so archiving keeps working. deliverTo(t, env, tgt) assert.True( - t, env.eng.ExportHasArchiveWriter(tgt.ID), + t, env.writers.ExportHasArchiveWriter(tgt.ID), "a later delivery should recreate the writer", ) } -// TestEngineStop_WriteAfterStopIsRefused proves the engine's stop -// closes each archive writer the way deleting its webhook does: a -// write that reaches a writer after the stop is refused, reopens -// nothing and adds no row. -func TestEngineStop_WriteAfterStopIsRefused(t *testing.T) { +// TestEvictAll_WriteAfterIsRefused proves that EvictAll, which the +// delivery engine calls when it stops, closes each archive writer the +// way deleting its webhook does: a write that reaches a writer after +// it is refused, reopens nothing and adds no row. +func TestEvictAll_WriteAfterIsRefused(t *testing.T) { t.Parallel() env := setupArchiveTest(t) @@ -388,16 +384,16 @@ func TestEngineStop_WriteAfterStopIsRefused(t *testing.T) { deliverTo(t, env, tgt) - w := env.eng.ExportArchiveWriterFor(tgt.ID) + w := env.writers.ExportArchiveWriterFor(tgt.ID) require.NotNil(t, w) require.True(t, w.HandleOpen()) - require.NoError(t, env.eng.ExportStop(context.Background())) + env.writers.EvictAll() err := w.Write(evictTestRow("ev-after-stop"), 0) require.ErrorIs( - t, err, delivery.ErrExportArchiveWriterEvicted, + t, err, archive.ErrExportArchiveWriterEvicted, "a write after the stop must be refused", ) assert.False( @@ -405,7 +401,7 @@ func TestEngineStop_WriteAfterStopIsRefused(t *testing.T) { "a refused write must not reopen the archive", ) assert.False( - t, env.eng.ExportHasArchiveWriter(tgt.ID), + t, env.writers.ExportHasArchiveWriter(tgt.ID), "the stop should empty the registry", ) diff --git a/internal/archive/export_test.go b/internal/archive/export_test.go new file mode 100644 index 0000000..0bb8648 --- /dev/null +++ b/internal/archive/export_test.go @@ -0,0 +1,280 @@ +package archive + +import ( + "context" + "log/slog" + "time" + + "go.uber.org/fx" + "gorm.io/gorm" + "sneak.berlin/go/webhooker/internal/database" +) + +// ErrExportArchiveWriterEvicted exposes the sentinel returned by +// an evicted archive writer. It carries the Err prefix rather +// than this file's usual Export one because it is a sentinel +// error. +var ErrExportArchiveWriterEvicted = errArchiveWriterEvicted + +// ExportArchivedEvent aliases the archive row type so black-box +// tests can construct and read archive rows. +type ExportArchivedEvent = archivedEvent + +// ExportArchiveWriter wraps an archiveWriter so black-box tests +// can exercise the archive file mechanics. +type ExportArchiveWriter struct { + w *archiveWriter +} + +// NewExportArchiveWriter builds an archive writer for tests, +// optionally overriding the reopen debounce (a non-positive +// debounce keeps the production default). +func NewExportArchiveWriter( + path string, log *slog.Logger, debounce time.Duration, +) *ExportArchiveWriter { + w := newArchiveWriter(path, log) + if debounce > 0 { + w.debounce = debounce + } + + return &ExportArchiveWriter{w: w} +} + +// Write archives a row through the writer, into the file named +// without a period. +func (e *ExportArchiveWriter) Write( + row ExportArchivedEvent, expiry time.Duration, +) error { + return e.w.write(row, expiry, "") +} + +// WritePeriod archives a row through the writer, into the file for +// period. +func (e *ExportArchiveWriter) WritePeriod( + row ExportArchivedEvent, expiry time.Duration, period string, +) error { + return e.w.write(row, expiry, period) +} + +// Open opens the archive file, pruning when expiry is positive. +func (e *ExportArchiveWriter) Open(expiry time.Duration) error { + return e.w.open(e.w.path, expiry) +} + +// Reopen closes and reopens the archive file. +func (e *ExportArchiveWriter) Reopen( + expiry time.Duration, +) error { + return e.w.reopen(e.w.path, expiry) +} + +// SetNow replaces the clock the writer measures its reopen +// debounce on. +func (e *ExportArchiveWriter) SetNow(now func() time.Time) { + e.w.now = now +} + +// Reopens reports how many times the file has been opened. +func (e *ExportArchiveWriter) Reopens() int { + return e.w.reopens +} + +// DB returns the writer's current open handle for row +// inspection in tests. +func (e *ExportArchiveWriter) DB() *gorm.DB { + return e.w.db +} + +// Path returns the archive file the writer owns. +func (e *ExportArchiveWriter) Path() string { + return e.w.path +} + +// OpenExisting opens the archive without permitting creation, +// the way the idle sweep does. +func (e *ExportArchiveWriter) OpenExisting( + expiry time.Duration, +) error { + return e.w.openMode(e.w.path, archiveModeExisting, expiry) +} + +// SweepExpired runs an idle sweep of the archive. +func (e *ExportArchiveWriter) SweepExpired( + expiry time.Duration, +) error { + return e.w.sweepExpired(expiry) +} + +// Evict marks the writer evicted and closes its handle, exactly +// as leaving the registry does. +func (e *ExportArchiveWriter) Evict() { + e.w.evict() +} + +// Rename gives the archive file a new name in the same directory, +// as a rename of the webhook or target does. +func (e *ExportArchiveWriter) Rename(name string) error { + return e.w.rename(name) +} + +// HandleOpen reports whether the writer currently holds an open +// archive handle. +func (e *ExportArchiveWriter) HandleOpen() bool { + e.w.mu.Lock() + defer e.w.mu.Unlock() + + return e.w.db != nil +} + +// Same reports whether both wrappers refer to the very same +// underlying archive writer, so a test can prove a registry entry +// is the writer it was handed rather than a replacement. +func (e *ExportArchiveWriter) Same( + other *ExportArchiveWriter, +) bool { + return other != nil && e.w == other.w +} + +// ExportArchiveWriterFor returns the archive writer the registry +// currently caches for a database target, or nil when none is +// cached. It never creates one, so a test can hold a reference to +// the very writer an eviction is about to detach. +func (ws *Writers) ExportArchiveWriterFor( + targetID string, +) *ExportArchiveWriter { + ws.mu.Lock() + defer ws.mu.Unlock() + + w, ok := ws.writers[targetID] + if !ok { + return nil + } + + return &ExportArchiveWriter{w: w} +} + +// ExportHasArchiveWriter reports whether the registry currently +// caches an archive writer for a target. +func (ws *Writers) ExportHasArchiveWriter( + targetID string, +) bool { + ws.mu.Lock() + defer ws.mu.Unlock() + + _, ok := ws.writers[targetID] + + return ok +} + +// ExportArchiveHandleOpen reports whether the cached archive +// writer for a target holds an open database handle. It +// returns false when no writer is cached. +func (ws *Writers) ExportArchiveHandleOpen( + targetID string, +) bool { + ws.mu.Lock() + w, ok := ws.writers[targetID] + ws.mu.Unlock() + + if !ok { + return false + } + + w.mu.Lock() + defer w.mu.Unlock() + + return w.db != nil +} + +// ExportEnsureArchiveWriter creates (if needed) and returns the +// archive file path of the cached writer for a target, so a +// test can prime the registry the way a delivery would. +func (ws *Writers) ExportEnsureArchiveWriter( + targetID string, +) (string, error) { + w, err := ws.writerFor(targetID) + if err != nil { + return "", err + } + + return w.path, nil +} + +// ExportSweepWriterFor takes a target's registry writer exactly +// as the idle sweep does, reporting whether the sweep had to +// create the entry. It lets a test drive the registry through the +// sweep's own entry point instead of choreographing goroutines. +func (ws *Writers) ExportSweepWriterFor( + targetID string, +) (*ExportArchiveWriter, bool, error) { + w, created, err := ws.sweepWriterFor(targetID) + if err != nil { + return nil, false, err + } + + return &ExportArchiveWriter{w: w}, created, nil +} + +// ExportReleaseSweepWriter releases a sweep-created registry entry +// exactly as a finished sweep does. +func (ws *Writers) ExportReleaseSweepWriter( + targetID string, w *ExportArchiveWriter, +) { + ws.releaseSweepWriter(targetID, w.w) +} + +// NewTestSweeper builds a Sweeper backed by the given main +// database and archive writers, without the fx lifecycle. +// Intended for tests. +func NewTestSweeper( + db *database.Database, + writers *Writers, + log *slog.Logger, +) *Sweeper { + return &Sweeper{ + db: db, + writers: writers, + log: log, + interval: time.Hour, + } +} + +// ExportSweep runs a single archive sweep synchronously for +// tests. +func (s *Sweeper) ExportSweep(ctx context.Context) { + s.sweep(ctx) +} + +// ExportStart starts the sweeper's background loop for tests. +func (s *Sweeper) ExportStart() { + s.start() +} + +// ExportRegisterHooks registers the sweeper's real fx lifecycle +// hooks on a lifecycle supplied by a test, so a test can drive +// the exact OnStart/OnStop functions the application runs and +// hand OnStart the kind of context fx actually supplies. +func (s *Sweeper) ExportRegisterHooks(lc fx.Lifecycle) { + s.registerHooks(lc) +} + +// ExportStop stops the sweeper's background loop for tests. +func (s *Sweeper) ExportStop(ctx context.Context) error { + return s.stop(ctx) +} + +// ExportWedgeLoop adds a goroutine to the sweeper's WaitGroup +// that never observes cancellation and returns only when release +// is closed. It stands in for a prune stuck on a locked archive. +func (s *Sweeper) ExportWedgeLoop( + release <-chan struct{}, +) { + s.wg.Go(func() { + <-release + }) +} + +// ExportSetInterval overrides the sweep interval for tests. +func (s *Sweeper) ExportSetInterval(d time.Duration) { + s.interval = d +} diff --git a/internal/delivery/target_database_rotation.go b/internal/archive/rotation.go similarity index 85% rename from internal/delivery/target_database_rotation.go rename to internal/archive/rotation.go index e16180e..fea54ba 100644 --- a/internal/delivery/target_database_rotation.go +++ b/internal/archive/rotation.go @@ -1,4 +1,4 @@ -package delivery +package archive import ( "encoding/json" @@ -41,10 +41,10 @@ type archiveFile struct { period string } -// ValidateArchiveRotation checks a user-supplied archive rotation for +// ValidateRotation checks a user-supplied archive rotation for // a database target: empty or none (both meaning one file), monthly, // daily or hourly. -func ValidateArchiveRotation(rotation string) error { +func ValidateRotation(rotation string) error { switch rotation { case "", archiveRotationNone, archiveRotationMonthly, archiveRotationDaily, archiveRotationHourly: @@ -54,21 +54,21 @@ func ValidateArchiveRotation(rotation string) error { } } -// parseArchiveRotation reads the rotation from a database target's +// ParseRotation reads the rotation from a database target's // config JSON. An empty config or an empty rotation is none. -func parseArchiveRotation(configJSON string) (string, error) { +func ParseRotation(configJSON string) (string, error) { if configJSON == "" { return archiveRotationNone, nil } - var cfg databaseTargetConfig + var cfg DatabaseTargetConfig err := json.Unmarshal([]byte(configJSON), &cfg) if err != nil { return "", fmt.Errorf("parsing database target config: %w", err) } - err = ValidateArchiveRotation(cfg.Rotation) + err = ValidateRotation(cfg.Rotation) if err != nil { return "", err } @@ -97,7 +97,7 @@ func archivePeriod(rotation string, receivedAt time.Time) string { } // archivePeriodPath returns the path of a database target's archive -// file for a period: path, as ArchivePath gives it, with "-" and the +// file for a period: path, as Path gives it, with "-" and the // period put before its ".db". The period "" gives path itself. func archivePeriodPath(path, period string) string { if period == "" { @@ -107,28 +107,28 @@ func archivePeriodPath(path, period string) string { return strings.TrimSuffix(path, ".db") + "-" + period + ".db" } -// ArchivePathAt returns the archive file a database target writes an -// event received at receivedAt to: ArchivePath's file, with the +// PathAt returns the archive file a database target writes an +// event received at receivedAt to: Path's file, with the // period in its name when the target rotates. -func ArchivePathAt( +func PathAt( dbMgr *database.WebhookDBManager, webhook *database.Webhook, target *database.Target, receivedAt time.Time, ) (string, error) { - rotation, err := parseArchiveRotation(target.Config) + rotation, err := ParseRotation(target.Config) if err != nil { return "", err } return archivePeriodPath( - ArchivePath(dbMgr, webhook, target), + Path(dbMgr, webhook, target), archivePeriod(rotation, receivedAt), ), nil } // archiveFiles lists the database target's archive files that exist, -// given the path ArchivePath gives it: the file at path, then each +// given the path Path gives it: the file at path, then each // file archivePeriodPath names for path and a period, oldest period // first. Which rotation wrote a file does not matter, so the files of // an earlier rotation setting are listed too. diff --git a/internal/delivery/target_database_rotation_test.go b/internal/archive/rotation_test.go similarity index 87% rename from internal/delivery/target_database_rotation_test.go rename to internal/archive/rotation_test.go index 667a23b..d7f338b 100644 --- a/internal/delivery/target_database_rotation_test.go +++ b/internal/archive/rotation_test.go @@ -1,4 +1,4 @@ -package delivery_test +package archive_test import ( "os" @@ -9,9 +9,9 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "sneak.berlin/go/webhooker/internal/archive" "sneak.berlin/go/webhooker/internal/database" "sneak.berlin/go/webhooker/internal/database/databasetest" - "sneak.berlin/go/webhooker/internal/delivery" ) // The archive rotations, and the configs of a daily target and of one @@ -40,32 +40,30 @@ func periodPath(path, period string) string { return strings.TrimSuffix(path, ".db") + "-" + period + ".db" } -// deliverReceivedAt delivers to a database target an event whose -// receive time is receivedAt, and returns the event's id. The receive -// time is what decides a rotated archive's file, so setting it is how -// these tests move the clock across a period boundary. +// deliverReceivedAt archives, as a delivery to a database target +// does, an event whose receive time is receivedAt, and returns the +// event's id. The receive time is what decides a rotated archive's +// file, so setting it is how these tests move the clock across a +// period boundary. func (env *archiveEnv) deliverReceivedAt( t *testing.T, tgt *database.Target, receivedAt time.Time, ) string { t.Helper() - webhookDB := testWebhookDB(t) - event := seedEvent(t, webhookDB, `{"n":1}`) + event := newEvent(`{"n":1}`) event.CreatedAt = receivedAt - env.eng.ExportDeliverDatabase( - webhookDB, seedDatabaseTargetDelivery(t, webhookDB, event, tgt), - ) + require.NoError(t, env.writers.Write(databaseTargetDelivery(event, tgt))) return event.ID } -// TestDeliverDatabase_RotatesAtEachPeriodBoundary delivers, for each +// TestWrite_RotatesAtEachPeriodBoundary writes, for each // rotation, an event received in the last second of a period and one // received in the first second of the next, and checks each lands in // the file named for its own period, in UTC. Rotation none keeps both // in the one file. -func TestDeliverDatabase_RotatesAtEachPeriodBoundary(t *testing.T) { +func TestWrite_RotatesAtEachPeriodBoundary(t *testing.T) { t.Parallel() berlin := time.FixedZone("CEST", 2*60*60) @@ -139,10 +137,10 @@ func TestDeliverDatabase_RotatesAtEachPeriodBoundary(t *testing.T) { }) } -// TestDeliverDatabase_EventWithoutReceiveTime proves an event whose +// TestWrite_EventWithoutReceiveTime proves an event whose // receive time is not known goes to the file for the time it is // archived, rather than to one for the year 1. -func TestDeliverDatabase_EventWithoutReceiveTime(t *testing.T) { +func TestWrite_EventWithoutReceiveTime(t *testing.T) { t.Parallel() env := setupArchiveTest(t) @@ -164,10 +162,10 @@ func TestDeliverDatabase_EventWithoutReceiveTime(t *testing.T) { assert.NoFileExists(t, periodPath(path, "0001-01")) } -// TestDeliverDatabase_RotationChangeKeepsOldFiles changes a target's +// TestWrite_RotationChangeKeepsOldFiles changes a target's // rotation from none to daily between two events, and checks the // second goes to the daily file while the first stays where it was. -func TestDeliverDatabase_RotationChangeKeepsOldFiles(t *testing.T) { +func TestWrite_RotationChangeKeepsOldFiles(t *testing.T) { t.Parallel() env := setupArchiveTest(t) @@ -237,7 +235,7 @@ func TestRename_MovesEveryFile(t *testing.T) { first := env.deliverReceivedAt(t, tgt, day) second := env.deliverReceivedAt(t, tgt, day.Add(24*time.Hour)) - require.NoError(t, env.eng.Rename(tgt.ID, "Orders", "Long Term")) + require.NoError(t, env.writers.Rename(tgt.ID, "Orders", "Long Term")) newPath := filepath.Join( env.dataDir, "archive-orders-long-term-"+tgt.ID+".db", @@ -283,8 +281,8 @@ func TestRename_NeverReplacesARotatedFile(t *testing.T) { require.NoError(t, os.WriteFile(planted, []byte("planted"), 0o600)) require.ErrorIs( - t, env.eng.Rename(tgt.ID, "Orders", "Long Term"), - delivery.ErrArchiveNameTaken, + t, env.writers.Rename(tgt.ID, "Orders", "Long Term"), + archive.ErrNameTaken, ) assert.FileExists(t, periodPath(oldPath, dayPeriod)) @@ -292,7 +290,7 @@ func TestRename_NeverReplacesARotatedFile(t *testing.T) { assert.NoFileExists(t, periodPath(newPath, dayPeriod)) } -// TestStatArchive_EveryFile proves StatArchive counts and adds up every +// TestStatArchive_EveryFile proves Stat counts and adds up every // one of a target's files, takes the latest write of any of them, and // leaves out files whose names only look like the target's. func TestStatArchive_EveryFile(t *testing.T) { @@ -324,7 +322,7 @@ func TestStatArchive_EveryFile(t *testing.T) { os.WriteFile(filepath.Join(dir, other), []byte("x"), 0o600)) } - got, err := delivery.StatArchive(path) + got, err := archive.Stat(path) require.NoError(t, err) assert.Equal(t, len(files), got.Files) assert.Equal(t, size, got.Size) @@ -357,7 +355,7 @@ func TestArchivePathAt(t *testing.T) { Config: config, } - got, err := delivery.ArchivePathAt(dbMgr, webhook, target, at) + got, err := archive.PathAt(dbMgr, webhook, target, at) require.NoError(t, err, config) assert.Equal(t, filepath.Join( @@ -367,7 +365,7 @@ func TestArchivePathAt(t *testing.T) { ) } - _, err := delivery.ArchivePathAt(dbMgr, webhook, &database.Target{ + _, err := archive.PathAt(dbMgr, webhook, &database.Target{ Config: weeklyConfig, }, at) require.Error(t, err) @@ -381,10 +379,10 @@ func TestValidateArchiveRotation(t *testing.T) { for _, ok := range []string{ "", rotationNone, rotationMonthly, rotationDaily, rotationHourly, } { - require.NoError(t, delivery.ValidateArchiveRotation(ok), ok) + require.NoError(t, archive.ValidateRotation(ok), ok) } for _, bad := range []string{"weekly", "Daily", "hourly "} { - require.Error(t, delivery.ValidateArchiveRotation(bad), bad) + require.Error(t, archive.ValidateRotation(bad), bad) } } diff --git a/internal/delivery/archive_sweeper.go b/internal/archive/sweeper.go similarity index 85% rename from internal/delivery/archive_sweeper.go rename to internal/archive/sweeper.go index 1a6b87a..34338df 100644 --- a/internal/delivery/archive_sweeper.go +++ b/internal/archive/sweeper.go @@ -1,4 +1,4 @@ -package delivery +package archive import ( "context" @@ -15,18 +15,18 @@ import ( "sneak.berlin/go/webhooker/internal/logger" ) -// ArchiveSweeperParams holds the fx dependencies for the -// ArchiveSweeper. -type ArchiveSweeperParams struct { +// SweeperParams holds the fx dependencies for the +// Sweeper. +type SweeperParams struct { fx.In Config *config.Config Database *database.Database - Engine *Engine + Writers *Writers Logger *logger.Logger } -// ArchiveSweeper periodically prunes expired rows from the +// Sweeper periodically prunes expired rows from the // archive databases of database targets that carry a positive // expiry. // @@ -40,9 +40,9 @@ type ArchiveSweeperParams struct { // It reuses Config.RetentionSweepInterval rather than // introducing a second interval: this is a retention sweep with // the same semantics as the event retention reaper. -type ArchiveSweeper struct { +type Sweeper struct { db *database.Database - eng *Engine + writers *Writers log *slog.Logger interval time.Duration @@ -54,16 +54,16 @@ type ArchiveSweeper struct { wg sync.WaitGroup } -// NewArchiveSweeper creates the archive sweeper and registers +// NewSweeper creates the archive sweeper and registers // its fx lifecycle hooks. The background sweep loop starts on // OnStart and stops cleanly on OnStop via context cancellation. -func NewArchiveSweeper( +func NewSweeper( lc fx.Lifecycle, - params ArchiveSweeperParams, -) *ArchiveSweeper { - s := &ArchiveSweeper{ + params SweeperParams, +) *Sweeper { + s := &Sweeper{ db: params.Database, - eng: params.Engine, + writers: params.Writers, log: params.Logger.Get(), interval: params.Config.RetentionSweepInterval, } @@ -77,7 +77,7 @@ func NewArchiveSweeper( // lifecycle. The start hook's context is deliberately ignored // (see start for why the background loop must not inherit it); // the stop hook's context is honoured (see stop). -func (s *ArchiveSweeper) registerHooks(lc fx.Lifecycle) { +func (s *Sweeper) registerHooks(lc fx.Lifecycle) { lc.Append(fx.Hook{ //nolint:contextcheck // Not passing the hook context is // the point: see start. @@ -103,7 +103,7 @@ func (s *ArchiveSweeper) registerHooks(lc fx.Lifecycle) { // goroutine must outlive the startup phase, so its lifetime is // bounded by OnStop instead: stop cancels this context and waits // on the WaitGroup. -func (s *ArchiveSweeper) start() { +func (s *Sweeper) start() { ctx, cancel := context.WithCancel(context.Background()) s.cancel = cancel @@ -121,7 +121,7 @@ func (s *ArchiveSweeper) start() { // exit, bounded by the stop hook's context: a prune wedged on a // locked archive must not hang the process past fx's stop // timeout. -func (s *ArchiveSweeper) stop(ctx context.Context) error { +func (s *Sweeper) stop(ctx context.Context) error { s.log.Info("archive sweeper stopping") if s.cancel != nil { @@ -140,7 +140,7 @@ func (s *ArchiveSweeper) stop(ctx context.Context) error { return nil } -func (s *ArchiveSweeper) run(ctx context.Context) { +func (s *Sweeper) run(ctx context.Context) { defer s.wg.Done() ticker := time.NewTicker(s.interval) @@ -164,7 +164,7 @@ func (s *ArchiveSweeper) run(ctx context.Context) { // A failure for one target is logged and the sweep continues, // matching how the write path already treats a prune error as // non-fatal. -func (s *ArchiveSweeper) sweep(ctx context.Context) { +func (s *Sweeper) sweep(ctx context.Context) { var targets []database.Target err := s.db.DB(). @@ -202,8 +202,8 @@ func (s *ArchiveSweeper) sweep(ctx context.Context) { // sweepTarget prunes the archive of a single database target. // A missing, empty, or "never" expiry parses as a zero duration // and is skipped entirely, so those archives are never pruned. -func (s *ArchiveSweeper) sweepTarget(target *database.Target) { - expiry, err := parseArchiveExpiry(target.Config) +func (s *Sweeper) sweepTarget(target *database.Target) { + expiry, err := ParseExpiry(target.Config) if err != nil { s.log.Error( "archive sweep: invalid database target config", @@ -219,11 +219,11 @@ func (s *ArchiveSweeper) sweepTarget(target *database.Target) { return } - if s.eng == nil || s.eng.dbTarget == nil { + if s.writers == nil { return } - err = s.eng.dbTarget.sweepArchive(target.ID, expiry) + err = s.writers.sweepArchive(target.ID, expiry) if err == nil { return } diff --git a/internal/delivery/archive_sweeper_test.go b/internal/archive/sweeper_test.go similarity index 85% rename from internal/delivery/archive_sweeper_test.go rename to internal/archive/sweeper_test.go index 46a1f3a..b9a3d4d 100644 --- a/internal/delivery/archive_sweeper_test.go +++ b/internal/archive/sweeper_test.go @@ -1,4 +1,4 @@ -package delivery_test +package archive_test import ( "bytes" @@ -16,14 +16,15 @@ import ( "github.com/google/uuid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "go.uber.org/fx" "gorm.io/driver/sqlite" "gorm.io/gorm" "gorm.io/gorm/clause" _ "modernc.org/sqlite" // Pure Go SQLite driver. + "sneak.berlin/go/webhooker/internal/archive" "sneak.berlin/go/webhooker/internal/database" "sneak.berlin/go/webhooker/internal/database/databasetest" "sneak.berlin/go/webhooker/internal/db/migrations" - "sneak.berlin/go/webhooker/internal/delivery" "sneak.berlin/go/webhooker/internal/gormlog" ) @@ -37,6 +38,18 @@ const ( // sweepConcurrentWrites is how many deliveries the // concurrent write-plus-sweep test races against the sweep. sweepConcurrentWrites = 20 + + // hookStopTimeout bounds how long a lifecycle test waits for + // the sweeper's OnStop hook to return before declaring the + // shutdown hung. + hookStopTimeout = 10 * time.Second + + // wedgeStopTimeout is the stop timeout a wedged-shutdown test + // hands OnStop, standing in for fx's StopTimeout. The test + // asserts only that the hook returns at all, and allows it + // hookStopTimeout — forty times this budget — to do so, so no + // assertion here races the wall clock. + wedgeStopTimeout = 250 * time.Millisecond ) // archiveTestWebhookName is the name of every webhook @@ -45,12 +58,12 @@ const ( const archiveTestWebhookName = "Sweep Test!" // archiveEnv bundles the pieces an archive test drives: a main -// configuration database holding webhooks and targets, a delivery -// engine owning the archive writer registry, the archive sweeper, -// and the data directory the archive files live in. +// configuration database holding webhooks and targets, the archive +// writers, the archive sweeper, and the data directory the archive +// files live in. type archiveEnv struct { - sweeper *delivery.ArchiveSweeper - eng *delivery.Engine + sweeper *archive.Sweeper + writers *archive.Writers mainDB *database.Database dataDir string } @@ -66,30 +79,28 @@ func setupArchiveTest(t *testing.T) *archiveEnv { t.Cleanup(func() { _ = mainDB.Close() }) - eng := delivery.NewTestEngineWithDB( - mainDB, - databasetest.NewWebhookDBManager(t, dataDir), - log, - &http.Client{Timeout: 5 * time.Second}, - 1, - ) + writers := archive.NewWriters(archive.WritersParams{ + Database: mainDB, + DBManager: databasetest.NewWebhookDBManager(t, dataDir), + Logger: log, + }) return &archiveEnv{ - sweeper: delivery.NewTestArchiveSweeper( - mainDB, eng, log, + sweeper: archive.NewTestSweeper( + mainDB, writers, log, ), - eng: eng, + writers: writers, mainDB: mainDB, dataDir: dataDir, } } -// archivePath returns where the engine keeps a database target's +// archivePath returns where the writers keep a database target's // archive file, for the names seedDatabaseTarget gave it. func (env *archiveEnv) archivePath(tgt *database.Target) string { return filepath.Join( env.dataDir, - delivery.ArchiveFileName( + archive.FileName( archiveTestWebhookName, tgt.Name, tgt.ID, ), ) @@ -177,7 +188,7 @@ func seedArchiveFile( require.NoError(t, migrations.Apply(gdb, migrations.Archive)) for i, at := range archivedAt { - row := delivery.ExportArchivedEvent{ + row := archive.ExportArchivedEvent{ EventID: fmt.Sprintf("ev-%d", i), WebhookID: webhookID, Method: http.MethodPost, @@ -197,7 +208,7 @@ func archivedEventIDs( ) []string { t.Helper() - var rows []delivery.ExportArchivedEvent + var rows []archive.ExportArchivedEvent rdb := openArchiveDBForRead(t, path) require.NoError(t, rdb.Order("event_id").Find(&rows).Error) @@ -234,7 +245,7 @@ func countArchivedRows(path string) (int64, error) { var count int64 - err = gdb.Model(&delivery.ExportArchivedEvent{}). + err = gdb.Model(&archive.ExportArchivedEvent{}). Count(&count).Error if err != nil { return 0, err @@ -243,6 +254,55 @@ func countArchivedRows(path string) (int64, error) { return count, nil } +// recordingLifecycle is a minimal fx.Lifecycle that records the +// hooks a component registers, so a test can invoke the real +// OnStart/OnStop functions with a context of its choosing. +type recordingLifecycle struct { + hooks []fx.Hook +} + +func (l *recordingLifecycle) Append(h fx.Hook) { + l.hooks = append(l.hooks, h) +} + +// requireStopHookExpires drives hook.OnStop with a stop context +// that expires while a wedged goroutine is still running, and +// requires the hook to return the deadline error naming +// component instead of blocking on the WaitGroup forever. +func requireStopHookExpires( + t *testing.T, hook fx.Hook, component string, +) { + t.Helper() + + stopCtx, cancel := context.WithTimeout( + context.Background(), wedgeStopTimeout, + ) + defer cancel() + + var stopErr error + + stopped := make(chan struct{}) + + go func() { + defer close(stopped) + + stopErr = hook.OnStop(stopCtx) + }() + + select { + case <-stopped: + case <-time.After(hookStopTimeout): + t.Fatal( + "OnStop did not return: it discarded the stop " + + "context and is waiting on a wedged goroutine " + + "that will never observe cancellation", + ) + } + + require.ErrorIs(t, stopErr, context.DeadlineExceeded) + require.ErrorContains(t, stopErr, component) +} + // TestArchiveSweeper_LoopOutlivesStartHookContext is the // regression test for a sweeper that never swept. fx calls // OnStart with a context carrying the application's start @@ -326,16 +386,16 @@ func TestArchiveSweep_DoesNotResurrectEvictedWriter( // the deletion path does. The target row is deliberately left // in place: this is the tick that listed the target before // the deletion committed. - _, err := env.eng.ExportEnsureArchiveWriter(tgt.ID) + _, err := env.writers.ExportEnsureArchiveWriter(tgt.ID) require.NoError(t, err) - env.eng.EvictTarget(tgt.ID) - require.False(t, env.eng.ExportHasArchiveWriter(tgt.ID)) + env.writers.EvictTarget(tgt.ID) + require.False(t, env.writers.ExportHasArchiveWriter(tgt.ID)) env.sweeper.ExportSweep(context.Background()) assert.False( - t, env.eng.ExportHasArchiveWriter(tgt.ID), + t, env.writers.ExportHasArchiveWriter(tgt.ID), "a sweep must never re-register a writer for a target "+ "whose registry entry has already been released", ) @@ -358,7 +418,7 @@ func TestArchiveSweep_LeavesNoRegistryEntry(t *testing.T) { time.Now().Add(-time.Minute), ) - require.False(t, env.eng.ExportHasArchiveWriter(tgt.ID)) + require.False(t, env.writers.ExportHasArchiveWriter(tgt.ID)) env.sweeper.ExportSweep(context.Background()) @@ -367,7 +427,7 @@ func TestArchiveSweep_LeavesNoRegistryEntry(t *testing.T) { "the sweep must still prune an idle archive", ) assert.False( - t, env.eng.ExportHasArchiveWriter(tgt.ID), + t, env.writers.ExportHasArchiveWriter(tgt.ID), "the sweep must release the registry entry it created", ) } @@ -389,24 +449,22 @@ func TestArchiveSweep_KeepsWriterAdoptedByDelivery( t, tgt, time.Now().Add(-48*time.Hour), ) - webhookDB := testWebhookDB(t) - event := seedEvent(t, webhookDB, `{"n":1}`) - d := seedDatabaseTargetDelivery(t, webhookDB, event, tgt) + d := databaseTargetDelivery(newEvent(`{"n":1}`), tgt) env.sweeper.ExportSweep(context.Background()) - require.False(t, env.eng.ExportHasArchiveWriter(tgt.ID)) + require.False(t, env.writers.ExportHasArchiveWriter(tgt.ID)) - env.eng.ExportDeliverDatabase(webhookDB, d) + require.NoError(t, env.writers.Write(d)) assert.True( - t, env.eng.ExportHasArchiveWriter(tgt.ID), + t, env.writers.ExportHasArchiveWriter(tgt.ID), "a delivery's writer must stay registered", ) env.sweeper.ExportSweep(context.Background()) assert.True( - t, env.eng.ExportHasArchiveWriter(tgt.ID), + t, env.writers.ExportHasArchiveWriter(tgt.ID), "a sweep must not drop a writer a delivery owns", ) } @@ -445,7 +503,7 @@ func TestArchiveSweep_KeepsWriterAdoptedDuringSweep( t, tgt, time.Now().Add(-48*time.Hour), ) - sweepWriter, created, err := env.eng.ExportSweepWriterFor( + sweepWriter, created, err := env.writers.ExportSweepWriterFor( tgt.ID, ) require.NoError(t, err) @@ -455,36 +513,34 @@ func TestArchiveSweep_KeepsWriterAdoptedDuringSweep( ) // The delivery lands mid-sweep and adopts the entry. - webhookDB := testWebhookDB(t) - event := seedEvent(t, webhookDB, `{"n":1}`) - d := seedDatabaseTargetDelivery(t, webhookDB, event, tgt) + d := databaseTargetDelivery(newEvent(`{"n":1}`), tgt) - env.eng.ExportDeliverDatabase(webhookDB, d) + require.NoError(t, env.writers.Write(d)) - adopted := env.eng.ExportArchiveWriterFor(tgt.ID) + adopted := env.writers.ExportArchiveWriterFor(tgt.ID) require.NotNil(t, adopted) require.True( t, sweepWriter.Same(adopted), "the delivery must have adopted the sweep's writer", ) require.True( - t, env.eng.ExportArchiveHandleOpen(tgt.ID), + t, env.writers.ExportArchiveHandleOpen(tgt.ID), "the delivery leaves the archive handle open", ) // The sweep finishes. - env.eng.ExportReleaseSweepWriter(tgt.ID, sweepWriter) + env.writers.ExportReleaseSweepWriter(tgt.ID, sweepWriter) require.True( - t, env.eng.ExportHasArchiveWriter(tgt.ID), + t, env.writers.ExportHasArchiveWriter(tgt.ID), "a writer adopted by a delivery during a sweep must "+ "stay registered, or its open handle is unreachable", ) - env.eng.EvictTarget(tgt.ID) + env.writers.EvictTarget(tgt.ID) assert.False( - t, env.eng.ExportHasArchiveWriter(tgt.ID), + t, env.writers.ExportHasArchiveWriter(tgt.ID), "the adopted writer must still be evictable", ) assert.False( @@ -548,7 +604,7 @@ func TestArchiveSweep_OpenExistingDoesNotCreateFile( dir := t.TempDir() path := filepath.Join(dir, "archive-absent.db") - w := delivery.NewExportArchiveWriter( + w := archive.NewExportArchiveWriter( path, archiveTestLogger(), 0, ) @@ -619,7 +675,7 @@ func TestArchiveSweep_LeavesArchiveClosed(t *testing.T) { t, tgt, time.Now().Add(-48*time.Hour), ) - w := delivery.NewExportArchiveWriter( + w := archive.NewExportArchiveWriter( path, archiveTestLogger(), 0, ) @@ -659,25 +715,23 @@ func TestArchiveSweep_ClosesHandleOfRegisteredWriter( t, tgt, time.Now().Add(-48*time.Hour), ) - webhookDB := testWebhookDB(t) - event := seedEvent(t, webhookDB, `{"n":1}`) - d := seedDatabaseTargetDelivery(t, webhookDB, event, tgt) + d := databaseTargetDelivery(newEvent(`{"n":1}`), tgt) - env.eng.ExportDeliverDatabase(webhookDB, d) + require.NoError(t, env.writers.Write(d)) require.True( - t, env.eng.ExportArchiveHandleOpen(tgt.ID), + t, env.writers.ExportArchiveHandleOpen(tgt.ID), "the delivery must leave the archive handle open", ) env.sweeper.ExportSweep(context.Background()) require.True( - t, env.eng.ExportHasArchiveWriter(tgt.ID), + t, env.writers.ExportHasArchiveWriter(tgt.ID), "the delivery's registry entry must survive the sweep", ) assert.False( - t, env.eng.ExportArchiveHandleOpen(tgt.ID), + t, env.writers.ExportArchiveHandleOpen(tgt.ID), "the sweep must leave the archive closed", ) } @@ -693,7 +747,7 @@ func TestArchiveSweep_ClosesHandleBeforeReopening(t *testing.T) { path := filepath.Join(t.TempDir(), "archive.db") - w := delivery.NewExportArchiveWriter( + w := archive.NewExportArchiveWriter( path, archiveTestLogger(), 0, ) @@ -721,8 +775,8 @@ func TestArchiveSweep_CancelledSweepLogsNoError(t *testing.T) { var errorLines bytes.Buffer - sweeper := delivery.NewTestArchiveSweeper( - env.mainDB, env.eng, + sweeper := archive.NewTestSweeper( + env.mainDB, env.writers, slog.New(slog.NewTextHandler( &errorLines, &slog.HandlerOptions{Level: slog.LevelError}, @@ -766,7 +820,7 @@ func TestArchiveSweep_NeverExpiryUntouched(t *testing.T) { "config %q must keep rows forever", configJSON, ) assert.False( - t, env.eng.ExportHasArchiveWriter(tgt.ID), + t, env.writers.ExportHasArchiveWriter(tgt.ID), "config %q must leave no registry entry behind", configJSON, ) @@ -831,7 +885,7 @@ func archiveTableExists(t *testing.T, path string) bool { return openArchiveDBForRead(t, path). Migrator(). - HasTable(&delivery.ExportArchivedEvent{}) + HasTable(&archive.ExportArchivedEvent{}) } // TestArchiveSweep_DoesNotCreateArchiveFile proves the sweep @@ -858,7 +912,7 @@ func TestArchiveSweep_DoesNotCreateArchiveFile(t *testing.T) { } assert.False( - t, env.eng.ExportHasArchiveWriter(tgt.ID), + t, env.writers.ExportHasArchiveWriter(tgt.ID), "the sweep must leave no registry entry behind", ) } @@ -876,7 +930,7 @@ func TestArchiveSweep_DoesNotCreateAfterWriterExists( tgt := env.seedDatabaseTarget(t, `{"expiry":"1h"}`) - path, err := env.eng.ExportEnsureArchiveWriter(tgt.ID) + path, err := env.writers.ExportEnsureArchiveWriter(tgt.ID) require.NoError(t, err) require.NoFileExists(t, path) @@ -923,21 +977,14 @@ func TestArchiveSweep_ConcurrentWrites(t *testing.T) { tgt := env.seedDatabaseTarget(t, `{"expiry":"1h"}`) - webhookDB := testWebhookDB(t) - - // The deliveries are seeded up front, on the test's own - // goroutine: the seed helpers assert, and testify assertions - // must not run off the test goroutine. deliveries := make( []*database.Delivery, 0, sweepConcurrentWrites, ) for range sweepConcurrentWrites { - event := seedEvent(t, webhookDB, `{"n":1}`) - deliveries = append( deliveries, - seedDatabaseTargetDelivery(t, webhookDB, event, tgt), + databaseTargetDelivery(newEvent(`{"n":1}`), tgt), ) } @@ -949,7 +996,7 @@ func TestArchiveSweep_ConcurrentWrites(t *testing.T) { defer wg.Done() for _, d := range deliveries { - env.eng.ExportDeliverDatabase(webhookDB, d) + _ = env.writers.Write(d) } }() diff --git a/internal/delivery/target_database_archive.go b/internal/archive/writer.go similarity index 92% rename from internal/delivery/target_database_archive.go rename to internal/archive/writer.go index 519af99..624ccae 100644 --- a/internal/delivery/target_database_archive.go +++ b/internal/archive/writer.go @@ -1,4 +1,4 @@ -package delivery +package archive import ( "encoding/json" @@ -18,9 +18,9 @@ import ( "sneak.berlin/go/webhooker/internal/gormlog" ) -// archiveExpiryNever is the expiry sentinel (and default) that +// ExpiryNever is the expiry sentinel (and default) that // disables pruning so archived rows are kept forever. -const archiveExpiryNever = "never" +const ExpiryNever = "never" // archiveReopenDebounce bounds how often an archive file is // closed and reopened. After each write the handle is closed @@ -71,17 +71,17 @@ var ( "archive writer has been evicted", ) - // ErrArchiveNameTaken is returned when an archive cannot be + // ErrNameTaken is returned when an archive cannot be // renamed because a file already has the new name. That file may // be an archive with rows of its own, so it is never replaced. - ErrArchiveNameTaken = errors.New( + ErrNameTaken = errors.New( "a file already has the archive's new name", ) ) -// databaseTargetConfig is the optional per-target JSON config +// DatabaseTargetConfig is the optional per-target JSON config // for a database (archive) target. -type databaseTargetConfig struct { +type DatabaseTargetConfig struct { // Expiry is a Go duration (e.g. "720h") after which // archived rows are pruned, or "never" (the default) to // keep them forever. @@ -112,21 +112,21 @@ type archivedEvent struct { ArchivedAt time.Time `gorm:"index"` } -// parseArchiveExpiry reads the optional expiry from a database +// ParseExpiry reads the optional expiry from a database // target's config JSON. An empty config, an empty expiry, or // the literal "never" all mean keep forever, returned as a zero // duration. Any other value must parse as a positive Go // duration; a set-but-invalid value (unparseable, zero, or // negative) is an error rather than a silent default, matching -// ValidateArchiveExpiry at target creation. -func parseArchiveExpiry( +// ValidateExpiry at target creation. +func ParseExpiry( configJSON string, ) (time.Duration, error) { if configJSON == "" { return 0, nil } - var cfg databaseTargetConfig + var cfg DatabaseTargetConfig err := json.Unmarshal([]byte(configJSON), &cfg) if err != nil { @@ -135,7 +135,7 @@ func parseArchiveExpiry( ) } - if cfg.Expiry == "" || cfg.Expiry == archiveExpiryNever { + if cfg.Expiry == "" || cfg.Expiry == ExpiryNever { return 0, nil } @@ -155,14 +155,14 @@ func parseArchiveExpiry( return dur, nil } -// ValidateArchiveExpiry checks a user-supplied archive expiry +// ValidateExpiry checks a user-supplied archive expiry // for a database target at configuration time. Valid values are // empty, "never" (both meaning keep forever), or a positive Go // duration such as "720h". Anything else is an error, so a bad // expiry is rejected when the target is created rather than // failing every subsequent delivery. -func ValidateArchiveExpiry(expiry string) error { - if expiry == "" || expiry == archiveExpiryNever { +func ValidateExpiry(expiry string) error { + if expiry == "" || expiry == ExpiryNever { return nil } @@ -171,7 +171,7 @@ func ValidateArchiveExpiry(expiry string) error { return fmt.Errorf( "expiry must be %q or a Go duration "+ "such as \"720h\": %w", - archiveExpiryNever, err, + ExpiryNever, err, ) } @@ -194,7 +194,7 @@ func ValidateArchiveExpiry(expiry string) error { type archiveWriter struct { mu sync.Mutex - // path is the target's archive file as ArchivePath names it. A + // path is the target's archive file as Path names it. A // target that rotates writes to the files archivePeriodPath names // for path and a period instead. path string @@ -232,7 +232,7 @@ type archiveWriter struct { // registry proper. // // Unlike every other field here it is guarded by - // databaseTarget.mu, not by this writer's mu: it describes the + // Writers.mu, not by this writer's mu: it describes the // registry entry rather than the file. sweepOwned bool } @@ -499,7 +499,7 @@ func (w *archiveWriter) sweepFile( // the next write creates its file under the new name. // // If a file already has one of the new names, nothing is moved and -// the error is ErrArchiveNameTaken. If one file fails to move, those +// the error is ErrNameTaken. If one file fails to move, those // already moved are moved back before the error is returned, so the // archive is never split across two names. func (w *archiveWriter) rename(name string) error { @@ -537,7 +537,7 @@ func (w *archiveWriter) rename(name string) error { for _, taken := range to { if fileExists(taken) { return fmt.Errorf( - "%w: %s", ErrArchiveNameTaken, filepath.Base(taken), + "%w: %s", ErrNameTaken, filepath.Base(taken), ) } } @@ -590,7 +590,7 @@ func (w *archiveWriter) evict() { // each row's archived time. It runs on every (re)open, so a // steadily written archive is swept by its own write traffic. An // archive that goes idle receives no further reopens, which is -// why ArchiveSweeper exists to drive sweepExpired on a timer. +// why Sweeper exists to drive sweepExpired on a timer. // Failures are logged, not fatal: a prune error must not stop // archiving. func (w *archiveWriter) prune(expiry time.Duration) { @@ -617,9 +617,9 @@ func (w *archiveWriter) prune(expiry time.Duration) { } } -// ArchiveFileInfo is what the metadata of a database target's archive +// FileInfo is what the metadata of a database target's archive // files says about them. -type ArchiveFileInfo struct { +type FileInfo struct { // Files counts the files. Files int @@ -631,18 +631,18 @@ type ArchiveFileInfo struct { Written time.Time } -// StatArchive reads the metadata of a database target's archive -// files, given the path ArchivePath gives it (see archiveFiles), and +// Stat reads the metadata of a database target's archive +// files, given the path Path gives it (see archiveFiles), and // of their -wal, without opening them. With no files, which is so // before the first write and after the operator moved them away, the // error wraps fs.ErrNotExist. -func StatArchive(path string) (ArchiveFileInfo, error) { +func Stat(path string) (FileInfo, error) { files, err := archiveFiles(path) if err != nil { - return ArchiveFileInfo{}, err + return FileInfo{}, err } - var info ArchiveFileInfo + var info FileInfo for _, file := range files { db, err := os.Stat(file.path) @@ -651,7 +651,7 @@ func StatArchive(path string) (ArchiveFileInfo, error) { } if err != nil { - return ArchiveFileInfo{}, err + return FileInfo{}, err } info.Files++ @@ -667,7 +667,7 @@ func StatArchive(path string) (ArchiveFileInfo, error) { } if err != nil { - return ArchiveFileInfo{}, err + return FileInfo{}, err } info.Size += wal.Size() @@ -678,7 +678,7 @@ func StatArchive(path string) (ArchiveFileInfo, error) { } if info.Files == 0 { - return ArchiveFileInfo{}, fmt.Errorf( + return FileInfo{}, fmt.Errorf( "no archive file for %s: %w", path, fs.ErrNotExist, ) } diff --git a/internal/delivery/target_database_archive_gormlog_test.go b/internal/archive/writer_gormlog_test.go similarity index 93% rename from internal/delivery/target_database_archive_gormlog_test.go rename to internal/archive/writer_gormlog_test.go index 1644f8c..5ada804 100644 --- a/internal/delivery/target_database_archive_gormlog_test.go +++ b/internal/archive/writer_gormlog_test.go @@ -1,4 +1,4 @@ -package delivery_test +package archive_test import ( "bytes" @@ -14,7 +14,7 @@ import ( "github.com/stretchr/testify/require" "gorm.io/gorm" gormlogger "gorm.io/gorm/logger" - "sneak.berlin/go/webhooker/internal/delivery" + "sneak.berlin/go/webhooker/internal/archive" "sneak.berlin/go/webhooker/internal/middleware" ) @@ -57,7 +57,7 @@ func (g *gormDefaultBuf) String() string { // without exporting production code to carry it, and a logging // detector is not worth a production symbol. What it detects is the // third gorm.Open in this service, at -// internal/delivery/target_database_archive.go — the archive writer, +// internal/archive/writer.go — the archive writer, // whose type is unexported, so nothing outside this package can drive // it. func captureArchiveGORMDefault(t *testing.T) *gormDefaultBuf { @@ -85,7 +85,7 @@ func captureArchiveGORMDefault(t *testing.T) *gormDefaultBuf { // writer's gorm.Open to the adapter. // // Restore a bare &gorm.Config{} at -// internal/delivery/target_database_archive.go and this fails: the +// internal/archive/writer.go and this fails: the // default logger prints the fully interpolated SELECT on every // ErrRecordNotFound, so the client-chosen event id below arrives whole // and unbounded on stdout, answering to no level the operator set. @@ -100,7 +100,7 @@ func TestArchiveWriter_NeverUsesGORMsDefaultLogger(t *testing.T) { gormDefault := captureArchiveGORMDefault(t) - w := delivery.NewExportArchiveWriter( + w := archive.NewExportArchiveWriter( filepath.Join(t.TempDir(), "archive.db"), slog.New(slog.NewTextHandler( &captured, &slog.HandlerOptions{Level: slog.LevelDebug}, @@ -118,7 +118,7 @@ func TestArchiveWriter_NeverUsesGORMsDefaultLogger(t *testing.T) { value := strings.Repeat("\x01", archiveGORMFillBytes) + archiveGORMTailMarker - var row delivery.ExportArchivedEvent + var row archive.ExportArchivedEvent err := w.DB().Where("event_id = ?", value).First(&row).Error require.ErrorIs(t, err, gorm.ErrRecordNotFound) diff --git a/internal/database/sqlite_mode_test.go b/internal/database/sqlite_mode_test.go index f75c3fa..70ca676 100644 --- a/internal/database/sqlite_mode_test.go +++ b/internal/database/sqlite_mode_test.go @@ -137,7 +137,7 @@ func TestPerWebhookEventDatabaseFilesAreOwnerOnly(t *testing.T) { } // TestArchiveDatabaseFilesAreOwnerOnly covers the archive-*.db tier. -// internal/delivery builds that path and opens it through OpenSQLite, +// internal/archive builds that path and opens it through OpenSQLite, // the same single open path exercised here, so the mode is settled for // all three tiers in one place. func TestArchiveDatabaseFilesAreOwnerOnly(t *testing.T) { diff --git a/internal/db/migrations/archive/001_schema.sql b/internal/db/migrations/archive/001_schema.sql index ceff586..7f1e403 100644 --- a/internal/db/migrations/archive/001_schema.sql +++ b/internal/db/migrations/archive/001_schema.sql @@ -1,5 +1,5 @@ -- Migration 001: the schema of a database target's archive file. The --- archivedEvent model in internal/delivery must agree with it. +-- archivedEvent model in internal/archive must agree with it. CREATE TABLE archived_events ( id integer PRIMARY KEY AUTOINCREMENT, diff --git a/internal/delivery/engine.go b/internal/delivery/engine.go index cde4128..b566809 100644 --- a/internal/delivery/engine.go +++ b/internal/delivery/engine.go @@ -13,6 +13,7 @@ import ( "go.uber.org/fx" "gorm.io/gorm" + "sneak.berlin/go/webhooker/internal/archive" "sneak.berlin/go/webhooker/internal/database" "sneak.berlin/go/webhooker/internal/globals" "sneak.berlin/go/webhooker/internal/lifecycle" @@ -124,31 +125,11 @@ type Notifier interface { Notify(tasks []Task) } -// Archives is how the handlers keep the database targets' archive -// files in step with the configuration. Deleting a webhook or a -// target releases the cached archive writers, whose open file -// handles would otherwise outlive them; renaming one renames the -// archive files, which are named for the webhook and the target -// (see ArchiveFileName). -// -// It is deliberately separate from Notifier: archiving lifecycle -// is not notification, and a small interface keeps the handlers -// package free of any dependency on the engine's internals while -// staying trivially fakeable in tests. -// -// Neither eviction deletes an archive file. Both are idempotent -// and are no-ops for a webhook or target with no engine state. -type Archives interface { - EvictWebhook(webhookID string) - EvictTarget(targetID string) - Rename(targetID, webhookName, targetName string) error -} - // CircuitBreakers is how the handlers read a target's circuit // breaker, so the webhook page and the event log can say that -// deliveries to the target are paused and until when. Like Archives, -// it keeps the handlers free of the engine's internals and is -// trivially faked in tests. +// deliveries to the target are paused and until when. It keeps the +// handlers free of the engine's internals and is trivially faked in +// tests. type CircuitBreakers interface { StateAndCooldown(targetID string) (CircuitState, time.Duration) } @@ -164,6 +145,7 @@ type EngineParams struct { Logger *logger.Logger SSRFGuard *Guard Metrics *metrics.Set + Archives *archive.Writers } // Engine processes queued deliveries in the background @@ -202,9 +184,10 @@ type Engine struct { httpTarget *httpTarget slackTarget *slackTarget - // dbTarget is retained so the engine can reach the archive - // writer registry for eviction, renames and the idle sweep. - dbTarget *databaseTarget + // archives is the database targets' archive writers. The + // database target writes each event through them, and stop + // closes them. + archives *archive.Writers // inflight is the set of deliveries this engine currently owns. // Recovery and the sweeps re-dispatch only what it does not @@ -228,6 +211,7 @@ func New( workers: defaultWorkers, version: params.Globals.Version, mtr: params.Metrics, + archives: params.Archives, } e.initTargets(&http.Client{ @@ -272,47 +256,6 @@ func (e *Engine) Notify(tasks []Task) { } } -// EvictWebhook implements Archives. The cached archive writer of -// every database target of the webhook is dropped from the -// registry and its file handle closed. The archive files -// themselves are left on disk — they are long-term storage the -// operator owns. -func (e *Engine) EvictWebhook(webhookID string) { - if e.dbTarget == nil { - return - } - - e.dbTarget.evictWebhook(webhookID) -} - -// EvictTarget implements Archives. It is EvictWebhook for a single -// database target, and leaves the archive file on disk the same -// way. -func (e *Engine) EvictTarget(targetID string) { - if e.dbTarget == nil { - return - } - - e.dbTarget.evict(targetID) -} - -// Rename implements Archives. It renames every one of a database -// target's archive files to ArchiveFileName(webhookName, targetName, -// targetID), each keeping the period in its name, under the lock the -// target's archive writes and the idle sweep take. It never replaces -// a file: if one already has a new name, the error is -// ErrArchiveNameTaken. The caller renames before it saves the new -// name: see databaseTarget.rename. -func (e *Engine) Rename( - targetID, webhookName, targetName string, -) error { - if e.dbTarget == nil { - return nil - } - - return e.dbTarget.rename(targetID, webhookName, targetName) -} - // StateAndCooldown implements CircuitBreakers. It returns the state of // the target's circuit breaker and, while the breaker is open, what is // left of its cooldown; the cooldown is zero once that has passed and @@ -466,7 +409,7 @@ func (e *Engine) stop(ctx context.Context) error { return err } - e.dbTarget.evictAll() + e.archives.EvictAll() e.log.Info("delivery engine stopped") diff --git a/internal/delivery/engine_lifecycle_test.go b/internal/delivery/engine_lifecycle_test.go index 1775fc8..36cf5d5 100644 --- a/internal/delivery/engine_lifecycle_test.go +++ b/internal/delivery/engine_lifecycle_test.go @@ -427,9 +427,9 @@ func TestEngine_StopLeavesLastRetryAttemptRetrying(t *testing.T) { // deliverToArchive gives the setup's webhook a database target, // runs one delivery to it through the running engine, and returns -// the target's ID and archive file path. The archive writer holds -// the file open afterwards. -func deliverToArchive(t *testing.T, s iSetup) (string, string) { +// the target's archive file path. The archive writer holds the file +// open afterwards. +func deliverToArchive(t *testing.T, s iSetup) string { t.Helper() iCreateWebhook(t, s.MainDB, s.WebhookID, "hook") @@ -452,7 +452,7 @@ func deliverToArchive(t *testing.T, s iSetup) (string, string) { iWaitForDelivered(t, s.WebhookDB, deliveryID) - return tgt.ID, filepath.Join( + return filepath.Join( filepath.Dir(s.DBMgr.DBPath(s.WebhookID)), "archive-hook-archive-"+tgt.ID+".db", ) @@ -471,7 +471,7 @@ func TestEngine_StopHookClosesArchives(t *testing.T) { lc := startEngineViaHook(t, s.Engine) - _, path := deliverToArchive(t, s) + path := deliverToArchive(t, s) require.FileExists( t, path+"-wal", "an open archive should have a -wal for the stop to remove", @@ -505,21 +505,23 @@ func TestEngine_StopHookTimeoutLeavesArchivesOpen(t *testing.T) { lc := startEngineViaHook(t, s.Engine) - targetID, _ := deliverToArchive(t, s) + path := deliverToArchive(t, s) release := make(chan struct{}) t.Cleanup(func() { close(release) - s.Engine.EvictWebhook(s.WebhookID) + s.Engine.ExportArchives().EvictWebhook(s.WebhookID) }) s.Engine.ExportWedgeWorker(release) requireStopHookExpires(t, lc.hooks[0], "delivery engine") - require.True( - t, s.Engine.ExportArchiveHandleOpen(targetID), + // Closing the last handle on an archive removes its -wal, so a + // -wal still there is a writer still open. + require.FileExists( + t, path+"-wal", "a stop that timed out must not close archive writers", ) } diff --git a/internal/delivery/export_test.go b/internal/delivery/export_test.go index 51a3e61..9256445 100644 --- a/internal/delivery/export_test.go +++ b/internal/delivery/export_test.go @@ -12,16 +12,11 @@ import ( "github.com/prometheus/client_golang/prometheus" "go.uber.org/fx" "gorm.io/gorm" + "sneak.berlin/go/webhooker/internal/archive" "sneak.berlin/go/webhooker/internal/database" "sneak.berlin/go/webhooker/internal/metrics" ) -// ErrExportArchiveWriterEvicted exposes the sentinel returned by -// an evicted archive writer. It carries the Err prefix rather -// than this file's usual Export one because it is a sentinel -// error. -var ErrExportArchiveWriterEvicted = errArchiveWriterEvicted - // Exported constants for test access. const ( ExportDeliveryChannelSize = deliveryChannelSize @@ -399,7 +394,8 @@ func (e *Engine) ExportRetryCh() chan Task { } // NewTestEngine creates an Engine for unit tests without -// database dependencies. +// database dependencies. Its archive writers have no data +// directory, so a delivery to a database target fails. func NewTestEngine( log *slog.Logger, client *http.Client, @@ -411,6 +407,7 @@ func NewTestEngine( retryCh: make(chan Task, retryChannelSize), workers: workers, mtr: metrics.New(prometheus.NewRegistry()), + archives: archive.NewWriters(archive.WritersParams{Logger: log}), } e.initTargets(client) @@ -423,9 +420,10 @@ func NewTestEngineSmallRetry( log *slog.Logger, ) *Engine { e := &Engine{ - log: log, - retryCh: make(chan Task, 1), - mtr: metrics.New(prometheus.NewRegistry()), + log: log, + retryCh: make(chan Task, 1), + mtr: metrics.New(prometheus.NewRegistry()), + archives: archive.NewWriters(archive.WritersParams{Logger: log}), } e.initTargets(nil) @@ -433,7 +431,8 @@ func NewTestEngineSmallRetry( } // NewTestEngineWithDB creates an Engine with a real -// database and dbManager for integration tests. +// database and dbManager for integration tests, and archive +// writers of its own over the same two. func NewTestEngineWithDB( db *database.Database, dbMgr *database.WebhookDBManager, @@ -449,12 +448,22 @@ func NewTestEngineWithDB( retryCh: make(chan Task, retryChannelSize), workers: workers, mtr: metrics.New(prometheus.NewRegistry()), + archives: archive.NewWriters(archive.WritersParams{ + Database: db, + DBManager: dbMgr, + Logger: log, + }), } e.initTargets(client) return e } +// ExportArchives returns the engine's archive writers. +func (e *Engine) ExportArchives() *archive.Writers { + return e.archives +} + // ExportSetMetrics substitutes the engine's metric set, so a test can // assert on collectors registered on a registry it holds. func (e *Engine) ExportSetMetrics(mtr *metrics.Set) { @@ -477,273 +486,3 @@ func NewTestCircuitBreaker( cooldown: cooldown, } } - -// ExportArchivedEvent aliases the archive row type so black-box -// tests can construct and read archive rows. -type ExportArchivedEvent = archivedEvent - -// ExportArchiveWriter wraps an archiveWriter so black-box tests -// can exercise the archive file mechanics. -type ExportArchiveWriter struct { - w *archiveWriter -} - -// NewExportArchiveWriter builds an archive writer for tests, -// optionally overriding the reopen debounce (a non-positive -// debounce keeps the production default). -func NewExportArchiveWriter( - path string, log *slog.Logger, debounce time.Duration, -) *ExportArchiveWriter { - w := newArchiveWriter(path, log) - if debounce > 0 { - w.debounce = debounce - } - - return &ExportArchiveWriter{w: w} -} - -// Write archives a row through the writer, into the file named -// without a period. -func (e *ExportArchiveWriter) Write( - row ExportArchivedEvent, expiry time.Duration, -) error { - return e.w.write(row, expiry, "") -} - -// WritePeriod archives a row through the writer, into the file for -// period. -func (e *ExportArchiveWriter) WritePeriod( - row ExportArchivedEvent, expiry time.Duration, period string, -) error { - return e.w.write(row, expiry, period) -} - -// Open opens the archive file, pruning when expiry is positive. -func (e *ExportArchiveWriter) Open(expiry time.Duration) error { - return e.w.open(e.w.path, expiry) -} - -// Reopen closes and reopens the archive file. -func (e *ExportArchiveWriter) Reopen( - expiry time.Duration, -) error { - return e.w.reopen(e.w.path, expiry) -} - -// SetNow replaces the clock the writer measures its reopen -// debounce on. -func (e *ExportArchiveWriter) SetNow(now func() time.Time) { - e.w.now = now -} - -// Reopens reports how many times the file has been opened. -func (e *ExportArchiveWriter) Reopens() int { - return e.w.reopens -} - -// DB returns the writer's current open handle for row -// inspection in tests. -func (e *ExportArchiveWriter) DB() *gorm.DB { - return e.w.db -} - -// Path returns the archive file the writer owns. -func (e *ExportArchiveWriter) Path() string { - return e.w.path -} - -// OpenExisting opens the archive without permitting creation, -// the way the idle sweep does. -func (e *ExportArchiveWriter) OpenExisting( - expiry time.Duration, -) error { - return e.w.openMode(e.w.path, archiveModeExisting, expiry) -} - -// SweepExpired runs an idle sweep of the archive. -func (e *ExportArchiveWriter) SweepExpired( - expiry time.Duration, -) error { - return e.w.sweepExpired(expiry) -} - -// Evict marks the writer evicted and closes its handle, exactly -// as leaving the registry does. -func (e *ExportArchiveWriter) Evict() { - e.w.evict() -} - -// Rename gives the archive file a new name in the same directory, -// as a rename of the webhook or target does. -func (e *ExportArchiveWriter) Rename(name string) error { - return e.w.rename(name) -} - -// HandleOpen reports whether the writer currently holds an open -// archive handle. -func (e *ExportArchiveWriter) HandleOpen() bool { - e.w.mu.Lock() - defer e.w.mu.Unlock() - - return e.w.db != nil -} - -// Same reports whether both wrappers refer to the very same -// underlying archive writer, so a test can prove a registry entry -// is the writer it was handed rather than a replacement. -func (e *ExportArchiveWriter) Same( - other *ExportArchiveWriter, -) bool { - return other != nil && e.w == other.w -} - -// ExportArchiveWriterFor returns the archive writer the registry -// currently caches for a database target, or nil when none is -// cached. It never creates one, so a test can hold a reference to -// the very writer an eviction is about to detach. -func (e *Engine) ExportArchiveWriterFor( - targetID string, -) *ExportArchiveWriter { - e.dbTarget.mu.Lock() - defer e.dbTarget.mu.Unlock() - - w, ok := e.dbTarget.writers[targetID] - if !ok { - return nil - } - - return &ExportArchiveWriter{w: w} -} - -// ExportHasArchiveWriter reports whether the database target -// type currently caches an archive writer for a target. -func (e *Engine) ExportHasArchiveWriter( - targetID string, -) bool { - e.dbTarget.mu.Lock() - defer e.dbTarget.mu.Unlock() - - _, ok := e.dbTarget.writers[targetID] - - return ok -} - -// ExportArchiveHandleOpen reports whether the cached archive -// writer for a target holds an open database handle. It -// returns false when no writer is cached. -func (e *Engine) ExportArchiveHandleOpen( - targetID string, -) bool { - e.dbTarget.mu.Lock() - w, ok := e.dbTarget.writers[targetID] - e.dbTarget.mu.Unlock() - - if !ok { - return false - } - - w.mu.Lock() - defer w.mu.Unlock() - - return w.db != nil -} - -// ExportEnsureArchiveWriter creates (if needed) and returns the -// archive file path of the cached writer for a target, so a -// test can prime the registry the way a delivery would. -func (e *Engine) ExportEnsureArchiveWriter( - targetID string, -) (string, error) { - w, err := e.dbTarget.writerFor(targetID) - if err != nil { - return "", err - } - - return w.path, nil -} - -// ExportSweepWriterFor takes a target's registry writer exactly -// as the idle sweep does, reporting whether the sweep had to -// create the entry. It lets a test drive the registry through the -// sweep's own entry point instead of choreographing goroutines. -func (e *Engine) ExportSweepWriterFor( - targetID string, -) (*ExportArchiveWriter, bool, error) { - w, created, err := e.dbTarget.sweepWriterFor(targetID) - if err != nil { - return nil, false, err - } - - return &ExportArchiveWriter{w: w}, created, nil -} - -// ExportReleaseSweepWriter releases a sweep-created registry entry -// exactly as a finished sweep does. -func (e *Engine) ExportReleaseSweepWriter( - targetID string, w *ExportArchiveWriter, -) { - e.dbTarget.releaseSweepWriter(targetID, w.w) -} - -// NewTestArchiveSweeper builds an ArchiveSweeper backed by the -// given main database and engine, without the fx lifecycle. -// Intended for tests. -func NewTestArchiveSweeper( - db *database.Database, - eng *Engine, - log *slog.Logger, -) *ArchiveSweeper { - return &ArchiveSweeper{ - db: db, - eng: eng, - log: log, - interval: time.Hour, - } -} - -// ExportSweep runs a single archive sweep synchronously for -// tests. -func (s *ArchiveSweeper) ExportSweep(ctx context.Context) { - s.sweep(ctx) -} - -// ExportStart starts the sweeper's background loop for tests. -func (s *ArchiveSweeper) ExportStart() { - s.start() -} - -// ExportRegisterHooks registers the sweeper's real fx lifecycle -// hooks on a lifecycle supplied by a test, so a test can drive -// the exact OnStart/OnStop functions the application runs and -// hand OnStart the kind of context fx actually supplies. -func (s *ArchiveSweeper) ExportRegisterHooks(lc fx.Lifecycle) { - s.registerHooks(lc) -} - -// ExportStop stops the sweeper's background loop for tests. -func (s *ArchiveSweeper) ExportStop(ctx context.Context) error { - return s.stop(ctx) -} - -// ExportWedgeLoop adds a goroutine to the sweeper's WaitGroup -// that never observes cancellation and returns only when release -// is closed. It stands in for a prune stuck on a locked archive. -func (s *ArchiveSweeper) ExportWedgeLoop( - release <-chan struct{}, -) { - s.wg.Go(func() { - <-release - }) -} - -// ExportSetInterval overrides the sweep interval for tests. -func (s *ArchiveSweeper) ExportSetInterval(d time.Duration) { - s.interval = d -} - -// ExportParseArchiveExpiry exposes parseArchiveExpiry. -func ExportParseArchiveExpiry( - configJSON string, -) (time.Duration, error) { - return parseArchiveExpiry(configJSON) -} diff --git a/internal/delivery/inflight_test.go b/internal/delivery/inflight_test.go index c50e3c0..f6ec27e 100644 --- a/internal/delivery/inflight_test.go +++ b/internal/delivery/inflight_test.go @@ -2,8 +2,6 @@ package delivery_test import ( "context" - "path/filepath" - "strings" "sync" "testing" "time" @@ -66,33 +64,6 @@ func fDrain(e *delivery.Engine) []delivery.Task { } } -// TestArchiveHandleIsWAL closes the last gap in the durability -// evidence: the main and per-webhook tiers each assert their journal -// mode on a live handle, and the archive tier gets its settings from -// the same code path but nothing checked the running file. -func TestArchiveHandleIsWAL(t *testing.T) { - t.Parallel() - - w := delivery.NewExportArchiveWriter( - filepath.Join(t.TempDir(), "archive-wal.db"), - archiveTestLogger(), 0, - ) - - require.NoError(t, w.Open(0)) - - var mode string - - row := w.DB().Raw("pragma journal_mode").Row() - require.NoError(t, row.Scan(&mode)) - assert.Equal(t, "wal", strings.ToLower(mode)) - - var busy string - - row = w.DB().Raw("pragma busy_timeout").Row() - require.NoError(t, row.Scan(&busy)) - assert.Equal(t, "10000", busy) -} - // TestSweepLeavesAQueuedDeliveryAlone is the case the age bound cannot // see. The delivery is queued and untouched, so its row is arbitrarily // old and still perfectly healthy; only ownership distinguishes it diff --git a/internal/delivery/target.go b/internal/delivery/target.go index 157565a..aa819db 100644 --- a/internal/delivery/target.go +++ b/internal/delivery/target.go @@ -103,16 +103,13 @@ func (e *Engine) initTargets(client *http.Client) { client: client, } - dbT := &databaseTarget{eng: e} - e.httpTarget = httpT e.slackTarget = slackT - e.dbTarget = dbT e.targets = map[database.TargetType]Target{ database.TargetTypeHTTP: httpT, database.TargetTypeSlack: slackT, - database.TargetTypeDatabase: dbT, + database.TargetTypeDatabase: &databaseTarget{eng: e}, database.TargetTypeLog: &logTarget{eng: e}, } } diff --git a/internal/delivery/target_config_edit.go b/internal/delivery/target_config_edit.go index 3e76a48..73cd8b2 100644 --- a/internal/delivery/target_config_edit.go +++ b/internal/delivery/target_config_edit.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" + "sneak.berlin/go/webhooker/internal/archive" "sneak.berlin/go/webhooker/internal/database" ) @@ -105,7 +106,7 @@ func databaseConfigForm( return TargetConfigForm{}, nil } - var cfg databaseTargetConfig + var cfg archive.DatabaseTargetConfig err := json.Unmarshal([]byte(configJSON), &cfg) if err != nil { @@ -114,18 +115,18 @@ func databaseConfigForm( ) } - err = ValidateArchiveRotation(cfg.Rotation) + err = archive.ValidateRotation(cfg.Rotation) if err != nil { return TargetConfigForm{}, err } form := TargetConfigForm{Rotation: cfg.Rotation} - if cfg.Expiry == "" || cfg.Expiry == archiveExpiryNever { + if cfg.Expiry == "" || cfg.Expiry == archive.ExpiryNever { return form, nil } - err = ValidateArchiveExpiry(cfg.Expiry) + err = archive.ValidateExpiry(cfg.Expiry) if err != nil { return TargetConfigForm{}, err } diff --git a/internal/delivery/target_config_view.go b/internal/delivery/target_config_view.go index e8b4771..2907b61 100644 --- a/internal/delivery/target_config_view.go +++ b/internal/delivery/target_config_view.go @@ -5,6 +5,7 @@ import ( "strconv" "time" + "sneak.berlin/go/webhooker/internal/archive" "sneak.berlin/go/webhooker/internal/database" ) @@ -200,17 +201,17 @@ func maxAttemptsField(t *database.Target) ConfigField { // not one of the four, is reported as unavailable rather than // echoed back. func databaseConfigFields(configJSON string) []ConfigField { - expiry, err := parseArchiveExpiry(configJSON) + expiry, err := archive.ParseExpiry(configJSON) if err != nil { return unavailableConfigFields() } - rotation, err := parseArchiveRotation(configJSON) + rotation, err := archive.ParseRotation(configJSON) if err != nil { return unavailableConfigFields() } - value := archiveExpiryNever + value := archive.ExpiryNever if expiry > 0 { value = plainDuration(expiry) } diff --git a/internal/delivery/target_config_view_test.go b/internal/delivery/target_config_view_test.go index 8a60b24..595607e 100644 --- a/internal/delivery/target_config_view_test.go +++ b/internal/delivery/target_config_view_test.go @@ -35,6 +35,17 @@ const ( viewMaxAttempts = "Delivery attempts" ) +// The archive rotations a database target's settings can name, and +// the config of one whose rotation is not one of the four. +const ( + rotationNone = "none" + rotationMonthly = "monthly" + rotationDaily = "daily" + rotationHourly = "hourly" + + weeklyConfig = `{"rotation":"weekly"}` +) + func TestMaskedWebhookURL(t *testing.T) { t.Parallel() diff --git a/internal/delivery/target_database.go b/internal/delivery/target_database.go index aead474..9b03171 100644 --- a/internal/delivery/target_database.go +++ b/internal/delivery/target_database.go @@ -2,83 +2,23 @@ package delivery import ( "context" - "fmt" - "strings" - "sync" "time" "gorm.io/gorm" "sneak.berlin/go/webhooker/internal/database" ) -// archiveNameMaxLen is how many characters of a webhook or target -// name an archive file name keeps. -const archiveNameMaxLen = 40 - // databaseTarget is a no-retry target that archives the full // inbound event into the target's own archive SQLite file, separate // from the per-webhook event database. The event is already // persisted in the per-webhook event DB by the time delivery runs; // the database target additionally writes a durable long-term copy -// into the file ArchiveFileName names, with a period added when the -// target rotates (see archivePeriodPath), and then records a single +// through the engine's archive.Writers, and then records a single // attempt whose outcome reflects whether the archive write -// succeeded. See archiveWriter for the close/reopen, auto-recreate, -// and expiry semantics. +// succeeded. Package archive holds everything about the files +// themselves. type databaseTarget struct { eng *Engine - - // writers holds one archive writer per database target, keyed - // by target ID. - mu sync.Mutex - writers map[string]*archiveWriter -} - -// ArchiveFileName returns the file name of a database target's -// archive: archive-WEBHOOKNAME-TARGETNAME-TARGETID.db, with both -// names passed through archiveNamePart. The target ID keeps the -// name unique when two targets' names come out the same. -func ArchiveFileName(webhookName, targetName, targetID string) string { - return "archive-" + archiveNamePart(webhookName) + "-" + - archiveNamePart(targetName) + "-" + targetID + ".db" -} - -// archiveNamePart makes a webhook or target name safe to put in a -// file name. It is lowercased; ASCII letters and digits are kept, -// every other run of characters becomes a single "-", and no "-" is -// left at either end. It is cut to archiveNameMaxLen characters, and -// a name with nothing left is "unnamed". -func archiveNamePart(name string) string { - var b strings.Builder - - dash := false - - for _, r := range strings.ToLower(name) { - if (r < 'a' || r > 'z') && (r < '0' || r > '9') { - dash = b.Len() > 0 - - continue - } - - if dash { - b.WriteByte('-') - - dash = false - } - - b.WriteRune(r) - } - - part := b.String() - if len(part) > archiveNameMaxLen { - part = strings.TrimRight(part[:archiveNameMaxLen], "-") - } - - if part == "" { - return "unnamed" - } - - return part } // Deliver implements Target. It archives the event, then @@ -98,7 +38,7 @@ func (t *databaseTarget) Deliver( ) { start := time.Now() - err := t.archive(d) + err := t.eng.archives.Write(d) elapsed := time.Since(start) @@ -145,308 +85,3 @@ func (t *databaseTarget) Deliver( database.DeliveryStatusDelivered, ) } - -// archive writes the full event as a row into the target's -// archive database, honouring the optional per-target expiry and -// rotation parsed from the target config JSON. With rotation, the -// event goes to the file for the period of its receive time. -func (t *databaseTarget) archive(d *database.Delivery) error { - webhookID := d.Event.WebhookID - if webhookID == "" { - return errArchiveMissingWebhookID - } - - expiry, err := parseArchiveExpiry(d.Target.Config) - if err != nil { - return err - } - - rotation, err := parseArchiveRotation(d.Target.Config) - if err != nil { - return err - } - - // An event whose stored row was gone before its delivery ran has - // no receive time (see Engine.hydrateEvent), and goes to the - // file for now. - receivedAt := d.Event.CreatedAt - if receivedAt.IsZero() { - receivedAt = time.Now() - } - - w, err := t.writerFor(d.TargetID) - if err != nil { - return err - } - - row := archivedEvent{ - EventID: d.Event.ID, - WebhookID: webhookID, - EntrypointID: d.Event.EntrypointID, - Method: d.Event.Method, - RawQuery: d.Event.RawQuery, - Headers: d.Event.Headers, - Body: d.Event.Body, - ContentType: d.Event.ContentType, - } - - return w.write(row, expiry, archivePeriod(rotation, receivedAt)) -} - -// writerFor returns the archive writer for a database target, -// creating and caching it on first use. Each target has one writer -// so its close/reopen debounce state is shared across concurrent -// deliveries, and so a rename and the idle sweep take the same lock -// as its writes. -func (t *databaseTarget) writerFor( - targetID string, -) (*archiveWriter, error) { - t.mu.Lock() - defer t.mu.Unlock() - - w, ok := t.writers[targetID] - if !ok { - var err error - - w, err = t.newWriter(targetID) - if err != nil { - return nil, err - } - - if t.writers == nil { - t.writers = make(map[string]*archiveWriter) - } - - t.writers[targetID] = w - } - - // A delivery claims the entry: even if the idle sweep created - // it moments ago, it now belongs to the registry proper and - // the sweep must leave it in place when it finishes. - w.sweepOwned = false - - return w, nil -} - -// sweepWriterFor returns the archive writer the idle sweep should -// prune a target's archive through, together with whether the sweep -// itself created the registry entry. -// -// The sweep must route its prune through the registered writer so -// the writer's mutex orders it against concurrent writes, but it -// must never leave a registry entry behind: a sweep that ran -// concurrently with the target's deletion would otherwise -// re-create an entry that nothing will ever evict again, which is -// exactly the leak eviction exists to prevent. An entry the sweep -// creates is therefore marked sweep-owned and handed back to -// releaseSweepWriter when the sweep is done. -func (t *databaseTarget) sweepWriterFor( - targetID string, -) (*archiveWriter, bool, error) { - t.mu.Lock() - defer t.mu.Unlock() - - w, ok := t.writers[targetID] - if ok { - return w, false, nil - } - - w, err := t.newWriter(targetID) - if err != nil { - return nil, false, err - } - - if t.writers == nil { - t.writers = make(map[string]*archiveWriter) - } - - w.sweepOwned = true - t.writers[targetID] = w - - return w, true, nil -} - -// releaseSweepWriter drops a registry entry that the idle sweep -// created, so a sweep leaves the registry exactly as it found it. -// -// The entry is removed only if it is still the very writer the -// sweep installed and no delivery has claimed it in the meantime -// (writerFor clears sweepOwned when it hands a writer to the -// write path). Both conditions are evaluated under the registry -// lock, so an eviction that raced the sweep — which removes the -// entry outright — simply finds nothing left to do here, and a -// delivery that adopted the writer keeps a registered, evictable -// one. -func (t *databaseTarget) releaseSweepWriter( - targetID string, w *archiveWriter, -) { - t.mu.Lock() - defer t.mu.Unlock() - - cur, ok := t.writers[targetID] - if !ok || cur != w || !cur.sweepOwned { - return - } - - delete(t.writers, targetID) -} - -// newWriter builds the writer for a database target's archive. Its -// path is the one ArchivePath gives for the webhook and the target as -// the main database names them now; from then on only rename changes -// the name the writer uses. It does not touch the archive files. -func (t *databaseTarget) newWriter( - targetID string, -) (*archiveWriter, error) { - if t.eng.dbManager == nil { - return nil, errArchiveNoDataDir - } - - var target database.Target - - err := t.eng.database.DB(). - Preload("Webhook"). - First(&target, "id = ?", targetID).Error - if err != nil { - return nil, fmt.Errorf( - "loading database target %s: %w", targetID, err, - ) - } - - w := newArchiveWriter( - ArchivePath(t.eng.dbManager, &target.Webhook, &target), - t.eng.log, - ) - w.webhookID = target.WebhookID - - return w, nil -} - -// rename moves every one of a database target's archive files to the -// name for webhookName and targetName. It goes through the target's -// writer, so the move holds the lock that writes and the idle sweep -// take, and later writes use the new name. -// -// The writer is created if there is none, and it stays cached. The -// handlers rename before they save the new name, so until the save -// the main database still has the old one; a delivery in that window -// must find this writer rather than build one from the old name. -func (t *databaseTarget) rename( - targetID, webhookName, targetName string, -) error { - w, err := t.writerFor(targetID) - if err != nil { - return err - } - - return w.rename(ArchiveFileName(webhookName, targetName, targetID)) -} - -// evict drops a database target's archive writer from the registry -// and closes its handle, so a deleted target does not leave a -// writer (and an open archive handle within its debounce window) -// alive for the process lifetime. -// -// The map entry is removed under the registry lock, which is -// then released before the handle is closed under the writer's -// own lock: that ordering keeps the registry available to other -// targets while an in-flight write on this one drains, and -// closing under the writer's lock means eviction can never race -// a write. -// -// Eviction is idempotent and silent for a target with no writer, -// which is the common case: only a database target that has -// received an event or been renamed has one. It never deletes the -// archive file. -func (t *databaseTarget) evict(targetID string) { - t.mu.Lock() - - w, ok := t.writers[targetID] - if ok { - delete(t.writers, targetID) - } - - t.mu.Unlock() - - if !ok { - return - } - - w.evict() - - t.eng.log.Info( - "evicted archive writer", - "target_id", targetID, - "path", w.path, - ) -} - -// evictWebhook evicts, exactly as evict does, the writer of every -// database target of a webhook. -func (t *databaseTarget) evictWebhook(webhookID string) { - t.mu.Lock() - - var gone []*archiveWriter - - for targetID, w := range t.writers { - if w.webhookID == webhookID { - delete(t.writers, targetID) - - gone = append(gone, w) - } - } - - t.mu.Unlock() - - for _, w := range gone { - w.evict() - - t.eng.log.Info( - "evicted archive writer", - "webhook_id", webhookID, - "path", w.path, - ) - } -} - -// evictAll evicts every cached archive writer, exactly as evict -// does for one target. The engine calls it at shutdown, once its -// workers have returned. Closing the last handle on an archive -// moves the contents of its -wal into the .db and removes the -// -wal, so a clean stop leaves each archive as a single file. -func (t *databaseTarget) evictAll() { - t.mu.Lock() - - writers := t.writers - t.writers = nil - - t.mu.Unlock() - - for _, w := range writers { - w.evict() - } -} - -// sweepArchive prunes one database target's archive of rows older -// than expiry, without requiring a write. A missing archive file is -// left missing (see sweepExpired), so a sweep never creates an -// archive for a target that has never received an event. -// -// It also never leaves a registry entry behind: an entry it had -// to create to reach the writer's mutex is released again once -// the prune is done, so a sweep racing a target deletion cannot -// resurrect the writer the eviction just dropped. -func (t *databaseTarget) sweepArchive( - targetID string, expiry time.Duration, -) error { - w, created, err := t.sweepWriterFor(targetID) - if err != nil { - return err - } - - if created { - defer t.releaseSweepWriter(targetID, w) - } - - return w.sweepExpired(expiry) -} diff --git a/internal/delivery/target_database_test.go b/internal/delivery/target_database_test.go index 7c96842..48b20c6 100644 --- a/internal/delivery/target_database_test.go +++ b/internal/delivery/target_database_test.go @@ -3,24 +3,65 @@ package delivery_test import ( "database/sql" "fmt" - "io/fs" "log/slog" + "net/http" "os" "path/filepath" - "strings" "testing" "time" + "github.com/google/uuid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "gorm.io/driver/sqlite" "gorm.io/gorm" + "gorm.io/gorm/clause" _ "modernc.org/sqlite" // Pure Go SQLite driver. + "sneak.berlin/go/webhooker/internal/archive" "sneak.berlin/go/webhooker/internal/database" + "sneak.berlin/go/webhooker/internal/database/databasetest" "sneak.berlin/go/webhooker/internal/delivery" "sneak.berlin/go/webhooker/internal/gormlog" ) +// archiveTestWebhookName is the name of every webhook +// seedDatabaseTarget creates. It is not safe in a file name as it +// stands, so every archive file name made from it is made safe first. +const archiveTestWebhookName = "Sweep Test!" + +// archiveEnv bundles the pieces a database target test drives: a main +// configuration database holding webhooks and targets, a delivery +// engine whose archive writers write into the data directory, and that +// directory. +type archiveEnv struct { + eng *delivery.Engine + mainDB *database.Database + dataDir string +} + +func setupArchiveTest(t *testing.T) *archiveEnv { + t.Helper() + + dataDir := t.TempDir() + + mainDB, err := database.Open(dataDir, slog.New(slog.DiscardHandler)) + require.NoError(t, err) + + t.Cleanup(func() { _ = mainDB.Close() }) + + return &archiveEnv{ + eng: delivery.NewTestEngineWithDB( + mainDB, + databasetest.NewWebhookDBManager(t, dataDir), + archiveTestLogger(), + &http.Client{Timeout: 5 * time.Second}, + 1, + ), + mainDB: mainDB, + dataDir: dataDir, + } +} + func archiveTestLogger() *slog.Logger { return slog.New(slog.NewTextHandler( os.Stderr, @@ -28,8 +69,85 @@ func archiveTestLogger() *slog.Logger { )) } +// archivePath returns where the engine keeps a database target's +// archive file, for the names seedDatabaseTarget gave it. +func (env *archiveEnv) archivePath(tgt *database.Target) string { + return filepath.Join( + env.dataDir, + archive.FileName(archiveTestWebhookName, tgt.Name, tgt.ID), + ) +} + +// seedDatabaseTarget creates a webhook with one database target +// carrying the given target config JSON, and returns the target. +func (env *archiveEnv) seedDatabaseTarget( + t *testing.T, configJSON string, +) *database.Target { + t.Helper() + + wh := &database.Webhook{ + UserID: uuid.New().String(), + Name: archiveTestWebhookName, + } + require.NoError( + t, + env.mainDB.DB(). + Omit(clause.Associations). + Create(wh).Error, + ) + + tgt := &database.Target{ + WebhookID: wh.ID, + Name: "Archive", + Type: database.TargetTypeDatabase, + Active: true, + Config: configJSON, + MaxAttempts: 1, + } + require.NoError( + t, + env.mainDB.DB(). + Omit(clause.Associations). + Create(tgt).Error, + ) + + return tgt +} + +// seedDatabaseTargetDelivery seeds a pending delivery of an event +// to a database target and returns the in-memory delivery the +// target handler is invoked with. +func seedDatabaseTargetDelivery( + t *testing.T, + webhookDB *gorm.DB, + event database.Event, + tgt *database.Target, +) *database.Delivery { + t.Helper() + + dlv := seedDelivery( + t, webhookDB, event.ID, tgt.ID, + database.DeliveryStatusPending, + ) + + d := &database.Delivery{ + EventID: event.ID, + TargetID: tgt.ID, + Status: database.DeliveryStatusPending, + Event: event, + Target: database.Target{ + Name: tgt.Name, + Type: database.TargetTypeDatabase, + Config: tgt.Config, + }, + } + d.ID = dlv.ID + + return d +} + // openArchiveDBForRead opens an archive file read-only so a -// test can inspect the rows the writer persisted. +// test can inspect the rows the database target archived. func openArchiveDBForRead( t *testing.T, path string, ) *gorm.DB { @@ -52,26 +170,45 @@ func openArchiveDBForRead( return gdb } -// archiveFileSuffixes returns the archive file itself and the -// SQLite sidecars that accompany an open database. A test that -// asserts no archive was created has to check all of them. -func archiveFileSuffixes() []string { - return []string{"", "-wal", "-shm"} +// countArchivedRows counts the rows in an archive file without +// asserting anything, so it is safe to poll from an +// assert.Eventually condition (which runs off the test +// goroutine, where testify assertions must not be used). +func countArchivedRows(path string) (int64, error) { + sqlDB, err := sql.Open( + "sqlite", fmt.Sprintf("file:%s?mode=ro", path), + ) + if err != nil { + return 0, err + } + + defer func() { _ = sqlDB.Close() }() + + gdb, err := gorm.Open( + sqlite.Dialector{Conn: sqlDB}, + &gorm.Config{Logger: gormlog.New(slog.New(slog.DiscardHandler))}, + ) + if err != nil { + return 0, err + } + + var count int64 + + err = gdb.Table("archived_events").Count(&count).Error + if err != nil { + return 0, err + } + + return count, nil } -// removeArchiveFiles simulates an operator moving the archive -// away by deleting the SQLite file and its sidecar files. -func removeArchiveFiles(t *testing.T, path string) { - t.Helper() - - for _, suffix := range []string{ - "", "-wal", "-shm", "-journal", - } { - err := os.Remove(path + suffix) - if err != nil && !os.IsNotExist(err) { - t.Fatalf("removing %s%s: %v", path, suffix, err) - } - } +// archivedRow is the columns of an archive row these tests read. +type archivedRow struct { + EventID string + WebhookID string + Method string + RawQuery string + Body string } // TestDeliverDatabase_ArchivesEvent verifies that delivering to @@ -107,9 +244,9 @@ func TestDeliverDatabase_ArchivesEvent(t *testing.T) { rdb := openArchiveDBForRead(t, archivePath) - var rows []delivery.ExportArchivedEvent + var rows []archivedRow - require.NoError(t, rdb.Find(&rows).Error) + require.NoError(t, rdb.Table("archived_events").Find(&rows).Error) require.Len(t, rows, 1) assert.Equal(t, event.ID, rows[0].EventID) assert.Equal(t, event.WebhookID, rows[0].WebhookID) @@ -118,262 +255,6 @@ func TestDeliverDatabase_ArchivesEvent(t *testing.T) { assert.JSONEq(t, `{"archived":true}`, rows[0].Body) } -func TestArchiveWriter_WritesRow(t *testing.T) { - t.Parallel() - - path := filepath.Join(t.TempDir(), "archive-wh.db") - w := delivery.NewExportArchiveWriter( - path, archiveTestLogger(), 0, - ) - - row := delivery.ExportArchivedEvent{ - EventID: "ev-1", - WebhookID: "wh-1", - EntrypointID: "ep-1", - Method: "POST", - Headers: `{"X":"Y"}`, - Body: `{"hello":"world"}`, - ContentType: "application/json", - } - - require.NoError(t, w.Write(row, 0)) - assert.FileExists(t, path) - - var got []delivery.ExportArchivedEvent - - require.NoError(t, w.DB().Find(&got).Error) - require.Len(t, got, 1) - assert.Equal(t, "ev-1", got[0].EventID) - assert.Equal(t, "wh-1", got[0].WebhookID) - assert.Equal(t, "ep-1", got[0].EntrypointID) - assert.Equal(t, row.Method, got[0].Method) - assert.Equal(t, row.ContentType, got[0].ContentType) - assert.JSONEq(t, `{"hello":"world"}`, got[0].Body) - assert.False(t, got[0].ArchivedAt.IsZero()) - - var archivedAt string - - require.NoError(t, w.DB().Raw( - "SELECT CAST(archived_at AS TEXT) FROM archived_events", - ).Row().Scan(&archivedAt)) - assertStoredTime(t, archivedAt) -} - -func TestArchiveWriter_RecreatesAfterRemoval( - t *testing.T, -) { - t.Parallel() - - path := filepath.Join(t.TempDir(), "archive-wh.db") - w := delivery.NewExportArchiveWriter( - path, archiveTestLogger(), 0, - ) - - require.NoError(t, w.Write( - delivery.ExportArchivedEvent{EventID: "a"}, 0, - )) - assert.FileExists(t, path) - - // The operator moves the archive away while the handle is - // still open. - removeArchiveFiles(t, path) - require.NoFileExists(t, path) - - // The next write recreates the file with a fresh schema and - // only the new row. - require.NoError(t, w.Write( - delivery.ExportArchivedEvent{EventID: "b"}, 0, - )) - assert.FileExists(t, path) - - var got []delivery.ExportArchivedEvent - - require.NoError(t, w.DB().Find(&got).Error) - require.Len(t, got, 1) - assert.Equal(t, "b", got[0].EventID) -} - -// TestStatArchive proves StatArchive finds no file before the first -// write; after a write still held in the -wal, counts the -wal in the -// size and takes its later time as the last write; and finds no file -// again once the file has been moved away. -func TestStatArchive(t *testing.T) { - t.Parallel() - - path := filepath.Join(t.TempDir(), "archive-wh.db") - - _, err := delivery.StatArchive(path) - require.ErrorIs(t, err, fs.ErrNotExist) - - // With the clock stopped, the reopen debounce never passes, so - // the handle stays open after the write. - stopped := time.Now() - w := delivery.NewExportArchiveWriter(path, archiveTestLogger(), 0) - w.SetNow(func() time.Time { return stopped }) - - require.NoError(t, w.Write(delivery.ExportArchivedEvent{EventID: "a"}, 0)) - - written := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC) - earlier := written.Add(-time.Hour) - require.NoError(t, os.Chtimes(path, earlier, earlier)) - require.NoError(t, os.Chtimes(path+"-wal", written, written)) - - file, err := os.Stat(path) - require.NoError(t, err) - - wal, err := os.Stat(path + "-wal") - require.NoError(t, err) - require.Positive(t, wal.Size()) - - got, err := delivery.StatArchive(path) - require.NoError(t, err) - assert.Equal(t, 1, got.Files) - assert.Equal(t, file.Size()+wal.Size(), got.Size) - assert.True(t, written.Equal(got.Written), got.Written) - - removeArchiveFiles(t, path) - - _, err = delivery.StatArchive(path) - require.ErrorIs(t, err, fs.ErrNotExist) -} - -func TestArchiveWriter_ReopenDebounce(t *testing.T) { - t.Parallel() - - const debounce = 2 * time.Second - - path := filepath.Join(t.TempDir(), "archive-wh.db") - w := delivery.NewExportArchiveWriter( - path, archiveTestLogger(), debounce, - ) - - // The writer measures its reopen debounce on this clock, which - // only the test moves, so how long the host takes between - // writes cannot change the result. - now := time.Now() - - w.SetNow(func() time.Time { return now }) - - require.NoError(t, w.Write( - delivery.ExportArchivedEvent{EventID: "a"}, 0, - )) - require.NoError(t, w.Write( - delivery.ExportArchivedEvent{EventID: "b"}, 0, - )) - - // Two writes inside the debounce window trigger only the - // initial open — no extra close/reopen. - assert.Equal(t, 1, w.Reopens()) - - now = now.Add(debounce) - - require.NoError(t, w.Write( - delivery.ExportArchivedEvent{EventID: "c"}, 0, - )) - - // A write after the window elapses closes and reopens once. - assert.Equal(t, 2, w.Reopens()) -} - -func TestArchiveWriter_ExpiryPrune(t *testing.T) { - t.Parallel() - - path := filepath.Join(t.TempDir(), "archive-wh.db") - w := delivery.NewExportArchiveWriter( - path, archiveTestLogger(), 0, - ) - - require.NoError(t, w.Open(0)) - - old := delivery.ExportArchivedEvent{ - EventID: "old", - ArchivedAt: time.Now().Add(-2 * time.Hour), - } - fresh := delivery.ExportArchivedEvent{ - EventID: "fresh", - ArchivedAt: time.Now(), - } - - require.NoError(t, w.DB().Create(&old).Error) - require.NoError(t, w.DB().Create(&fresh).Error) - - // Reopening with a one-hour expiry prunes the old row. - require.NoError(t, w.Reopen(time.Hour)) - - var got []delivery.ExportArchivedEvent - - require.NoError(t, w.DB().Find(&got).Error) - require.Len(t, got, 1) - assert.Equal(t, "fresh", got[0].EventID) -} - -func TestParseArchiveExpiry(t *testing.T) { - t.Parallel() - - cases := []struct { - name string - in string - want time.Duration - wantErr bool - }{ - {"empty config", "", 0, false}, - {"explicit never", `{"expiry":"never"}`, 0, false}, - {"empty expiry", `{"expiry":""}`, 0, false}, - {"duration", `{"expiry":"1h"}`, time.Hour, false}, - {"unparseable", `{"expiry":"nonsense"}`, 0, true}, - {"zero duration", `{"expiry":"0s"}`, 0, true}, - {"negative duration", `{"expiry":"-5h"}`, 0, true}, - } - - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - - got, err := delivery.ExportParseArchiveExpiry(tc.in) - if tc.wantErr { - require.Error(t, err) - - return - } - - require.NoError(t, err) - assert.Equal(t, tc.want, got) - }) - } -} - -// seedDatabaseTargetDelivery seeds a pending delivery of an event -// to a database target and returns the in-memory delivery the -// target handler is invoked with. -func seedDatabaseTargetDelivery( - t *testing.T, - webhookDB *gorm.DB, - event database.Event, - tgt *database.Target, -) *database.Delivery { - t.Helper() - - dlv := seedDelivery( - t, webhookDB, event.ID, tgt.ID, - database.DeliveryStatusPending, - ) - - d := &database.Delivery{ - EventID: event.ID, - TargetID: tgt.ID, - Status: database.DeliveryStatusPending, - Event: event, - Target: database.Target{ - Name: tgt.Name, - Type: database.TargetTypeDatabase, - Config: tgt.Config, - }, - } - d.ID = dlv.ID - - return d -} - // TestDeliverDatabase_ArchiveFailureFailsDelivery verifies that // an archive error (here: an unparseable expiry in the target // config) fails the delivery loudly: the attempt is recorded as @@ -423,348 +304,3 @@ func TestDeliverDatabase_ArchiveFailureFailsDelivery( "no archive file should exist for a failed config", ) } - -func TestValidateArchiveExpiry(t *testing.T) { - t.Parallel() - - valid := []string{"", "never", "1h", "720h", "30m"} - for _, in := range valid { - require.NoError(t, - delivery.ValidateArchiveExpiry(in), - "expiry %q should be accepted", in, - ) - } - - invalid := []string{"nonsense", "7d", "-5h", "0s", "0"} - for _, in := range invalid { - require.Error(t, - delivery.ValidateArchiveExpiry(in), - "expiry %q should be rejected", in, - ) - } -} - -// TestArchiveFileName pins the archive file name and the rules -// that make a webhook or target name safe to put in it. -func TestArchiveFileName(t *testing.T) { - t.Parallel() - - const id = "3f2a1c9e-8d4b-4c1a-9e2f-0a1b2c3d4e5f" - - cases := []struct { - name string - webhook string - target string - want string - }{ - { - "plain names", "orders", "archive", - "archive-orders-archive-" + id + ".db", - }, - { - "lowercased", "Orders", "Main Archive", - "archive-orders-main-archive-" + id + ".db", - }, - { - "a run of other characters is one dash", - `a /\..b`, "c__--d", - "archive-a-b-c-d-" + id + ".db", - }, - { - "no dash at either end", " --orders!! ", "(archive)", - "archive-orders-archive-" + id + ".db", - }, - { - "path separators", "../../etc/passwd", "a/b", - "archive-etc-passwd-a-b-" + id + ".db", - }, - { - "letters outside ASCII are dropped", - "Bestellungen Größe", "café", - "archive-bestellungen-gr-e-caf-" + id + ".db", - }, - { - "nothing left is unnamed", "", "!!!", - "archive-unnamed-unnamed-" + id + ".db", - }, - { - "cut to 40 characters", strings.Repeat("a", 50), "x", - "archive-" + strings.Repeat("a", 40) + "-x-" + id + ".db", - }, - { - "no dash left by the cut", - strings.Repeat("a", 39) + " b", "x", - "archive-" + strings.Repeat("a", 39) + "-x-" + id + ".db", - }, - } - - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - - assert.Equal( - t, tc.want, - delivery.ArchiveFileName(tc.webhook, tc.target, id), - ) - }) - } -} - -// TestDeliverDatabase_EachTargetHasItsOwnArchive proves two -// database targets of one webhook archive into separate files. -func TestDeliverDatabase_EachTargetHasItsOwnArchive(t *testing.T) { - t.Parallel() - - env := setupArchiveTest(t) - first := env.seedDatabaseTarget(t, "") - second := env.addDatabaseTarget(t, first.WebhookID, "") - - webhookDB := testWebhookDB(t) - event := seedEvent(t, webhookDB, `{"n":1}`) - - for _, tgt := range []*database.Target{first, second} { - env.eng.ExportDeliverDatabase( - webhookDB, - seedDatabaseTargetDelivery(t, webhookDB, event, tgt), - ) - } - - require.NotEqual( - t, env.archivePath(first), env.archivePath(second), - ) - assert.Equal( - t, []string{event.ID}, - archivedEventIDs(t, env.archivePath(first)), - ) - assert.Equal( - t, []string{event.ID}, - archivedEventIDs(t, env.archivePath(second)), - ) -} - -// TestRename_MovesTheFile proves a rename moves the archive, rows -// and all, and that later writes go to the new name. -func TestRename_MovesTheFile(t *testing.T) { - t.Parallel() - - env := setupArchiveTest(t) - tgt := env.seedDatabaseTarget(t, "") - oldPath := env.archivePath(tgt) - - webhookDB := testWebhookDB(t) - first := seedEvent(t, webhookDB, `{"n":1}`) - env.eng.ExportDeliverDatabase( - webhookDB, seedDatabaseTargetDelivery(t, webhookDB, first, tgt), - ) - require.FileExists(t, oldPath) - - require.NoError( - t, env.eng.Rename(tgt.ID, "Orders", "Long Term"), - ) - - newPath := filepath.Join( - env.dataDir, "archive-orders-long-term-"+tgt.ID+".db", - ) - - assert.NoFileExists(t, oldPath) - assert.Equal(t, []string{first.ID}, archivedEventIDs(t, newPath)) - - second := seedEvent(t, webhookDB, `{"n":2}`) - env.eng.ExportDeliverDatabase( - webhookDB, - seedDatabaseTargetDelivery(t, webhookDB, second, tgt), - ) - - assert.ElementsMatch( - t, []string{first.ID, second.ID}, - archivedEventIDs(t, newPath), - ) - assert.NoFileExists( - t, oldPath, "a write after the rename must use the new name", - ) -} - -// TestRename_NeverReplacesAFile plants a file at the new name, once -// the .db alone, once a lone -wal and once a lone -shm, and proves -// each time that the rename is refused, the planted file survives, -// and the archive keeps its name and its rows. -func TestRename_NeverReplacesAFile(t *testing.T) { - t.Parallel() - - for _, suffix := range archiveFileSuffixes() { - t.Run("planted .db"+suffix, func(t *testing.T) { - t.Parallel() - - env := setupArchiveTest(t) - tgt := env.seedDatabaseTarget(t, "") - oldPath := env.archivePath(tgt) - - webhookDB := testWebhookDB(t) - first := seedEvent(t, webhookDB, `{"n":1}`) - env.eng.ExportDeliverDatabase( - webhookDB, - seedDatabaseTargetDelivery(t, webhookDB, first, tgt), - ) - - newPath := filepath.Join( - env.dataDir, "archive-orders-long-term-"+tgt.ID+".db", - ) - plantedPath := newPath + suffix - require.NoError( - t, os.WriteFile(plantedPath, []byte("planted"), 0o600), - ) - - require.ErrorIs( - t, env.eng.Rename(tgt.ID, "Orders", "Long Term"), - delivery.ErrArchiveNameTaken, - ) - - //nolint:gosec // reads the file the test planted under t.TempDir() - planted, err := os.ReadFile(plantedPath) - require.NoError(t, err) - assert.Equal(t, "planted", string(planted)) - - second := seedEvent(t, webhookDB, `{"n":2}`) - env.eng.ExportDeliverDatabase( - webhookDB, - seedDatabaseTargetDelivery(t, webhookDB, second, tgt), - ) - - assert.ElementsMatch( - t, []string{first.ID, second.ID}, - archivedEventIDs(t, oldPath), - ) - }) - } -} - -// TestRename_BeforeTheNameIsSaved covers the order the handlers -// use: they rename before they save the new name, so a delivery in -// between must write under the new name although the main database -// still has the old one. It also shows that renaming an archive that -// does not exist yet is not an error. -func TestRename_BeforeTheNameIsSaved(t *testing.T) { - t.Parallel() - - env := setupArchiveTest(t) - tgt := env.seedDatabaseTarget(t, "") - - require.NoError( - t, env.eng.Rename(tgt.ID, "Orders", "Archive"), - ) - - webhookDB := testWebhookDB(t) - event := seedEvent(t, webhookDB, `{"n":1}`) - env.eng.ExportDeliverDatabase( - webhookDB, seedDatabaseTargetDelivery(t, webhookDB, event, tgt), - ) - - assert.FileExists( - t, - filepath.Join( - env.dataDir, "archive-orders-archive-"+tgt.ID+".db", - ), - ) - assert.NoFileExists(t, env.archivePath(tgt)) -} - -// TestArchiveWriter_RenameMovesSidecars proves a rename carries -// the -wal and -shm a crash can leave beside an archive no handle -// has opened since. SQLite finds them by name, so a -wal left -// behind would lose the transactions it holds. -func TestArchiveWriter_RenameMovesSidecars(t *testing.T) { - t.Parallel() - - dir := t.TempDir() - oldPath := filepath.Join(dir, "archive-old.db") - newPath := filepath.Join(dir, "archive-new.db") - - for _, suffix := range archiveFileSuffixes() { - require.NoError( - t, os.WriteFile(oldPath+suffix, []byte(suffix), 0o600), - ) - } - - w := delivery.NewExportArchiveWriter( - oldPath, archiveTestLogger(), 0, - ) - - require.NoError(t, w.Rename("archive-new.db")) - - for _, suffix := range archiveFileSuffixes() { - assert.NoFileExists(t, oldPath+suffix) - assert.FileExists(t, newPath+suffix) - } - - assert.Equal(t, newPath, w.Path()) -} - -// TestArchiveWriter_RenameMovesBackOnFailure makes the -wal fail to -// move after the .db has moved, and proves the .db is moved back, so -// the archive is never split across two names. The new name is 255 -// bytes, the longest a file name may be, so the .db can take it but -// the -wal, four bytes longer, cannot. -func TestArchiveWriter_RenameMovesBackOnFailure(t *testing.T) { - t.Parallel() - - dir := t.TempDir() - oldPath := filepath.Join(dir, "archive-old.db") - newName := strings.Repeat("a", 252) + ".db" - - for _, suffix := range archiveFileSuffixes() { - require.NoError( - t, os.WriteFile(oldPath+suffix, []byte(suffix), 0o600), - ) - } - - w := delivery.NewExportArchiveWriter( - oldPath, archiveTestLogger(), 0, - ) - - require.Error(t, w.Rename(newName)) - - for _, suffix := range archiveFileSuffixes() { - assert.FileExists(t, oldPath+suffix) - } - - assert.NoFileExists(t, filepath.Join(dir, newName)) - assert.Equal(t, oldPath, w.Path()) -} - -// TestArchiveWriter_RenameMovesBackEveryFile renames a target with a -// file without a period and a file for a month, and proves that when -// the month's file fails to move, the file already moved is moved back: -// both files are under the old name with their rows, and nothing is -// under the new name. The new name is 251 bytes, so the file without a -// period, with its -wal and -shm, can take it, but the month's file, -// eight bytes longer, cannot. -func TestArchiveWriter_RenameMovesBackEveryFile(t *testing.T) { - t.Parallel() - - dir := t.TempDir() - oldPath := filepath.Join(dir, "archive-old.db") - monthPath := filepath.Join(dir, "archive-old-2026-03.db") - w := delivery.NewExportArchiveWriter(oldPath, archiveTestLogger(), 0) - - require.NoError(t, w.WritePeriod( - delivery.ExportArchivedEvent{EventID: "in-none"}, 0, "", - )) - require.NoError(t, w.WritePeriod( - delivery.ExportArchivedEvent{EventID: "in-month"}, 0, "2026-03", - )) - - require.Error(t, w.Rename(strings.Repeat("a", 248)+".db")) - - assert.Equal(t, []string{"in-none"}, archivedEventIDs(t, oldPath)) - assert.Equal(t, []string{"in-month"}, archivedEventIDs(t, monthPath)) - - entries, err := os.ReadDir(dir) - require.NoError(t, err) - - for _, entry := range entries { - assert.True(t, strings.HasPrefix(entry.Name(), "archive-old"), - "%s is not under the old name", entry.Name()) - } - - assert.Equal(t, oldPath, w.Path()) -} diff --git a/internal/handlers/archive_rotation_test.go b/internal/handlers/archive_rotation_test.go index ff2c511..94b27bc 100644 --- a/internal/handlers/archive_rotation_test.go +++ b/internal/handlers/archive_rotation_test.go @@ -12,8 +12,8 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "sneak.berlin/go/webhooker/internal/archive" "sneak.berlin/go/webhooker/internal/database" - "sneak.berlin/go/webhooker/internal/delivery" ) // rotationNone is the archive rotation that keeps one file. @@ -181,7 +181,7 @@ func TestArchiveFileView_Rotated(t *testing.T) { `{"rotation":"daily"}`, ) - path := delivery.ArchivePath(env.dbMgr, &webhook, target) + path := archive.Path(env.dbMgr, &webhook, target) stem := strings.TrimSuffix(path, ".db") written := time.Date(2026, 10, 2, 9, 0, 0, 0, time.UTC) diff --git a/internal/handlers/gormlogbound_test.go b/internal/handlers/gormlogbound_test.go index d5458dc..21a8743 100644 --- a/internal/handlers/gormlogbound_test.go +++ b/internal/handlers/gormlogbound_test.go @@ -374,7 +374,7 @@ func assertFloodBounded(t *testing.T, label, out string) { // this service's three gorm.Open sites: the main database behind the // two unauthenticated lookups, and the per-webhook database the // WebhookDBManager opens. The third, the archive writer, is pinned in -// internal/delivery, where its type lives. +// internal/archive, where its type lives. // // What each assertion is worth, since two of the three would pass // against a service that had never been fixed if the capture were set diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go index 2b689e6..0770809 100644 --- a/internal/handlers/handlers.go +++ b/internal/handlers/handlers.go @@ -10,11 +10,11 @@ import ( "html/template" "log/slog" "net/http" - "sync" "sync/atomic" "github.com/prometheus/client_golang/prometheus" "go.uber.org/fx" + "sneak.berlin/go/webhooker/internal/archive" "sneak.berlin/go/webhooker/internal/config" "sneak.berlin/go/webhooker/internal/database" "sneak.berlin/go/webhooker/internal/delivery" @@ -65,7 +65,7 @@ type HandlersParams struct { Session *session.Session Middleware *middleware.Middleware Notifier delivery.Notifier - Archives delivery.Archives + Archives archive.Archives CircuitBreakers delivery.CircuitBreakers SSRFGuard *delivery.Guard Metrics *metrics.Set @@ -83,7 +83,7 @@ type Handlers struct { session *session.Session mw *middleware.Middleware notifier delivery.Notifier - archives delivery.Archives + archives archive.Archives breakers delivery.CircuitBreakers mtr *metrics.Set templates map[string]*template.Template @@ -93,17 +93,6 @@ type Handlers struct { // is one delivery will actually attempt. ssrf *delivery.Guard - // renameMu makes the webhook edit, the target edit and target - // creation run one at a time, each held from loading the stored - // names through the archive rename, the save and any move back. - // Interleaved, one could rename an archive between another's - // rename and save, leaving the file named for one edit and the - // stored names from the other. An archive download holds it while - // it reads the stored names and lists the files they give, and - // again for each file while it finds the file under the names - // stored then and opens it. - renameMu sync.Mutex - // dummyVerifications counts the equivalent-cost verifications // charged for usernames that do not exist. It exists so a test // can prove that path runs without measuring wall-clock time. diff --git a/internal/handlers/handlers_test.go b/internal/handlers/handlers_test.go index 2cbeaf1..be87aad 100644 --- a/internal/handlers/handlers_test.go +++ b/internal/handlers/handlers_test.go @@ -16,6 +16,7 @@ import ( "github.com/stretchr/testify/require" "go.uber.org/fx" "go.uber.org/fx/fxtest" + "sneak.berlin/go/webhooker/internal/archive" "sneak.berlin/go/webhooker/internal/config" "sneak.berlin/go/webhooker/internal/database" "sneak.berlin/go/webhooker/internal/delivery" @@ -55,12 +56,14 @@ func (n *recordingNotifier) Tasks() []delivery.Task { return out } -// recordingArchives is a delivery.Archives that records what it +// recordingArchives is an archive.Archives that records what it // was asked to do, so a test can prove that a deletion or rename -// path reached the delivery engine. After FailRenames, every +// path reached the archive writers. After FailRenames, every // rename of that target fails with the given error. After // BlockNextRename, the next rename is recorded and then waits. +// RenameLock returns a plain mutex, as in archive.Writers. type recordingArchives struct { + renameMu sync.Mutex mu sync.Mutex evicted []string evictedTargets []string @@ -73,10 +76,10 @@ type recordingArchives struct { // errInjectedRename is the failure a test hands FailRenames. var errInjectedRename = errors.New("injected rename failure") -// errNameTaken is what the delivery engine returns when a file +// errNameTaken is what the archive writers return when a file // already has an archive's new name, here archive-taken.db. var errNameTaken = fmt.Errorf( - "%w: archive-taken.db", delivery.ErrArchiveNameTaken, + "%w: archive-taken.db", archive.ErrNameTaken, ) // archiveRename is one recorded Rename call. @@ -86,6 +89,10 @@ type archiveRename struct { TargetName string } +func (r *recordingArchives) RenameLock() *sync.Mutex { + return &r.renameMu +} + func (r *recordingArchives) EvictWebhook(webhookID string) { r.mu.Lock() defer r.mu.Unlock() @@ -265,7 +272,7 @@ func newTestAppWithConfig( func() *recordingArchives { return &recordingArchives{} }, - func(r *recordingArchives) delivery.Archives { + func(r *recordingArchives) archive.Archives { return r }, func() *testCircuitBreakers { diff --git a/internal/handlers/source_delete_test.go b/internal/handlers/source_delete_test.go index 4ef4143..5d7b8eb 100644 --- a/internal/handlers/source_delete_test.go +++ b/internal/handlers/source_delete_test.go @@ -16,8 +16,8 @@ import ( "github.com/stretchr/testify/require" "gorm.io/gorm" "gorm.io/gorm/clause" + "sneak.berlin/go/webhooker/internal/archive" "sneak.berlin/go/webhooker/internal/database" - "sneak.berlin/go/webhooker/internal/delivery" "sneak.berlin/go/webhooker/internal/handlers" "sneak.berlin/go/webhooker/internal/session" ) @@ -180,7 +180,7 @@ func failSaveOnTable( } // archivePathFor returns the archive database path the -// delivery engine would use for a database target: beside the +// archive writers would use for a database target: beside the // webhook's event database in the data directory. func archivePathFor( t *testing.T, @@ -192,7 +192,7 @@ func archivePathFor( return filepath.Join( filepath.Dir(mgr.DBPath(wh.ID)), - delivery.ArchiveFileName(wh.Name, tgt.Name, tgt.ID), + archive.FileName(wh.Name, tgt.Name, tgt.ID), ) } @@ -228,9 +228,9 @@ func postRequest( } // TestHandleSourceDelete_EvictsArchiveWriter proves that -// deleting a webhook reaches the delivery engine and releases -// the webhook's archive writers, exercised through the real -// deletion handler rather than by calling the engine directly. +// deleting a webhook releases the webhook's archive writers, +// exercised through the real deletion handler rather than by +// calling the archive writers directly. func TestHandleSourceDelete_EvictsArchiveWriter(t *testing.T) { t.Parallel() @@ -290,7 +290,7 @@ func TestHandleSourceDelete_KeepsArchiveFile(t *testing.T) { wh := seedWebhook(t, db) tgt := seedTarget(t, db, wh.ID, database.TargetTypeDatabase) - // Place an archive file where the delivery engine would. + // Place an archive file where the archive writers would. archivePath := archivePathFor(t, mgr, wh, tgt) require.NoError( t, diff --git a/internal/handlers/target_create.go b/internal/handlers/target_create.go index 5410f47..4bde6fd 100644 --- a/internal/handlers/target_create.go +++ b/internal/handlers/target_create.go @@ -9,6 +9,7 @@ import ( "strings" "github.com/go-chi/chi" + "sneak.berlin/go/webhooker/internal/archive" "sneak.berlin/go/webhooker/internal/database" "sneak.berlin/go/webhooker/internal/delivery" ) @@ -27,8 +28,8 @@ func (h *Handlers) HandleTargetCreate() http.HandlerFunc { sourceID := chi.URLParam(r, "sourceID") - h.renameMu.Lock() - defer h.renameMu.Unlock() + h.archives.RenameLock().Lock() + defer h.archives.RenameLock().Unlock() var webhook database.Webhook @@ -361,12 +362,12 @@ func buildDatabaseTargetConfig( ) (string, string, error) { expiry = strings.TrimSpace(expiry) - err := delivery.ValidateArchiveExpiry(expiry) + err := archive.ValidateExpiry(expiry) if err != nil { return "", fmt.Sprintf("Invalid archive expiry: %v", err), nil } - err = delivery.ValidateArchiveRotation(rotation) + err = archive.ValidateRotation(rotation) if err != nil { return "", fmt.Sprintf("Invalid archive rotation: %v", err), nil } diff --git a/internal/handlers/target_download.go b/internal/handlers/target_download.go index 2638d5a..3c59676 100644 --- a/internal/handlers/target_download.go +++ b/internal/handlers/target_download.go @@ -6,8 +6,8 @@ import ( "net/http" "time" + "sneak.berlin/go/webhooker/internal/archive" "sneak.berlin/go/webhooker/internal/database" - "sneak.berlin/go/webhooker/internal/delivery" ) // downloadWriteTimeout is how long one write of a download may wait @@ -16,7 +16,7 @@ const downloadWriteTimeout = 60 * time.Second // HandleTargetDownload serves a database target's archive as one // gzipped JSON file, named for the webhook, the target and the time; -// see delivery.ArchiveExport.WriteGzipJSON for what it holds. Other +// see archive.Export.WriteGzipJSON for what it holds. Other // target types have no archive and are a 404. // // A download runs for as long as the client keeps reading: it reads @@ -37,7 +37,7 @@ func (h *Handlers) HandleTargetDownload() http.HandlerFunc { w.Header().Set("Content-Type", "application/gzip") w.Header().Set( "Content-Disposition", - `attachment; filename="`+delivery.ArchiveExportFileName( + `attachment; filename="`+archive.ExportFileName( webhook.Name, target.Name, now, )+`"`, ) @@ -82,17 +82,17 @@ func (d downloadWriter) Write(b []byte) (int, error) { // listTargetArchive lists the archive files of the request's database // target for export. It reports false once it has written the response. // -// It holds renameMu, which every archive rename runs under, while it -// reads the stored names and lists the files, so the files it lists -// are the ones those names give. It lets go before the export is -// streamed, which takes renameMu again for each file only while it +// It holds the rename lock, which every archive rename runs under, +// while it reads the stored names and lists the files, so the files it +// lists are the ones those names give. It lets go before the export is +// streamed, which takes the lock again for each file only while it // finds the file under the names stored then and opens it. func (h *Handlers) listTargetArchive( w http.ResponseWriter, r *http.Request, -) (database.Webhook, *database.Target, *delivery.ArchiveExport, bool) { - h.renameMu.Lock() - defer h.renameMu.Unlock() +) (database.Webhook, *database.Target, *archive.Export, bool) { + h.archives.RenameLock().Lock() + defer h.archives.RenameLock().Unlock() webhook, target, ok := h.ownedTarget(w, r) if !ok { @@ -105,9 +105,9 @@ func (h *Handlers) listTargetArchive( return database.Webhook{}, nil, nil, false } - export, err := delivery.NewArchiveExport( - delivery.ArchivePath(h.dbMgr, &webhook, target), - &h.renameMu, + export, err := archive.NewExport( + archive.Path(h.dbMgr, &webhook, target), + h.archives.RenameLock(), func() (string, error) { return h.storedArchivePath(webhook.ID, target.ID) }, @@ -122,9 +122,9 @@ func (h *Handlers) listTargetArchive( return webhook, target, export, true } -// storedArchivePath returns the path delivery.ArchivePath gives a +// storedArchivePath returns the path archive.Path gives a // database target under the names stored for it and its webhook now. -// Its caller holds renameMu. A webhook or target deleted since is still +// Its caller holds the rename lock. A webhook or target deleted since is still // found, since deleting one leaves its archive files under their names. func (h *Handlers) storedArchivePath( webhookID, targetID string, @@ -143,5 +143,5 @@ func (h *Handlers) storedArchivePath( return "", err } - return delivery.ArchivePath(h.dbMgr, &webhook, &target), nil + return archive.Path(h.dbMgr, &webhook, &target), nil } diff --git a/internal/handlers/target_download_test.go b/internal/handlers/target_download_test.go index f52b6c7..272fdfc 100644 --- a/internal/handlers/target_download_test.go +++ b/internal/handlers/target_download_test.go @@ -21,9 +21,9 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "sneak.berlin/go/webhooker/internal/archive" "sneak.berlin/go/webhooker/internal/config" "sneak.berlin/go/webhooker/internal/database" - "sneak.berlin/go/webhooker/internal/delivery" "sneak.berlin/go/webhooker/internal/middleware/middlewaretest" ) @@ -223,8 +223,8 @@ func TestHandleTargetDownload_FindsFilesAfterRename(t *testing.T) { env := setupSourceTest(t) wh := seedWebhookWithRetention(t, env.db, 7) - archive := seedTarget(t, env.db, wh.ID, database.TargetTypeDatabase) - oldPath := delivery.ArchivePath(env.dbMgr, &wh, archive) + target := seedTarget(t, env.db, wh.ID, database.TargetTypeDatabase) + oldPath := archive.Path(env.dbMgr, &wh, target) month := func(path string) string { return strings.TrimSuffix(path, ".db") + "-2026-10.db" @@ -233,19 +233,19 @@ func TestHandleTargetDownload_FindsFilesAfterRename(t *testing.T) { seedArchive(t, oldPath, 1, 16) seedArchive(t, month(oldPath), 1, 16) - sw, downloaded := startStalledDownload(t, env, wh.ID, archive.ID) + sw, downloaded := startStalledDownload(t, env, wh.ID, target.ID) require.Equal(t, - http.StatusSeeOther, renameTarget(env, wh.ID, archive.ID).Code, + http.StatusSeeOther, renameTarget(env, wh.ID, target.ID).Code, ) // The test's archives record a rename without moving any file, so - // the files are moved here, as the delivery engine moves them. + // the files are moved here, as the archive writers move them. var renamed database.Target - require.NoError(t, env.db.DB().First(&renamed, "id = ?", archive.ID).Error) + require.NoError(t, env.db.DB().First(&renamed, "id = ?", target.ID).Error) - newPath := delivery.ArchivePath(env.dbMgr, &wh, &renamed) + newPath := archive.Path(env.dbMgr, &wh, &renamed) require.NoError(t, os.Rename(oldPath, newPath)) require.NoError(t, os.Rename(month(oldPath), month(newPath))) @@ -355,16 +355,16 @@ func TestHandleTargetDownload_OutlastsTheRequestLimit(t *testing.T) { env := setupSourceTest(t) wh := seedWebhookWithRetention(t, env.db, 7) - archive := seedTarget(t, env.db, wh.ID, database.TargetTypeDatabase) + target := seedTarget(t, env.db, wh.ID, database.TargetTypeDatabase) seedArchive( - t, delivery.ArchivePath(env.dbMgr, &wh, archive), rows, bodySize, + t, archive.Path(env.dbMgr, &wh, target), rows, bodySize, ) srv, accessLog := limitedServer(t, env, limit) req, err := http.NewRequestWithContext( t.Context(), http.MethodGet, - srv.URL+downloadPath(wh.ID, archive.ID), nil, + srv.URL+downloadPath(wh.ID, target.ID), nil, ) require.NoError(t, err) diff --git a/internal/handlers/target_edit.go b/internal/handlers/target_edit.go index ce57455..75e8561 100644 --- a/internal/handlers/target_edit.go +++ b/internal/handlers/target_edit.go @@ -6,6 +6,7 @@ import ( "strconv" "github.com/go-chi/chi" + "sneak.berlin/go/webhooker/internal/archive" "sneak.berlin/go/webhooker/internal/database" "sneak.berlin/go/webhooker/internal/delivery" ) @@ -96,8 +97,8 @@ func (h *Handlers) HandleTargetEdit() http.HandlerFunc { // HandleTargetEditSubmit handles the target edit form submission. func (h *Handlers) HandleTargetEditSubmit() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - h.renameMu.Lock() - defer h.renameMu.Unlock() + h.archives.RenameLock().Lock() + defer h.archives.RenameLock().Unlock() webhook, target, ok := h.ownedTarget(w, r) if !ok { @@ -157,7 +158,7 @@ func (h *Handlers) applyTargetEdit( } // A new name renames the archive file before it is saved (see - // delivery.Engine.Rename). If either step fails, it goes back to + // archive.Writers.Rename). If either step fails, it goes back to // the name that is still stored. err = h.renameTargetArchive( target, webhook.Name, target.Name, edited.Name, @@ -178,7 +179,7 @@ func (h *Handlers) applyTargetEdit( ) } - if errors.Is(err, delivery.ErrArchiveNameTaken) { + if errors.Is(err, archive.ErrNameTaken) { h.renderTargetEdit( w, r, webhook, target, in, "Not saved: "+err.Error()+ diff --git a/internal/handlers/target_list.go b/internal/handlers/target_list.go index 6161ee0..9e200b7 100644 --- a/internal/handlers/target_list.go +++ b/internal/handlers/target_list.go @@ -10,6 +10,7 @@ import ( "github.com/dustin/go-humanize" "gorm.io/gorm" + "sneak.berlin/go/webhooker/internal/archive" "sneak.berlin/go/webhooker/internal/database" "sneak.berlin/go/webhooker/internal/delivery" ) @@ -242,7 +243,7 @@ func readTargetDeliveries( func (h *Handlers) archiveFileView( webhook *database.Webhook, target *database.Target, now time.Time, ) *ArchiveFileView { - current, err := delivery.ArchivePathAt(h.dbMgr, webhook, target, now) + current, err := archive.PathAt(h.dbMgr, webhook, target, now) if err != nil { return h.archiveUnreadable(&ArchiveFileView{}, target, err) } @@ -254,8 +255,8 @@ func (h *Handlers) archiveFileView( view.Note = "not created yet" } - files, err := delivery.StatArchive( - delivery.ArchivePath(h.dbMgr, webhook, target), + files, err := archive.Stat( + archive.Path(h.dbMgr, webhook, target), ) switch { diff --git a/internal/handlers/target_list_test.go b/internal/handlers/target_list_test.go index a12fa89..cf244a9 100644 --- a/internal/handlers/target_list_test.go +++ b/internal/handlers/target_list_test.go @@ -10,8 +10,8 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "sneak.berlin/go/webhooker/internal/archive" "sneak.berlin/go/webhooker/internal/database" - "sneak.berlin/go/webhooker/internal/delivery" "sneak.berlin/go/webhooker/internal/handlers" "sneak.berlin/go/webhooker/internal/logger" "sneak.berlin/go/webhooker/internal/session" @@ -38,10 +38,10 @@ func TestHandleSourceDetail_ShowsArchiveFile(t *testing.T) { t.Cleanup(app.RequireStop) wh := seedWebhook(t, db) - archive := seedTarget(t, db, wh.ID, database.TargetTypeDatabase) + target := seedTarget(t, db, wh.ID, database.TargetTypeDatabase) seedTarget(t, db, wh.ID, database.TargetTypeLog) - path := delivery.ArchivePath(dbMgr, wh, archive) + path := archive.Path(dbMgr, wh, target) body := renderSourceDetailPage(t, h, sess, wh.ID) assert.Equal(t, 1, strings.Count(body, "Archive file:")) diff --git a/internal/handlers/webhook_delete.go b/internal/handlers/webhook_delete.go index 209081d..2660ba9 100644 --- a/internal/handlers/webhook_delete.go +++ b/internal/handlers/webhook_delete.go @@ -77,8 +77,8 @@ func (h *Handlers) deleteWebhookResources( "user_id", userID, ) - // Release the delivery engine's per-webhook archiving state - // so a deleted webhook's archive writer (and any handle open + // Release the webhook's archive writers so a deleted + // webhook's archive writer (and any handle open // within its debounce window) does not linger for the // process lifetime. The archive file itself is deliberately // left on disk; see evictArchiveWriter. @@ -150,9 +150,9 @@ func (h *Handlers) commitWebhookDeletion( return tx.Commit().Error } -// evictArchiveWriter asks the delivery engine to drop the cached -// archive writers of a webhook's database targets, closing their -// archive file handles. +// evictArchiveWriter asks the archive writers to drop the cached +// writers of a webhook's database targets, closing their archive +// file handles. // // The archive database files are NOT deleted. Unlike the event // database — which is per-webhook working storage and is diff --git a/internal/handlers/webhook_edit.go b/internal/handlers/webhook_edit.go index f3ca94b..22ef512 100644 --- a/internal/handlers/webhook_edit.go +++ b/internal/handlers/webhook_edit.go @@ -6,8 +6,8 @@ import ( "strconv" "github.com/go-chi/chi" + "sneak.berlin/go/webhooker/internal/archive" "sneak.berlin/go/webhooker/internal/database" - "sneak.berlin/go/webhooker/internal/delivery" ) // HandleSourceEdit shows the form to edit a webhook. @@ -59,8 +59,8 @@ func (h *Handlers) HandleSourceEditSubmit() http.HandlerFunc { sourceID := chi.URLParam(r, "sourceID") - h.renameMu.Lock() - defer h.renameMu.Unlock() + h.archives.RenameLock().Lock() + defer h.archives.RenameLock().Unlock() var webhook database.Webhook @@ -130,7 +130,7 @@ func (h *Handlers) applyWebhookEdit( edited.RetentionDays = retentionDays // A new name renames the archive files before it is saved (see - // delivery.Engine.Rename). If either step fails, the same targets' + // archive.Writers.Rename). If either step fails, the same targets' // archives go back to the name that is still stored, without // reading the main database again. targets, err := h.renameWebhookArchives( @@ -150,7 +150,7 @@ func (h *Handlers) applyWebhookEdit( ) } - if errors.Is(err, delivery.ErrArchiveNameTaken) { + if errors.Is(err, archive.ErrNameTaken) { h.renderWebhookEdit( w, r, webhook, name, description, retention, "Not saved: "+err.Error()+ diff --git a/internal/resetpw/resetpw_test.go b/internal/resetpw/resetpw_test.go index fe4fdce..490ce3a 100644 --- a/internal/resetpw/resetpw_test.go +++ b/internal/resetpw/resetpw_test.go @@ -10,6 +10,7 @@ import ( "os" "path/filepath" "strings" + "sync" "testing" "time" @@ -17,6 +18,7 @@ import ( "github.com/stretchr/testify/require" "go.uber.org/fx" "go.uber.org/fx/fxtest" + "sneak.berlin/go/webhooker/internal/archive" "sneak.berlin/go/webhooker/internal/config" "sneak.berlin/go/webhooker/internal/database" "sneak.berlin/go/webhooker/internal/datadir" @@ -133,7 +135,13 @@ type noopNotifier struct{} func (n *noopNotifier) Notify([]delivery.Task) {} -type noopArchives struct{} +type noopArchives struct { + renameMu sync.Mutex +} + +func (n *noopArchives) RenameLock() *sync.Mutex { + return &n.renameMu +} func (n *noopArchives) EvictWebhook(string) {} @@ -183,7 +191,7 @@ func newServerApp( healthcheck.New, session.New, func() delivery.Notifier { return &noopNotifier{} }, - func() delivery.Archives { return &noopArchives{} }, + func() archive.Archives { return &noopArchives{} }, func() delivery.CircuitBreakers { return &noopCircuitBreakers{} }, diff --git a/internal/server/routes_test.go b/internal/server/routes_test.go index f7d9b57..cf07351 100644 --- a/internal/server/routes_test.go +++ b/internal/server/routes_test.go @@ -11,6 +11,7 @@ import ( "slices" "strconv" "strings" + "sync" "testing" "time" @@ -20,6 +21,7 @@ import ( "go.uber.org/fx" "go.uber.org/fx/fxtest" "gorm.io/gorm/clause" + "sneak.berlin/go/webhooker/internal/archive" "sneak.berlin/go/webhooker/internal/config" "sneak.berlin/go/webhooker/internal/database" "sneak.berlin/go/webhooker/internal/delivery" @@ -50,10 +52,16 @@ type noopNotifier struct{} func (n *noopNotifier) Notify([]delivery.Task) {} -// noopArchives satisfies handlers.New's delivery.Archives +// noopArchives satisfies handlers.New's archive.Archives // dependency. No test here checks what gets evicted or renamed, so -// it records nothing. -type noopArchives struct{} +// it records nothing. Its rename lock is a plain mutex. +type noopArchives struct { + renameMu sync.Mutex +} + +func (e *noopArchives) RenameLock() *sync.Mutex { + return &e.renameMu +} func (e *noopArchives) EvictWebhook(string) {} @@ -138,7 +146,7 @@ func newTestEnvWithConfig( healthcheck.New, session.New, func() delivery.Notifier { return &noopNotifier{} }, - func() delivery.Archives { return &noopArchives{} }, + func() archive.Archives { return &noopArchives{} }, func() delivery.CircuitBreakers { return &noopCircuitBreakers{} }, -- 2.54.0 From ded39aa158a86382163cb48343aea393260a5fa3 Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Thu, 15 Oct 2026 21:57:05 +0200 Subject: [PATCH 123/126] Use one recovery routine at start and on every sweep tick (closes #542) Start-up recovery and the 60-second sweep were two parallel sets of functions doing the same job. recoverDeliveries now does it, run once at start and then on every tick from one goroutine. A retrying delivery is scheduled after what is left of its backoff, now if nothing is left, so backoffElapsed is gone. The run at start still takes every pending delivery and opens a webhook database whose file is missing; a tick still takes only pending deliveries untouched for 15 minutes, at most 500, and skips a missing file. A retrying delivery is taken before its event is read. The README describes one routine. Model: opus-5-5 --- README.md | 76 +-- internal/database/event_tier_indexes_test.go | 5 +- internal/database/retention.go | 5 +- internal/delivery/engine.go | 536 ++++++------------ internal/delivery/engine_integration_test.go | 61 +- internal/delivery/engine_lifecycle_test.go | 21 +- internal/delivery/export_test.go | 27 +- internal/delivery/inflight.go | 18 +- internal/delivery/inflight_test.go | 44 +- internal/delivery/metrics_test.go | 4 +- internal/delivery/queue_depth.go | 3 +- internal/delivery/recovery_durability_test.go | 27 +- internal/delivery/recovery_test.go | 340 +++++++++++ internal/delivery/target.go | 27 +- internal/delivery/target_http.go | 30 +- internal/delivery/terminal_state_test.go | 75 +-- 16 files changed, 705 insertions(+), 594 deletions(-) create mode 100644 internal/delivery/recovery_test.go diff --git a/README.md b/README.md index 9e6ec4e..c9e3436 100644 --- a/README.md +++ b/README.md @@ -1346,11 +1346,11 @@ webhooker solves this by acting as a durable intermediary: **That guarantee is at-least-once, not exactly-once.** When a send reaches its target but the write recording that outcome fails, the delivery is deliberately left in a recoverable state rather than marked done — losing a - delivery is the worse failure — so the pending sweep picks it up about - fifteen minutes later, or the next restart does, and the target receives a - payload it already got. A send still in flight when the service stops is - left the same way: its attempt is not recorded, and the next start sends it - again (see Shutdown). webhooker adds no delivery identifier of its own to an + delivery is the worse failure — so the sweep picks it up about fifteen + minutes later, or the next restart does, and the target receives a payload + it already got. A send still in flight when the service stops is left the + same way: its attempt is not recorded, and the next start sends it again + (see Shutdown). webhooker adds no delivery identifier of its own to an outbound request, so **make your receiver idempotent** against whatever the payload itself carries. @@ -1865,15 +1865,15 @@ These indexes on the per-webhook event databases are created by `internal/db/migrations/events/001_schema.sql`, and declared in the model tags to match: -| 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, the webhook page's statistics and target list and the webhook list, which count each target's deliveries by status and when they finished, and the event log, which lists and counts the events with a failed delivery or one pending or retrying | -| `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 | -| `events` | `deleted_at`, `created_at` | The webhook page's statistics, which count recent events | -| `events` | `resubmitted_from_id`, `deleted_at` | The event log, which counts the events resubmitted from each event on a page | -| `events` | `entrypoint_id`, `deleted_at`, `resubmitted_from_id`, `created_at` | The webhook page's entrypoint list, which counts the events that arrived on each entrypoint's URL within the retention period | -| `events` | `created_at` | Retention, which selects expired events by age | +| Table | Columns | Serves | +| ------------------ | ------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `deliveries` | `status`, `deleted_at`, `finished_at`, `target_id` | Delivery recovery at start and every 60 seconds and the queue-depth sampler every 30 seconds, which select deliveries by status, the webhook page's statistics and target list and the webhook list, which count each target's deliveries by status and when they finished, and the event log, which lists and counts the events with a failed delivery or one pending or retrying | +| `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 | +| `events` | `deleted_at`, `created_at` | The webhook page's statistics, which count recent events | +| `events` | `resubmitted_from_id`, `deleted_at` | The event log, which counts the events resubmitted from each event on a page | +| `events` | `entrypoint_id`, `deleted_at`, `resubmitted_from_id`, `created_at` | The webhook page's entrypoint list, which counts the events that arrived on each entrypoint's URL within the retention period | +| `events` | `created_at` | Retention, which selects expired events by age | GORM's soft delete adds `deleted_at IS NULL` to these queries; retention leaves it out. SQLite keeps no statistics on these tables, and without them it rates @@ -2199,7 +2199,7 @@ preventing goroutine explosions regardless of queue depth. Workers are the ONLY goroutines doing actual HTTP delivery. - **Retry backpressure with DB fallback:** When a retry timer fires and the retry channel is full, the timer is dropped — the delivery stays in `retrying` - status in the database. A periodic sweep (every 60s) scans for these + status in the database. The sweep (every 60s, see Recovery below) finds these "orphaned" retries and re-queues them. No blocked goroutines, no unbounded timer chains. - **Bounded concurrency:** At most N deliveries (N = number of workers) are @@ -2226,24 +2226,36 @@ This means: that did not finish are still running — an unclean shutdown is reported rather than hidden. -**Recovery paths:** +**Recovery:** one routine finds the deliveries the engine is not working on and +hands them back. It runs once when the engine starts, and then every 60 seconds; +that periodic run is the sweep. For every webhook it reads the `pending` and +`retrying` deliveries. One that already has a successful attempt is marked +`delivered` and not sent again. A `retrying` delivery gets a backoff timer for +what is left of its backoff, and is queued at once if nothing is left. A +`pending` delivery is queued on the delivery channel. A delivery the engine has +queued, is attempting, or holds a backoff timer for is left alone, at start as +on every sweep. This is what catches "orphaned" retries, whose in-memory timer +was dropped because the retry channel was full, and deliveries a stop or a crash +cut off: the database is the durable fallback that ensures no delivery is +permanently lost, even under extreme backpressure. -1. **Startup recovery:** When the engine starts, it scans all per-webhook - databases for `pending` and `retrying` deliveries. Pending deliveries are - sent to the delivery channel; retrying deliveries get backoff timers - scheduled. -2. **Periodic retry sweep (DB-mediated fallback):** Every 60 seconds the engine - scans for `retrying` deliveries whose backoff period has elapsed. This - catches "orphaned" retries — ones whose in-memory timer was dropped because - the retry channel was full. The database is the durable fallback that ensures - no retry is permanently lost, even under extreme backpressure. +The run at start and the sweep differ in two ways: + +- At start every `pending` delivery is taken, however recent, so one a stop cut + off moments ago is sent again at once (see Shutdown). The sweep takes only + `pending` deliveries untouched for 15 minutes, at most 500 per webhook each + time, so a delivery whose outcome the database will not record is sent again + every 15 minutes rather than every minute. +- At start every webhook's database is opened, so that a missing one is reported + then (see Per-Webhook Event Databases). The sweep skips a webhook whose + database file is missing rather than create it. **Changing a target's type does not migrate in-flight deliveries.** Only `http` and `slack` targets own durable retries; `database` and `log` targets are fire-and-forget and never produce a `retrying` delivery. If a target's `type` is edited from a retrying type to a non-retrying (or unknown) one while one of its -deliveries is still `retrying`, both recovery paths above terminally mark that -delivery `failed` and record a `DeliveryResult` naming the current target type +deliveries is still `retrying`, the recovery routine above terminally marks that +delivery `failed` and records a `DeliveryResult` naming the current target type as the reason, logging it at warn level. The delivery is not re-dispatched under the new type — the operator never asked for that delivery — and the event itself remains stored in the per-webhook event database. Nothing is lost: the delivery @@ -2256,8 +2268,8 @@ re-inject it to every currently active target. `http` and `slack` targets with `max_attempts` > 1 are protected by a **per-target circuit breaker** that prevents hammering a down target with repeated failed delivery attempts. The circuit breaker is in-memory only and -resets on restart (which is fine — startup recovery rescans the database -anyway). +resets on restart (which is fine — the recovery routine rescans the database at +start anyway). **States:** @@ -3336,9 +3348,9 @@ normal operation. The delivery engine cancels a delivery request in flight. That attempt is not recorded or counted in the metrics, and the delivery keeps its `pending` or -`retrying` status, so startup recovery sends it again on the next start (see -Recovery paths under Bounded Worker Pool). If the receiver already had it, it -receives it twice: the at-least-once duplicate described under Rationale. +`retrying` status, so recovery at the next start sends it again (see Recovery +under Bounded Worker Pool). If the receiver already had it, it receives it +twice: the at-least-once duplicate described under Rationale. The HTTP drain budget is deliberately **shorter** than the sequence budget. Were the two equal, a drain that used its whole budget would exhaust the sequence diff --git a/internal/database/event_tier_indexes_test.go b/internal/database/event_tier_indexes_test.go index 65c1375..64051a8 100644 --- a/internal/database/event_tier_indexes_test.go +++ b/internal/database/event_tier_indexes_test.go @@ -79,8 +79,9 @@ func TestEventTierQueriesUseTheirIndexes(t *testing.T) { byStatus := "idx_deliveries_status (status=? AND deleted_at=?)" byEvent := "idx_deliveries_event_id (event_id=? AND deleted_at=?)" - // The delivery engine: recovery and the retry sweep, the sweep for - // stranded pending deliveries, and the queue depth count. + // The delivery engine: the recovery routine, which reads the + // retrying deliveries and, on a tick of the sweep, the pending ones + // untouched for a while; and the queue depth count. assertPlanUses(t, db, dry.Where( "status = ?", database.DeliveryStatusRetrying, ).Find(&deliveries), byStatus) diff --git a/internal/database/retention.go b/internal/database/retention.go index a57bc95..e0a9cec 100644 --- a/internal/database/retention.go +++ b/internal/database/retention.go @@ -184,8 +184,9 @@ func (r *RetentionReaper) sweep(ctx context.Context) { wh := webhooks[i] - // A missing database has nothing to reap. Restart recovery - // reports a lost one (see WebhookDBManager.GetDB). + // A missing database has nothing to reap. The delivery + // engine's recovery routine reports a lost one at start (see + // WebhookDBManager.GetDB). if !r.dbManager.DBExists(wh.ID) { continue } diff --git a/internal/delivery/engine.go b/internal/delivery/engine.go index b566809..5e1c164 100644 --- a/internal/delivery/engine.go +++ b/internal/delivery/engine.go @@ -40,9 +40,10 @@ const ( // explosions regardless of queue depth. defaultWorkers = 10 - // retrySweepInterval is how often the periodic retry - // sweep runs. - retrySweepInterval = 60 * time.Second + // sweepInterval is how often the recovery routine runs + // once it has run at start. Each of those runs is a tick of + // the sweep. + sweepInterval = 60 * time.Second // pendingSweepMinAge is how long a delivery must have sat // untouched at pending before the sweep will look at it. @@ -63,10 +64,10 @@ const ( pendingSweepMinAge = 15 * time.Minute // pendingSweepBatch bounds how many stranded pending deliveries - // one sweep of one webhook re-dispatches. The sweep runs every - // retrySweepInterval, so a larger backlog drains across - // successive sweeps instead of arriving as one burst against a - // database that was already struggling to accept writes. + // one tick of the sweep re-dispatches for one webhook. The sweep + // ticks every sweepInterval, so a larger backlog drains + // across successive ticks instead of arriving as one burst against + // a database that was already struggling to accept writes. pendingSweepBatch = 500 // MaxInlineBodySize is the maximum event body size that @@ -151,7 +152,7 @@ type EngineParams struct { // Engine processes queued deliveries in the background // using a bounded worker pool architecture. It owns only // the cross-target machinery: the worker pool, the queue -// and retry channels, restart recovery, and the persistence +// and retry channels, the recovery routine, and the persistence // helpers and Scheduler that individual targets rely on. // Each target type owns its own delivery, including retries, // backoff, and circuit breaking. @@ -190,7 +191,7 @@ type Engine struct { archives *archive.Writers // inflight is the set of deliveries this engine currently owns. - // Recovery and the sweeps re-dispatch only what it does not + // The recovery routine re-dispatches only what it does not // hold. Held by value: its zero value works, so no constructor // can leave it out. See inflight.go. inflight inflightSet @@ -342,8 +343,8 @@ func (e *Engine) registerHooks(lc fx.Lifecycle) { }) } -// start launches the worker pool, restart recovery, and the -// periodic retry sweep. +// start launches the worker pool and the recovery routine, which +// runs now and then on every tick of the sweep (see runRecovery). // // Their context is derived from context.Background(), NOT from // the fx OnStart hook context. The hook context carries fx's @@ -366,11 +367,7 @@ func (e *Engine) start() { e.wg.Add(1) - go e.recoverPending(ctx) - - e.wg.Add(1) - - go e.retrySweep(ctx) + go e.runRecovery(ctx) e.wg.Add(1) @@ -453,12 +450,6 @@ func (e *Engine) runTask( run(ctx, task) } -func (e *Engine) recoverPending(ctx context.Context) { - defer e.wg.Done() - - e.recoverInFlight(ctx) -} - func (e *Engine) processNewTask( ctx context.Context, task *Task, ) { @@ -473,9 +464,10 @@ func (e *Engine) processNewTask( return } - // Restart recovery can send and release this delivery before the - // receiver's Notify queues it. Ownership cannot refuse a delivery - // nobody holds, so the row decides whether it still needs sending. + // The recovery routine's run at start can send and release this + // delivery before the receiver's Notify queues it. Ownership cannot + // refuse a delivery nobody holds, so the row decides whether it + // still needs sending. row, err := e.loadDelivery(webhookDB, task.DeliveryID) if err != nil { e.log.Error( @@ -605,9 +597,9 @@ func (e *Engine) processRetryTask( // else on this path reads the target row. Without this check a // deletion stops nothing: the timer keeps firing and keeps sending to // the destination the operator removed, for the whole remaining -// backoff chain. Terminalising in the recovery and sweep paths alone -// is not enough, because those only see the delivery once nothing -// holds it in memory — which is to say after a restart. +// backoff chain. Terminalising in the recovery routine alone is not +// enough, because it only sees the delivery once nothing holds it in +// memory — which is to say after a restart. // // The worker already owns this delivery, so the terminal write happens // here directly, exactly as a target's own Deliver fails one. Claiming @@ -655,7 +647,47 @@ func (e *Engine) abandonRetryForMissingTarget( return true } -func (e *Engine) recoverInFlight(ctx context.Context) { +// runRecovery runs the recovery routine, recoverDeliveries, once when +// the engine starts and then on every tick of the sweep, every +// sweepInterval, until the engine stops. +func (e *Engine) runRecovery(ctx context.Context) { + defer e.wg.Done() + + ticker := time.NewTicker(sweepInterval) + defer ticker.Stop() + + e.recoverDeliveries(ctx, true) + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + e.recoverDeliveries(ctx, false) + } + } +} + +// recoverDeliveries is the engine's one recovery routine: atStart is +// true for its run when the engine starts and false on a tick of the +// sweep. +// +// For every webhook it finds the pending and retrying deliveries the +// engine is not working on, such as those a stop or a crash cut off, a +// full channel dropped, or a failed bookkeeping write left behind. One +// that already has a successful attempt is marked delivered. A retrying +// one is scheduled after what is left of its backoff, or now when +// nothing is left. A pending one goes to a worker now. +// +// Ownership keeps it off a delivery the engine is working on, has +// queued, or holds a retry timer for, the same at start as on a tick: +// every delivery it acts on is taken through the inflight set first. +// See inflight.go. +// +// A run at start differs from a tick in which webhooks it opens (see +// recoverWebhookDeliveries) and which pending deliveries it takes (see +// recoverPendingDeliveries). +func (e *Engine) recoverDeliveries(ctx context.Context, atStart bool) { var webhookIDs []string err := e.database.DB(). @@ -677,18 +709,27 @@ func (e *Engine) recoverInFlight(ctx context.Context) { default: } - // Opened even when its file is missing, so that a lost - // database is reported at start, not when the webhook next - // receives an event, which for a quiet webhook may be never. - e.recoverWebhookDeliveries(ctx, webhookID) + e.recoverWebhookDeliveries(ctx, webhookID, atStart) } } +// recoverWebhookDeliveries runs the recovery routine over one webhook. +// +// At start the webhook's database is opened even when its file is +// missing, so that a lost database is reported at start, not when the +// webhook next receives an event, which for a quiet webhook may be +// never. A tick leaves a missing file alone rather than create one: a +// webhook being created has its row saved a moment before its file, +// and a tick in between would report that file as lost. func (e *Engine) recoverWebhookDeliveries( - ctx context.Context, webhookID string, + ctx context.Context, webhookID string, atStart bool, ) { - // The web interface is already serving, so the webhook may have - // been deleted since the list was read. Opening its database then + if !atStart && !e.dbManager.DBExists(webhookID) { + return + } + + // The web interface is serving, so the webhook may have been + // deleted since the list was read. Opening its database then // would create the file again after the delete removed it. stillExists := func() (bool, error) { var count int64 @@ -720,16 +761,18 @@ func (e *Engine) recoverWebhookDeliveries( } e.recoverPendingDeliveries( - ctx, webhookDB, webhookID, + ctx, webhookDB, webhookID, atStart, ) e.recoverRetryingDeliveries( - webhookDB, webhookID, + ctx, webhookDB, webhookID, ) } +// recoverRetryingDeliveries recovers every retrying delivery of one +// webhook, at start and on a tick alike. func (e *Engine) recoverRetryingDeliveries( - webhookDB *gorm.DB, webhookID string, + ctx context.Context, webhookDB *gorm.DB, webhookID string, ) { var retrying []database.Delivery @@ -756,6 +799,12 @@ func (e *Engine) recoverRetryingDeliveries( ) for i := range retrying { + select { + case <-ctx.Done(): + return + default: + } + if _, ok := settled[retrying[i].ID]; ok { continue } @@ -767,8 +816,9 @@ func (e *Engine) recoverRetryingDeliveries( } // recoverSingleRetry hands an orphaned retrying delivery back -// to its target to recompute the remaining backoff, then -// reschedules it. Targets that do not own durable retries +// to its target to work out what is left of its backoff, then +// schedules its next attempt after that, or now when nothing is +// left. Targets that do not own durable retries // (fire-and-forget) never produce retrying deliveries, so a // delivery found in that state has had its target's type // changed underneath it and is terminally failed. @@ -816,6 +866,19 @@ func (e *Engine) recoverSingleRetry( webhookDB, d.ID, attemptNum, ) + // Taken before the event is read, so that a delivery the engine + // already holds, most often one waiting out its backoff on a + // timer, costs a tick no read of its body. + if !e.takeForRedispatch( + webhookDB, d.ID, database.DeliveryStatusRetrying, + ) { + return + } + + // ScheduleRetry takes a reference of its own before it returns, + // and that one keeps the delivery owned through the backoff. + defer e.inflight.release(d.ID) + event, err := e.loadEvent(webhookDB, d.EventID) if err != nil { e.log.Error( @@ -829,13 +892,9 @@ func (e *Engine) recoverSingleRetry( return } - task := buildRecoveryTask( + e.ScheduleRetry(buildRecoveryTask( d, webhookID, &event, &target, attemptNum+1, - ) - - if !e.rescheduleRecovered(webhookDB, task, remaining) { - return - } + ), remaining) e.log.Info( "recovering retrying delivery", @@ -846,28 +905,54 @@ func (e *Engine) recoverSingleRetry( ) } +// recoverPendingDeliveries recovers the pending deliveries of one +// webhook: it marks delivered those that already have a successful +// attempt and sends the rest. +// +// A delivery is created pending and leaves that state only when its +// outcome is written, so a pending row the engine does not own is one +// it stopped working on before then: for example one whose bookkeeping +// write failed, whose attempt a stop cut off, that was still queued +// when the engine last stopped, whose task was dropped because the +// delivery channel was full (by Notify or by queueRecovered), or whose +// worker could not open the webhook's database. +// +// At start every pending delivery is taken, however recent, so one a +// stop cut off moments ago is sent again now. A tick takes only those +// untouched for pendingSweepMinAge, at most pendingSweepBatch of them. +// The age bound is not what keeps a tick off live work: a delivery +// waiting in deliveryCh is pending and arbitrarily old. Ownership is, +// checked per delivery in takeForRedispatch. See inflight.go. func (e *Engine) recoverPendingDeliveries( ctx context.Context, webhookDB *gorm.DB, webhookID string, + atStart bool, ) { + query := webhookDB.Where( + "status = ?", database.DeliveryStatusPending, + ) + + if !atStart { + query = query. + Where( + "updated_at < ?", + time.Now().Add(-pendingSweepMinAge), + ). + Limit(pendingSweepBatch) + } + var deliveries []database.Delivery // No Preload: event bodies are read one at a time in // sendRecoveredDeliveries, and only for the deliveries actually // being sent. - result := webhookDB. - Where( - "status = ?", - database.DeliveryStatusPending, - ). - Find(&deliveries) - - if result.Error != nil { + err := query.Find(&deliveries).Error + if err != nil { e.log.Error( - "failed to query pending deliveries", + "failed to query pending deliveries for recovery", "webhook_id", webhookID, - "error", result.Error, + "error", err, ) return @@ -883,21 +968,6 @@ func (e *Engine) recoverPendingDeliveries( "count", len(deliveries), ) - e.recoverPendingBatch( - ctx, webhookDB, webhookID, deliveries, - ) -} - -// recoverPendingBatch settles every delivery in the batch that was -// already delivered, and re-dispatches only the rest. Both the -// restart-time recovery and the periodic sweep go through it, so a -// pending delivery is treated the same however it was found. -func (e *Engine) recoverPendingBatch( - ctx context.Context, - webhookDB *gorm.DB, - webhookID string, - deliveries []database.Delivery, -) { targetMap := e.loadTargetMap(deliveries) settled := e.reconcileDelivered( @@ -925,11 +995,11 @@ func (e *Engine) recoverPendingBatch( // wire I/O happened and was recorded, and all that is missing is the // status. // -// Every recovery path runs this, not only the pending one. A delivery -// abandoned at retrying can hold a successful result just as a pending -// one can — a second attempt that reached the receiver and whose status -// write then failed sits at retrying with success recorded — and -// re-sending it is the same duplicate. +// The recovery routine runs this for retrying deliveries as well as +// pending ones. A delivery abandoned at retrying can hold a successful +// result just as a pending one can — a second attempt that reached the +// receiver and whose status write then failed sits at retrying with +// success recorded — and re-sending it is the same duplicate. // // Deliveries whose result row itself never landed are not in the // returned set and are re-sent, recorded as the further attempt they @@ -1016,247 +1086,8 @@ func (e *Engine) reconcileDelivered( return settled } -func (e *Engine) retrySweep(ctx context.Context) { - defer e.wg.Done() - - ticker := time.NewTicker(retrySweepInterval) - defer ticker.Stop() - - for { - select { - case <-ctx.Done(): - return - case <-ticker.C: - e.sweepOrphanedRetries(ctx) - } - } -} - -func (e *Engine) sweepOrphanedRetries( - ctx context.Context, -) { - var webhookIDs []string - - err := e.database.DB(). - Model(&database.Webhook{}). - Pluck("id", &webhookIDs).Error - if err != nil { - e.log.Error( - "retry sweep: failed to query webhook IDs", - "error", err, - ) - - return - } - - for _, webhookID := range webhookIDs { - select { - case <-ctx.Done(): - return - default: - } - - if !e.dbManager.DBExists(webhookID) { - continue - } - - e.sweepWebhookRetries(ctx, webhookID) - } -} - -func (e *Engine) sweepWebhookRetries( - ctx context.Context, webhookID string, -) { - webhookDB, err := e.dbManager.GetDB(webhookID) - if err != nil { - e.log.Error( - "retry sweep: failed to get webhook database", - "webhook_id", webhookID, - "error", err, - ) - - return - } - - var retrying []database.Delivery - - err = webhookDB. - Where( - "status = ?", - database.DeliveryStatusRetrying, - ). - Find(&retrying).Error - if err != nil { - e.log.Error( - "retry sweep: "+ - "failed to query retrying deliveries", - "webhook_id", webhookID, - "error", err, - ) - - return - } - - settled := e.reconcileDelivered( - webhookDB, webhookID, retrying, - e.loadTargetMap(retrying), - ) - - for i := range retrying { - select { - case <-ctx.Done(): - return - default: - } - - if _, ok := settled[retrying[i].ID]; ok { - continue - } - - e.sweepSingleRetry( - webhookDB, webhookID, &retrying[i], - ) - } - - e.sweepWebhookPending(ctx, webhookDB, webhookID) -} - -// sweepWebhookPending recovers deliveries stranded at pending. -// -// A delivery is created pending and leaves that state only when its -// outcome is written, so a pending row the engine does not own is one -// whose bookkeeping write failed — the state that used to sit there -// until a restart, and then produce a duplicate at the receiver. The -// sweep gives it the same reconcile-then-dispatch treatment restart -// recovery gets, so it costs a minute rather than an operator -// noticing. -// -// What keeps the sweep off live work is ownership, checked per -// delivery in takeForRedispatch, not the age bound in this query. -// A delivery waiting in deliveryCh is pending and arbitrarily old — -// the channel holds 10000 tasks and 10 workers drain it — so -// reasoning from the row's age alone re-sends it. See inflight.go. -func (e *Engine) sweepWebhookPending( - ctx context.Context, - webhookDB *gorm.DB, - webhookID string, -) { - var pending []database.Delivery - - err := webhookDB. - Where( - "status = ? AND updated_at < ?", - database.DeliveryStatusPending, - time.Now().Add(-pendingSweepMinAge), - ). - Limit(pendingSweepBatch). - Find(&pending).Error - if err != nil { - e.log.Error( - "retry sweep: "+ - "failed to query pending deliveries", - "webhook_id", webhookID, - "error", err, - ) - - return - } - - if len(pending) == 0 { - return - } - - e.log.Info( - "retry sweep: recovering stranded pending deliveries", - "webhook_id", webhookID, - "count", len(pending), - ) - - e.recoverPendingBatch(ctx, webhookDB, webhookID, pending) -} - -// sweepSingleRetry re-enqueues an orphaned retrying delivery -// whose backoff window has elapsed, delegating the backoff -// decision to the delivery's target. A delivery whose target -// no longer owns durable retries is terminally failed. -func (e *Engine) sweepSingleRetry( - webhookDB *gorm.DB, - webhookID string, - d *database.Delivery, -) { - target, err := e.loadTarget(d.TargetID) - if err != nil { - // Deleted is terminal, unreadable is not; see - // recoverSingleRetry. - if errors.Is(err, gorm.ErrRecordNotFound) { - e.failMissingTarget(webhookDB, webhookID, d) - - return - } - - e.log.Error( - "retry sweep: failed to load target", - "delivery_id", d.ID, - "target_id", d.TargetID, - "error", err, - ) - - return - } - - rs, ok := e.targets[target.Type].(rescheduler) - if !ok { - e.failUnretryableRetry( - webhookDB, webhookID, d, &target, - ) - - return - } - - attemptNum := e.countAttempts(webhookDB, d.ID) - - if !rs.backoffElapsed( - webhookDB, d.ID, attemptNum, - ) { - return - } - - event, err := e.loadEvent(webhookDB, d.EventID) - if err != nil { - e.log.Error( - "retry sweep: failed to load event", - "delivery_id", d.ID, - "event_id", d.EventID, - "error", err, - ) - - return - } - - task := buildRecoveryTask( - d, webhookID, &event, &target, attemptNum+1, - ) - - if !e.redispatch( - e.retryCh, webhookDB, task, - database.DeliveryStatusRetrying, - ) { - return - } - - e.log.Info( - "retry sweep: "+ - "recovered orphaned retrying delivery", - "delivery_id", d.ID, - "webhook_id", webhookID, - "attempt", attemptNum+1, - ) -} - // failUnretryableRetry terminally fails an orphaned retrying -// delivery whose target type no longer supports retries. Both -// restart recovery and the periodic sweep call it, so the -// terminal transition exists once. +// delivery whose target type no longer supports retries. // // This is only reachable when a target's type has been changed // out from under an in-flight retrying delivery (or the type is @@ -1304,8 +1135,8 @@ func (e *Engine) failUnretryableRetry( } // failMissingTarget terminally fails a recovered delivery, pending or -// retrying, whose target row is gone. Restart recovery and the periodic -// sweep call it for both statuses, so the transition exists once. +// retrying, whose target row is gone. The recovery routine calls it for +// both statuses. // // Until it existed those paths logged the failed lookup and moved on, // which left the delivery where it was for the life of the database and @@ -1830,11 +1661,11 @@ func (e *Engine) countAttempts( // answer cannot go stale between asking and acting. // // Stamping updated_at is the same statement, and it is a cadence -// control rather than a claim: the pending sweep selects on that -// column, so a delivery handed out now is not selected again on the -// next tick a minute later but after pendingSweepMinAge. A delivery -// the database refuses to settle is therefore retried on that -// interval instead of every tick. +// control rather than a claim: a tick of the sweep selects pending +// deliveries on that column, so one handed out now is not selected +// again on the next tick a minute later but after pendingSweepMinAge. +// A delivery the database refuses to settle is therefore retried on +// that interval instead of every tick. // // A failed write is a refusal. It means the database is not accepting // writes, which is the condition that stranded the delivery in the @@ -1882,14 +1713,11 @@ func (e *Engine) takeForRedispatch( return true } -// queueRecovered puts an owned delivery's task on a worker channel, -// dropping the ownership the gate took if it does not fit. -func (e *Engine) queueRecovered( - ch chan<- Task, task Task, -) bool { +// queueRecovered puts an owned pending delivery's task on the delivery +// channel, dropping the ownership the gate took if it does not fit. +func (e *Engine) queueRecovered(task Task) { select { - case ch <- task: - return true + case e.deliveryCh <- task: default: e.inflight.release(task.DeliveryID) e.log.Warn( @@ -1898,53 +1726,9 @@ func (e *Engine) queueRecovered( "delivery_id", task.DeliveryID, "webhook_id", task.WebhookID, ) - - return false } } -// redispatch hands a recovered delivery to a worker channel through -// takeForRedispatch, and reports whether the task was queued. -func (e *Engine) redispatch( - ch chan<- Task, - webhookDB *gorm.DB, - task Task, - eligible database.DeliveryStatus, -) bool { - if !e.takeForRedispatch( - webhookDB, task.DeliveryID, eligible, - ) { - return false - } - - return e.queueRecovered(ch, task) -} - -// rescheduleRecovered hands an orphaned retrying delivery back to the -// retry timer, through the same gate. It reports whether the delivery -// was rescheduled. -// -// The reference taken by the gate is dropped as soon as ScheduleRetry -// has taken its own, which it does before returning: what keeps the -// delivery owned through the backoff window is ScheduleRetry's -// reference, not this one. -func (e *Engine) rescheduleRecovered( - webhookDB *gorm.DB, task Task, delay time.Duration, -) bool { - if !e.takeForRedispatch( - webhookDB, task.DeliveryID, - database.DeliveryStatusRetrying, - ) { - return false - } - - defer e.inflight.release(task.DeliveryID) - - e.ScheduleRetry(task, delay) - - return true -} - // countAttemptsBatch counts the recorded attempts of every delivery // in a batch with one grouped query, keyed by delivery id. Deliveries // with no attempts are simply absent from the result, which reads back @@ -2202,6 +1986,6 @@ func (e *Engine) sendRecoveredDeliveries( attempts[deliveries[i].ID]+1, ) - e.queueRecovered(e.deliveryCh, task) + e.queueRecovered(task) } } diff --git a/internal/delivery/engine_integration_test.go b/internal/delivery/engine_integration_test.go index 14580c6..ded9c3f 100644 --- a/internal/delivery/engine_integration_test.go +++ b/internal/delivery/engine_integration_test.go @@ -659,7 +659,7 @@ func TestRecoverPendingDeliveries(t *testing.T) { s.Engine.ExportRecoverPendingDeliveries( context.Background(), s.WebhookDB, - s.WebhookID, + s.WebhookID, atStart, ) for i := range 3 { @@ -756,7 +756,7 @@ func TestRecoverWebhookDeliveries_RetryingDeliveries( ) s.Engine.ExportRecoverWebhookDeliveries( - context.Background(), s.WebhookID, + context.Background(), s.WebhookID, atStart, ) select { @@ -880,7 +880,7 @@ func TestRecoverSingleRetry_TypeNoLongerRetries( ) s.Engine.ExportRecoverWebhookDeliveries( - context.Background(), s.WebhookID, + context.Background(), s.WebhookID, atStart, ) iAssertTerminallyFailed( @@ -888,7 +888,7 @@ func TestRecoverSingleRetry_TypeNoLongerRetries( ) } -func TestSweepSingleRetry_TypeNoLongerRetries( +func TestRecoverSingleRetry_OnTick_TypeNoLongerRetries( t *testing.T, ) { t.Parallel() @@ -903,8 +903,8 @@ func TestSweepSingleRetry_TypeNoLongerRetries( t, s, database.TargetTypeDatabase, ) - s.Engine.ExportSweepWebhookRetries( - context.Background(), s.WebhookID, + s.Engine.ExportRecoverWebhookDeliveries( + context.Background(), s.WebhookID, onTick, ) iAssertTerminallyFailed( @@ -955,8 +955,8 @@ func TestFailUnretryableRetry_WritesNoTargetRow( iSeedFailedResult(t, s.WebhookDB, d.ID) - s.Engine.ExportSweepWebhookRetries( - context.Background(), s.WebhookID, + s.Engine.ExportRecoverWebhookDeliveries( + context.Background(), s.WebhookID, onTick, ) iAssertStatus(t, s.WebhookDB, d.ID, @@ -987,13 +987,13 @@ func TestRecoverSingleRetry_UnknownTargetType( deliveryID := iSeedRetryingWithType(t, s, unknown) s.Engine.ExportRecoverWebhookDeliveries( - context.Background(), s.WebhookID, + context.Background(), s.WebhookID, atStart, ) iAssertTerminallyFailed(t, s, deliveryID, unknown) } -func TestSweepSingleRetry_UnknownTargetType( +func TestRecoverSingleRetry_OnTick_UnknownTargetType( t *testing.T, ) { t.Parallel() @@ -1008,8 +1008,8 @@ func TestSweepSingleRetry_UnknownTargetType( deliveryID := iSeedRetryingWithType(t, s, unknown) - s.Engine.ExportSweepWebhookRetries( - context.Background(), s.WebhookID, + s.Engine.ExportRecoverWebhookDeliveries( + context.Background(), s.WebhookID, onTick, ) iAssertTerminallyFailed(t, s, deliveryID, unknown) @@ -1051,19 +1051,19 @@ func iCreateWebhook( require.NoError(t, mainDB.Create(&webhook).Error) } -// --- recoverInFlight Tests --- +// --- recoverDeliveries Tests --- -func TestRecoverInFlight_NoWebhooks(t *testing.T) { +func TestRecoverDeliveries_NoWebhooks(t *testing.T) { t.Parallel() s := newISetup(t) - s.Engine.ExportRecoverInFlight( - context.Background(), + s.Engine.ExportRecoverDeliveries( + context.Background(), atStart, ) } -func TestRecoverInFlight_WithPendingDeliveries( +func TestRecoverDeliveries_WithPendingDeliveries( t *testing.T, ) { t.Parallel() @@ -1090,8 +1090,8 @@ func TestRecoverInFlight_WithPendingDeliveries( database.DeliveryStatusPending, ) - s.Engine.ExportRecoverInFlight( - context.Background(), + s.Engine.ExportRecoverDeliveries( + context.Background(), atStart, ) select { @@ -1102,16 +1102,16 @@ func TestRecoverInFlight_WithPendingDeliveries( database.TargetTypeLog, task.TargetType, ) case <-time.After(2 * time.Second): - t.Fatal("expected task from recoverInFlight") + t.Fatal("expected task from recovery at start") } } -// TestRecoverInFlight_ReportsAMissingWebhookDatabase covers a webhook -// whose database file is gone, after a partial restore say. Restart -// recovery opens every webhook's database, so the empty one made in its +// TestRecoverDeliveries_ReportsAMissingWebhookDatabase covers a webhook +// whose database file is gone, after a partial restore say. Recovery at +// start opens every webhook's database, so the empty one made in its // place is reported at start, naming the file // (https://git.eeqj.de/sneak/webhooker/issues/290). -func TestRecoverInFlight_ReportsAMissingWebhookDatabase(t *testing.T) { +func TestRecoverDeliveries_ReportsAMissingWebhookDatabase(t *testing.T) { t.Parallel() mainDB := iMainDB(t) @@ -1131,7 +1131,7 @@ func TestRecoverInFlight_ReportsAMissingWebhookDatabase(t *testing.T) { &http.Client{Timeout: 5 * time.Second}, 1, ) - engine.ExportRecoverInFlight(context.Background()) + engine.ExportRecoverDeliveries(context.Background(), atStart) assert.Contains( t, logs.String(), @@ -1140,10 +1140,11 @@ func TestRecoverInFlight_ReportsAMissingWebhookDatabase(t *testing.T) { ) } -// TestRecoverInFlight_SkipsAWebhookDeletedAfterTheListIsRead covers a -// webhook deleted from the web interface while restart recovery runs. -// Its database file is gone, and recovery must not create it again. -func TestRecoverInFlight_SkipsAWebhookDeletedAfterTheListIsRead( +// TestRecoverDeliveries_SkipsAWebhookDeletedAfterTheListIsRead covers a +// webhook deleted from the web interface while the recovery routine +// runs. Its database file is gone, and recovery must not create it +// again. +func TestRecoverDeliveries_SkipsAWebhookDeletedAfterTheListIsRead( t *testing.T, ) { t.Parallel() @@ -1179,7 +1180,7 @@ func TestRecoverInFlight_SkipsAWebhookDeletedAfterTheListIsRead( &http.Client{Timeout: 5 * time.Second}, 1, ) - engine.ExportRecoverInFlight(context.Background()) + engine.ExportRecoverDeliveries(context.Background(), atStart) require.True(t, deleted) assert.False(t, dbMgr.DBExists(webhookID)) diff --git a/internal/delivery/engine_lifecycle_test.go b/internal/delivery/engine_lifecycle_test.go index 36cf5d5..02a6aa7 100644 --- a/internal/delivery/engine_lifecycle_test.go +++ b/internal/delivery/engine_lifecycle_test.go @@ -99,7 +99,7 @@ func requireStopHookExpires( // the caller can drive OnStop too. // // Callers must not seed pending or retrying deliveries before -// calling this: restart recovery enqueues those during startup, +// calling this: the recovery routine's run at start enqueues those, // which would put work in the queue while the pool is still // racing its first select. func startEngineViaHook( @@ -178,8 +178,8 @@ func TestEngine_WorkersOutliveStartHookContext(t *testing.T) { _ = lc.hooks[0].OnStop(context.Background()) }) - // Seeded only after the pool has settled, so restart recovery - // cannot enqueue it during startup. + // Seeded only after the pool has settled, so the recovery + // routine's run at start cannot enqueue it. deliveryID, task := seedLogTask(t, s) s.Engine.Notify([]delivery.Task{task}) @@ -345,15 +345,15 @@ func stopDuringAttempt( require.NoError(t, lc.hooks[0].OnStop(context.Background())) } -// requireRequeued runs restart recovery on the stopped engine and -// requires it to queue task's attempt again on ch. +// requireRequeued runs the recovery routine as at start on the stopped +// engine and requires it to queue task's attempt again on ch. func requireRequeued( t *testing.T, s iSetup, ch <-chan delivery.Task, task delivery.Task, ) { t.Helper() s.Engine.ExportRecoverWebhookDeliveries( - context.Background(), s.WebhookID, + context.Background(), s.WebhookID, atStart, ) select { @@ -361,7 +361,7 @@ func requireRequeued( assert.Equal(t, task.DeliveryID, got.DeliveryID) assert.Equal(t, task.AttemptNum, got.AttemptNum) case <-time.After(5 * time.Second): - t.Fatal("restart recovery did not queue the delivery again") + t.Fatal("recovery at start did not queue the delivery again") } } @@ -371,7 +371,8 @@ func requireRequeued( // no retries that was the delivery's only attempt, recorded as failed // with "context canceled", and nothing ever sent it again. The // delivery must instead stay pending, with no result and no count in -// the attempt metrics, for restart recovery to send. +// the attempt metrics, for the recovery routine to send at the next +// start. func TestEngine_StopLeavesFireAndForgetAttemptPending(t *testing.T) { t.Parallel() @@ -399,8 +400,8 @@ func TestEngine_StopLeavesFireAndForgetAttemptPending(t *testing.T) { // TestEngine_StopLeavesLastRetryAttemptRetrying is the same regression // for a target with retries, on its last attempt: the cancelled attempt // used to exhaust the retries and end the delivery failed. It must stay -// retrying with only its earlier attempt recorded, and restart recovery -// must send that last attempt again. +// retrying with only its earlier attempt recorded, and the recovery +// routine at the next start must send that last attempt again. func TestEngine_StopLeavesLastRetryAttemptRetrying(t *testing.T) { t.Parallel() diff --git a/internal/delivery/export_test.go b/internal/delivery/export_test.go index 9256445..0ec1e95 100644 --- a/internal/delivery/export_test.go +++ b/internal/delivery/export_test.go @@ -281,32 +281,27 @@ func (e *Engine) ExportRecoverPendingDeliveries( ctx context.Context, webhookDB *gorm.DB, webhookID string, + atStart bool, ) { e.recoverPendingDeliveries( - ctx, webhookDB, webhookID, + ctx, webhookDB, webhookID, atStart, ) } // ExportRecoverWebhookDeliveries exposes // recoverWebhookDeliveries. func (e *Engine) ExportRecoverWebhookDeliveries( - ctx context.Context, webhookID string, + ctx context.Context, webhookID string, atStart bool, ) { - e.recoverWebhookDeliveries(ctx, webhookID) + e.recoverWebhookDeliveries(ctx, webhookID, atStart) } -// ExportRecoverInFlight exposes recoverInFlight. -func (e *Engine) ExportRecoverInFlight( - ctx context.Context, +// ExportRecoverDeliveries exposes recoverDeliveries, the recovery +// routine. +func (e *Engine) ExportRecoverDeliveries( + ctx context.Context, atStart bool, ) { - e.recoverInFlight(ctx) -} - -// ExportSweepWebhookRetries exposes sweepWebhookRetries. -func (e *Engine) ExportSweepWebhookRetries( - ctx context.Context, webhookID string, -) { - e.sweepWebhookRetries(ctx, webhookID) + e.recoverDeliveries(ctx, atStart) } // ExportStart exposes start for testing. @@ -353,9 +348,9 @@ func (e *Engine) ExportRetainDelivery(deliveryID string) bool { // ExportRecoverRetryingDeliveries exposes recoverRetryingDeliveries. func (e *Engine) ExportRecoverRetryingDeliveries( - webhookDB *gorm.DB, webhookID string, + ctx context.Context, webhookDB *gorm.DB, webhookID string, ) { - e.recoverRetryingDeliveries(webhookDB, webhookID) + e.recoverRetryingDeliveries(ctx, webhookDB, webhookID) } // ExportFailMissingTarget exposes failMissingTarget, so a test can hand diff --git a/internal/delivery/inflight.go b/internal/delivery/inflight.go index 9596e30..c1ca6b7 100644 --- a/internal/delivery/inflight.go +++ b/internal/delivery/inflight.go @@ -6,10 +6,10 @@ import "sync" // // A delivery is owned from the moment a task for it is handed to a // channel or to a retry timer until the engine has no further plan for -// it in memory. Restart recovery and both arms of the periodic sweep -// re-dispatch only deliveries the set does not hold, which is what -// makes them exact rather than a guess about how long a row has sat at -// pending. +// it in memory. The recovery routine, at start and on every tick of the +// sweep, re-dispatches only deliveries the set does not hold, which is +// what makes it exact rather than a guess about how long a row has sat +// at pending. // // This replaces reasoning from timestamps. A delivery's row says // pending from creation until its outcome is written, which covers @@ -25,8 +25,8 @@ import "sync" // In-memory state is sufficient because a data directory admits one // process: internal/datadir takes an flock on it at startup and a // second instance refuses to run. Deliveries owned by a process that -// died are not in any successor's set, and restart recovery is what -// picks those up. +// died are not in any successor's set, and the recovery routine's run +// at start is what picks those up. // // References are counted rather than held as a plain set because // ownership outlives the worker that took it. A target that schedules @@ -61,9 +61,9 @@ func (s *inflightSet) retain(deliveryID string) { // retainIdle takes the first reference on a delivery, and reports // whether it got it. It fails when the engine already owns the -// delivery, which is what makes two claimants — restart recovery and -// the sweep run concurrently, or two sweep arms — mutually exclusive -// rather than merely atomic. +// delivery, which is what makes two claimants — Notify and the +// recovery routine, say — mutually exclusive rather than merely +// atomic. func (s *inflightSet) retainIdle(deliveryID string) bool { s.mu.Lock() defer s.mu.Unlock() diff --git a/internal/delivery/inflight_test.go b/internal/delivery/inflight_test.go index f6ec27e..24ebc65 100644 --- a/internal/delivery/inflight_test.go +++ b/internal/delivery/inflight_test.go @@ -95,8 +95,8 @@ func TestSweepLeavesAQueuedDeliveryAlone(t *testing.T) { require.Equal(t, 1, s.Engine.ExportInflightHeld()) - s.Engine.ExportSweepWebhookRetries( - context.Background(), s.WebhookID, + s.Engine.ExportRecoverWebhookDeliveries( + context.Background(), s.WebhookID, onTick, ) tasks := fDrain(s.Engine) @@ -107,9 +107,11 @@ func TestSweepLeavesAQueuedDeliveryAlone(t *testing.T) { ) } -// TestRecoveryAndSweepDoNotDoubleDispatch drives the two entry points -// the engine starts concurrently against one aged pending row. Before -// ownership they both dispatched it. +// TestRecoveryAndSweepDoNotDoubleDispatch drives the recovery routine's +// run at start and a tick of the sweep at once against one aged pending +// row. The engine runs them one after the other, but Notify can race +// either, and ownership is what keeps any two claimants from both +// sending. Before ownership the two runs both dispatched the row. func TestRecoveryAndSweepDoNotDoubleDispatch(t *testing.T) { t.Parallel() @@ -133,12 +135,12 @@ func TestRecoveryAndSweepDoNotDoubleDispatch(t *testing.T) { wg.Go(func() { s.Engine.ExportRecoverPendingDeliveries( - ctx, s.WebhookDB, s.WebhookID, + ctx, s.WebhookDB, s.WebhookID, atStart, ) }) wg.Go(func() { - s.Engine.ExportSweepWebhookRetries( - ctx, s.WebhookID, + s.Engine.ExportRecoverWebhookDeliveries( + ctx, s.WebhookID, onTick, ) }) wg.Wait() @@ -247,9 +249,10 @@ func TestOwnershipIsReleasedAfterDelivery(t *testing.T) { // TestNotifyAfterRecoveryDoesNotSendAgain is the startup race of // https://git.eeqj.de/sneak/webhooker/issues/299. The receiver has -// written a delivery, restart recovery finds it pending, sends it and -// releases it, and only then does the receiver's Notify for it arrive. -// Nothing owns the delivery by then, so Notify takes it. +// written a delivery, the recovery routine's run at start finds it +// pending, sends it and releases it, and only then does the receiver's +// Notify for it arrive. Nothing owns the delivery by then, so Notify +// takes it. func TestNotifyAfterRecoveryDoesNotSendAgain(t *testing.T) { t.Parallel() @@ -273,7 +276,8 @@ func TestNotifyAfterRecoveryDoesNotSendAgain(t *testing.T) { ) }() - // Restart recovery sends the delivery and lets it go. + // The recovery routine's run at start sends the delivery and lets + // it go. iWaitForDelivered(t, s.WebhookDB, d.ID) require.Eventually( t, @@ -334,7 +338,7 @@ func TestRetryingRecoverySkipsASuccessfulResult(t *testing.T) { rSeedResult(t, s.WebhookDB, d.ID, 2, true) s.Engine.ExportRecoverRetryingDeliveries( - s.WebhookDB, s.WebhookID, + context.Background(), s.WebhookDB, s.WebhookID, ) assert.Empty( @@ -349,8 +353,8 @@ func TestRetryingRecoverySkipsASuccessfulResult(t *testing.T) { ) } -// TestRetryingSweepSkipsASuccessfulResult is the same rule on the -// periodic sweep's retrying arm. +// TestRetryingSweepSkipsASuccessfulResult is the same rule on a tick of +// the sweep. func TestRetryingSweepSkipsASuccessfulResult(t *testing.T) { t.Parallel() @@ -368,8 +372,8 @@ func TestRetryingSweepSkipsASuccessfulResult(t *testing.T) { rSeedResult(t, s.WebhookDB, d.ID, 1, false) rSeedResult(t, s.WebhookDB, d.ID, 2, true) - s.Engine.ExportSweepWebhookRetries( - context.Background(), s.WebhookID, + s.Engine.ExportRecoverWebhookDeliveries( + context.Background(), s.WebhookID, onTick, ) assert.Empty(t, fDrain(s.Engine)) @@ -420,8 +424,8 @@ func TestScheduledRetryIsNotSweptDuringBackoff(t *testing.T) { require.Equal(t, 1, s.Engine.ExportInflightHeld()) - s.Engine.ExportSweepWebhookRetries( - context.Background(), s.WebhookID, + s.Engine.ExportRecoverWebhookDeliveries( + context.Background(), s.WebhookID, onTick, ) assert.Empty( @@ -453,7 +457,7 @@ func TestRedispatchStampsTheRow(t *testing.T) { ctx := context.Background() - s.Engine.ExportSweepWebhookRetries(ctx, s.WebhookID) + s.Engine.ExportRecoverWebhookDeliveries(ctx, s.WebhookID, onTick) require.Len(t, fDrain(s.Engine), 1) var row database.Delivery diff --git a/internal/delivery/metrics_test.go b/internal/delivery/metrics_test.go index 6bd124c..fd99066 100644 --- a/internal/delivery/metrics_test.go +++ b/internal/delivery/metrics_test.go @@ -444,8 +444,8 @@ func TestDeliveryMetrics_OrphanedRetryFailureLabelled( t, s, database.TargetTypeLog, ) - s.Engine.ExportSweepWebhookRetries( - context.Background(), s.WebhookID, + s.Engine.ExportRecoverWebhookDeliveries( + context.Background(), s.WebhookID, onTick, ) iAssertStatus(t, s.WebhookDB, deliveryID, diff --git a/internal/delivery/queue_depth.go b/internal/delivery/queue_depth.go index e627650..83166f3 100644 --- a/internal/delivery/queue_depth.go +++ b/internal/delivery/queue_depth.go @@ -20,7 +20,8 @@ const queueDepthSampleInterval = 30 * time.Second // to be seeded correctly at startup from rows written by a previous // process, and would drift permanently on any transition that failed // to persist. Counting is the measurement that cannot go wrong, and -// it is the same whole-database walk the retry sweep already makes. +// it is the same whole-database walk each tick of the sweep already +// makes. func (e *Engine) queueDepthSampler(ctx context.Context) { defer e.wg.Done() diff --git a/internal/delivery/recovery_durability_test.go b/internal/delivery/recovery_durability_test.go index 1eabec4..9ed60c2 100644 --- a/internal/delivery/recovery_durability_test.go +++ b/internal/delivery/recovery_durability_test.go @@ -88,7 +88,7 @@ func TestRecoverySkipsPendingWithSuccessfulResult( ) s.Engine.ExportRecoverPendingDeliveries( - context.Background(), s.WebhookDB, s.WebhookID, + context.Background(), s.WebhookDB, s.WebhookID, atStart, ) select { @@ -145,7 +145,7 @@ func TestRecoveryContinuesTheAttemptNumbering(t *testing.T) { rSeedResult(t, s.WebhookDB, d.ID, 2, false) s.Engine.ExportRecoverPendingDeliveries( - context.Background(), s.WebhookDB, s.WebhookID, + context.Background(), s.WebhookDB, s.WebhookID, atStart, ) select { @@ -183,8 +183,8 @@ func TestSweepRecoversStrandedPending(t *testing.T) { database.DeliveryStatusPending, ) - s.Engine.ExportSweepWebhookRetries( - context.Background(), s.WebhookID, + s.Engine.ExportRecoverWebhookDeliveries( + context.Background(), s.WebhookID, onTick, ) select { @@ -230,7 +230,7 @@ func TestSweepClaimsAStrandedDeliveryOnlyOnce(t *testing.T) { ctx := context.Background() - s.Engine.ExportSweepWebhookRetries(ctx, s.WebhookID) + s.Engine.ExportRecoverWebhookDeliveries(ctx, s.WebhookID, onTick) select { case task := <-s.Engine.ExportDeliveryCh(): @@ -246,7 +246,7 @@ func TestSweepClaimsAStrandedDeliveryOnlyOnce(t *testing.T) { database.DeliveryStatusPending, ) - s.Engine.ExportSweepWebhookRetries(ctx, s.WebhookID) + s.Engine.ExportRecoverWebhookDeliveries(ctx, s.WebhookID, onTick) select { case task := <-s.Engine.ExportDeliveryCh(): @@ -258,9 +258,9 @@ func TestSweepClaimsAStrandedDeliveryOnlyOnce(t *testing.T) { } } -// TestSweepSettlesStrandedPendingWithoutResending is the sweep's own -// version of the reconcile: a stranded delivery holding a successful -// result is settled where it stands, and the receiver hears nothing. +// TestSweepSettlesStrandedPendingWithoutResending is the reconcile on a +// tick of the sweep: a stranded delivery holding a successful result is +// settled where it stands, and the receiver hears nothing. func TestSweepSettlesStrandedPendingWithoutResending( t *testing.T, ) { @@ -280,8 +280,8 @@ func TestSweepSettlesStrandedPendingWithoutResending( rSeedResult(t, s.WebhookDB, d.ID, 1, true) rAgePending(t, s.WebhookDB, d.ID) - s.Engine.ExportSweepWebhookRetries( - context.Background(), s.WebhookID, + s.Engine.ExportRecoverWebhookDeliveries( + context.Background(), s.WebhookID, onTick, ) select { @@ -312,8 +312,9 @@ func TestSweepSettlesStrandedPendingWithoutResending( // TestFailedResultWriteLeavesDeliveryRecoverable is the rule the // targets now follow: a bookkeeping write that fails must not advance -// the status, because pending and retrying are the states the sweeps -// recover and delivered is a claim the database refused to record. +// the status, because pending and retrying are the states the recovery +// routine recovers and delivered is a claim the database refused to +// record. func TestFailedResultWriteLeavesDeliveryRecoverable( t *testing.T, ) { diff --git a/internal/delivery/recovery_test.go b/internal/delivery/recovery_test.go new file mode 100644 index 0000000..5cbfcba --- /dev/null +++ b/internal/delivery/recovery_test.go @@ -0,0 +1,340 @@ +package delivery_test + +import ( + "context" + "log/slog" + "net/http" + "testing" + "time" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "sneak.berlin/go/webhooker/internal/database" + "sneak.berlin/go/webhooker/internal/database/databasetest" + "sneak.berlin/go/webhooker/internal/delivery" +) + +// These tests cover the recovery routine, which the engine runs once +// when it starts and then on every tick of the sweep +// (https://git.eeqj.de/sneak/webhooker/issues/542). Every case runs it +// both ways, each on a webhook of its own. + +// atStart and onTick are the two values of the recovery routine's +// atStart parameter: its run when the engine starts, and a tick of the +// sweep. +const ( + atStart = true + onTick = false +) + +// recoveryRun names one of the two runs of the recovery routine. +type recoveryRun struct { + name string + atStart bool +} + +// recoveryRuns returns the routine's run at start and a tick. +func recoveryRuns() []recoveryRun { + return []recoveryRun{ + {"at start", atStart}, + {"on a tick", onTick}, + } +} + +// recoverySetup gives a fresh webhook its main-database row and an http +// target with retries, and returns the setup and the target's ID. No +// worker runs, so whatever the routine queues stays in the channels for +// the test to read, and nothing is sent to the target. +func recoverySetup(t *testing.T) (iSetup, string) { + t.Helper() + + s := newISetup(t) + targetID := uuid.New().String() + + iCreateWebhook(t, s.MainDB, s.WebhookID, "recovery") + iCreateTarget( + t, s.MainDB, targetID, s.WebhookID, "recovery", + database.TargetTypeHTTP, + iHTTPConfig("http://example.com/hook"), 20, + ) + + return s, targetID +} + +// seedRecoveryDelivery seeds a delivery at status with the given +// recorded attempts, failed but for the last when lastSucceeded is set, +// all recorded now. +func seedRecoveryDelivery( + t *testing.T, + s iSetup, + targetID string, + status database.DeliveryStatus, + attempts int, + lastSucceeded bool, +) string { + t.Helper() + + event := iSeedEvent(t, s.WebhookDB, s.WebhookID, `{"recovery":true}`) + d := iSeedDelivery(t, s.WebhookDB, event.ID, targetID, status) + + for n := 1; n <= attempts; n++ { + rSeedResult( + t, s.WebhookDB, d.ID, n, lastSucceeded && n == attempts, + ) + } + + return d.ID +} + +// backdateAttempts moves every recorded attempt of a delivery an hour +// into the past, long past the backoff after its first attempt. +func backdateAttempts(t *testing.T, s iSetup, deliveryID string) { + t.Helper() + + require.NoError(t, s.WebhookDB.Model(&database.DeliveryResult{}). + Where("delivery_id = ?", deliveryID). + UpdateColumn("created_at", s.WebhookDB.NowFunc().Add(-time.Hour)). + Error) +} + +// requireQueued waits for a task on ch. A retrying delivery reaches the +// retry channel through a timer, even one due now, so it is waited for. +func requireQueued( + t *testing.T, ch <-chan delivery.Task, +) delivery.Task { + t.Helper() + + select { + case task := <-ch: + return task + case <-time.After(5 * time.Second): + t.Fatal("the recovery routine queued nothing") + + return delivery.Task{} + } +} + +// assertNothingQueued asserts the engine queues nothing, giving a retry +// timer that is due now the moment it needs to fire. +func assertNothingQueued(t *testing.T, e *delivery.Engine) { + t.Helper() + + select { + case task := <-e.ExportDeliveryCh(): + t.Errorf("queued pending delivery %s", task.DeliveryID) + case task := <-e.ExportRetryCh(): + t.Errorf("queued retrying delivery %s", task.DeliveryID) + case <-time.After(200 * time.Millisecond): + } +} + +// TestRecovery_RetryingBackoffPassed: a retrying delivery whose backoff +// is over is queued for its next attempt at once. +func TestRecovery_RetryingBackoffPassed(t *testing.T) { + t.Parallel() + + for _, run := range recoveryRuns() { + t.Run(run.name, func(t *testing.T) { + t.Parallel() + + s, targetID := recoverySetup(t) + id := seedRecoveryDelivery( + t, s, targetID, database.DeliveryStatusRetrying, 1, false, + ) + backdateAttempts(t, s, id) + + s.Engine.ExportRecoverDeliveries(context.Background(), run.atStart) + + task := requireQueued(t, s.Engine.ExportRetryCh()) + assert.Equal(t, id, task.DeliveryID) + assert.Equal(t, 2, task.AttemptNum) + iAssertStatus(t, s.WebhookDB, id, database.DeliveryStatusRetrying) + }) + } +} + +// TestRecovery_RetryingBackoffNotPassed: a retrying delivery whose last +// attempt was just now waits out the rest of its backoff, ten attempts +// in, eight and a half minutes. It is not sent now, and the engine holds +// it, so a later tick leaves it to the timer. +func TestRecovery_RetryingBackoffNotPassed(t *testing.T) { + t.Parallel() + + for _, run := range recoveryRuns() { + t.Run(run.name, func(t *testing.T) { + t.Parallel() + + s, targetID := recoverySetup(t) + id := seedRecoveryDelivery( + t, s, targetID, database.DeliveryStatusRetrying, 10, false, + ) + + s.Engine.ExportRecoverDeliveries(context.Background(), run.atStart) + + assertNothingQueued(t, s.Engine) + iAssertStatus(t, s.WebhookDB, id, database.DeliveryStatusRetrying) + assert.Equal(t, 1, s.Engine.ExportInflightHeld(), + "no retry timer holds the delivery", + ) + }) + } +} + +// TestRecovery_PendingOlderThanTheAgeBound: a pending delivery untouched +// for longer than the sweep's age bound is sent by either run. +func TestRecovery_PendingOlderThanTheAgeBound(t *testing.T) { + t.Parallel() + + for _, run := range recoveryRuns() { + t.Run(run.name, func(t *testing.T) { + t.Parallel() + + s, targetID := recoverySetup(t) + id := seedRecoveryDelivery( + t, s, targetID, database.DeliveryStatusPending, 0, false, + ) + rAgePending(t, s.WebhookDB, id) + + s.Engine.ExportRecoverDeliveries(context.Background(), run.atStart) + + task := requireQueued(t, s.Engine.ExportDeliveryCh()) + assert.Equal(t, id, task.DeliveryID) + assert.Equal(t, 1, task.AttemptNum) + }) + } +} + +// TestRecovery_PendingYoungerThanTheAgeBound: a pending delivery touched +// a moment ago, as one a stop cut off is, is sent at start, which is how +// it reaches its target on the next start +// (https://git.eeqj.de/sneak/webhooker/issues/527). A tick leaves it. +func TestRecovery_PendingYoungerThanTheAgeBound(t *testing.T) { + t.Parallel() + + for _, run := range recoveryRuns() { + t.Run(run.name, func(t *testing.T) { + t.Parallel() + + s, targetID := recoverySetup(t) + id := seedRecoveryDelivery( + t, s, targetID, database.DeliveryStatusPending, 0, false, + ) + + s.Engine.ExportRecoverDeliveries(context.Background(), run.atStart) + + if run.atStart { + task := requireQueued(t, s.Engine.ExportDeliveryCh()) + assert.Equal(t, id, task.DeliveryID) + + return + } + + assertNothingQueued(t, s.Engine) + iAssertStatus(t, s.WebhookDB, id, database.DeliveryStatusPending) + assert.Zero(t, s.Engine.ExportInflightHeld()) + }) + } +} + +// TestRecovery_AlreadySucceeded: a pending or retrying delivery whose +// success is recorded but whose status write was lost is marked +// delivered where it stands, and not sent again. +func TestRecovery_AlreadySucceeded(t *testing.T) { + t.Parallel() + + for _, run := range recoveryRuns() { + t.Run(run.name, func(t *testing.T) { + t.Parallel() + + s, targetID := recoverySetup(t) + pending := seedRecoveryDelivery( + t, s, targetID, database.DeliveryStatusPending, 1, true, + ) + rAgePending(t, s.WebhookDB, pending) + + retrying := seedRecoveryDelivery( + t, s, targetID, database.DeliveryStatusRetrying, 2, true, + ) + backdateAttempts(t, s, retrying) + + s.Engine.ExportRecoverDeliveries(context.Background(), run.atStart) + + assertNothingQueued(t, s.Engine) + + for id, attempts := range map[string]int{pending: 1, retrying: 2} { + iAssertStatus( + t, s.WebhookDB, id, database.DeliveryStatusDelivered, + ) + assert.Len(t, iResults(t, s.WebhookDB, id), attempts, + "marking the delivery delivered recorded an attempt", + ) + } + }) + } +} + +// TestRecovery_MissingDatabase: a tick leaves a webhook whose database +// file is missing alone, and the run at start opens it, which creates an +// empty one and reports the loss (see +// TestRecoverDeliveries_ReportsAMissingWebhookDatabase). +func TestRecovery_MissingDatabase(t *testing.T) { + t.Parallel() + + mainDB := iMainDB(t) + webhookID := uuid.New().String() + iCreateWebhook(t, mainDB.DB(), webhookID, "missing-database") + + dbMgr := databasetest.NewWebhookDBManager(t, t.TempDir()) + t.Cleanup(func() { _ = dbMgr.CloseAll() }) + + engine := delivery.NewTestEngineWithDB( + mainDB, dbMgr, + slog.New(slog.DiscardHandler), + &http.Client{Timeout: 5 * time.Second}, 1, + ) + + engine.ExportRecoverDeliveries(context.Background(), onTick) + assert.False(t, dbMgr.DBExists(webhookID), + "a tick created the missing database", + ) + + engine.ExportRecoverDeliveries(context.Background(), atStart) + assert.True(t, dbMgr.DBExists(webhookID), + "the run at start did not open the missing database", + ) +} + +// TestRecovery_LeavesADeliveryTheEngineHolds: a delivery the engine is +// working on, has queued, or holds a retry timer for is left alone by +// either run, pending and retrying alike, however old. +func TestRecovery_LeavesADeliveryTheEngineHolds(t *testing.T) { + t.Parallel() + + for _, run := range recoveryRuns() { + t.Run(run.name, func(t *testing.T) { + t.Parallel() + + s, targetID := recoverySetup(t) + pending := seedRecoveryDelivery( + t, s, targetID, database.DeliveryStatusPending, 0, false, + ) + rAgePending(t, s.WebhookDB, pending) + + retrying := seedRecoveryDelivery( + t, s, targetID, database.DeliveryStatusRetrying, 1, false, + ) + backdateAttempts(t, s, retrying) + + require.True(t, s.Engine.ExportRetainDelivery(pending)) + require.True(t, s.Engine.ExportRetainDelivery(retrying)) + + s.Engine.ExportRecoverDeliveries(context.Background(), run.atStart) + + assertNothingQueued(t, s.Engine) + iAssertStatus(t, s.WebhookDB, pending, database.DeliveryStatusPending) + iAssertStatus(t, s.WebhookDB, retrying, database.DeliveryStatusRetrying) + assert.Equal(t, 2, s.Engine.ExportInflightHeld()) + }) + } +} diff --git a/internal/delivery/target.go b/internal/delivery/target.go index aa819db..88f6df7 100644 --- a/internal/delivery/target.go +++ b/internal/delivery/target.go @@ -13,8 +13,8 @@ import ( // The engine provides one to each target so a target can own // its retries durably: it records the attempt, marks the // delivery retrying, and asks the Scheduler to deliver the -// next attempt after delay — exactly what the engine does for -// its own restart recovery. +// next attempt after delay — exactly what the engine's +// recovery routine does for a retrying delivery. type Scheduler interface { ScheduleRetry(task Task, delay time.Duration) } @@ -45,29 +45,20 @@ type Target interface { } // rescheduler is implemented by targets that own durable -// retries. The engine's restart recovery and periodic sweep -// use it to let the target recompute the schedule for an -// orphaned retrying delivery, keeping the retry schedule -// target-owned. Fire-and-forget targets do not implement it -// and their (never-occurring) retrying deliveries are -// skipped. +// retries. The engine's recovery routine uses it to let the +// target recompute the schedule for an orphaned retrying +// delivery, keeping the retry schedule target-owned. +// Fire-and-forget targets do not implement it, and a retrying +// delivery found for one is terminally failed. type rescheduler interface { // remainingBackoff returns how long to wait before the - // next attempt of a recovered retrying delivery. + // next attempt of a recovered retrying delivery: zero once + // its backoff has passed. remainingBackoff( webhookDB *gorm.DB, deliveryID string, attemptNum int, ) time.Duration - - // backoffElapsed reports whether the backoff window for - // the last attempt has already passed, so the periodic - // sweep can re-enqueue the delivery now. - backoffElapsed( - webhookDB *gorm.DB, - deliveryID string, - attemptNum int, - ) bool } // attemptResult is the outcome of a single delivery attempt, diff --git a/internal/delivery/target_http.go b/internal/delivery/target_http.go index ce18d22..b4ff824 100644 --- a/internal/delivery/target_http.go +++ b/internal/delivery/target_http.go @@ -84,8 +84,8 @@ func (c *httpCore) deliver( // engine is stopping, which cancels the context its request is made // with. Such an attempt is not recorded: no DeliveryResult, no circuit // breaker outcome, no status change, no count in the attempt metrics. -// The delivery stays pending or retrying, and startup recovery sends -// it again on the next start. +// The delivery stays pending or retrying, and the recovery routine +// sends it again on the next start. // // A target's own timeout leaves that context alone, so its attempt is // still recorded as the failure it is. @@ -334,7 +334,8 @@ func (c *httpCore) publishCircuitState( // remainingBackoff returns how long remains of the backoff // window for the last attempt of a recovered retrying -// delivery. It implements rescheduler. +// delivery: zero once it has passed, or when the last attempt +// cannot be read. It implements rescheduler. func (c *httpCore) remainingBackoff( webhookDB *gorm.DB, deliveryID string, @@ -357,29 +358,6 @@ func (c *httpCore) remainingBackoff( return max(remaining, 0) } -// backoffElapsed reports whether the backoff window for the -// last attempt of a retrying delivery has passed. It -// implements rescheduler. -func (c *httpCore) backoffElapsed( - webhookDB *gorm.DB, - deliveryID string, - attemptNum int, -) bool { - var lastResult database.DeliveryResult - - err := webhookDB. - Where("delivery_id = ?", deliveryID). - Order("created_at DESC"). - First(&lastResult).Error - if err != nil { - return true - } - - backoff := Backoff(attemptNum) - - return time.Since(lastResult.CreatedAt) >= backoff -} - // Backoff is how long an http or slack target with retries waits after // a delivery's failed attempt attemptNum before trying it again. func Backoff(attemptNum int) time.Duration { diff --git a/internal/delivery/terminal_state_test.go b/internal/delivery/terminal_state_test.go index ef2b0cf..7f49ab5 100644 --- a/internal/delivery/terminal_state_test.go +++ b/internal/delivery/terminal_state_test.go @@ -178,7 +178,7 @@ func TestRecoverSingleRetry_TargetDeleted(t *testing.T) { ) s.Engine.ExportRecoverWebhookDeliveries( - context.Background(), s.WebhookID, + context.Background(), s.WebhookID, atStart, ) iAssertStatus( @@ -201,7 +201,7 @@ func TestRecoverSingleRetry_TargetDeleted(t *testing.T) { ) } -func TestSweepSingleRetry_TargetDeleted(t *testing.T) { +func TestRecoverSingleRetry_OnTick_TargetDeleted(t *testing.T) { t.Parallel() s := newISetup(t) @@ -218,11 +218,11 @@ func TestSweepSingleRetry_TargetDeleted(t *testing.T) { // Twice, because the bug was an error the sweep repeated every // minute for the life of the database: the second sweep must // find nothing left to do. - s.Engine.ExportSweepWebhookRetries( - context.Background(), s.WebhookID, + s.Engine.ExportRecoverWebhookDeliveries( + context.Background(), s.WebhookID, onTick, ) - s.Engine.ExportSweepWebhookRetries( - context.Background(), s.WebhookID, + s.Engine.ExportRecoverWebhookDeliveries( + context.Background(), s.WebhookID, onTick, ) iAssertStatus( @@ -239,10 +239,11 @@ func TestSweepSingleRetry_TargetDeleted(t *testing.T) { assert.Zero(t, s.Engine.ExportInflightHeld()) } -// TestSweepSingleRetry_TargetNeverExisted covers the other half of the -// soft-delete distinction: an id with no row at all, deleted or -// otherwise, must not be reported as something the operator deleted. -func TestSweepSingleRetry_TargetNeverExisted(t *testing.T) { +// TestRecoverSingleRetry_OnTick_TargetNeverExisted covers the other +// half of the soft-delete distinction: an id with no row at all, +// deleted or otherwise, must not be reported as something the operator +// deleted. +func TestRecoverSingleRetry_OnTick_TargetNeverExisted(t *testing.T) { t.Parallel() s := newISetup(t) @@ -264,8 +265,8 @@ func TestSweepSingleRetry_TargetNeverExisted(t *testing.T) { iSeedFailedResult(t, s.WebhookDB, d.ID) - s.Engine.ExportSweepWebhookRetries( - context.Background(), s.WebhookID, + s.Engine.ExportRecoverWebhookDeliveries( + context.Background(), s.WebhookID, onTick, ) iAssertStatus( @@ -303,8 +304,8 @@ func TestFailMissingTarget_WritesNoTargetRow( database.DeliveryStatusRetrying, ) - s.Engine.ExportSweepWebhookRetries( - context.Background(), s.WebhookID, + s.Engine.ExportRecoverWebhookDeliveries( + context.Background(), s.WebhookID, onTick, ) iAssertStatus( @@ -546,7 +547,7 @@ func TestRecoverSingleRetry_TargetUnreadable_LeavesDeliveryAlone( require.NoError(t, sqlDB.Close()) s.Engine.ExportRecoverRetryingDeliveries( - s.WebhookDB, s.WebhookID, + context.Background(), s.WebhookDB, s.WebhookID, ) iAssertStatus( @@ -564,7 +565,7 @@ func TestRecoverSingleRetry_TargetUnreadable_LeavesDeliveryAlone( // --- 4. A pending delivery whose target is gone --- -func TestRecoverPending_TargetDeleted(t *testing.T) { +func TestRecoverPendingDeliveries_TargetDeleted(t *testing.T) { t.Parallel() s := newISetup(t) @@ -577,7 +578,7 @@ func TestRecoverPending_TargetDeleted(t *testing.T) { ) s.Engine.ExportRecoverWebhookDeliveries( - context.Background(), s.WebhookID, + context.Background(), s.WebhookID, atStart, ) iAssertStatus( @@ -600,10 +601,10 @@ func TestRecoverPending_TargetDeleted(t *testing.T) { ) } -// TestRecoverPending_TargetDeleted_LeavesAnOwnedDeliveryAlone: the -// terminal write takes ownership like every other recovery write, so a -// delivery the engine still holds is not failed underneath its worker. -func TestRecoverPending_TargetDeleted_LeavesAnOwnedDeliveryAlone( +// TestRecoverPendingDeliveries_TargetDeleted_LeavesAnOwnedDeliveryAlone: +// the terminal write takes ownership like every other recovery write, so +// a delivery the engine still holds is not failed underneath its worker. +func TestRecoverPendingDeliveries_TargetDeleted_LeavesAnOwnedDeliveryAlone( t *testing.T, ) { t.Parallel() @@ -620,7 +621,7 @@ func TestRecoverPending_TargetDeleted_LeavesAnOwnedDeliveryAlone( require.True(t, s.Engine.ExportRetainDelivery(deliveryID)) s.Engine.ExportRecoverWebhookDeliveries( - context.Background(), s.WebhookID, + context.Background(), s.WebhookID, atStart, ) iAssertStatus( @@ -675,18 +676,18 @@ func TestFailMissingTarget_LeavesASettledDeliveryAlone( assert.Zero(t, s.Engine.ExportInflightHeld()) } -// TestSweepPending_TargetDeleted sweeps twice over a batch that also -// holds a healthy stranded delivery. The one whose target is gone is -// failed once and then left alone; the healthy one is queued by the -// first sweep and not again by the second. -func TestSweepPending_TargetDeleted(t *testing.T) { +// TestRecoverPendingDeliveries_OnTick_TargetDeleted sweeps twice over a +// batch that also holds a healthy stranded delivery. The one whose +// target is gone is failed once and then left alone; the healthy one is +// queued by the first sweep and not again by the second. +func TestRecoverPendingDeliveries_OnTick_TargetDeleted(t *testing.T) { t.Parallel() liveTargetID := uuid.New().String() s := fSweepSetup(t, liveTargetID, "still-there") deliveryID := tSeedDeletedTarget( - t, s, "gone-on-pending-sweep", "http://example.com/hook", + t, s, "gone-while-pending-on-tick", "http://example.com/hook", database.DeliveryStatusPending, ) rAgePending(t, s.WebhookDB, deliveryID) @@ -703,7 +704,7 @@ func TestSweepPending_TargetDeleted(t *testing.T) { ctx := context.Background() - s.Engine.ExportSweepWebhookRetries(ctx, s.WebhookID) + s.Engine.ExportRecoverWebhookDeliveries(ctx, s.WebhookID, onTick) tasks := fDrain(s.Engine) require.Len(t, tasks, 1, @@ -711,7 +712,7 @@ func TestSweepPending_TargetDeleted(t *testing.T) { ) assert.Equal(t, healthy.ID, tasks[0].DeliveryID) - s.Engine.ExportSweepWebhookRetries(ctx, s.WebhookID) + s.Engine.ExportRecoverWebhookDeliveries(ctx, s.WebhookID, onTick) assert.Empty(t, fDrain(s.Engine), "the second sweep queued a delivery again", @@ -724,7 +725,7 @@ func TestSweepPending_TargetDeleted(t *testing.T) { last := tLastResult(t, s, deliveryID, 2) - assert.Contains(t, last.Error, "gone-on-pending-sweep") + assert.Contains(t, last.Error, "gone-while-pending-on-tick") assert.Contains(t, last.Error, "was deleted") } @@ -775,11 +776,11 @@ func TestSendRecoveredDeliveries_TargetMissingFromMap( ) } -// TestRecoverPending_TargetUnreadable_LeavesDeliveryAlone: a failed -// read of the main database is not a deleted target. Restart recovery -// holds every pending delivery of the webhook in one batch, so failing -// on this would fail all of them. -func TestRecoverPending_TargetUnreadable_LeavesDeliveryAlone( +// TestRecoverPendingDeliveries_TargetUnreadable_LeavesDeliveryAlone: a +// failed read of the main database is not a deleted target. At start the +// recovery routine holds every pending delivery of the webhook in one +// batch, so failing on this would fail all of them. +func TestRecoverPendingDeliveries_TargetUnreadable_LeavesDeliveryAlone( t *testing.T, ) { t.Parallel() @@ -807,7 +808,7 @@ func TestRecoverPending_TargetUnreadable_LeavesDeliveryAlone( require.NoError(t, sqlDB.Close()) s.Engine.ExportRecoverPendingDeliveries( - context.Background(), s.WebhookDB, s.WebhookID, + context.Background(), s.WebhookDB, s.WebhookID, atStart, ) iAssertStatus( -- 2.54.0 From 8406c779490ab1c245fd3cbb21aad36d6f86eaac Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Thu, 15 Oct 2026 23:02:39 +0200 Subject: [PATCH 124/126] Store a target's settings as columns (closes #543) A target's settings move from the per-type JSON string in targets.config to columns: url, headers, timeout_seconds, forward_query, archive_expiry and archive_rotation. A column a type does not use stays empty. headers holds the header map as JSON text through GORM's JSON serializer, and archive_expiry holds a time.Duration, 0 meaning never. The forms check each setting once, with the same refusals and messages, and the edit form is filled from the columns, so the per-type config structs, their parsers and the "could not be read" branches are gone. A delivery task carries the six settings in place of the JSON string; tests check that each copy into a task, and back out of one, keeps all six. Model: opus-5-5 --- README.md | 100 +++---- internal/archive/archive.go | 22 +- internal/archive/archive_test.go | 56 ++-- internal/archive/evict_test.go | 16 +- internal/archive/rotation.go | 41 +-- internal/archive/rotation_test.go | 53 ++-- internal/archive/sweeper.go | 21 +- internal/archive/sweeper_test.go | 110 ++++---- internal/archive/writer.go | 84 +----- internal/database/event_db_isolation_test.go | 2 +- internal/database/model_secrets_test.go | 25 +- internal/database/model_target.go | 41 ++- internal/database/model_target_test.go | 21 +- internal/database/schema_test.go | 19 +- internal/db/migrations/main/001_schema.sql | 7 +- internal/delivery/client_ssrf_test.go | 22 +- internal/delivery/engine.go | 65 +++-- internal/delivery/engine_integration_test.go | 220 ++++++--------- internal/delivery/engine_lifecycle_test.go | 5 +- internal/delivery/engine_test.go | 266 ++++-------------- internal/delivery/event_db_isolation_test.go | 10 +- internal/delivery/event_timestamp_test.go | 26 +- internal/delivery/export_test.go | 26 +- internal/delivery/metrics_test.go | 27 +- internal/delivery/queue_depth_gormlog_test.go | 2 +- internal/delivery/recovery_durability_test.go | 4 +- internal/delivery/recovery_test.go | 51 +++- internal/delivery/redirect_test.go | 26 +- internal/delivery/target_config_edit.go | 137 --------- internal/delivery/target_config_view.go | 107 +++---- internal/delivery/target_config_view_test.go | 180 ++++-------- internal/delivery/target_database_test.go | 127 +++++++-- internal/delivery/target_headers.go | 16 +- internal/delivery/target_headers_test.go | 103 +------ internal/delivery/target_http.go | 120 ++------ internal/delivery/target_http_query_test.go | 46 ++- internal/delivery/target_redact.go | 33 +-- internal/delivery/target_redact_test.go | 58 ++-- internal/delivery/target_slack.go | 100 ++----- internal/delivery/terminal_state_test.go | 17 +- internal/delivery/url_mask_test.go | 20 +- internal/delivery/user_agent_test.go | 12 +- internal/handlers/archive_expiry.go | 24 +- internal/handlers/archive_expiry_test.go | 36 +-- internal/handlers/archive_rotation_test.go | 62 ++-- internal/handlers/delivery_replay.go | 35 ++- internal/handlers/delivery_replay_test.go | 71 +++-- .../handlers/delivery_result_view_test.go | 24 +- internal/handlers/event_log.go | 6 +- internal/handlers/event_resubmit_test.go | 14 +- internal/handlers/export_test.go | 42 ++- internal/handlers/handlers_test.go | 141 ++++++---- .../handlers/source_create_targets_test.go | 35 ++- internal/handlers/source_detail_test.go | 148 ++++------ .../source_logs_deleted_target_test.go | 3 +- internal/handlers/source_logs_test.go | 3 +- internal/handlers/target_create.go | 179 +++++------- internal/handlers/target_create_query_test.go | 2 +- internal/handlers/target_create_test.go | 107 ++++--- internal/handlers/target_edit.go | 79 ++---- internal/handlers/target_edit_test.go | 180 +++++++----- internal/handlers/target_list.go | 5 +- internal/handlers/target_toggle_test.go | 4 +- internal/handlers/webhook.go | 35 ++- internal/handlers/webhook_create.go | 18 +- internal/handlers/webhook_detail.go | 2 +- internal/handlers/webhook_stats_test.go | 2 +- internal/handlers/webhook_test.go | 70 +++++ internal/server/alpine_browser_test.go | 58 ++-- internal/server/routes.go | 2 +- internal/server/routes_test.go | 2 +- 71 files changed, 1665 insertions(+), 2168 deletions(-) delete mode 100644 internal/delivery/target_config_edit.go diff --git a/README.md b/README.md index c9e3436..45a474e 100644 --- a/README.md +++ b/README.md @@ -1579,15 +1579,20 @@ did not arrive on the URL and counts in neither. A delivery destination for events. Each target defines where and how events should be forwarded. -| Field | Type | Description | -| -------------- | ---------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `id` | UUID | Primary key | -| `webhook_id` | UUID | Foreign key → Webhook | -| `name` | string | Human-readable name | -| `type` | TargetType | One of: `http`, `slack`, `database`, `log` | -| `active` | boolean | Whether deliveries are enabled (default: true) | -| `config` | JSON text | Type-specific configuration | -| `max_attempts` | integer | How many times an `http` or `slack` target attempts each delivery in all: at least 1, and 1 by default. Ignored by `database` and `log` targets. The web UI labels it Delivery attempts | +| Field | Type | Description | +| ------------------ | ---------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `id` | UUID | Primary key | +| `webhook_id` | UUID | Foreign key → Webhook | +| `name` | string | Human-readable name | +| `type` | TargetType | One of: `http`, `slack`, `database`, `log` | +| `active` | boolean | Whether deliveries are enabled (default: true) | +| `url` | string | Where an `http` or `slack` target sends each event. It is a credential: the target list shows only its scheme and host, and only the target edit form shows it whole | +| `headers` | JSON text | An `http` target's extra request headers, a JSON object of name to value | +| `timeout_seconds` | integer | An `http` target's per-request timeout; 0 uses the shared 30-second client timeout | +| `forward_query` | boolean | Whether an `http` target is sent the query string each event arrived with | +| `archive_expiry` | integer | How long a `database` target keeps each archived event, as a Go `time.Duration` (nanoseconds); 0 keeps them forever | +| `archive_rotation` | string | How often a `database` target starts a new archive file: `none`, `monthly`, `daily` or `hourly` | +| `max_attempts` | integer | How many times an `http` or `slack` target attempts each delivery in all: at least 1, and 1 by default. Ignored by `database` and `log` targets. The web UI labels it Delivery attempts | **Relations:** Belongs to Webhook. Has many Deliveries. @@ -1598,10 +1603,10 @@ should be forwarded. failed delivery with exponential backoff, up to that many attempts in all, behind a per-target circuit breaker. - **`slack`** — Post the event as a formatted message to a Slack-compatible - incoming webhook URL (`webhookUrl` in `config`). It is built on the same HTTP - core as `http` and honours `max_attempts` identically, circuit breaker - included. See the Slack target section under "Per-Webhook Event Databases" for - the message format. + incoming webhook URL, its `url`. It is built on the same HTTP core as `http` + and honours `max_attempts` identically, circuit breaker included. See the + Slack target section under "Per-Webhook Event Databases" for the message + format. - **`database`** — Archive the full event as a row into the target's own archive database (`archive-{webhook_name}-{target_name}-{target_uuid}.db`) for long-term retention, with an optional creation-validated expiry (default: keep @@ -1617,29 +1622,28 @@ should be forwarded. - **`log`** — Write the event to the application log (stdout). Useful for debugging. -The `config` field stores type-specific configuration as JSON (e.g., destination -URL, custom headers, timeout settings). +Each type uses only its own settings, and the ones it does not use stay empty: +`http` uses `url`, `headers`, `timeout_seconds` and `forward_query`; `slack` +uses `url`; `database` uses `archive_expiry` and `archive_rotation`; `log` uses +none. The add target form, the new webhook form and the target edit form check +every setting before it is saved, and each setting is a column of its own, so +nothing that reads a target parses its settings or has to handle one it cannot +read. -**`http` target configuration:** +An `http` target POSTs the event to its `url`. Its `headers` are applied last, +so they win over the event's own forwarded headers. -| Key | Type | Description | -| -------------- | ------------- | ----------------------------------------------------------------------------------------- | -| `url` | string | Destination the event is POSTed to | -| `headers` | object | Extra request headers, applied last so they win over the event's own forwarded headers | -| `timeout` | integer (sec) | Per-target request timeout; unset (or 0) uses the shared 30-second client timeout | -| `forwardQuery` | boolean | Pass the query string each event arrived with on to the target; unset (or false) does not | - -`forwardQuery` is off by default, and the target URL is then sent exactly as +`forward_query` is off by default, and the target URL is then sent exactly as configured. On, each delivery appends the event's query string to the target URL, joined with `&` when the URL already has a query string of its own; a replayed delivery and a resubmitted event's deliveries do the same. Both target forms offer it as "Pass the query string on to this target", and the target list shows it when it is on. -`timeout` is capped at **300 seconds**, and the form rejects anything above it -rather than substituting the cap. A delivery attempt holds one of the bounded -pool's workers for its whole duration, so an unbounded timeout would let a -single unresponsive destination stall the queue. +`timeout_seconds` is capped at **300 seconds**, and the form rejects anything +above it rather than substituting the cap. A delivery attempt holds one of the +bounded pool's workers for its whole duration, so an unbounded timeout would let +a single unresponsive destination stall the queue. `headers` rejects the names the delivery path or `net/http` writes regardless of what is configured: `Host`, `Content-Length`, `Transfer-Encoding`, `Connection`, @@ -1713,7 +1717,7 @@ The event log and the event's own page show the query string with the rest of the request. The event log leaves out one larger than 32 KiB, as it does request headers, and links to the event's page, which shows it whole. The `database` and `log` targets carry it with the rest of the event. An `http` target receives it -only when its `forwardQuery` setting is on. +only when its `forward_query` setting is on. #### Delivery @@ -1992,11 +1996,10 @@ target UUID keeps the file name unique. A webhook named `Orders (EU)` with a target named `Long-term archive` archives into `archive-orders-eu-long-term-archive-{target_uuid}.db`. -An optional `rotation` in the target's config JSON (e.g. `{"rotation":"daily"}`) -is `none`, the default, which keeps the one file, or `monthly`, `daily` or -`hourly`. A target that rotates writes each event to a file named for the period -of the event's receive time, in UTC, put before the `.db`: -`archive-orders-eu-long-term-archive-{target_uuid}-2026-10.db` monthly, +A target's `archive_rotation` is `none`, the default, which keeps the one file, +or `monthly`, `daily` or `hourly`. A target that rotates writes each event to a +file named for the period of the event's receive time, in UTC, put before the +`.db`: `archive-orders-eu-long-term-archive-{target_uuid}-2026-10.db` monthly, `…-2026-10-01.db` daily and `…-2026-10-01-19.db` hourly. Each file holds exactly its period's events, and the first event of a new period starts the next file, so a finished period's file can be moved away like any archive. A changed @@ -2031,12 +2034,11 @@ archiving without stopping the service; a moved or removed archive file is recreated automatically on the next write, from the SQL files in `internal/db/migrations/archive/`. A zero-length archive file is written to as a new archive: SQLite opens it as an empty database, so it holds nothing to lose. -An optional `expiry` in the target's config JSON (e.g. `{"expiry":"720h"}`) is -validated when the target is created — the default (unset or the literal -`never`) keeps rows forever — and rows older than the expiry are pruned each -time the archive is (re)opened. An archive write failure is never silent -success: the delivery records a failed attempt with the error and is marked -failed. +A target's `archive_expiry` is checked by the form that saves it — the default, +`never` on the forms and 0 in the column, keeps rows forever — and rows older +than the expiry are pruned each time the archive is (re)opened. An archive write +failure is never silent success: the delivery records a failed attempt with the +error and is marked failed. Because reopens only happen on writes, an archive whose target has stopped receiving events would never be pruned. A background **archive sweeper** closes @@ -2047,8 +2049,7 @@ traffic. The sweep never creates an archive — a target whose archive file does not yet exist is skipped, not initialised — it takes the same per-target lock the write path uses, so it can never interleave with a write, and it leaves the archive closed afterwards so the move-the-file-away workflow keeps working. -Archives with no expiry, or the expiry `never`, are not touched by the sweep at -all. +Archives with the expiry `never` are not touched by the sweep at all. For a target with several files, the write path prunes only the file it writes to, and the sweep prunes every one of them, taking the target's lock for one @@ -2116,9 +2117,8 @@ compatible services). Each message includes event metadata (HTTP method, content type, timestamp, body size) and the payload pretty-printed in a code block. JSON payloads are automatically formatted with indentation for readability; non-JSON payloads are shown as raw text. Large payloads are truncated to keep messages -reasonable. Config stores `webhookUrl` — the Slack/Mattermost incoming webhook -endpoint. That is the JSON key; the error text for a missing one reads -`webhook_url is required`, which is the message, not the key. +reasonable. The target's `url` is the Slack/Mattermost incoming webhook +endpoint. The database uses the [modernc.org/sqlite](https://pkg.go.dev/modernc.org/sqlite) driver. @@ -2156,7 +2156,7 @@ External Service 3. Capture full request as Event 4. Create Delivery records for each active Target 5. Build self-contained delivery.Task structs - (target config + event data inline for + (target settings + event data inline for bodies < 16 KiB) 6. Notify Engine via channel (no DB read needed) │ @@ -3060,14 +3060,14 @@ webhooker/ │ │ ├── target_slack.go # Slack/Mattermost incoming-webhook target │ │ ├── target_database.go # Database archive target, writing through internal/archive │ │ ├── target_log.go # Log target (stdout) -│ │ ├── target_config_view.go # Masked target config for templates +│ │ ├── target_config_view.go # Masked target settings for templates │ │ ├── queue_depth.go # Periodic sampler behind the queue-depth gauges │ │ ├── url_mask.go # Strips credentials from *url.Error │ │ └── ssrf.go # SSRF prevention (IP validation, safe HTTP transport) │ ├── handlers/ │ │ ├── handlers.go # Base handler struct, JSON helpers, template rendering │ │ ├── auth.go # Login, logout handlers -│ │ ├── delivery_replay.go # Per-delivery replay: new delivery, current target config +│ │ ├── delivery_replay.go # Per-delivery replay: new delivery, current target settings │ │ ├── event_resubmit.go # Event resubmit: new event, all currently active targets │ │ ├── entrypoint_view.go # Masked entrypoint view for templates │ │ ├── event_log_view.go # Event log projection, byte-capped in SQL @@ -3082,7 +3082,7 @@ webhooker/ │ │ ├── webhook_delete.go # Webhook delete, event database and archive writer removal │ │ ├── event_log.go # Event log page: loaders, filters, delivery views │ │ ├── entrypoint.go # Entrypoint create, edit, delete and toggle -│ │ ├── target_create.go # Target create, per-type config builders +│ │ ├── target_create.go # Target create, each type's settings from the form │ │ ├── target_delete.go # Target delete │ │ ├── target_toggle.go # Target toggle │ │ ├── shared.go # Helpers shared by several handlers @@ -3164,7 +3164,7 @@ The server starts via which triggers the fx lifecycle hooks in dependency order. The `delivery.Notifier` interface allows the webhook handler to send self-contained `delivery.Task` slices to the engine without a direct package dependency. Each -task carries all target config and event data inline (for bodies under 16 KiB, +task carries all target settings and event data inline (for bodies under 16 KiB, `delivery.MaxInlineBodySize`), so the engine can deliver without reading from any database — it only writes to record results. diff --git a/internal/archive/archive.go b/internal/archive/archive.go index 7177547..c39db6a 100644 --- a/internal/archive/archive.go +++ b/internal/archive/archive.go @@ -143,26 +143,15 @@ func archiveNamePart(name string) string { } // Write writes the full event of a delivery to a database target as -// a row into the target's archive database, honouring the optional -// per-target expiry and rotation parsed from the target config JSON. -// With rotation, the event goes to the file for the period of its -// receive time. +// a row into the target's archive database, honouring the target's +// ArchiveExpiry and ArchiveRotation. With rotation, the event goes to +// the file for the period of its receive time. func (ws *Writers) Write(d *database.Delivery) error { webhookID := d.Event.WebhookID if webhookID == "" { return errArchiveMissingWebhookID } - expiry, err := ParseExpiry(d.Target.Config) - if err != nil { - return err - } - - rotation, err := ParseRotation(d.Target.Config) - if err != nil { - return err - } - // An event whose stored row was gone before its delivery ran has // no receive time (see delivery's Engine.hydrateEvent), and goes // to the file for now. @@ -187,7 +176,10 @@ func (ws *Writers) Write(d *database.Delivery) error { ContentType: d.Event.ContentType, } - return w.write(row, expiry, archivePeriod(rotation, receivedAt)) + return w.write( + row, d.Target.ArchiveExpiry, + archivePeriod(d.Target.ArchiveRotation, receivedAt), + ) } // Rename implements Archives. It renames every one of a database diff --git a/internal/archive/archive_test.go b/internal/archive/archive_test.go index 0c0031b..7484a88 100644 --- a/internal/archive/archive_test.go +++ b/internal/archive/archive_test.go @@ -123,9 +123,10 @@ func databaseTargetDelivery( TargetID: tgt.ID, Event: event, Target: database.Target{ - Name: tgt.Name, - Type: database.TargetTypeDatabase, - Config: tgt.Config, + Name: tgt.Name, + Type: database.TargetTypeDatabase, + ArchiveExpiry: tgt.ArchiveExpiry, + ArchiveRotation: tgt.ArchiveRotation, }, } } @@ -355,13 +356,16 @@ func TestParseArchiveExpiry(t *testing.T) { want time.Duration wantErr bool }{ - {"empty config", "", 0, false}, - {"explicit never", `{"expiry":"never"}`, 0, false}, - {"empty expiry", `{"expiry":""}`, 0, false}, - {"duration", `{"expiry":"1h"}`, time.Hour, false}, - {"unparseable", `{"expiry":"nonsense"}`, 0, true}, - {"zero duration", `{"expiry":"0s"}`, 0, true}, - {"negative duration", `{"expiry":"-5h"}`, 0, true}, + {"empty", "", 0, false}, + {"explicit never", "never", 0, false}, + {"hour", "1h", time.Hour, false}, + {"thirty days", "720h", 720 * time.Hour, false}, + {"minutes", "30m", 30 * time.Minute, false}, + {"unparseable", "nonsense", 0, true}, + {"days are not a Go unit", "7d", 0, true}, + {"zero duration", "0s", 0, true}, + {"bare zero", "0", 0, true}, + {"negative duration", "-5h", 0, true}, } for _, tc := range cases { @@ -381,26 +385,6 @@ func TestParseArchiveExpiry(t *testing.T) { } } -func TestValidateArchiveExpiry(t *testing.T) { - t.Parallel() - - valid := []string{"", "never", "1h", "720h", "30m"} - for _, in := range valid { - require.NoError(t, - archive.ValidateExpiry(in), - "expiry %q should be accepted", in, - ) - } - - invalid := []string{"nonsense", "7d", "-5h", "0s", "0"} - for _, in := range invalid { - require.Error(t, - archive.ValidateExpiry(in), - "expiry %q should be rejected", in, - ) - } -} - // TestArchiveFileName pins the archive file name and the rules // that make a webhook or target name safe to put in it. func TestArchiveFileName(t *testing.T) { @@ -473,8 +457,8 @@ func TestWrite_EachTargetHasItsOwnArchive(t *testing.T) { t.Parallel() env := setupArchiveTest(t) - first := env.seedDatabaseTarget(t, "") - second := env.addDatabaseTarget(t, first.WebhookID, "") + first := env.seedDatabaseTarget(t, 0, "") + second := env.addDatabaseTarget(t, first.WebhookID, 0, "") event := newEvent(`{"n":1}`) @@ -503,7 +487,7 @@ func TestRename_MovesTheFile(t *testing.T) { t.Parallel() env := setupArchiveTest(t) - tgt := env.seedDatabaseTarget(t, "") + tgt := env.seedDatabaseTarget(t, 0, "") oldPath := env.archivePath(tgt) first := newEvent(`{"n":1}`) @@ -545,7 +529,7 @@ func TestRename_NeverReplacesAFile(t *testing.T) { t.Parallel() env := setupArchiveTest(t) - tgt := env.seedDatabaseTarget(t, "") + tgt := env.seedDatabaseTarget(t, 0, "") oldPath := env.archivePath(tgt) first := newEvent(`{"n":1}`) @@ -593,7 +577,7 @@ func TestRename_BeforeTheNameIsSaved(t *testing.T) { t.Parallel() env := setupArchiveTest(t) - tgt := env.seedDatabaseTarget(t, "") + tgt := env.seedDatabaseTarget(t, 0, "") require.NoError( t, env.writers.Rename(tgt.ID, "Orders", "Archive"), @@ -721,7 +705,7 @@ func TestRenameLock_HeldByOneEditAtATime(t *testing.T) { t.Parallel() env := setupArchiveTest(t) - tgt := env.seedDatabaseTarget(t, "") + tgt := env.seedDatabaseTarget(t, 0, "") event := newEvent(`{"n":1}`) env.writers.RenameLock().Lock() diff --git a/internal/archive/evict_test.go b/internal/archive/evict_test.go index 5e39c85..8eaf1ab 100644 --- a/internal/archive/evict_test.go +++ b/internal/archive/evict_test.go @@ -37,9 +37,9 @@ func TestEvictWebhook_ClosesAndRemovesWriter(t *testing.T) { t.Parallel() env := setupArchiveTest(t) - first := env.seedDatabaseTarget(t, "") - second := env.addDatabaseTarget(t, first.WebhookID, "") - other := env.seedDatabaseTarget(t, "") + first := env.seedDatabaseTarget(t, 0, "") + second := env.addDatabaseTarget(t, first.WebhookID, 0, "") + other := env.seedDatabaseTarget(t, 0, "") for _, tgt := range []*database.Target{first, second, other} { deliverTo(t, env, tgt) @@ -80,8 +80,8 @@ func TestEvictTarget_LeavesOtherTargets(t *testing.T) { t.Parallel() env := setupArchiveTest(t) - doomed := env.seedDatabaseTarget(t, "") - kept := env.addDatabaseTarget(t, doomed.WebhookID, "") + doomed := env.seedDatabaseTarget(t, 0, "") + kept := env.addDatabaseTarget(t, doomed.WebhookID, 0, "") deliverTo(t, env, doomed) deliverTo(t, env, kept) @@ -310,7 +310,7 @@ func TestEvictWebhook_RacingWriteDoesNotReopenHandle( t.Parallel() env := setupArchiveTest(t) - tgt := env.seedDatabaseTarget(t, "") + tgt := env.seedDatabaseTarget(t, 0, "") // Prime the registry so the test can hold the very writer the // eviction is about to detach. @@ -355,7 +355,7 @@ func TestEvictWebhook_LaterDeliveryRecreatesWriter(t *testing.T) { t.Parallel() env := setupArchiveTest(t) - tgt := env.seedDatabaseTarget(t, "") + tgt := env.seedDatabaseTarget(t, 0, "") deliverTo(t, env, tgt) require.True(t, env.writers.ExportHasArchiveWriter(tgt.ID)) @@ -380,7 +380,7 @@ func TestEvictAll_WriteAfterIsRefused(t *testing.T) { t.Parallel() env := setupArchiveTest(t) - tgt := env.seedDatabaseTarget(t, "") + tgt := env.seedDatabaseTarget(t, 0, "") deliverTo(t, env, tgt) diff --git a/internal/archive/rotation.go b/internal/archive/rotation.go index fea54ba..9cb371f 100644 --- a/internal/archive/rotation.go +++ b/internal/archive/rotation.go @@ -1,7 +1,6 @@ package archive import ( - "encoding/json" "errors" "fmt" "os" @@ -54,35 +53,10 @@ func ValidateRotation(rotation string) error { } } -// ParseRotation reads the rotation from a database target's -// config JSON. An empty config or an empty rotation is none. -func ParseRotation(configJSON string) (string, error) { - if configJSON == "" { - return archiveRotationNone, nil - } - - var cfg DatabaseTargetConfig - - err := json.Unmarshal([]byte(configJSON), &cfg) - if err != nil { - return "", fmt.Errorf("parsing database target config: %w", err) - } - - err = ValidateRotation(cfg.Rotation) - if err != nil { - return "", err - } - - if cfg.Rotation == "" { - return archiveRotationNone, nil - } - - return cfg.Rotation, nil -} - // archivePeriod returns the period, in UTC, that a rotation puts an // event received at receivedAt in: "2026-10" for monthly, -// "2026-10-01" for daily, "2026-10-01-19" for hourly, and "" for none. +// "2026-10-01" for daily, "2026-10-01-19" for hourly, and "" for none +// or an empty rotation. func archivePeriod(rotation string, receivedAt time.Time) string { switch rotation { case archiveRotationMonthly: @@ -115,16 +89,11 @@ func PathAt( webhook *database.Webhook, target *database.Target, receivedAt time.Time, -) (string, error) { - rotation, err := ParseRotation(target.Config) - if err != nil { - return "", err - } - +) string { return archivePeriodPath( Path(dbMgr, webhook, target), - archivePeriod(rotation, receivedAt), - ), nil + archivePeriod(target.ArchiveRotation, receivedAt), + ) } // archiveFiles lists the database target's archive files that exist, diff --git a/internal/archive/rotation_test.go b/internal/archive/rotation_test.go index d7f338b..688aa78 100644 --- a/internal/archive/rotation_test.go +++ b/internal/archive/rotation_test.go @@ -14,16 +14,12 @@ import ( "sneak.berlin/go/webhooker/internal/database/databasetest" ) -// The archive rotations, and the configs of a daily target and of one -// whose rotation is not one of the four. +// The archive rotations. const ( rotationNone = "none" rotationMonthly = "monthly" rotationDaily = "daily" rotationHourly = "hourly" - - dailyConfig = `{"rotation":"daily"}` - weeklyConfig = `{"rotation":"weekly"}` ) // The periods the tests archive into most: two days, and an hour of @@ -108,7 +104,7 @@ func TestWrite_RotatesAtEachPeriodBoundary(t *testing.T) { t.Parallel() env := setupArchiveTest(t) - tgt := env.seedDatabaseTarget(t, `{"rotation":"`+tc.rotation+`"}`) + tgt := env.seedDatabaseTarget(t, 0, tc.rotation) path := env.archivePath(tgt) first := env.deliverReceivedAt(t, tgt, tc.before) @@ -127,7 +123,7 @@ func TestWrite_RotatesAtEachPeriodBoundary(t *testing.T) { t.Parallel() env := setupArchiveTest(t) - tgt := env.seedDatabaseTarget(t, `{"rotation":"`+rotationNone+`"}`) + tgt := env.seedDatabaseTarget(t, 0, rotationNone) first := env.deliverReceivedAt(t, tgt, cases[0].before) second := env.deliverReceivedAt(t, tgt, cases[0].after) @@ -144,7 +140,7 @@ func TestWrite_EventWithoutReceiveTime(t *testing.T) { t.Parallel() env := setupArchiveTest(t) - tgt := env.seedDatabaseTarget(t, `{"rotation":"`+rotationMonthly+`"}`) + tgt := env.seedDatabaseTarget(t, 0, rotationMonthly) path := env.archivePath(tgt) before := time.Now().UTC().Format("2006-01") @@ -169,13 +165,13 @@ func TestWrite_RotationChangeKeepsOldFiles(t *testing.T) { t.Parallel() env := setupArchiveTest(t) - tgt := env.seedDatabaseTarget(t, "") + tgt := env.seedDatabaseTarget(t, 0, rotationNone) path := env.archivePath(tgt) at := time.Date(2026, 3, 4, 12, 0, 0, 0, time.UTC) first := env.deliverReceivedAt(t, tgt, at) - tgt.Config = dailyConfig + tgt.ArchiveRotation = rotationDaily second := env.deliverReceivedAt(t, tgt, at) assert.Equal(t, []string{first}, archivedEventIDs(t, path)) @@ -193,9 +189,7 @@ func TestArchiveSweep_PrunesEveryFile(t *testing.T) { t.Parallel() env := setupArchiveTest(t) - tgt := env.seedDatabaseTarget( - t, `{"expiry":"1h","rotation":"`+rotationDaily+`"}`, - ) + tgt := env.seedDatabaseTarget(t, time.Hour, rotationDaily) path := env.archivePath(tgt) emptied := periodPath(path, dayPeriod) kept := periodPath(path, nextDayPeriod) @@ -225,13 +219,13 @@ func TestRename_MovesEveryFile(t *testing.T) { t.Parallel() env := setupArchiveTest(t) - tgt := env.seedDatabaseTarget(t, "") + tgt := env.seedDatabaseTarget(t, 0, rotationNone) oldPath := env.archivePath(tgt) day := time.Date(2026, 3, 4, 12, 0, 0, 0, time.UTC) unrotated := env.deliverReceivedAt(t, tgt, day) - tgt.Config = dailyConfig + tgt.ArchiveRotation = rotationDaily first := env.deliverReceivedAt(t, tgt, day) second := env.deliverReceivedAt(t, tgt, day.Add(24*time.Hour)) @@ -267,7 +261,7 @@ func TestRename_NeverReplacesARotatedFile(t *testing.T) { t.Parallel() env := setupArchiveTest(t) - tgt := env.seedDatabaseTarget(t, dailyConfig) + tgt := env.seedDatabaseTarget(t, 0, rotationDaily) oldPath := env.archivePath(tgt) day := time.Date(2026, 3, 4, 12, 0, 0, 0, time.UTC) @@ -341,34 +335,27 @@ func TestArchivePathAt(t *testing.T) { at := time.Date(2026, 10, 1, 19, 30, 0, 0, time.UTC) cases := map[string]string{ - "": "", - `{"rotation":"` + rotationNone + `"}`: "", - `{"rotation":"` + rotationMonthly + `"}`: "-2026-10", - dailyConfig: "-2026-10-01", - `{"rotation":"` + rotationHourly + `"}`: "-2026-10-01-19", + "": "", + rotationNone: "", + rotationMonthly: "-2026-10", + rotationDaily: "-2026-10-01", + rotationHourly: "-2026-10-01-19", } - for config, period := range cases { + for rotation, period := range cases { target := &database.Target{ - BaseModel: database.BaseModel{ID: "tgt-id"}, - Name: "Archive", - Config: config, + BaseModel: database.BaseModel{ID: "tgt-id"}, + Name: "Archive", + ArchiveRotation: rotation, } - got, err := archive.PathAt(dbMgr, webhook, target, at) - require.NoError(t, err, config) assert.Equal(t, filepath.Join( dataDir, "archive-orders-archive-tgt-id"+period+".db", ), - got, config, + archive.PathAt(dbMgr, webhook, target, at), rotation, ) } - - _, err := archive.PathAt(dbMgr, webhook, &database.Target{ - Config: weeklyConfig, - }, at) - require.Error(t, err) } // TestValidateArchiveRotation accepts the four rotations, and empty, diff --git a/internal/archive/sweeper.go b/internal/archive/sweeper.go index 34338df..f4099c0 100644 --- a/internal/archive/sweeper.go +++ b/internal/archive/sweeper.go @@ -199,22 +199,11 @@ func (s *Sweeper) sweep(ctx context.Context) { } } -// sweepTarget prunes the archive of a single database target. -// A missing, empty, or "never" expiry parses as a zero duration -// and is skipped entirely, so those archives are never pruned. +// sweepTarget prunes the archive of a single database target. A +// target with an ArchiveExpiry of 0 keeps its archive forever and is +// skipped entirely, so that archive is never pruned. func (s *Sweeper) sweepTarget(target *database.Target) { - expiry, err := ParseExpiry(target.Config) - if err != nil { - s.log.Error( - "archive sweep: invalid database target config", - "webhook_id", target.WebhookID, - "target_id", target.ID, - "error", err, - ) - - return - } - + expiry := target.ArchiveExpiry if expiry <= 0 { return } @@ -223,7 +212,7 @@ func (s *Sweeper) sweepTarget(target *database.Target) { return } - err = s.writers.sweepArchive(target.ID, expiry) + err := s.writers.sweepArchive(target.ID, expiry) if err == nil { return } diff --git a/internal/archive/sweeper_test.go b/internal/archive/sweeper_test.go index b9a3d4d..5ef5aaa 100644 --- a/internal/archive/sweeper_test.go +++ b/internal/archive/sweeper_test.go @@ -106,10 +106,10 @@ func (env *archiveEnv) archivePath(tgt *database.Target) string { ) } -// seedDatabaseTarget creates a webhook with one database target -// carrying the given target config JSON, and returns the target. +// seedDatabaseTarget creates a webhook with one database target with +// the given archive expiry and rotation, and returns the target. func (env *archiveEnv) seedDatabaseTarget( - t *testing.T, configJSON string, + t *testing.T, expiry time.Duration, rotation string, ) *database.Target { t.Helper() @@ -124,23 +124,27 @@ func (env *archiveEnv) seedDatabaseTarget( Create(wh).Error, ) - return env.addDatabaseTarget(t, wh.ID, configJSON) + return env.addDatabaseTarget(t, wh.ID, expiry, rotation) } // addDatabaseTarget creates one more database target on an // existing webhook and returns it. func (env *archiveEnv) addDatabaseTarget( - t *testing.T, webhookID, configJSON string, + t *testing.T, + webhookID string, + expiry time.Duration, + rotation string, ) *database.Target { t.Helper() tgt := &database.Target{ - WebhookID: webhookID, - Name: "Archive", - Type: database.TargetTypeDatabase, - Active: true, - Config: configJSON, - MaxAttempts: 1, + WebhookID: webhookID, + Name: "Archive", + Type: database.TargetTypeDatabase, + Active: true, + ArchiveExpiry: expiry, + ArchiveRotation: rotation, + MaxAttempts: 1, } require.NoError( t, @@ -323,7 +327,7 @@ func TestArchiveSweeper_LoopOutlivesStartHookContext( env := setupArchiveTest(t) - tgt := env.seedDatabaseTarget(t, `{"expiry":"1h"}`) + tgt := env.seedDatabaseTarget(t, time.Hour, "") now := time.Now() path := env.seedArchiveRows( @@ -377,7 +381,7 @@ func TestArchiveSweep_DoesNotResurrectEvictedWriter( env := setupArchiveTest(t) - tgt := env.seedDatabaseTarget(t, `{"expiry":"1h"}`) + tgt := env.seedDatabaseTarget(t, time.Hour, "") env.seedArchiveRows( t, tgt, time.Now().Add(-48*time.Hour), ) @@ -411,7 +415,7 @@ func TestArchiveSweep_LeavesNoRegistryEntry(t *testing.T) { env := setupArchiveTest(t) - tgt := env.seedDatabaseTarget(t, `{"expiry":"1h"}`) + tgt := env.seedDatabaseTarget(t, time.Hour, "") path := env.seedArchiveRows( t, tgt, time.Now().Add(-48*time.Hour), @@ -444,7 +448,7 @@ func TestArchiveSweep_KeepsWriterAdoptedByDelivery( env := setupArchiveTest(t) - tgt := env.seedDatabaseTarget(t, `{"expiry":"1h"}`) + tgt := env.seedDatabaseTarget(t, time.Hour, "") env.seedArchiveRows( t, tgt, time.Now().Add(-48*time.Hour), ) @@ -498,7 +502,7 @@ func TestArchiveSweep_KeepsWriterAdoptedDuringSweep( env := setupArchiveTest(t) - tgt := env.seedDatabaseTarget(t, `{"expiry":"1h"}`) + tgt := env.seedDatabaseTarget(t, time.Hour, "") env.seedArchiveRows( t, tgt, time.Now().Add(-48*time.Hour), ) @@ -551,8 +555,8 @@ func TestArchiveSweep_KeepsWriterAdoptedDuringSweep( // TestArchiveSweep_ContinuesAfterPerTargetFailure proves a // failure for one target does not abort the sweep for the -// others: an unparseable expiry and an unreadable archive both -// have to be logged and stepped over. +// others: an unreadable archive has to be logged and stepped +// over. func TestArchiveSweep_ContinuesAfterPerTargetFailure( t *testing.T, ) { @@ -560,21 +564,16 @@ func TestArchiveSweep_ContinuesAfterPerTargetFailure( env := setupArchiveTest(t) - // Seeded first so the sweep reaches them before the healthy + // Seeded first so the sweep reaches it before the healthy // target: targets come back in insertion order. - badConfig := env.seedDatabaseTarget(t, `{"expiry":"!!!"}`) - env.seedArchiveRows( - t, badConfig, time.Now().Add(-48*time.Hour), - ) - - corrupt := env.seedDatabaseTarget(t, `{"expiry":"1h"}`) + corrupt := env.seedDatabaseTarget(t, time.Hour, "") require.NoError(t, os.WriteFile( env.archivePath(corrupt), []byte("this is not a sqlite database"), 0o600, )) - healthy := env.seedDatabaseTarget(t, `{"expiry":"1h"}`) + healthy := env.seedDatabaseTarget(t, time.Hour, "") healthyPath := env.seedArchiveRows( t, healthy, time.Now().Add(-48*time.Hour), @@ -631,7 +630,7 @@ func TestArchiveSweep_PrunesIdleArchive(t *testing.T) { env := setupArchiveTest(t) - tgt := env.seedDatabaseTarget(t, `{"expiry":"1h"}`) + tgt := env.seedDatabaseTarget(t, time.Hour, "") now := time.Now() path := env.seedArchiveRows( @@ -670,7 +669,7 @@ func TestArchiveSweep_LeavesArchiveClosed(t *testing.T) { env := setupArchiveTest(t) - tgt := env.seedDatabaseTarget(t, `{"expiry":"1h"}`) + tgt := env.seedDatabaseTarget(t, time.Hour, "") path := env.seedArchiveRows( t, tgt, time.Now().Add(-48*time.Hour), ) @@ -710,7 +709,7 @@ func TestArchiveSweep_ClosesHandleOfRegisteredWriter( env := setupArchiveTest(t) - tgt := env.seedDatabaseTarget(t, `{"expiry":"1h"}`) + tgt := env.seedDatabaseTarget(t, time.Hour, "") env.seedArchiveRows( t, tgt, time.Now().Add(-48*time.Hour), ) @@ -795,36 +794,29 @@ func TestArchiveSweep_CancelledSweepLogsNoError(t *testing.T) { } // TestArchiveSweep_NeverExpiryUntouched proves the sweep is a -// no-op for the default retention policy, so archives with no -// expiry (or the literal "never") behave exactly as before. +// no-op for the default retention policy, so an archive with an +// expiry of 0, which keeps it forever, is left alone. func TestArchiveSweep_NeverExpiryUntouched(t *testing.T) { t.Parallel() - for _, configJSON := range []string{ - `{"expiry":"never"}`, - `{"expiry":""}`, - "", - } { - env := setupArchiveTest(t) + env := setupArchiveTest(t) - tgt := env.seedDatabaseTarget(t, configJSON) - path := env.seedArchiveRows( - t, tgt, - time.Now().Add(-10000*time.Hour), - ) + tgt := env.seedDatabaseTarget(t, 0, "") + path := env.seedArchiveRows( + t, tgt, + time.Now().Add(-10000*time.Hour), + ) - env.sweeper.ExportSweep(context.Background()) + env.sweeper.ExportSweep(context.Background()) - assert.Equal( - t, []string{sweepRowOld}, archivedEventIDs(t, path), - "config %q must keep rows forever", configJSON, - ) - assert.False( - t, env.writers.ExportHasArchiveWriter(tgt.ID), - "config %q must leave no registry entry behind", - configJSON, - ) - } + assert.Equal( + t, []string{sweepRowOld}, archivedEventIDs(t, path), + "an expiry of 0 must keep rows forever", + ) + assert.False( + t, env.writers.ExportHasArchiveWriter(tgt.ID), + "an expiry of 0 must leave no registry entry behind", + ) } // TestArchiveSweep_NeverExpirySkipsBeforeOpening pins the @@ -845,7 +837,7 @@ func TestArchiveSweep_NeverExpirySkipsBeforeOpening( env := setupArchiveTest(t) - tgt := env.seedDatabaseTarget(t, `{"expiry":"never"}`) + tgt := env.seedDatabaseTarget(t, 0, "") path := env.archivePath(tgt) seedUnmigratedArchive(t, path) @@ -897,7 +889,7 @@ func TestArchiveSweep_DoesNotCreateArchiveFile(t *testing.T) { env := setupArchiveTest(t) - tgt := env.seedDatabaseTarget(t, `{"expiry":"1h"}`) + tgt := env.seedDatabaseTarget(t, time.Hour, "") path := env.archivePath(tgt) require.NoFileExists(t, path) @@ -928,7 +920,7 @@ func TestArchiveSweep_DoesNotCreateAfterWriterExists( env := setupArchiveTest(t) - tgt := env.seedDatabaseTarget(t, `{"expiry":"1h"}`) + tgt := env.seedDatabaseTarget(t, time.Hour, "") path, err := env.writers.ExportEnsureArchiveWriter(tgt.ID) require.NoError(t, err) @@ -947,7 +939,7 @@ func TestArchiveSweep_SkipsDeletedWebhookTargets(t *testing.T) { env := setupArchiveTest(t) - tgt := env.seedDatabaseTarget(t, `{"expiry":"1h"}`) + tgt := env.seedDatabaseTarget(t, time.Hour, "") path := env.seedArchiveRows( t, tgt, time.Now().Add(-48*time.Hour), ) @@ -975,7 +967,7 @@ func TestArchiveSweep_ConcurrentWrites(t *testing.T) { env := setupArchiveTest(t) - tgt := env.seedDatabaseTarget(t, `{"expiry":"1h"}`) + tgt := env.seedDatabaseTarget(t, time.Hour, "") deliveries := make( []*database.Delivery, 0, sweepConcurrentWrites, @@ -1020,7 +1012,7 @@ func TestArchiveSweeper_StopsCleanly(t *testing.T) { env := setupArchiveTest(t) - tgt := env.seedDatabaseTarget(t, `{"expiry":"1h"}`) + tgt := env.seedDatabaseTarget(t, time.Hour, "") env.seedArchiveRows( t, tgt, time.Now().Add(-48*time.Hour), ) diff --git a/internal/archive/writer.go b/internal/archive/writer.go index 624ccae..fa17269 100644 --- a/internal/archive/writer.go +++ b/internal/archive/writer.go @@ -1,7 +1,6 @@ package archive import ( - "encoding/json" "errors" "fmt" "io/fs" @@ -18,8 +17,9 @@ import ( "sneak.berlin/go/webhooker/internal/gormlog" ) -// ExpiryNever is the expiry sentinel (and default) that -// disables pruning so archived rows are kept forever. +// ExpiryNever is the archive expiry, as the target forms take it, +// that disables pruning so archived rows are kept forever. It is +// stored as an ArchiveExpiry of 0. const ExpiryNever = "never" // archiveReopenDebounce bounds how often an archive file is @@ -79,19 +79,6 @@ var ( ) ) -// DatabaseTargetConfig is the optional per-target JSON config -// for a database (archive) target. -type DatabaseTargetConfig struct { - // Expiry is a Go duration (e.g. "720h") after which - // archived rows are pruned, or "never" (the default) to - // keep them forever. - Expiry string `json:"expiry"` - - // Rotation is none (the default), monthly, daily or hourly: see - // archivePeriod. - Rotation string `json:"rotation"` -} - // archivedEvent is one fully captured webhook event stored in a // database target's archive for long-term retention. It is a // self-contained copy — independent of the per-webhook event @@ -112,63 +99,20 @@ type archivedEvent struct { ArchivedAt time.Time `gorm:"index"` } -// ParseExpiry reads the optional expiry from a database -// target's config JSON. An empty config, an empty expiry, or -// the literal "never" all mean keep forever, returned as a zero -// duration. Any other value must parse as a positive Go -// duration; a set-but-invalid value (unparseable, zero, or -// negative) is an error rather than a silent default, matching -// ValidateExpiry at target creation. -func ParseExpiry( - configJSON string, -) (time.Duration, error) { - if configJSON == "" { - return 0, nil - } - - var cfg DatabaseTargetConfig - - err := json.Unmarshal([]byte(configJSON), &cfg) - if err != nil { - return 0, fmt.Errorf( - "parsing database target config: %w", err, - ) - } - - if cfg.Expiry == "" || cfg.Expiry == ExpiryNever { - return 0, nil - } - - dur, err := time.ParseDuration(cfg.Expiry) - if err != nil { - return 0, fmt.Errorf( - "parsing archive expiry %q: %w", cfg.Expiry, err, - ) - } - - if dur <= 0 { - return 0, fmt.Errorf( - "%w: %q", errArchiveExpiryNotPositive, cfg.Expiry, - ) - } - - return dur, nil -} - -// ValidateExpiry checks a user-supplied archive expiry -// for a database target at configuration time. Valid values are -// empty, "never" (both meaning keep forever), or a positive Go -// duration such as "720h". Anything else is an error, so a bad -// expiry is rejected when the target is created rather than -// failing every subsequent delivery. -func ValidateExpiry(expiry string) error { +// ParseExpiry reads a user-supplied archive expiry for a database +// target, as the target forms submit it, into the ArchiveExpiry the +// target stores. Empty and "never" both mean keep forever, which is +// 0; anything else must be a positive Go duration such as "720h". Any +// other value is an error, so a bad expiry is refused at the form +// rather than stored. +func ParseExpiry(expiry string) (time.Duration, error) { if expiry == "" || expiry == ExpiryNever { - return nil + return 0, nil } dur, err := time.ParseDuration(expiry) if err != nil { - return fmt.Errorf( + return 0, fmt.Errorf( "expiry must be %q or a Go duration "+ "such as \"720h\": %w", ExpiryNever, err, @@ -176,12 +120,12 @@ func ValidateExpiry(expiry string) error { } if dur <= 0 { - return fmt.Errorf( + return 0, fmt.Errorf( "%w: %q", errArchiveExpiryNotPositive, expiry, ) } - return nil + return dur, nil } // archiveWriter owns one database target's archive SQLite files. diff --git a/internal/database/event_db_isolation_test.go b/internal/database/event_db_isolation_test.go index 762a415..14ed820 100644 --- a/internal/database/event_db_isolation_test.go +++ b/internal/database/event_db_isolation_test.go @@ -28,7 +28,7 @@ func TestEventDBCreateOmitsAssociations(t *testing.T) { WebhookID: webhookID, Name: "leaky-target", Type: database.TargetTypeSlack, - Config: `{"webhookUrl":"https://hooks.example/secret"}`, + URL: "https://hooks.example/secret", MaxAttempts: 1, } target.ID = uuid.New().String() diff --git a/internal/database/model_secrets_test.go b/internal/database/model_secrets_test.go index 37fb4d0..08237c6 100644 --- a/internal/database/model_secrets_test.go +++ b/internal/database/model_secrets_test.go @@ -29,8 +29,10 @@ func marshalModel(t *testing.T, v any) string { // these tags the first handler that marshals a model serialises the // secret with it. Each field below is a live credential: // -// - Target.Config holds an incoming-webhook URL whose path segments -// are the bearer token. +// - Target.URL is an incoming-webhook URL whose path segments are +// the bearer token. +// - Target.Headers holds header values, routinely authorization +// tokens. // - APIKey.Key is a bearer token outright. // - Setting.Value holds the session encryption key. // - User.Password holds the Argon2 hash, and was already tagged. @@ -44,11 +46,20 @@ func TestModelsDoNotMarshalTheirSecrets(t *testing.T) { model any }{ { - name: "target config", + name: "target url", model: database.Target{ Name: keptField, Type: database.TargetTypeSlack, - Config: `{"webhookUrl":"https://h/s/` + marker + `"}`, + URL: "https://h/s/" + marker, + MaxAttempts: 1, + }, + }, + { + name: "target headers", + model: database.Target{ + Name: keptField, + Type: database.TargetTypeHTTP, + Headers: map[string]string{"Authorization": marker}, MaxAttempts: 1, }, }, @@ -87,10 +98,10 @@ func TestModelsDoNotMarshalTheirSecrets(t *testing.T) { } } -// TestWebhookMarshalsNoTargetConfig covers the nested case: a webhook +// TestWebhookMarshalsNoTargetURL covers the nested case: a webhook // marshalled with its targets preloaded must not carry the credential // through the association either. -func TestWebhookMarshalsNoTargetConfig(t *testing.T) { +func TestWebhookMarshalsNoTargetURL(t *testing.T) { t.Parallel() const marker = "QQNESTEDMARKERQQ" @@ -99,7 +110,7 @@ func TestWebhookMarshalsNoTargetConfig(t *testing.T) { Name: keptField, Targets: []database.Target{{ Name: "slack", - Config: `{"webhookUrl":"https://h/s/` + marker + `"}`, + URL: "https://h/s/" + marker, MaxAttempts: 1, }}, }) diff --git a/internal/database/model_target.go b/internal/database/model_target.go index c38dad5..ff8136e 100644 --- a/internal/database/model_target.go +++ b/internal/database/model_target.go @@ -1,5 +1,7 @@ package database +import "time" + // TargetType represents the type of delivery target type TargetType string @@ -11,7 +13,9 @@ const ( TargetTypeSlack TargetType = "slack" ) -// Target represents a delivery target for a webhook +// Target represents a delivery target for a webhook. Its settings +// are the fields from URL to ArchiveRotation; each type uses some of +// them, and the ones a type does not use stay empty. type Target struct { BaseModel @@ -20,14 +24,35 @@ type Target struct { Type TargetType `gorm:"not null" json:"type"` Active bool `gorm:"default:true" json:"active"` - // Configuration fields (JSON stored based on type). + // URL is where an http or slack target sends each event. // - // json:"-" because the blob holds the target's credential — a - // Slack incoming-webhook URL, or an http destination whose path - // segments are the secret. delivery.TargetView is the masking - // barrier for the HTML path; this tag is the barrier for any - // handler that marshals the model itself. - Config string `gorm:"type:text" json:"-"` // JSON configuration + // json:"-" because the URL is the target's credential: a Slack + // incoming-webhook URL, or an http destination whose path segments + // are the secret. delivery.TargetView is the masking barrier for + // the HTML path; this tag is the barrier for any handler that + // marshals the model itself. + URL string `json:"-"` + + // Headers is an http target's extra request headers, name to + // value, stored as JSON text. json:"-" for the same reason as URL: + // a header value is routinely an authorization token. + Headers map[string]string `gorm:"serializer:json" json:"-"` + + // TimeoutSeconds is an http target's per-request timeout; 0 uses + // the delivery engine's shared client timeout. + TimeoutSeconds int `json:"timeoutSeconds,omitempty"` + + // ForwardQuery passes each event's query string on to an http + // target, appended to URL. Off, URL is sent exactly as stored. + ForwardQuery bool `json:"forwardQuery,omitempty"` + + // ArchiveExpiry is how long a database target keeps each archived + // event; 0 keeps them forever. + ArchiveExpiry time.Duration `json:"archiveExpiry,omitempty"` + + // ArchiveRotation is how often a database target starts a new + // archive file: none, monthly, daily or hourly. + ArchiveRotation string `json:"archiveRotation,omitempty"` // MaxAttempts is how many times an HTTP or Slack target attempts // each delivery in all, at least 1. 1 is a single attempt with no diff --git a/internal/database/model_target_test.go b/internal/database/model_target_test.go index 76fcd7c..00ebdc8 100644 --- a/internal/database/model_target_test.go +++ b/internal/database/model_target_test.go @@ -16,7 +16,7 @@ func newHTTPTarget(maxAttempts int) *database.Target { WebhookID: uuid.New().String(), Name: "target", Type: database.TargetTypeHTTP, - Config: `{"url":"https://example.com/"}`, + URL: "https://example.com/", MaxAttempts: maxAttempts, } } @@ -42,3 +42,22 @@ func TestTargetCreate_AcceptsOneAttempt(t *testing.T) { require.NoError(t, db.Create(newHTTPTarget(1)).Error) } + +// TestTargetHeaders_StoredAsJSON reads the headers column back as +// text, to show it holds the header map as JSON. +func TestTargetHeaders_StoredAsJSON(t *testing.T) { + t.Parallel() + + db := startedTestDB(t) + + target := newHTTPTarget(1) + target.Headers = map[string]string{"Authorization": "Bearer x"} + require.NoError(t, db.Create(target).Error) + + var headers string + + require.NoError(t, db.Raw( + "SELECT headers FROM targets WHERE id = ?", target.ID, + ).Scan(&headers).Error) + assert.JSONEq(t, `{"Authorization":"Bearer x"}`, headers) +} diff --git a/internal/database/schema_test.go b/internal/database/schema_test.go index 10e2d75..9de3287 100644 --- a/internal/database/schema_test.go +++ b/internal/database/schema_test.go @@ -87,13 +87,18 @@ func TestMainSchemaHoldsEveryModel(t *testing.T) { Active: true, }) assertRoundTrip(t, db, &database.Target{ - BaseModel: newBaseModel(), - WebhookID: uuid.New().String(), - Name: "target", - Type: database.TargetTypeHTTP, - Active: true, - Config: `{"url":"https://example.com/"}`, - MaxAttempts: 3, + BaseModel: newBaseModel(), + WebhookID: uuid.New().String(), + Name: "target", + Type: database.TargetTypeHTTP, + Active: true, + URL: "https://example.com/", + Headers: map[string]string{"X-Api-Key": "key"}, + TimeoutSeconds: 30, + ForwardQuery: true, + ArchiveExpiry: 720 * time.Hour, + ArchiveRotation: "daily", + MaxAttempts: 3, }) } diff --git a/internal/db/migrations/main/001_schema.sql b/internal/db/migrations/main/001_schema.sql index a03edc8..2dc041e 100644 --- a/internal/db/migrations/main/001_schema.sql +++ b/internal/db/migrations/main/001_schema.sql @@ -79,7 +79,12 @@ CREATE TABLE targets ( name text NOT NULL, type text NOT NULL, active numeric DEFAULT true, - config text, + url text, + headers text, + timeout_seconds integer, + forward_query numeric, + archive_expiry integer, + archive_rotation text, max_attempts integer NOT NULL, PRIMARY KEY (id), CONSTRAINT fk_webhooks_targets diff --git a/internal/delivery/client_ssrf_test.go b/internal/delivery/client_ssrf_test.go index 0bea976..0666d1e 100644 --- a/internal/delivery/client_ssrf_test.go +++ b/internal/delivery/client_ssrf_test.go @@ -46,12 +46,7 @@ func TestClientForRequest_TimeoutKeepsSSRFGuard(t *testing.T) { t.Run(target, func(t *testing.T) { t.Parallel() - cfg := &delivery.HTTPTargetConfig{ - URL: target, - Timeout: 5, - } - - client := engine.ExportClientForRequest(cfg, nil) + client := engine.ExportClientForRequest(5, nil) require.NotSame(t, engine.ExportClient(), client, "a per-target timeout must yield a "+ @@ -101,11 +96,7 @@ func TestClientForRequest_NoTimeoutUnchanged(t *testing.T) { engine := newSSRFTestEngine() - cfg := &delivery.HTTPTargetConfig{ - URL: "https://example.com/hook", - } - - client := engine.ExportClientForRequest(cfg, nil) + client := engine.ExportClientForRequest(0, nil) assert.Same(t, engine.ExportClient(), client, "without a per-target timeout the shared client "+ @@ -123,15 +114,8 @@ func TestClientForRequest_HeadersKeepSSRFGuard(t *testing.T) { engine := newSSRFTestEngine() - cfg := &delivery.HTTPTargetConfig{ - URL: "https://example.com/with-headers", - Headers: map[string]string{ - "X-Api-Key": "configured", - }, - } - client := engine.ExportClientForRequest( - cfg, []string{"X-Api-Key"}, + 0, []string{probeHeaderName}, ) require.NotNil(t, client.CheckRedirect, diff --git a/internal/delivery/engine.go b/internal/delivery/engine.go index 5e1c164..967f2b3 100644 --- a/internal/delivery/engine.go +++ b/internal/delivery/engine.go @@ -105,12 +105,19 @@ type Task struct { WebhookID string EntrypointID string - TargetID string - TargetName string - TargetType database.TargetType - TargetConfig string - MaxAttempts int + // The target's fields, as they were when the task was made. + TargetID string + TargetName string + TargetType database.TargetType + TargetURL string + TargetHeaders map[string]string + TimeoutSeconds int + ForwardQuery bool + ArchiveExpiry time.Duration + ArchiveRotation string + MaxAttempts int + // The event's fields. Method string RawQuery string Headers string @@ -1543,10 +1550,15 @@ func buildEventFromTask(task *Task) database.Event { func buildTargetFromTask(task *Task) database.Target { target := database.Target{ - Name: task.TargetName, - Type: task.TargetType, - Config: task.TargetConfig, - MaxAttempts: task.MaxAttempts, + Name: task.TargetName, + Type: task.TargetType, + URL: task.TargetURL, + Headers: task.TargetHeaders, + TimeoutSeconds: task.TimeoutSeconds, + ForwardQuery: task.ForwardQuery, + ArchiveExpiry: task.ArchiveExpiry, + ArchiveRotation: task.ArchiveRotation, + MaxAttempts: task.MaxAttempts, } target.ID = task.TargetID @@ -1825,21 +1837,26 @@ func buildRecoveryTask( } return Task{ - DeliveryID: d.ID, - EventID: d.EventID, - WebhookID: webhookID, - EntrypointID: event.EntrypointID, - TargetID: target.ID, - TargetName: target.Name, - TargetType: target.Type, - TargetConfig: target.Config, - MaxAttempts: target.MaxAttempts, - Method: event.Method, - RawQuery: event.RawQuery, - Headers: event.Headers, - ContentType: event.ContentType, - Body: bodyPtr, - AttemptNum: attemptNum, + DeliveryID: d.ID, + EventID: d.EventID, + WebhookID: webhookID, + EntrypointID: event.EntrypointID, + TargetID: target.ID, + TargetName: target.Name, + TargetType: target.Type, + TargetURL: target.URL, + TargetHeaders: target.Headers, + TimeoutSeconds: target.TimeoutSeconds, + ForwardQuery: target.ForwardQuery, + ArchiveExpiry: target.ArchiveExpiry, + ArchiveRotation: target.ArchiveRotation, + MaxAttempts: target.MaxAttempts, + Method: event.Method, + RawQuery: event.RawQuery, + Headers: event.Headers, + ContentType: event.ContentType, + Body: bodyPtr, + AttemptNum: attemptNum, } } diff --git a/internal/delivery/engine_integration_test.go b/internal/delivery/engine_integration_test.go index ded9c3f..d226d99 100644 --- a/internal/delivery/engine_integration_test.go +++ b/internal/delivery/engine_integration_test.go @@ -3,7 +3,6 @@ package delivery_test import ( "bytes" "context" - "encoding/json" "fmt" "io" "log/slog" @@ -97,17 +96,6 @@ func iSeedWebhookDB( return db } -func iHTTPConfig(url string) string { - cfg := delivery.HTTPTargetConfig{URL: url} - - data, err := json.Marshal(cfg) - if err != nil { - panic("failed to marshal HTTPTargetConfig") - } - - return string(data) -} - func iEngine( t *testing.T, workers int, ) *delivery.Engine { @@ -169,24 +157,24 @@ func iSeedDelivery( func iTask( d database.Delivery, event database.Event, - webhookID, targetID, name, config string, + webhookID, targetID, name, targetURL string, maxAttempts, attemptNum int, body *string, ) delivery.Task { return delivery.Task{ - DeliveryID: d.ID, - EventID: event.ID, - WebhookID: webhookID, - TargetID: targetID, - TargetName: name, - TargetType: database.TargetTypeHTTP, - TargetConfig: config, - MaxAttempts: maxAttempts, - Method: event.Method, - Headers: event.Headers, - ContentType: event.ContentType, - Body: body, - AttemptNum: attemptNum, + DeliveryID: d.ID, + EventID: event.ID, + WebhookID: webhookID, + TargetID: targetID, + TargetName: name, + TargetType: database.TargetTypeHTTP, + TargetURL: targetURL, + MaxAttempts: maxAttempts, + Method: event.Method, + Headers: event.Headers, + ContentType: event.ContentType, + Body: body, + AttemptNum: attemptNum, } } @@ -239,10 +227,9 @@ func TestProcessNewTask_InlineBody(t *testing.T) { ) bodyStr := event.Body - cfg := iHTTPConfig(ts.URL) task := iTask( d, event, s.WebhookID, targetID, - "test-target", cfg, 1, 1, &bodyStr, + "test-target", ts.URL, 1, 1, &bodyStr, ) s.Engine.ExportProcessNewTask( @@ -290,10 +277,9 @@ func TestProcessNewTask_LargeBody_FetchFromDB( database.DeliveryStatusPending, ) - cfg := iHTTPConfig(ts.URL) task := iTask( d, event, s.WebhookID, targetID, - "test-large", cfg, 1, 1, nil, + "test-large", ts.URL, 1, 1, nil, ) s.Engine.ExportProcessNewTask( @@ -313,16 +299,16 @@ func TestProcessNewTask_InvalidWebhookID(t *testing.T) { s := newISetup(t) task := delivery.Task{ - DeliveryID: uuid.New().String(), - EventID: uuid.New().String(), - WebhookID: uuid.New().String(), - TargetID: uuid.New().String(), - TargetName: "test", - TargetType: database.TargetTypeHTTP, - TargetConfig: iHTTPConfig("http://localhost:9999"), - MaxAttempts: 1, - Body: nil, - AttemptNum: 1, + DeliveryID: uuid.New().String(), + EventID: uuid.New().String(), + WebhookID: uuid.New().String(), + TargetID: uuid.New().String(), + TargetName: "test", + TargetType: database.TargetTypeHTTP, + TargetURL: "http://localhost:9999", + MaxAttempts: 1, + Body: nil, + AttemptNum: 1, } s.Engine.ExportProcessNewTask( @@ -363,7 +349,6 @@ func TestProcessRetryTask_SuccessfulRetry(t *testing.T) { ) bodyStr := event.Body - cfg := iHTTPConfig(ts.URL) // The target row exists because the engine confirms a scheduled // retry's target has not been deleted before it runs it. A retry @@ -372,12 +357,12 @@ func TestProcessRetryTask_SuccessfulRetry(t *testing.T) { // task. See https://git.eeqj.de/sneak/webhooker/issues/107. iCreateTarget( t, s.MainDB, targetID, s.WebhookID, "retry-target", - database.TargetTypeHTTP, cfg, 5, + database.TargetTypeHTTP, ts.URL, 5, ) task := iTask( d, event, s.WebhookID, targetID, - "retry-target", cfg, 5, 2, &bodyStr, + "retry-target", ts.URL, 5, 2, &bodyStr, ) s.Engine.ExportProcessRetryTask( @@ -410,10 +395,9 @@ func TestProcessRetryTask_SkipsNonRetryingDelivery( ) bodyStr := event.Body - cfg := iHTTPConfig("http://localhost:1") task := iTask( d, event, s.WebhookID, targetID, - "skip-target", cfg, 5, 2, &bodyStr, + "skip-target", "http://localhost:1", 5, 2, &bodyStr, ) s.Engine.ExportProcessRetryTask( @@ -460,16 +444,14 @@ func TestProcessRetryTask_LargeBody_FetchFromDB( database.DeliveryStatusRetrying, ) - cfg := iHTTPConfig(ts.URL) - iCreateTarget( t, s.MainDB, targetID, s.WebhookID, "retry-large", - database.TargetTypeHTTP, cfg, 5, + database.TargetTypeHTTP, ts.URL, 5, ) task := iTask( d, event, s.WebhookID, targetID, - "retry-large", cfg, 5, 2, nil, + "retry-large", ts.URL, 5, 2, nil, ) s.Engine.ExportProcessRetryTask( @@ -570,16 +552,15 @@ func TestWorkerLifecycle_ProcessesRetryChannel( s.Engine.ExportStart() bodyStr := event.Body - cfg := iHTTPConfig(ts.URL) iCreateTarget( t, s.MainDB, targetID, s.WebhookID, "retry-chan-test", - database.TargetTypeHTTP, cfg, 5, + database.TargetTypeHTTP, ts.URL, 5, ) task := iTask( d, event, s.WebhookID, targetID, - "retry-chan-test", cfg, 5, 2, &bodyStr, + "retry-chan-test", ts.URL, 5, 2, &bodyStr, ) s.Engine.ExportRetryCh() <- task @@ -685,7 +666,7 @@ func iCreateTarget( mainDB *gorm.DB, targetID, webhookID, name string, targetType database.TargetType, - config string, + targetURL string, maxAttempts int, ) { t.Helper() @@ -695,7 +676,7 @@ func iCreateTarget( Name: name, Type: targetType, Active: true, - Config: config, + URL: targetURL, MaxAttempts: maxAttempts, } target.ID = targetID @@ -736,7 +717,7 @@ func TestRecoverWebhookDeliveries_RetryingDeliveries( iCreateTarget(t, s.MainDB, targetID, s.WebhookID, "retry-recovery", database.TargetTypeHTTP, - iHTTPConfig("http://example.com/hook"), 5, + "http://example.com/hook", 5, ) event := iSeedEvent( @@ -796,7 +777,7 @@ func iSeedRetryingWithType( iCreateTarget(t, s.MainDB, targetID, s.WebhookID, "mutated-target", targetType, - iHTTPConfig("http://example.com/hook"), 5, + "http://example.com/hook", 5, ) event := iSeedEvent( @@ -934,14 +915,14 @@ func TestFailUnretryableRetry_WritesNoTargetRow( targetID := uuid.New().String() - // A Slack incoming-webhook URL: the target config IS the + // A Slack incoming-webhook URL: the target's URL IS the // credential, which is what makes a leaked target row a // disclosure rather than a curiosity. hookURL := "https://hooks.slack.com/services/T00/B00/x" iCreateTarget(t, s.MainDB, targetID, s.WebhookID, "credential-bearing", - database.TargetTypeLog, iHTTPConfig(hookURL), 5, + database.TargetTypeLog, hookURL, 5, ) event := iSeedEvent( @@ -1186,7 +1167,7 @@ func TestRecoverDeliveries_SkipsAWebhookDeletedAfterTheListIsRead( assert.False(t, dbMgr.DBExists(webhookID)) } -// --- HTTP Config with custom headers --- +// --- HTTP target with custom headers --- func TestDeliverHTTP_CustomTargetHeaders(t *testing.T) { t.Parallel() @@ -1208,16 +1189,6 @@ func TestDeliverHTTP_CustomTargetHeaders(t *testing.T) { ) defer ts.Close() - cfg := delivery.HTTPTargetConfig{ - URL: ts.URL, - Headers: map[string]string{ - "Authorization": "Bearer secret-token", - }, - } - - cfgJSON, err := json.Marshal(cfg) - require.NoError(t, err) - event := iSeedEvent( t, s.WebhookDB, s.WebhookID, `{"auth":"test"}`, @@ -1232,9 +1203,12 @@ func TestDeliverHTTP_CustomTargetHeaders(t *testing.T) { bodyStr := event.Body task := iTask( d, event, s.WebhookID, targetID, - "auth-target", string(cfgJSON), + "auth-target", ts.URL, 1, 1, &bodyStr, ) + task.TargetHeaders = map[string]string{ + headerAuthorization: "Bearer secret-token", + } s.Engine.ExportProcessNewTask( context.TODO(), &task, @@ -1247,11 +1221,14 @@ func TestDeliverHTTP_CustomTargetHeaders(t *testing.T) { // --- HTTP delivery with custom timeout --- +// TestDeliverHTTP_TargetTimeout proves a task's TimeoutSeconds reaches +// the delivery through the engine. The destination answers after two +// seconds, inside the engine's own five-second client timeout, so only +// the task's one second can fail the delivery. func TestDeliverHTTP_TargetTimeout(t *testing.T) { t.Parallel() - db := testWebhookDB(t) - e := iEngine(t, 1) + s := newISetup(t) ts := httptest.NewServer( http.HandlerFunc( @@ -1263,31 +1240,30 @@ func TestDeliverHTTP_TargetTimeout(t *testing.T) { ) defer ts.Close() - cfg := delivery.HTTPTargetConfig{ - URL: ts.URL, - Timeout: 1, - } + event := iSeedEvent( + t, s.WebhookDB, s.WebhookID, `{"timeout":"test"}`, + ) + targetID := uuid.New().String() - cfgJSON, err := json.Marshal(cfg) - require.NoError(t, err) - - event, del := iSeedEventAndDelivery( - t, db, `{"timeout":"test"}`, - string(cfgJSON), + d := iSeedDelivery( + t, s.WebhookDB, event.ID, targetID, + database.DeliveryStatusPending, ) - task, d := iHTTPTaskAndDelivery( - event, del, "timeout-target", - string(cfgJSON), 1, 1, + bodyStr := event.Body + task := iTask( + d, event, s.WebhookID, targetID, + "timeout-target", ts.URL, 1, 1, &bodyStr, ) + task.TimeoutSeconds = 1 - e.ExportDeliverHTTP(context.TODO(), db, d, task) + s.Engine.ExportProcessNewTask(context.TODO(), &task) - iAssertStatus(t, db, del.ID, + iAssertStatus(t, s.WebhookDB, d.ID, database.DeliveryStatusFailed, ) - iAssertResultFailed(t, db, del.ID) + iAssertResultFailed(t, s.WebhookDB, d.ID) } // TestDeliverHTTP_CutsStoredResponseAtMaxBodyLog pins the size @@ -1325,8 +1301,6 @@ func TestDeliverHTTP_CutsStoredResponseAtMaxBodyLog( )) defer ts.Close() - cfgJSON := iHTTPConfig(ts.URL) - event := iSeedEvent( t, s.WebhookDB, s.WebhookID, `{"cut":"test"}`, ) @@ -1340,7 +1314,7 @@ func TestDeliverHTTP_CutsStoredResponseAtMaxBodyLog( bodyStr := event.Body task := iTask( del, event, s.WebhookID, targetID, - "cut-target", cfgJSON, 1, 1, &bodyStr, + "cut-target", ts.URL, 1, 1, &bodyStr, ) s.Engine.ExportProcessNewTask(context.TODO(), &task) @@ -1372,7 +1346,7 @@ func TestDeliverHTTP_CutsStoredResponseAtMaxBodyLog( func iSeedEventAndDelivery( t *testing.T, db *gorm.DB, - body, _ string, + body string, ) (database.Event, database.Delivery) { t.Helper() @@ -1403,19 +1377,19 @@ func iSeedEventAndDelivery( func iHTTPTaskAndDelivery( event database.Event, del database.Delivery, - name, config string, + name, targetURL string, maxAttempts, attemptNum int, ) (*delivery.Task, *database.Delivery) { task := &delivery.Task{ - DeliveryID: del.ID, - EventID: event.ID, - WebhookID: event.WebhookID, - TargetID: del.TargetID, - TargetName: name, - TargetType: database.TargetTypeHTTP, - TargetConfig: config, - MaxAttempts: maxAttempts, - AttemptNum: attemptNum, + DeliveryID: del.ID, + EventID: event.ID, + WebhookID: event.WebhookID, + TargetID: del.TargetID, + TargetName: name, + TargetType: database.TargetTypeHTTP, + TargetURL: targetURL, + MaxAttempts: maxAttempts, + AttemptNum: attemptNum, } d := &database.Delivery{ @@ -1426,7 +1400,7 @@ func iHTTPTaskAndDelivery( Target: database.Target{ Name: name, Type: database.TargetTypeHTTP, - Config: config, + URL: targetURL, MaxAttempts: maxAttempts, }, } @@ -1455,53 +1429,27 @@ func iAssertResultFailed( assert.NotEmpty(t, result.Error) } -// --- HTTP request with invalid config --- +// --- HTTP request with no URL --- -func TestDeliverHTTP_InvalidConfig(t *testing.T) { +// TestDeliverHTTP_NoURL proves an http target without a URL, which +// the forms never store, fails its delivery and records why. +func TestDeliverHTTP_NoURL(t *testing.T) { t.Parallel() db := testWebhookDB(t) e := iEngine(t, 1) - event, del := iSeedEventAndDelivery( - t, db, `{"config":"invalid"}`, "", - ) + event, del := iSeedEventAndDelivery(t, db, `{"url":"none"}`) - task, d := iHTTPTaskAndDelivery( - event, del, "bad-config", `not-json`, 1, 1, - ) + task, d := iHTTPTaskAndDelivery(event, del, "no-url", "", 1, 1) e.ExportDeliverHTTP(context.TODO(), db, d, task) iAssertStatus(t, db, del.ID, database.DeliveryStatusFailed, ) -} -// TestDeliverHTTP_InvalidConfigUnrecordedStaysPending: a delivery is -// failed for an invalid config only once the reason is recorded. -// Unrecorded, it stays pending, where the sweep finds it again. -func TestDeliverHTTP_InvalidConfigUnrecordedStaysPending(t *testing.T) { - t.Parallel() - - db := testWebhookDB(t) - e := testEngine(t, 1) - - event, del := iSeedEventAndDelivery( - t, db, `{"config":"invalid"}`, "", - ) - - task, d := iHTTPTaskAndDelivery( - event, del, "bad-config", `not-json`, 1, 1, - ) - - require.NoError(t, db.Exec("drop table delivery_results").Error) - - e.ExportDeliverHTTP(context.TODO(), db, d, task) - - iAssertStatus(t, db, del.ID, - database.DeliveryStatusPending, - ) + iAssertResultFailed(t, db, del.ID) } // --- Notify batching --- diff --git a/internal/delivery/engine_lifecycle_test.go b/internal/delivery/engine_lifecycle_test.go index 02a6aa7..0bc2459 100644 --- a/internal/delivery/engine_lifecycle_test.go +++ b/internal/delivery/engine_lifecycle_test.go @@ -309,13 +309,12 @@ func seedHeldDelivery( )) t.Cleanup(ts.Close) - cfg := iHTTPConfig(ts.URL) targetID := uuid.New().String() iCreateWebhook(t, s.MainDB, s.WebhookID, "hook") iCreateTarget( t, s.MainDB, targetID, s.WebhookID, "held", - database.TargetTypeHTTP, cfg, maxAttempts, + database.TargetTypeHTTP, ts.URL, maxAttempts, ) event := iSeedEvent(t, s.WebhookDB, s.WebhookID, `{"held":true}`) @@ -324,7 +323,7 @@ func seedHeldDelivery( bodyStr := event.Body return iTask( - d, event, s.WebhookID, targetID, "held", cfg, + d, event, s.WebhookID, targetID, "held", ts.URL, maxAttempts, attemptNum, &bodyStr, ), arrived } diff --git a/internal/delivery/engine_test.go b/internal/delivery/engine_test.go index 52a3d44..1caf3ca 100644 --- a/internal/delivery/engine_test.go +++ b/internal/delivery/engine_test.go @@ -65,20 +65,6 @@ func testEngine( ) } -func newHTTPTargetConfig(url string) string { - cfg := delivery.HTTPTargetConfig{URL: url} - - data, err := json.Marshal(cfg) - if err != nil { - panic( - "failed to marshal HTTPTargetConfig: " + - err.Error(), - ) - } - - return string(data) -} - func seedEvent( t *testing.T, db *gorm.DB, body string, ) database.Event { @@ -141,19 +127,19 @@ type httpDeliveryFixture struct { func buildHTTPFixture( dlv database.Delivery, event database.Event, - targetID, name, config string, + targetID, name, targetURL string, maxAttempts, attemptNum int, ) httpDeliveryFixture { task := &delivery.Task{ - DeliveryID: dlv.ID, - EventID: event.ID, - WebhookID: event.WebhookID, - TargetID: targetID, - TargetName: name, - TargetType: database.TargetTypeHTTP, - TargetConfig: config, - MaxAttempts: maxAttempts, - AttemptNum: attemptNum, + DeliveryID: dlv.ID, + EventID: event.ID, + WebhookID: event.WebhookID, + TargetID: targetID, + TargetName: name, + TargetType: database.TargetTypeHTTP, + TargetURL: targetURL, + MaxAttempts: maxAttempts, + AttemptNum: attemptNum, } d := &database.Delivery{ @@ -164,7 +150,7 @@ func buildHTTPFixture( Target: database.Target{ Name: name, Type: database.TargetTypeHTTP, - Config: config, + URL: targetURL, MaxAttempts: maxAttempts, }, } @@ -275,9 +261,8 @@ func TestDeliverHTTP_Success(t *testing.T) { database.DeliveryStatusPending, ) - cfg := newHTTPTargetConfig(ts.URL) fix := buildHTTPFixture( - dlv, event, targetID, "test-http", cfg, 1, 1, + dlv, event, targetID, "test-http", ts.URL, 1, 1, ) e.ExportDeliverHTTP( @@ -321,10 +306,9 @@ func TestDeliverHTTP_Failure(t *testing.T) { database.DeliveryStatusPending, ) - cfg := newHTTPTargetConfig(ts.URL) fix := buildHTTPFixture( dlv, event, targetID, - "test-http-fail", cfg, 1, 1, + "test-http-fail", ts.URL, 1, 1, ) e.ExportDeliverHTTP( @@ -351,7 +335,7 @@ func TestDeliverDatabase_ImmediateSuccess( // The database target archives for real, so the engine needs // the target in the main database and a data directory. env := setupArchiveTest(t) - tgt := env.seedDatabaseTarget(t, "") + tgt := env.seedDatabaseTarget(t) event := seedEvent(t, db, `{"db":"target"}`) d := seedDatabaseTargetDelivery(t, db, event, tgt) @@ -452,10 +436,9 @@ func TestDeliverHTTP_WithRetries_Success(t *testing.T) { database.DeliveryStatusPending, ) - cfg := newHTTPTargetConfig(ts.URL) fix := buildHTTPFixture( dlv, event, targetID, - "test-http-retry", cfg, 5, 1, + "test-http-retry", ts.URL, 5, 1, ) e.ExportDeliverHTTP( @@ -510,13 +493,12 @@ func TestDeliverHTTP_MaxAttemptsExhausted(t *testing.T) { database.DeliveryStatusPending, ) - cfg := newHTTPTargetConfig(ts.URL) sched := &recordingScheduler{} for attempt := 1; attempt <= maxAttempts; attempt++ { fix := buildHTTPFixture( dlv, event, targetID, - "test-http-exhaust", cfg, maxAttempts, attempt, + "test-http-exhaust", ts.URL, maxAttempts, attempt, ) e.ExportDeliverHTTPWithScheduler( @@ -574,10 +556,9 @@ func TestDeliverHTTP_SingleAttemptSkipsCircuitBreaker(t *testing.T) { database.DeliveryStatusPending, ) - cfg := newHTTPTargetConfig(ts.URL) fix := buildHTTPFixture( dlv, event, targetID, - "test-http-single", cfg, 1, 1, + "test-http-single", ts.URL, 1, 1, ) sched := &recordingScheduler{} @@ -624,10 +605,9 @@ func TestDeliverHTTP_SchedulesRetryOnFailure( database.DeliveryStatusPending, ) - cfg := newHTTPTargetConfig(ts.URL) fix := buildHTTPFixture( dlv, event, targetID, - "test-http-schedule", cfg, 5, 1, + "test-http-schedule", ts.URL, 5, 1, ) e.ExportDeliverHTTP( @@ -789,7 +769,7 @@ func seedConcurrencyTasks( t *testing.T, db *gorm.DB, n int, - targetCfg string, + targetURL string, ) ([]database.Delivery, []delivery.Task) { t.Helper() @@ -815,21 +795,21 @@ func seedConcurrencyTasks( Target: database.Target{ Name: fmt.Sprintf("task-%d", i), Type: database.TargetTypeHTTP, - Config: targetCfg, + URL: targetURL, MaxAttempts: 1, }, } tasks[i].ID = dlv.ID dtasks[i] = delivery.Task{ - DeliveryID: tasks[i].ID, - EventID: tasks[i].EventID, - TargetID: tasks[i].TargetID, - TargetName: tasks[i].Target.Name, - TargetType: tasks[i].Target.Type, - TargetConfig: tasks[i].Target.Config, - MaxAttempts: 1, - AttemptNum: 1, + DeliveryID: tasks[i].ID, + EventID: tasks[i].EventID, + TargetID: tasks[i].TargetID, + TargetName: tasks[i].Target.Name, + TargetType: tasks[i].Target.Type, + TargetURL: tasks[i].Target.URL, + MaxAttempts: 1, + AttemptNum: 1, } } @@ -865,9 +845,8 @@ func TestWorkerPool_BoundedConcurrency(t *testing.T) { defer ts.Close() e := testEngine(t, numWorkers) - cfg := newHTTPTargetConfig(ts.URL) tasks, dtasks := seedConcurrencyTasks( - t, db, numTasks, cfg, + t, db, numTasks, ts.URL, ) runConcurrencyWorkers( @@ -948,12 +927,9 @@ func TestDeliverHTTP_CircuitBreakerBlocks(t *testing.T) { database.DeliveryStatusPending, ) - cfg := newHTTPTargetConfig( - "http://will-not-be-called.invalid", - ) fix := buildHTTPFixture( dlv, event, targetID, - "test-cb-block", cfg, 5, 1, + "test-cb-block", "http://will-not-be-called.invalid", 5, 1, ) e.ExportDeliverHTTP( @@ -1016,9 +992,8 @@ func TestDeliverHTTP_HalfOpenBreakerDelaysQueuedTasks(t *testing.T) { require.True(t, cb.Allow(), "the probe delivery should go through") require.Equal(t, delivery.CircuitHalfOpen, cb.State()) - cfg := newHTTPTargetConfig( - "http://will-not-be-called.invalid", - ) + const targetURL = "http://will-not-be-called.invalid" + sched := &recordingScheduler{} const queued, passes = 3, 4 @@ -1040,7 +1015,7 @@ func TestDeliverHTTP_HalfOpenBreakerDelaysQueuedTasks(t *testing.T) { fix := buildHTTPFixture( row, event, targetID, - "test-cb-half-open", cfg, 5, 1, + "test-cb-half-open", targetURL, 5, 1, ) e.ExportDeliverHTTPWithScheduler( @@ -1155,62 +1130,6 @@ func TestStateAndCooldown_ReadsHTTPAndSlackBreakers(t *testing.T) { assert.Zero(t, cooldown, "half-open") } -func TestParseHTTPConfig_Valid(t *testing.T) { - t.Parallel() - - e := testEngine(t, 1) - - cfg, err := e.ExportParseHTTPConfig( - `{"url":"https://example.com/hook",` + - `"headers":{"X-Token":"secret"}}`, - ) - - require.NoError(t, err) - assert.Equal(t, "https://example.com/hook", cfg.URL) - assert.Equal(t, "secret", cfg.Headers["X-Token"]) -} - -func TestParseHTTPConfig_Empty(t *testing.T) { - t.Parallel() - - e := testEngine(t, 1) - - _, err := e.ExportParseHTTPConfig("") - - assert.Error(t, err, - "empty config should return error", - ) -} - -func TestParseHTTPConfig_MissingURL(t *testing.T) { - t.Parallel() - - e := testEngine(t, 1) - - _, err := e.ExportParseHTTPConfig( - `{"headers":{"X-Token":"secret"}}`, - ) - - assert.Error(t, err, - "config without URL should return error", - ) -} - -func TestParseHTTPConfig_Undecodable(t *testing.T) { - t.Parallel() - - e := testEngine(t, 1) - - _, err := e.ExportParseHTTPConfig( - `{"url":"https://example.com/hook","timeout":"soon"}`, - ) - - assert.Error(t, err, - "config that does not decode should return error, "+ - "even when the part that did names a URL", - ) -} - func TestScheduleRetry_SendsToRetryChannel( t *testing.T, ) { @@ -1358,7 +1277,7 @@ func TestDoHTTPRequest_ForwardsHeaders(t *testing.T) { e := testEngine(t, 1) - cfg := &delivery.HTTPTargetConfig{ + target := &database.Target{ URL: ts.URL, Headers: map[string]string{ "X-Target-Auth": "bearer xyz", @@ -1373,7 +1292,7 @@ func TestDoHTTPRequest_ForwardsHeaders(t *testing.T) { } statusCode, _, _, err := e.ExportDoHTTPRequest( - context.TODO(), cfg, event, + context.TODO(), target, event, ) require.NoError(t, err) @@ -1415,7 +1334,7 @@ func TestDoHTTPRequest_CutShortResponseIsAnError(t *testing.T) { _, body, _, err := e.ExportDoHTTPRequest( context.TODO(), - &delivery.HTTPTargetConfig{URL: ts.URL}, + &database.Target{URL: ts.URL}, &database.Event{}, ) @@ -1467,9 +1386,9 @@ func TestApplyRequestHeaders_SendsOneContentType(t *testing.T) { }) require.NoError(t, err) - cfg := &delivery.HTTPTargetConfig{} + var configured map[string]string if tc.configured != "" { - cfg.Headers = map[string]string{ + configured = map[string]string{ headerContentType: tc.configured, } } @@ -1488,7 +1407,7 @@ func TestApplyRequestHeaders_SendsOneContentType(t *testing.T) { Headers: string(inbound), ContentType: tc.event, }, - cfg, + configured, "webhooker/dev", ) @@ -1519,7 +1438,7 @@ func TestApplyRequestHeaders_UndecodableInboundForwardsNothing( &database.Event{ Headers: `{"X-Custom":["value1"],"X-Broken":"not a list"}`, }, - &delivery.HTTPTargetConfig{}, + nil, "webhooker/dev", ) @@ -1537,7 +1456,7 @@ func TestProcessDelivery_RoutesToCorrectHandler( // The database target archives for real, so the engine needs // the target in the main database and a data directory. env := setupArchiveTest(t) - archive := env.seedDatabaseTarget(t, "") + archive := env.seedDatabaseTarget(t) tests := []struct { name string @@ -1622,51 +1541,6 @@ func TestMaxInlineBodySize_Constant(t *testing.T) { ) } -func TestParseSlackConfig_Valid(t *testing.T) { - t.Parallel() - - e := testEngine(t, 1) - - cfg, err := e.ExportParseSlackConfig( - `{"webhookUrl":"https://hooks.slack.com/services/T00/B00/xxx"}`, - ) - - require.NoError(t, err) - - assert.Equal(t, - "https://hooks.slack.com/services/T00/B00/xxx", - cfg.WebhookURL, - ) -} - -func TestParseSlackConfig_Empty(t *testing.T) { - t.Parallel() - - e := testEngine(t, 1) - - _, err := e.ExportParseSlackConfig("") - - assert.Error(t, err, - "empty config should return error", - ) -} - -func TestParseSlackConfig_MissingWebhookURL( - t *testing.T, -) { - t.Parallel() - - e := testEngine(t, 1) - - _, err := e.ExportParseSlackConfig( - `{"other":"field"}`, - ) - - assert.Error(t, err, - "config without webhook_url should return error", - ) -} - func TestFormatSlackMessage_JSONBody(t *testing.T) { t.Parallel() @@ -1763,11 +1637,11 @@ func TestFormatSlackMessage_LargeJSONTruncated( } // buildSlackDelivery creates a database.Delivery -// with a Slack target config for testing. +// to a Slack target with the given webhook URL for testing. func buildSlackDelivery( dlv database.Delivery, event database.Event, - targetID, name, slackCfg string, + targetID, name, webhookURL string, ) *database.Delivery { d := &database.Delivery{ EventID: event.ID, @@ -1777,7 +1651,7 @@ func buildSlackDelivery( Target: database.Target{ Name: name, Type: database.TargetTypeSlack, - Config: slackCfg, + URL: webhookURL, MaxAttempts: 1, }, } @@ -1815,11 +1689,10 @@ func TestDeliverSlack_Success(t *testing.T) { ) defer ts.Close() - e, targetID, slackCfg, event, dlv := - setupSlackTest(t, db, ts.URL) + e, targetID, event, dlv := setupSlackTest(t, db) d := buildSlackDelivery( - dlv, event, targetID, "test-slack", slackCfg, + dlv, event, targetID, "test-slack", ts.URL, ) e.ExportDeliverSlack(context.TODO(), db, d) @@ -1838,9 +1711,8 @@ func TestDeliverSlack_Success(t *testing.T) { func setupSlackTest( t *testing.T, db *gorm.DB, - serverURL string, ) ( - *delivery.Engine, string, string, + *delivery.Engine, string, database.Event, database.Delivery, ) { t.Helper() @@ -1848,13 +1720,6 @@ func setupSlackTest( e := testEngine(t, 1) targetID := uuid.New().String() - cfgBytes, err := json.Marshal( - delivery.SlackTargetConfig{ - WebhookURL: serverURL, - }, - ) - require.NoError(t, err) - event := seedEvent( t, db, `{"action":"test","data":"value"}`, ) @@ -1864,7 +1729,7 @@ func setupSlackTest( database.DeliveryStatusPending, ) - return e, targetID, string(cfgBytes), event, dlv + return e, targetID, event, dlv } func assertSlackPayload( @@ -1913,13 +1778,6 @@ func TestDeliverSlack_Failure(t *testing.T) { e := testEngine(t, 1) targetID := uuid.New().String() - slackCfg, err := json.Marshal( - delivery.SlackTargetConfig{ - WebhookURL: ts.URL, - }, - ) - require.NoError(t, err) - event := seedEvent(t, db, `{"test":true}`) dlv := seedDelivery( @@ -1929,7 +1787,7 @@ func TestDeliverSlack_Failure(t *testing.T) { d := buildSlackDelivery( dlv, event, targetID, - "test-slack-fail", string(slackCfg), + "test-slack-fail", ts.URL, ) e.ExportDeliverSlack(context.TODO(), db, d) @@ -1943,7 +1801,10 @@ func TestDeliverSlack_Failure(t *testing.T) { ) } -func TestDeliverSlack_InvalidConfig(t *testing.T) { +// TestDeliverSlack_NoURL proves a Slack target without a URL, which +// the forms never store, fails its delivery rather than reporting it +// delivered. +func TestDeliverSlack_NoURL(t *testing.T) { t.Parallel() db := testWebhookDB(t) @@ -1964,7 +1825,6 @@ func TestDeliverSlack_InvalidConfig(t *testing.T) { Target: database.Target{ Name: "test-slack-bad", Type: database.TargetTypeSlack, - Config: `{"not_webhook_url":"missing"}`, MaxAttempts: 1, }, } @@ -2002,13 +1862,6 @@ func TestProcessDelivery_RoutesToSlack(t *testing.T) { e := testEngine(t, 1) - slackCfg, err := json.Marshal( - delivery.SlackTargetConfig{ - WebhookURL: ts.URL, - }, - ) - require.NoError(t, err) - event := seedEvent(t, db, `{"route":"slack"}`) dlv := seedDelivery( @@ -2018,7 +1871,7 @@ func TestProcessDelivery_RoutesToSlack(t *testing.T) { d := buildSlackDelivery( dlv, event, dlv.TargetID, - "test-slack-route", string(slackCfg), + "test-slack-route", ts.URL, ) task := &delivery.Task{ @@ -2133,7 +1986,7 @@ func TestDeliverLog_LogsFullContent(t *testing.T) { func buildSlackRetryDelivery( dlv database.Delivery, event database.Event, - targetID, cfg string, + targetID, webhookURL string, ) *database.Delivery { d := &database.Delivery{ EventID: event.ID, @@ -2143,7 +1996,7 @@ func buildSlackRetryDelivery( Target: database.Target{ Name: "test-slack-retry", Type: database.TargetTypeSlack, - Config: cfg, + URL: webhookURL, MaxAttempts: 5, }, } @@ -2162,11 +2015,6 @@ func TestDeliverSlack_WithRetries_SchedulesRetry( e := testEngine(t, 1) targetID := uuid.New().String() - slackCfg, err := json.Marshal( - delivery.SlackTargetConfig{WebhookURL: ts.URL}, - ) - require.NoError(t, err) - event := seedEvent(t, db, `{"slack":"retry"}`) dlv := seedDelivery( @@ -2175,7 +2023,7 @@ func TestDeliverSlack_WithRetries_SchedulesRetry( ) d := buildSlackRetryDelivery( - dlv, event, targetID, string(slackCfg), + dlv, event, targetID, ts.URL, ) task := &delivery.Task{ diff --git a/internal/delivery/event_db_isolation_test.go b/internal/delivery/event_db_isolation_test.go index 2428b65..dc6d839 100644 --- a/internal/delivery/event_db_isolation_test.go +++ b/internal/delivery/event_db_isolation_test.go @@ -69,7 +69,6 @@ func TestEventDBHoldsNoTargetRows(t *testing.T) { )) defer ts.Close() - cfg := iHTTPConfig(ts.URL) targetID := uuid.New().String() dbPath := s.DBMgr.DBPath(s.WebhookID) @@ -85,7 +84,7 @@ func TestEventDBHoldsNoTargetRows(t *testing.T) { ) task := iTask( d, event, s.WebhookID, targetID, - "leaky-target", cfg, 5, 1, &body, + "leaky-target", ts.URL, 5, 1, &body, ) s.Engine.ExportProcessNewTask(context.TODO(), &task) @@ -102,7 +101,7 @@ func TestEventDBHoldsNoTargetRows(t *testing.T) { // https://git.eeqj.de/sneak/webhooker/issues/107. iCreateTarget( t, s.MainDB, targetID, s.WebhookID, "leaky-target", - database.TargetTypeHTTP, cfg, 5, + database.TargetTypeHTTP, ts.URL, 5, ) rd := iSeedDelivery( @@ -111,7 +110,7 @@ func TestEventDBHoldsNoTargetRows(t *testing.T) { ) rTask := iTask( rd, event, s.WebhookID, targetID, - "leaky-target", cfg, 5, 2, &body, + "leaky-target", ts.URL, 5, 2, &body, ) s.Engine.ExportProcessRetryTask(context.TODO(), &rTask) @@ -138,7 +137,6 @@ func TestEventDBHoldsNoTargetRowsOnFailedDelivery(t *testing.T) { )) defer ts.Close() - cfg := iHTTPConfig(ts.URL) targetID := uuid.New().String() event := iSeedEvent( @@ -152,7 +150,7 @@ func TestEventDBHoldsNoTargetRowsOnFailedDelivery(t *testing.T) { ) task := iTask( d, event, s.WebhookID, targetID, - "leaky-target", cfg, 1, 1, &body, + "leaky-target", ts.URL, 1, 1, &body, ) s.Engine.ExportProcessNewTask(context.TODO(), &task) diff --git a/internal/delivery/event_timestamp_test.go b/internal/delivery/event_timestamp_test.go index 14b39a0..6c8c81a 100644 --- a/internal/delivery/event_timestamp_test.go +++ b/internal/delivery/event_timestamp_test.go @@ -93,17 +93,6 @@ func (s *tsSink) text(t *testing.T) string { } } -func tsSlackConfig(t *testing.T, url string) string { - t.Helper() - - data, err := json.Marshal( - delivery.SlackTargetConfig{WebhookURL: url}, - ) - require.NoError(t, err) - - return string(data) -} - // tsSeedEvent writes an event whose CreatedAt is tsEventCreatedAt // rather than the write time, so an assertion on the rendered // timestamp cannot pass by accident against "roughly now". @@ -142,7 +131,7 @@ func tsSeedEvent( // tsSeedTarget writes the slack target row into the main database. // The retry path confirms the target still exists before sending. func tsSeedTarget( - t *testing.T, mainDB *gorm.DB, webhookID, config string, + t *testing.T, mainDB *gorm.DB, webhookID, webhookURL string, ) database.Target { t.Helper() @@ -150,7 +139,7 @@ func tsSeedTarget( WebhookID: webhookID, Name: "slack-sink", Type: database.TargetTypeSlack, - Config: config, + URL: webhookURL, Active: true, MaxAttempts: 1, } @@ -176,7 +165,7 @@ func tsTask( TargetID: target.ID, TargetName: target.Name, TargetType: database.TargetTypeSlack, - TargetConfig: target.Config, + TargetURL: target.URL, MaxAttempts: 1, Method: event.Method, Headers: event.Headers, @@ -226,8 +215,7 @@ func (c tsCase) run(t *testing.T) (iSetup, database.Delivery, string) { s := newISetup(t) sink := newTSSink(t) - cfg := tsSlackConfig(t, sink.URL) - target := tsSeedTarget(t, s.MainDB, s.WebhookID, cfg) + target := tsSeedTarget(t, s.MainDB, s.WebhookID, sink.URL) event := tsSeedEvent(t, s.WebhookDB, s.WebhookID) d := iSeedDelivery( @@ -338,8 +326,7 @@ func TestFormatSlackMessageOverTaskReconstructedEvent( s := newISetup(t) - cfg := tsSlackConfig(t, tsUndeliverableHook) - target := tsSeedTarget(t, s.MainDB, s.WebhookID, cfg) + target := tsSeedTarget(t, s.MainDB, s.WebhookID, tsUndeliverableHook) event := tsSeedEvent(t, s.WebhookDB, s.WebhookID) d := iSeedDelivery( @@ -410,8 +397,7 @@ func TestEventReconstructionSurvivesAReapedRow(t *testing.T) { s := newISetup(t) - cfg := tsSlackConfig(t, tsUndeliverableHook) - target := tsSeedTarget(t, s.MainDB, s.WebhookID, cfg) + target := tsSeedTarget(t, s.MainDB, s.WebhookID, tsUndeliverableHook) event := tsSeedEvent(t, s.WebhookDB, s.WebhookID) d := iSeedDelivery( diff --git a/internal/delivery/export_test.go b/internal/delivery/export_test.go index 0ec1e95..74dc555 100644 --- a/internal/delivery/export_test.go +++ b/internal/delivery/export_test.go @@ -77,10 +77,10 @@ func ExportIsForwardableHeader(name string) bool { func ExportApplyRequestHeaders( req *http.Request, event *database.Event, - cfg *HTTPTargetConfig, + configured map[string]string, userAgent string, ) []string { - return applyRequestHeaders(req, event, cfg, userAgent) + return applyRequestHeaders(req, event, configured, userAgent) } // ExportTruncate exposes truncate for testing. @@ -216,37 +216,23 @@ func (e *Engine) ExportSetSlackCircuitBreaker( e.slackTarget.circuitBreakers.Store(targetID, cb) } -// ExportParseHTTPConfig exposes parseHTTPConfig. -func (e *Engine) ExportParseHTTPConfig( - configJSON string, -) (*HTTPTargetConfig, error) { - return parseHTTPConfig(configJSON) -} - -// ExportParseSlackConfig exposes parseSlackConfig. -func (e *Engine) ExportParseSlackConfig( - configJSON string, -) (*SlackTargetConfig, error) { - return parseSlackConfig(configJSON) -} - // ExportDoHTTPRequest exposes the http target's // doHTTPRequest. func (e *Engine) ExportDoHTTPRequest( ctx context.Context, - cfg *HTTPTargetConfig, + target *database.Target, event *database.Event, ) (int, string, int64, error) { - return e.httpTarget.doHTTPRequest(ctx, cfg, event) + return e.httpTarget.doHTTPRequest(ctx, target, event) } // ExportClientForRequest exposes the http target's // clientForRequest. func (e *Engine) ExportClientForRequest( - cfg *HTTPTargetConfig, + timeoutSeconds int, originScoped []string, ) *http.Client { - return e.httpTarget.clientForRequest(cfg, originScoped) + return e.httpTarget.clientForRequest(timeoutSeconds, originScoped) } // ErrExportTooManyRedirects exposes the sentinel the redirect diff --git a/internal/delivery/metrics_test.go b/internal/delivery/metrics_test.go index fd99066..91443a1 100644 --- a/internal/delivery/metrics_test.go +++ b/internal/delivery/metrics_test.go @@ -192,7 +192,7 @@ func mDeliverOK(t *testing.T, s iSetup) { body := event.Body task := iTask( d, event, s.WebhookID, targetID, - "metrics-ok", iHTTPConfig(ts.URL), 3, 1, &body, + "metrics-ok", ts.URL, 3, 1, &body, ) s.Engine.ExportProcessNewTask(context.TODO(), &task) @@ -226,18 +226,17 @@ func mExhaustRetries(t *testing.T, s iSetup) { ) body := event.Body - cfg := iHTTPConfig(ts.URL) // The retry below is only run if its target still exists; see // https://git.eeqj.de/sneak/webhooker/issues/107. iCreateTarget( t, s.MainDB, targetID, s.WebhookID, "metrics-fail", - database.TargetTypeHTTP, cfg, 2, + database.TargetTypeHTTP, ts.URL, 2, ) first := iTask( d, event, s.WebhookID, targetID, - "metrics-fail", cfg, 2, 1, &body, + "metrics-fail", ts.URL, 2, 1, &body, ) s.Engine.ExportProcessNewTask(context.TODO(), &first) @@ -251,7 +250,7 @@ func mExhaustRetries(t *testing.T, s iSetup) { // deterministic and off the wall clock. second := iTask( d, event, s.WebhookID, targetID, - "metrics-fail", cfg, 2, 2, &body, + "metrics-fail", ts.URL, 2, 2, &body, ) s.Engine.ExportProcessRetryTask( @@ -289,7 +288,6 @@ func TestDeliveryMetrics_CircuitBreakerGauge(t *testing.T) { ) body := event.Body - cfg := iHTTPConfig(ts.URL) // An attempt budget above the failure threshold, so the breaker // rather than the budget is what stops the delivery. @@ -299,12 +297,12 @@ func TestDeliveryMetrics_CircuitBreakerGauge(t *testing.T) { // see https://git.eeqj.de/sneak/webhooker/issues/107. iCreateTarget( t, s.MainDB, targetID, s.WebhookID, "metrics-trip", - database.TargetTypeHTTP, cfg, maxAttempts, + database.TargetTypeHTTP, ts.URL, maxAttempts, ) first := iTask( d, event, s.WebhookID, targetID, - "metrics-trip", cfg, maxAttempts, 1, &body, + "metrics-trip", ts.URL, maxAttempts, 1, &body, ) s.Engine.ExportProcessNewTask(context.TODO(), &first) @@ -316,7 +314,7 @@ func TestDeliveryMetrics_CircuitBreakerGauge(t *testing.T) { ExportDefaultFailureThreshold; attempt++ { task := iTask( d, event, s.WebhookID, targetID, - "metrics-trip", cfg, maxAttempts, attempt, &body, + "metrics-trip", ts.URL, maxAttempts, attempt, &body, ) s.Engine.ExportProcessRetryTask( @@ -363,17 +361,16 @@ func TestDeliveryMetrics_BreakerBlockedIsNotAnAttempt( ) body := event.Body - cfg := iHTTPConfig(ts.URL) maxAttempts := delivery.ExportDefaultFailureThreshold + 5 iCreateTarget( t, s.MainDB, targetID, s.WebhookID, "metrics-blocked", - database.TargetTypeHTTP, cfg, maxAttempts, + database.TargetTypeHTTP, ts.URL, maxAttempts, ) first := iTask( d, event, s.WebhookID, targetID, - "metrics-blocked", cfg, maxAttempts, 1, &body, + "metrics-blocked", ts.URL, maxAttempts, 1, &body, ) s.Engine.ExportProcessNewTask(context.TODO(), &first) @@ -382,7 +379,7 @@ func TestDeliveryMetrics_BreakerBlockedIsNotAnAttempt( ExportDefaultFailureThreshold; attempt++ { task := iTask( d, event, s.WebhookID, targetID, - "metrics-blocked", cfg, maxAttempts, attempt, &body, + "metrics-blocked", ts.URL, maxAttempts, attempt, &body, ) s.Engine.ExportProcessRetryTask(context.TODO(), &task) @@ -405,7 +402,7 @@ func TestDeliveryMetrics_BreakerBlockedIsNotAnAttempt( blocked := iTask( d, event, s.WebhookID, targetID, - "metrics-blocked", cfg, maxAttempts, + "metrics-blocked", ts.URL, maxAttempts, delivery.ExportDefaultFailureThreshold+1, &body, ) @@ -474,7 +471,7 @@ func TestDeliveryMetrics_QueueDepthGauges(t *testing.T) { iCreateTarget(t, s.MainDB, targetID, s.WebhookID, "queue-depth-target", database.TargetTypeHTTP, - iHTTPConfig("https://example.com/hook"), 3, + "https://example.com/hook", 3, ) event := iSeedEvent( diff --git a/internal/delivery/queue_depth_gormlog_test.go b/internal/delivery/queue_depth_gormlog_test.go index 51ccd75..18b4b68 100644 --- a/internal/delivery/queue_depth_gormlog_test.go +++ b/internal/delivery/queue_depth_gormlog_test.go @@ -102,7 +102,7 @@ func TestQueueDepthSample_LogsNoBoundValue(t *testing.T) { iCreateTarget(t, mainDB.DB(), targetID, webhookID, "queue-depth-gormlog-target", database.TargetTypeHTTP, - iHTTPConfig("https://example.com/hook"), 3, + "https://example.com/hook", 3, ) event := iSeedEvent( diff --git a/internal/delivery/recovery_durability_test.go b/internal/delivery/recovery_durability_test.go index 9ed60c2..9bdbd44 100644 --- a/internal/delivery/recovery_durability_test.go +++ b/internal/delivery/recovery_durability_test.go @@ -357,7 +357,7 @@ func TestFailedResultWriteLeavesDeliveryRecoverable( Target: database.Target{ Name: "unwritable", Type: database.TargetTypeHTTP, - Config: iHTTPConfig(ts.URL), + URL: ts.URL, MaxAttempts: 1, }, } @@ -448,7 +448,7 @@ func TestFailedResultWriteWithRetriesLeavesDeliveryRecoverable( Target: database.Target{ Name: "unwritable", Type: database.TargetTypeHTTP, - Config: iHTTPConfig(ts.URL), + URL: ts.URL, MaxAttempts: 3, }, } diff --git a/internal/delivery/recovery_test.go b/internal/delivery/recovery_test.go index 5cbfcba..0ec9afe 100644 --- a/internal/delivery/recovery_test.go +++ b/internal/delivery/recovery_test.go @@ -56,7 +56,7 @@ func recoverySetup(t *testing.T) (iSetup, string) { iCreateTarget( t, s.MainDB, targetID, s.WebhookID, "recovery", database.TargetTypeHTTP, - iHTTPConfig("http://example.com/hook"), 20, + "http://example.com/hook", 20, ) return s, targetID @@ -205,6 +205,55 @@ func TestRecovery_PendingOlderThanTheAgeBound(t *testing.T) { } } +// TestRecovery_TaskCarriesEveryTargetSetting: the task the recovery +// routine queues carries every one of the target's settings, all six +// set here. +// They are copied into the task one field at a time, and a field left +// out is sent empty without any error. +func TestRecovery_TaskCarriesEveryTargetSetting(t *testing.T) { + t.Parallel() + + for _, run := range recoveryRuns() { + t.Run(run.name, func(t *testing.T) { + t.Parallel() + + s := newISetup(t) + iCreateWebhook(t, s.MainDB, s.WebhookID, "recovery") + + target := database.Target{ + WebhookID: s.WebhookID, + Name: "recovery", + Type: database.TargetTypeHTTP, + Active: true, + URL: "http://example.com/hook", + Headers: map[string]string{headerAuthorization: "Bearer t"}, + TimeoutSeconds: 7, + ForwardQuery: true, + ArchiveExpiry: time.Hour, + ArchiveRotation: "daily", + MaxAttempts: 1, + } + require.NoError(t, s.MainDB.Create(&target).Error) + + id := seedRecoveryDelivery( + t, s, target.ID, database.DeliveryStatusPending, 0, false, + ) + rAgePending(t, s.WebhookDB, id) + + s.Engine.ExportRecoverDeliveries(context.Background(), run.atStart) + + task := requireQueued(t, s.Engine.ExportDeliveryCh()) + assert.Equal(t, target.ID, task.TargetID) + assert.Equal(t, target.URL, task.TargetURL) + assert.Equal(t, target.Headers, task.TargetHeaders) + assert.Equal(t, target.TimeoutSeconds, task.TimeoutSeconds) + assert.Equal(t, target.ForwardQuery, task.ForwardQuery) + assert.Equal(t, target.ArchiveExpiry, task.ArchiveExpiry) + assert.Equal(t, target.ArchiveRotation, task.ArchiveRotation) + }) + } +} + // TestRecovery_PendingYoungerThanTheAgeBound: a pending delivery touched // a moment ago, as one a stop cut off is, is sent at start, which is how // it reaches its target on the next start diff --git a/internal/delivery/redirect_test.go b/internal/delivery/redirect_test.go index 76027e7..5fe1a14 100644 --- a/internal/delivery/redirect_test.go +++ b/internal/delivery/redirect_test.go @@ -85,19 +85,14 @@ func deliverWithProbeHeaders( database.DeliveryStatusPending, ) - cfg, err := json.Marshal(delivery.HTTPTargetConfig{ - URL: targetURL, - Headers: map[string]string{ - probeHeaderName: probeHeaderValue, - }, - }) - require.NoError(t, err) - body := event.Body task := iTask( d, event, s.WebhookID, targetID, - "redirect-target", string(cfg), 1, 1, &body, + "redirect-target", targetURL, 1, 1, &body, ) + task.TargetHeaders = map[string]string{ + probeHeaderName: probeHeaderValue, + } s.Engine.ExportProcessNewTask(context.TODO(), &task) @@ -309,8 +304,7 @@ func TestRedirectPolicy_StopsAtHopCap(t *testing.T) { ) client := engine.ExportClientForRequest( - &delivery.HTTPTargetConfig{URL: srv.URL}, - []string{probeHeaderName}, + 0, []string{probeHeaderName}, ) require.NotNil(t, client.CheckRedirect) @@ -350,7 +344,7 @@ func TestApplyRequestHeaders_ReportsOriginScopedNames(t *testing.T) { inbound, err := json.Marshal(map[string][]string{ inboundHeaderName: {inboundHeaderValue}, "Content-Type": {testContentType}, - "User-Agent": {"curl/8.7.1"}, + headerUserAgent: {"curl/8.7.1"}, "Host": {"inbound.example.com"}, }) require.NoError(t, err) @@ -369,11 +363,9 @@ func TestApplyRequestHeaders_ReportsOriginScopedNames(t *testing.T) { Headers: string(inbound), ContentType: testContentType, }, - &delivery.HTTPTargetConfig{ - Headers: map[string]string{ - probeHeaderName: probeHeaderValue, - "Content-Type": testContentType, - }, + map[string]string{ + probeHeaderName: probeHeaderValue, + "Content-Type": testContentType, }, "webhooker/dev", ) diff --git a/internal/delivery/target_config_edit.go b/internal/delivery/target_config_edit.go deleted file mode 100644 index 73cd8b2..0000000 --- a/internal/delivery/target_config_edit.go +++ /dev/null @@ -1,137 +0,0 @@ -package delivery - -import ( - "encoding/json" - "errors" - "fmt" - - "sneak.berlin/go/webhooker/internal/archive" - "sneak.berlin/go/webhooker/internal/database" -) - -// errUnknownTargetTypeForEdit is returned when a stored target has a -// type the edit form has no field set for. -var errUnknownTargetTypeForEdit = errors.New( - "unknown target type", -) - -// TargetConfigForm is the UNMASKED projection of a target's stored -// configuration, for pre-filling the target edit form. -// -// It is the deliberate exception to the rule TargetView enforces -// everywhere else: TargetView exists so that no template can render -// a target's stored blob, because a destination URL's path segments -// and a header value are both routinely the credential. An operator -// cannot correct a value they cannot see, so the edit form — and -// only the edit form — is shown the full value. -// -// Everything that keeps that exception narrow lives at the call -// site: the route is behind RequireAuth and the webhook's ownership -// check, and its group sets NoCache so the rendered secret is not -// written to a shared cache. Do not reach for this type from any -// other page. -type TargetConfigForm struct { - // URL is the destination for an HTTP target and the webhook - // URL for a Slack target. - URL string - // Headers is the HTTP target's configured headers in the - // textarea representation, one "Name: value" per line. - Headers string - // Timeout is the HTTP target's per-request timeout in seconds, - // empty when unset. - Timeout string - // ForwardQuery is the HTTP target's setting that passes each - // event's query string on to it. - ForwardQuery bool - // Expiry is the database (archive) target's row expiry. - Expiry string - // Rotation is the database (archive) target's rotation. - Rotation string -} - -// NewTargetConfigForm parses a target's stored configuration into -// the edit form's fields. -// -// A configuration that does not parse is an error rather than a -// zero-valued form that silently looks like a target with no -// settings. The caller shows the operator that the stored value -// could not be read, so that saving the form is understood as -// replacing it rather than preserving it. -func NewTargetConfigForm( - t *database.Target, -) (TargetConfigForm, error) { - switch t.Type { - case database.TargetTypeHTTP: - cfg, err := parseHTTPConfig(t.Config) - if err != nil { - return TargetConfigForm{}, err - } - - return TargetConfigForm{ - URL: cfg.URL, - Headers: FormatTargetHeaders(cfg.Headers), - Timeout: FormatTargetTimeout(cfg.Timeout), - ForwardQuery: cfg.ForwardQuery, - }, nil - case database.TargetTypeSlack: - cfg, err := parseSlackConfig(t.Config) - if err != nil { - return TargetConfigForm{}, err - } - - return TargetConfigForm{URL: cfg.WebhookURL}, nil - case database.TargetTypeDatabase: - return databaseConfigForm(t.Config) - case database.TargetTypeLog: - // The log target takes no configuration. - return TargetConfigForm{}, nil - default: - return TargetConfigForm{}, fmt.Errorf( - "%w: %q", errUnknownTargetTypeForEdit, t.Type, - ) - } -} - -// databaseConfigForm parses an archive target's optional expiry and -// rotation. An absent, empty or never expiry yields an empty expiry, -// on which the edit form starts at never; saving it unchanged stores -// never, which means the same as an empty expiry. An absent rotation -// is empty too, and the form starts at none. An expiry that is set -// but not a valid duration, or a rotation that is not one of the -// four, is an error, not a blank field. -func databaseConfigForm( - configJSON string, -) (TargetConfigForm, error) { - if configJSON == "" { - return TargetConfigForm{}, nil - } - - var cfg archive.DatabaseTargetConfig - - err := json.Unmarshal([]byte(configJSON), &cfg) - if err != nil { - return TargetConfigForm{}, fmt.Errorf( - "parsing config JSON: %w", err, - ) - } - - err = archive.ValidateRotation(cfg.Rotation) - if err != nil { - return TargetConfigForm{}, err - } - - form := TargetConfigForm{Rotation: cfg.Rotation} - - if cfg.Expiry == "" || cfg.Expiry == archive.ExpiryNever { - return form, nil - } - - err = archive.ValidateExpiry(cfg.Expiry) - if err != nil { - return TargetConfigForm{}, err - } - - form.Expiry = cfg.Expiry - - return form, nil -} diff --git a/internal/delivery/target_config_view.go b/internal/delivery/target_config_view.go index 2907b61..393d86a 100644 --- a/internal/delivery/target_config_view.go +++ b/internal/delivery/target_config_view.go @@ -9,16 +9,17 @@ import ( "sneak.berlin/go/webhooker/internal/database" ) -// configUnavailable is what a target's configuration renders -// as when it is absent, of an unknown type, or does not -// parse. The stored blob is never shown as a fallback: it can -// hold a credential (a Slack incoming webhook URL is a bearer -// token) and a UI that prints it leaks that credential into -// browser history, screenshots and screen shares. +// configUnavailable is what a target's settings render as when +// the target is of an unknown type, and what its URL renders as +// when it does not parse into a scheme and host. The stored URL is +// never shown as a fallback: it can be a credential (a Slack +// incoming webhook URL is a bearer token) and a UI that prints it +// leaks that credential into browser history, screenshots and +// screen shares. const configUnavailable = "(unavailable)" // ConfigField is one labelled, display-safe value derived -// from a target's stored configuration. +// from a target's settings. type ConfigField struct { Label string Value string @@ -32,9 +33,9 @@ type ConfigField struct { const deletedNameSuffix = " (deleted)" // TargetView is the display-safe projection of a target for -// the UI. It deliberately has no raw configuration field, so -// no template — present or future — can render the stored -// blob. +// the UI. It deliberately has no URL or header field, so no +// template — present or future — can render the stored +// credential. type TargetView struct { ID string Name string @@ -61,8 +62,7 @@ func (v TargetView) DisplayName() string { } // NewTargetViews projects targets for rendering, replacing -// each stored configuration blob with named, display-safe -// fields. +// each target's settings with named, display-safe fields. // // A soft-deleted row projects exactly as a live one does, minus // the deleted marker on its name: masking is a property of the @@ -91,8 +91,8 @@ func NewTargetViews( } // targetConfigFields returns the display-safe fields for a -// target's configuration. Anything it cannot parse becomes -// the neutral placeholder. +// target's settings. A target of an unknown type gets the +// neutral placeholder. func targetConfigFields( t *database.Target, ) []ConfigField { @@ -102,36 +102,25 @@ func targetConfigFields( case database.TargetTypeHTTP: return httpConfigFields(t) case database.TargetTypeDatabase: - return databaseConfigFields(t.Config) + return databaseConfigFields(t) case database.TargetTypeLog: - // The log target takes no configuration. + // The log target takes no settings. return nil default: - return unavailableConfigFields() + return []ConfigField{{ + Label: "Configuration", + Value: configUnavailable, + }} } } -// unavailableConfigFields is the neutral placeholder shown -// for a configuration that could not be presented. -func unavailableConfigFields() []ConfigField { - return []ConfigField{{ - Label: "Configuration", - Value: configUnavailable, - }} -} - // slackConfigFields describes a Slack target: its masked // webhook URL and its number of delivery attempts. Only the // masked URL is shown; the full URL is the credential. func slackConfigFields(t *database.Target) []ConfigField { - cfg, err := parseSlackConfig(t.Config) - if err != nil { - return unavailableConfigFields() - } - return []ConfigField{{ Label: "Webhook URL", - Value: cfg.MaskedWebhookURL(), + Value: MaskURL(t.URL), }, maxAttemptsField(t)} } @@ -146,33 +135,28 @@ func slackConfigFields(t *database.Target) []ConfigField { // whose path segments are the credential, and the field takes // an arbitrary URL, so no segment can be assumed non-secret. func httpConfigFields(t *database.Target) []ConfigField { - cfg, err := parseHTTPConfig(t.Config) - if err != nil { - return unavailableConfigFields() - } - fields := []ConfigField{{ Label: "Destination URL", - Value: MaskURL(cfg.URL), + Value: MaskURL(t.URL), }} - if cfg.Timeout > 0 { + if t.TimeoutSeconds > 0 { fields = append(fields, ConfigField{ Label: "Timeout", - Value: strconv.Itoa(cfg.Timeout) + "s", + Value: strconv.Itoa(t.TimeoutSeconds) + "s", }) } - if len(cfg.Headers) > 0 { + if len(t.Headers) > 0 { fields = append(fields, ConfigField{ Label: "Headers", Value: fmt.Sprintf( - "%d configured", len(cfg.Headers), + "%d configured", len(t.Headers), ), }) } - if cfg.ForwardQuery { + if t.ForwardQuery { fields = append(fields, ConfigField{ Label: "Query string", Value: "passed on to this target", @@ -185,8 +169,7 @@ func httpConfigFields(t *database.Target) []ConfigField { } // maxAttemptsField describes a target's number of delivery -// attempts, which lives on the target row rather than in its -// configuration blob. +// attempts. func maxAttemptsField(t *database.Target) ConfigField { return ConfigField{ Label: "Delivery attempts", @@ -196,24 +179,11 @@ func maxAttemptsField(t *database.Target) ConfigField { // databaseConfigFields describes an archive target by its // expiry in plain units, such as "30 days", or "never" when -// the archive is kept forever, and by its rotation. An expiry -// that is set but not a valid duration, or a rotation that is -// not one of the four, is reported as unavailable rather than -// echoed back. -func databaseConfigFields(configJSON string) []ConfigField { - expiry, err := archive.ParseExpiry(configJSON) - if err != nil { - return unavailableConfigFields() - } - - rotation, err := archive.ParseRotation(configJSON) - if err != nil { - return unavailableConfigFields() - } - +// the archive is kept forever, and by its rotation. +func databaseConfigFields(t *database.Target) []ConfigField { value := archive.ExpiryNever - if expiry > 0 { - value = plainDuration(expiry) + if t.ArchiveExpiry > 0 { + value = plainDuration(t.ArchiveExpiry) } return []ConfigField{{ @@ -221,7 +191,7 @@ func databaseConfigFields(configJSON string) []ConfigField { Value: value, }, { Label: "Archive rotation", - Value: rotation, + Value: t.ArchiveRotation, }} } @@ -257,14 +227,3 @@ func plainDuration(d time.Duration) string { return d.String() } - -// MaskedWebhookURL returns the Slack webhook URL reduced to -// its scheme and host, with the path, query and any userinfo -// elided. The path segments are the credential, so none of -// them is shown: the field accepts an arbitrary URL, so no -// segment can be assumed non-secret. A URL that does not -// parse into a scheme and host yields the neutral -// placeholder, never the raw string. -func (c *SlackTargetConfig) MaskedWebhookURL() string { - return MaskURL(c.WebhookURL) -} diff --git a/internal/delivery/target_config_view_test.go b/internal/delivery/target_config_view_test.go index 595607e..75c0863 100644 --- a/internal/delivery/target_config_view_test.go +++ b/internal/delivery/target_config_view_test.go @@ -35,18 +35,17 @@ const ( viewMaxAttempts = "Delivery attempts" ) -// The archive rotations a database target's settings can name, and -// the config of one whose rotation is not one of the four. +// The archive rotations a database target's settings can name. const ( rotationNone = "none" rotationMonthly = "monthly" rotationDaily = "daily" rotationHourly = "hourly" - - weeklyConfig = `{"rotation":"weekly"}` ) -func TestMaskedWebhookURL(t *testing.T) { +// TestNewTargetViews_SlackMasksWebhookURL shows what a Slack target's +// URL renders as on the target list. +func TestNewTargetViews_SlackMasksWebhookURL(t *testing.T) { t.Parallel() tests := map[string]struct { @@ -90,28 +89,24 @@ func TestMaskedWebhookURL(t *testing.T) { t.Run(name, func(t *testing.T) { t.Parallel() - cfg := &delivery.SlackTargetConfig{ - WebhookURL: tc.url, - } + target := slackTarget() + target.URL = tc.url assert.Equal( - t, tc.want, cfg.MaskedWebhookURL(), + t, tc.want, + fieldMap(viewFor(t, target).Config)["Webhook URL"], ) }) } } -// TestMaskedWebhookURL_NeverLeaksPath is the direct +// TestNewTargetViews_SlackNeverLeaksPath is the direct // expression of the rule: whatever the input, the masked // value never contains a path segment of it. -func TestMaskedWebhookURL_NeverLeaksPath(t *testing.T) { +func TestNewTargetViews_SlackNeverLeaksPath(t *testing.T) { t.Parallel() - cfg := &delivery.SlackTargetConfig{ - WebhookURL: slackWebhookURL, - } - - masked := cfg.MaskedWebhookURL() + masked := fieldMap(viewFor(t, slackTarget()).Config)["Webhook URL"] assert.NotContains(t, masked, "T00000000") assert.NotContains(t, masked, "B00000000") @@ -177,11 +172,10 @@ func TestNewTargetViews_DeletedTarget(t *testing.T) { // share. func slackTarget() database.Target { return database.Target{ - Name: slackTargetName, - Type: database.TargetTypeSlack, - Active: true, - Config: `{"webhookUrl":"` + - slackWebhookURL + `"}`, + Name: slackTargetName, + Type: database.TargetTypeSlack, + Active: true, + URL: slackWebhookURL, MaxAttempts: 1, } } @@ -233,12 +227,12 @@ func TestNewTargetViews_HTTP(t *testing.T) { t.Parallel() view := viewFor(t, database.Target{ - Type: database.TargetTypeHTTP, - Config: `{"url":"` + viewExampleHook + `",` + - `"timeout":30,` + - `"headers":{"Authorization":"Bearer sekrit"},` + - `"forwardQuery":true}`, - MaxAttempts: 5, + Type: database.TargetTypeHTTP, + URL: viewExampleHook, + TimeoutSeconds: 30, + Headers: map[string]string{headerAuthorization: "Bearer sekrit"}, + ForwardQuery: true, + MaxAttempts: 5, }) fields := fieldMap(view.Config) @@ -268,7 +262,7 @@ func TestNewTargetViews_HTTPSingleAttempt(t *testing.T) { view := viewFor(t, database.Target{ Type: database.TargetTypeHTTP, - Config: `{"url":"` + viewExampleHook + `"}`, + URL: viewExampleHook, MaxAttempts: 1, }) @@ -291,7 +285,7 @@ func TestNewTargetViews_HTTPMasksDestinationURL(t *testing.T) { view := viewFor(t, database.Target{ Type: database.TargetTypeHTTP, - Config: `{"url":"` + slackWebhookURL + `"}`, + URL: slackWebhookURL, MaxAttempts: 1, }) @@ -315,25 +309,20 @@ func TestNewTargetViews_Database(t *testing.T) { t.Parallel() tests := map[string]struct { - config string + expiry time.Duration want string }{ - "empty config": {config: "", want: viewExpiryNever}, - "empty expiry": {config: `{}`, want: viewExpiryNever}, - "never literal": { - config: `{"expiry":"` + viewExpiryNever + `"}`, - want: viewExpiryNever, - }, - "1h": {config: `{"expiry":"1h"}`, want: "1 hour"}, - "12h": {config: `{"expiry":"12h"}`, want: "12 hours"}, - "24h": {config: `{"expiry":"24h"}`, want: "1 day"}, - "720h": {config: `{"expiry":"720h"}`, want: "30 days"}, - "2160h": {config: `{"expiry":"2160h"}`, want: "90 days"}, - "8760h": {config: `{"expiry":"8760h"}`, want: "365 days"}, - "36h": {config: `{"expiry":"36h"}`, want: "36 hours"}, - "1h30m": {config: `{"expiry":"1h30m"}`, want: "90 minutes"}, - "45s": {config: `{"expiry":"45s"}`, want: "45 seconds"}, - "1.5s": {config: `{"expiry":"1.5s"}`, want: "1.5s"}, + "never": {expiry: 0, want: viewExpiryNever}, + "1h": {expiry: time.Hour, want: "1 hour"}, + "12h": {expiry: 12 * time.Hour, want: "12 hours"}, + "24h": {expiry: 24 * time.Hour, want: "1 day"}, + "720h": {expiry: 720 * time.Hour, want: "30 days"}, + "2160h": {expiry: 2160 * time.Hour, want: "90 days"}, + "8760h": {expiry: 8760 * time.Hour, want: "365 days"}, + "36h": {expiry: 36 * time.Hour, want: "36 hours"}, + "1h30m": {expiry: 90 * time.Minute, want: "90 minutes"}, + "45s": {expiry: 45 * time.Second, want: "45 seconds"}, + "1.5s": {expiry: 1500 * time.Millisecond, want: "1.5s"}, } for name, tc := range tests { @@ -341,8 +330,9 @@ func TestNewTargetViews_Database(t *testing.T) { t.Parallel() view := viewFor(t, database.Target{ - Type: database.TargetTypeDatabase, - Config: tc.config, + Type: database.TargetTypeDatabase, + ArchiveExpiry: tc.expiry, + ArchiveRotation: rotationNone, }) assert.Equal( @@ -358,33 +348,23 @@ func TestNewTargetViews_Database(t *testing.T) { } // TestNewTargetViews_DatabaseRotation proves the target list shows a -// database target's rotation, none when it has none stored. +// database target's rotation. func TestNewTargetViews_DatabaseRotation(t *testing.T) { t.Parallel() - // Each stored config, and the rotation the list shows for it. - tests := map[string]string{ - "": rotationNone, - `{"rotation":""}`: rotationNone, - } - for _, rotation := range []string{ rotationNone, rotationMonthly, rotationDaily, rotationHourly, } { - tests[`{"rotation":"`+rotation+`"}`] = rotation - } - - for config, want := range tests { - t.Run(config, func(t *testing.T) { + t.Run(rotation, func(t *testing.T) { t.Parallel() view := viewFor(t, database.Target{ - Type: database.TargetTypeDatabase, - Config: config, + Type: database.TargetTypeDatabase, + ArchiveRotation: rotation, }) assert.Equal( - t, want, fieldMap(view.Config)["Archive rotation"], + t, rotation, fieldMap(view.Config)["Archive rotation"], ) }) } @@ -394,72 +374,28 @@ func TestNewTargetViews_Log(t *testing.T) { t.Parallel() view := viewFor(t, database.Target{ - Type: database.TargetTypeLog, - Config: "", + Type: database.TargetTypeLog, }) assert.Empty(t, view.Config) } -// TestNewTargetViews_Unpresentable proves that no config the -// view cannot present falls back to the stored blob. -func TestNewTargetViews_Unpresentable(t *testing.T) { +// TestNewTargetViews_UnknownType proves that a target of a type the +// view does not know shows the neutral placeholder, and never its +// URL. +func TestNewTargetViews_UnknownType(t *testing.T) { t.Parallel() - const blob = `{"webhookUrl":"https://hooks.slack.com` + - slackSecretPath + `"` + view := viewFor(t, database.Target{ + Type: database.TargetType("carrier-pigeon"), + URL: slackWebhookURL, + }) - tests := map[string]database.Target{ - "unknown target type": { - Type: database.TargetType("carrier-pigeon"), - Config: blob, + assert.Equal( + t, + map[string]string{ + "Configuration": viewUnavailable, }, - "unparseable json": { - Type: database.TargetTypeSlack, - Config: blob, - MaxAttempts: 1, - }, - "empty slack config": { - Type: database.TargetTypeSlack, - MaxAttempts: 1, - }, - "slack config without url": { - Type: database.TargetTypeSlack, - Config: `{}`, - MaxAttempts: 1, - }, - "unparseable http json": { - Type: database.TargetTypeHTTP, - Config: `{"url":`, - MaxAttempts: 1, - }, - "unparseable archive json": { - Type: database.TargetTypeDatabase, - Config: `{"expiry":`, - }, - "invalid archive expiry": { - Type: database.TargetTypeDatabase, - Config: `{"expiry":"a fortnight"}`, - }, - "invalid archive rotation": { - Type: database.TargetTypeDatabase, - Config: weeklyConfig, - }, - } - - for name, target := range tests { - t.Run(name, func(t *testing.T) { - t.Parallel() - - view := viewFor(t, target) - - assert.Equal( - t, - map[string]string{ - "Configuration": viewUnavailable, - }, - fieldMap(view.Config), - ) - }) - } + fieldMap(view.Config), + ) } diff --git a/internal/delivery/target_database_test.go b/internal/delivery/target_database_test.go index 48b20c6..60d3e76 100644 --- a/internal/delivery/target_database_test.go +++ b/internal/delivery/target_database_test.go @@ -1,6 +1,7 @@ package delivery_test import ( + "context" "database/sql" "fmt" "log/slog" @@ -20,6 +21,7 @@ import ( "sneak.berlin/go/webhooker/internal/archive" "sneak.berlin/go/webhooker/internal/database" "sneak.berlin/go/webhooker/internal/database/databasetest" + "sneak.berlin/go/webhooker/internal/db/migrations" "sneak.berlin/go/webhooker/internal/delivery" "sneak.berlin/go/webhooker/internal/gormlog" ) @@ -78,10 +80,11 @@ func (env *archiveEnv) archivePath(tgt *database.Target) string { ) } -// seedDatabaseTarget creates a webhook with one database target -// carrying the given target config JSON, and returns the target. +// seedDatabaseTarget creates a webhook with one database target, +// which keeps its archive forever in one file, and returns the +// target. func (env *archiveEnv) seedDatabaseTarget( - t *testing.T, configJSON string, + t *testing.T, ) *database.Target { t.Helper() @@ -97,12 +100,12 @@ func (env *archiveEnv) seedDatabaseTarget( ) tgt := &database.Target{ - WebhookID: wh.ID, - Name: "Archive", - Type: database.TargetTypeDatabase, - Active: true, - Config: configJSON, - MaxAttempts: 1, + WebhookID: wh.ID, + Name: "Archive", + Type: database.TargetTypeDatabase, + Active: true, + ArchiveRotation: "none", + MaxAttempts: 1, } require.NoError( t, @@ -136,9 +139,10 @@ func seedDatabaseTargetDelivery( Status: database.DeliveryStatusPending, Event: event, Target: database.Target{ - Name: tgt.Name, - Type: database.TargetTypeDatabase, - Config: tgt.Config, + Name: tgt.Name, + Type: database.TargetTypeDatabase, + ArchiveExpiry: tgt.ArchiveExpiry, + ArchiveRotation: tgt.ArchiveRotation, }, } d.ID = dlv.ID @@ -218,7 +222,7 @@ func TestDeliverDatabase_ArchivesEvent(t *testing.T) { t.Parallel() env := setupArchiveTest(t) - tgt := env.seedDatabaseTarget(t, "") + tgt := env.seedDatabaseTarget(t) webhookDB := testWebhookDB(t) event := seedEvent(t, webhookDB, `{"archived":true}`) @@ -256,9 +260,9 @@ func TestDeliverDatabase_ArchivesEvent(t *testing.T) { } // TestDeliverDatabase_ArchiveFailureFailsDelivery verifies that -// an archive error (here: an unparseable expiry in the target -// config) fails the delivery loudly: the attempt is recorded as -// failed with the error and the delivery is marked failed, not +// an archive error (here: an event with no webhook id to record) +// fails the delivery loudly: the attempt is recorded as failed +// with the error and the delivery is marked failed, not // delivered. func TestDeliverDatabase_ArchiveFailureFailsDelivery( t *testing.T, @@ -266,11 +270,12 @@ func TestDeliverDatabase_ArchiveFailureFailsDelivery( t.Parallel() env := setupArchiveTest(t) - tgt := env.seedDatabaseTarget(t, `{"expiry":"nonsense"}`) + tgt := env.seedDatabaseTarget(t) webhookDB := testWebhookDB(t) event := seedEvent(t, webhookDB, `{"archived":false}`) d := seedDatabaseTargetDelivery(t, webhookDB, event, tgt) + d.Event.WebhookID = "" env.eng.ExportDeliverDatabase(webhookDB, d) @@ -295,12 +300,96 @@ func TestDeliverDatabase_ArchiveFailureFailsDelivery( "the attempt must be recorded as failed", ) assert.Contains(t, - results[0].Error, "nonsense", + results[0].Error, "without a webhook id", "the archive error must be recorded on the attempt", ) assert.NoFileExists(t, env.archivePath(tgt), - "no archive file should exist for a failed config", + "no archive file should exist for a failed write", + ) +} + +// seedArchivedRow creates the archive file at path holding one row, for +// eventID, archived at archivedAt. +func seedArchivedRow( + t *testing.T, path, eventID string, archivedAt time.Time, +) { + t.Helper() + + sqlDB, err := sql.Open( + "sqlite", fmt.Sprintf("file:%s?mode=rwc", path), + ) + require.NoError(t, err) + + defer func() { _ = sqlDB.Close() }() + + gdb, err := gorm.Open( + sqlite.Dialector{Conn: sqlDB}, + &gorm.Config{Logger: gormlog.New(slog.New(slog.DiscardHandler))}, + ) + require.NoError(t, err) + + require.NoError(t, migrations.Apply(gdb, migrations.Archive)) + require.NoError(t, gdb.Exec( + "INSERT INTO archived_events (event_id, archived_at) VALUES (?, ?)", + eventID, archivedAt, + ).Error) +} + +// TestDeliverDatabase_TaskArchiveSettings proves a task's ArchiveExpiry +// and ArchiveRotation reach the archive through the engine. The +// target's row has neither, so only the task can bring them: with daily +// rotation the event goes to the file for its day, and opening that +// file with a one-hour expiry prunes the row it already held, archived +// two hours ago. +func TestDeliverDatabase_TaskArchiveSettings(t *testing.T) { + t.Parallel() + + s := newISetup(t) + + webhook := database.Webhook{UserID: uuid.NewString(), Name: "settings"} + webhook.ID = s.WebhookID + require.NoError(t, s.MainDB.Create(&webhook).Error) + + target := database.Target{ + WebhookID: s.WebhookID, + Name: "archive", + Type: database.TargetTypeDatabase, + Active: true, + MaxAttempts: 1, + } + require.NoError(t, s.MainDB.Create(&target).Error) + + event := iSeedEvent(t, s.WebhookDB, s.WebhookID, `{"archived":true}`) + d := iSeedDelivery( + t, s.WebhookDB, event.ID, target.ID, + database.DeliveryStatusPending, + ) + + task := iTask( + d, event, s.WebhookID, target.ID, target.Name, "", 1, 1, + &event.Body, + ) + task.TargetType = database.TargetTypeDatabase + task.ArchiveExpiry = time.Hour + task.ArchiveRotation = "daily" + + daily := target + daily.ArchiveRotation = task.ArchiveRotation + dailyFile := archive.PathAt(s.DBMgr, &webhook, &daily, event.CreatedAt) + seedArchivedRow(t, dailyFile, "expired", time.Now().Add(-2*time.Hour)) + + s.Engine.ExportProcessNewTask(context.TODO(), &task) + + iAssertStatus(t, s.WebhookDB, d.ID, database.DeliveryStatusDelivered) + + var rows []archivedRow + + require.NoError(t, openArchiveDBForRead(t, dailyFile). + Table("archived_events").Find(&rows).Error) + require.Len(t, rows, 1) + assert.Equal(t, event.ID, rows[0].EventID, + "the event goes to its day's file and the expired row is pruned", ) } diff --git a/internal/delivery/target_headers.go b/internal/delivery/target_headers.go index ca2b069..c8229c4 100644 --- a/internal/delivery/target_headers.go +++ b/internal/delivery/target_headers.go @@ -17,7 +17,7 @@ import ( const MaxTargetTimeoutSeconds = 300 // Errors returned when a target's header or timeout form input -// cannot be turned into a configuration. +// cannot be turned into the target's setting. // // None of these ever quotes a header VALUE. A target header value // is routinely an authorization token, and these messages are shown @@ -48,8 +48,8 @@ var ( ) // isReservedTargetHeader reports whether name (canonicalised) is a -// header a target configuration may not set, because the delivery -// path or net/http itself writes it regardless. +// header a target may not set, because the delivery path or +// net/http itself writes it regardless. // // These are rejected rather than accepted-and-ignored. Storing a // header that provably never reaches the wire tells the operator @@ -77,13 +77,11 @@ func isReservedTargetHeader(name string) bool { // ParseTargetHeaders turns the target form's headers field — one // "Name: value" pair per line, blank lines ignored — into the map -// stored in HTTPTargetConfig.Headers. Names are canonicalised, so a +// stored in database.Target.Headers. Names are canonicalised, so a // name repeated in a different case is still a duplicate rather than // one pair silently overwriting the other. // -// An input with no pairs yields an empty map, which omitempty drops -// from the stored config, so a target configured with no headers -// stores no headers key at all. +// An input with no pairs yields an empty map. func ParseTargetHeaders(raw string) (map[string]string, error) { headers := make(map[string]string) @@ -223,8 +221,8 @@ func FormatTargetHeaders(headers map[string]string) string { // ParseTargetTimeout interprets the target form's timeout field as // a whole number of seconds. An empty field means "unset" and yields -// 0, which omitempty drops from the stored config and which the -// delivery path reads as "use the shared client's timeout". +// 0, which the delivery path reads as "use the shared client's +// timeout". // // Anything else that is not a whole number in range is an error, not // a silently substituted default: a target whose timeout was typed diff --git a/internal/delivery/target_headers_test.go b/internal/delivery/target_headers_test.go index ef765bc..4c999f0 100644 --- a/internal/delivery/target_headers_test.go +++ b/internal/delivery/target_headers_test.go @@ -1,23 +1,21 @@ package delivery_test import ( - "encoding/json" "strconv" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "sneak.berlin/go/webhooker/internal/database" "sneak.berlin/go/webhooker/internal/delivery" ) -// Literals these tests repeat, named so that the header names and the -// keep-forever archive config each have one definition. +// Literals these tests repeat, named so that each header name has one +// definition. const ( headerAuthorization = "Authorization" headerContentType = "Content-Type" + headerUserAgent = "User-Agent" bearerValue = "Bearer abc" - archiveConfigNever = "{\"expiry\":\"never\"}" ) func TestParseTargetHeaders_AcceptsPairs(t *testing.T) { @@ -41,23 +39,13 @@ func TestParseTargetHeaders_AcceptsPairs(t *testing.T) { ) } -// A configuration with no headers must stay indistinguishable from -// one written before the field existed, so omitempty drops the key. +// An input of blank lines is no headers, not an error. func TestParseTargetHeaders_EmptyInputYieldsNoHeaders(t *testing.T) { t.Parallel() got, err := delivery.ParseTargetHeaders("\n \n") require.NoError(t, err) assert.Empty(t, got) - - encoded, err := json.Marshal(delivery.HTTPTargetConfig{ - URL: "https://example.com/h", - Headers: got, - }) - require.NoError(t, err) - assert.JSONEq( - t, `{"url":"https://example.com/h"}`, string(encoded), - ) } func TestParseTargetHeaders_Rejects(t *testing.T) { @@ -181,89 +169,6 @@ func TestFormatTargetTimeout(t *testing.T) { assert.Empty(t, delivery.FormatTargetTimeout(-1)) } -func TestNewTargetConfigForm(t *testing.T) { - t.Parallel() - - form, err := delivery.NewTargetConfigForm(&database.Target{ - Type: database.TargetTypeHTTP, - Config: `{"url":"https://example.com/h",` + - `"headers":{"Authorization":"Bearer abc"},` + - `"timeout":9}`, - MaxAttempts: 1, - }) - require.NoError(t, err) - assert.Equal(t, "https://example.com/h", form.URL) - assert.Equal(t, "Authorization: Bearer abc\n", form.Headers) - assert.Equal(t, "9", form.Timeout) - - form, err = delivery.NewTargetConfigForm(&database.Target{ - Type: database.TargetTypeSlack, - Config: `{"webhookUrl":"https://hooks.example/s"}`, - MaxAttempts: 1, - }) - require.NoError(t, err) - assert.Equal(t, "https://hooks.example/s", form.URL) - - form, err = delivery.NewTargetConfigForm(&database.Target{ - Type: database.TargetTypeDatabase, - Config: `{"expiry":"720h","rotation":"daily"}`, - }) - require.NoError(t, err) - assert.Equal(t, "720h", form.Expiry) - assert.Equal(t, rotationDaily, form.Rotation) - - form, err = delivery.NewTargetConfigForm(&database.Target{ - Type: database.TargetTypeLog, - }) - require.NoError(t, err) - assert.Empty(t, form.URL) -} - -// A keep-forever archive target yields an empty expiry, so the edit -// form starts on never; saving it unchanged stores never, which means -// the same as an empty expiry. -func TestNewTargetConfigForm_DatabaseNeverIsBlank(t *testing.T) { - t.Parallel() - - for _, cfg := range []string{"", `{}`, archiveConfigNever} { - form, err := delivery.NewTargetConfigForm( - &database.Target{ - Type: database.TargetTypeDatabase, - Config: cfg, - }, - ) - require.NoError(t, err, cfg) - assert.Empty(t, form.Expiry, cfg) - } -} - -// An unreadable stored config is an error rather than a blank form -// that looks like a target with no settings, so the caller can tell -// the operator that saving replaces the stored value. -func TestNewTargetConfigForm_UnreadableConfigErrors(t *testing.T) { - t.Parallel() - - cases := []*database.Target{ - {Type: database.TargetTypeHTTP, Config: "not json", MaxAttempts: 1}, - {Type: database.TargetTypeHTTP, Config: `{}`, MaxAttempts: 1}, - {Type: database.TargetTypeSlack, Config: "", MaxAttempts: 1}, - { - Type: database.TargetTypeDatabase, - Config: `{"expiry":"soon"}`, - }, - { - Type: database.TargetTypeDatabase, - Config: weeklyConfig, - }, - {Type: database.TargetType("nope")}, - } - - for _, target := range cases { - _, err := delivery.NewTargetConfigForm(target) - require.Error(t, err, target.Type) - } -} - // The ceiling exists so one misconfigured target cannot hold a // delivery worker indefinitely, and it is inclusive. func TestParseTargetTimeout_CeilingIsInclusive(t *testing.T) { diff --git a/internal/delivery/target_http.go b/internal/delivery/target_http.go index b4ff824..839485e 100644 --- a/internal/delivery/target_http.go +++ b/internal/delivery/target_http.go @@ -4,7 +4,6 @@ import ( "bytes" "context" "encoding/json" - "errors" "fmt" "io" "net/http" @@ -17,29 +16,6 @@ import ( "sneak.berlin/go/webhooker/internal/database" ) -// Sentinel errors returned by the config parsers. -var ( - errEmptyTargetConfig = errors.New( - "empty target config", - ) - errMissingTargetURL = errors.New( - "target URL is required", - ) -) - -// HTTPTargetConfig holds configuration for http target -// types. -type HTTPTargetConfig struct { - URL string `json:"url"` - Headers map[string]string `json:"headers,omitempty"` - Timeout int `json:"timeout,omitempty"` - - // ForwardQuery passes each event's query string on to the target, - // appended to URL. Off, the target URL is sent exactly as - // configured. - ForwardQuery bool `json:"forwardQuery,omitempty"` -} - // httpCore holds the retry, backoff, and circuit-breaker // machinery shared by the HTTP and Slack targets. Each of // those targets owns its own httpCore instance (and thus its @@ -385,34 +361,8 @@ func (t *httpTarget) Deliver( task *Task, sched Scheduler, ) { - cfg, err := parseHTTPConfig(d.Target.Config) - if err != nil { - t.eng.log.Error( - "invalid HTTP target config", - "target_id", d.TargetID, - "error", err, - ) - - recErr := t.eng.recordResult( - webhookDB, d, task.AttemptNum, - false, 0, "", err.Error(), 0, - ) - if recErr != nil { - t.eng.bookkeepingFailed(d, recErr) - - return - } - - t.eng.settleStatus( - webhookDB, d, d.Target.Type, - database.DeliveryStatusFailed, - ) - - return - } - attempt := func() attemptResult { - return t.attempt(ctx, cfg, &d.Event) + return t.attempt(ctx, &d.Target, &d.Event) } t.deliver( @@ -427,11 +377,11 @@ func (t *httpTarget) Deliver( // string; only a transport-level error does. func (t *httpTarget) attempt( ctx context.Context, - cfg *HTTPTargetConfig, + target *database.Target, event *database.Event, ) attemptResult { statusCode, respBody, duration, reqErr := - t.doHTTPRequest(ctx, cfg, event) + t.doHTTPRequest(ctx, target, event) success := reqErr == nil && statusCode >= httpSuccessMin && @@ -453,7 +403,7 @@ func (t *httpTarget) attempt( func (t *httpTarget) doHTTPRequest( ctx context.Context, - cfg *HTTPTargetConfig, + target *database.Target, event *database.Event, ) (int, string, int64, error) { start := time.Now() @@ -461,7 +411,7 @@ func (t *httpTarget) doHTTPRequest( req, reqErr := http.NewRequestWithContext( ctx, http.MethodPost, - cfg.URL, + target.URL, bytes.NewReader([]byte(event.Body)), ) if reqErr != nil { @@ -471,15 +421,15 @@ func (t *httpTarget) doHTTPRequest( ) } - if cfg.ForwardQuery { + if target.ForwardQuery { appendQuery(req.URL, event.RawQuery) } originScoped := applyRequestHeaders( - req, event, cfg, t.eng.userAgent(), + req, event, target.Headers, t.eng.userAgent(), ) - client := t.clientForRequest(cfg, originScoped) + client := t.clientForRequest(target.TimeoutSeconds, originScoped) resp, doErr := executeHTTPRequest(client, req) @@ -519,17 +469,17 @@ func appendQuery(u *url.URL, rawQuery string) { } // clientForRequest returns the client for one delivery attempt. -// originScoped is the header set applyRequestHeaders built for that -// attempt; a request with neither a per-target timeout nor an -// origin-scoped header gets the shared client, because there is -// then nothing for the redirect policy to strip and net/http's -// default policy already withholds Authorization and Cookie across -// hosts. +// timeoutSeconds is the target's TimeoutSeconds, and originScoped is +// the header set applyRequestHeaders built for that attempt; a +// request with neither a per-target timeout nor an origin-scoped +// header gets the shared client, because there is then nothing for +// the redirect policy to strip and net/http's default policy already +// withholds Authorization and Cookie across hosts. func (t *httpTarget) clientForRequest( - cfg *HTTPTargetConfig, + timeoutSeconds int, originScoped []string, ) *http.Client { - if cfg.Timeout <= 0 && len(originScoped) == 0 { + if timeoutSeconds <= 0 && len(originScoped) == 0 { return t.client } @@ -542,9 +492,9 @@ func (t *httpTarget) clientForRequest( Transport: t.client.Transport, } - if cfg.Timeout > 0 { + if timeoutSeconds > 0 { client.Timeout = time.Duration( - cfg.Timeout, + timeoutSeconds, ) * time.Second } @@ -555,31 +505,6 @@ func (t *httpTarget) clientForRequest( return client } -func parseHTTPConfig( - configJSON string, -) (*HTTPTargetConfig, error) { - if configJSON == "" { - return nil, errEmptyTargetConfig - } - - var cfg HTTPTargetConfig - - err := json.Unmarshal( - []byte(configJSON), &cfg, - ) - if err != nil { - return nil, fmt.Errorf( - "parsing config JSON: %w", err, - ) - } - - if cfg.URL == "" { - return nil, errMissingTargetURL - } - - return &cfg, nil -} - // isForwardableHeader returns true if the header should // be forwarded to targets. func isForwardableHeader(name string) bool { @@ -611,11 +536,12 @@ func isForwardableHeader(name string) bool { // wins, otherwise the event's ContentType, otherwise none. The inbound // Content-Type in the event's headers is never forwarded. // -// userAgent is set last, over any configured or inbound User-Agent. +// configured is the target's Headers. userAgent is set last, over +// any configured or inbound User-Agent. func applyRequestHeaders( req *http.Request, event *database.Event, - cfg *HTTPTargetConfig, + configured map[string]string, userAgent string, ) []string { if event.ContentType != "" { @@ -626,7 +552,7 @@ func applyRequestHeaders( originScoped := forwardEventHeaders(req, event) - for k, v := range cfg.Headers { + for k, v := range configured { req.Header.Set(k, v) originScoped[http.CanonicalHeaderKey(k)] = struct{}{} } @@ -691,7 +617,7 @@ func forwardEventHeaders( } // executeHTTPRequest sends an HTTP request using the provided -// client. URLs are validated by the config parsers and the +// client. URLs are validated by the target forms and the // SSRF-safe transport before reaching here. // // Transport failures are masked here, at the single point diff --git a/internal/delivery/target_http_query_test.go b/internal/delivery/target_http_query_test.go index b06a398..d776a46 100644 --- a/internal/delivery/target_http_query_test.go +++ b/internal/delivery/target_http_query_test.go @@ -2,7 +2,6 @@ package delivery_test import ( "context" - "encoding/json" "net/http" "net/http/httptest" "testing" @@ -18,25 +17,13 @@ import ( // with. const eventQuery = "a=1&b=2" -// httpTargetConfig is the stored configuration of an HTTP target at -// targetURL. -func httpTargetConfig( - t *testing.T, targetURL string, forwardQuery bool, -) string { - t.Helper() - - cfg, err := json.Marshal(delivery.HTTPTargetConfig{ - URL: targetURL, ForwardQuery: forwardQuery, - }) - require.NoError(t, err) - - return string(cfg) -} - // deliverWithQuery sends one event that arrived with eventQuery to an -// HTTP target configured with cfg, through the path a received event's -// delivery takes, and returns the attempt it recorded. -func deliverWithQuery(t *testing.T, cfg string) database.DeliveryResult { +// HTTP target at targetURL, with its ForwardQuery setting as given, +// through the path a received event's delivery takes, and returns the +// attempt it recorded. +func deliverWithQuery( + t *testing.T, targetURL string, forwardQuery bool, +) database.DeliveryResult { t.Helper() s := newISetup(t) @@ -47,8 +34,10 @@ func deliverWithQuery(t *testing.T, cfg string) database.DeliveryResult { database.DeliveryStatusPending, ) task := iTask( - d, event, s.WebhookID, d.TargetID, "query", cfg, 1, 1, &event.Body, + d, event, s.WebhookID, d.TargetID, "query", targetURL, 1, 1, + &event.Body, ) + task.ForwardQuery = forwardQuery task.RawQuery = eventQuery s.Engine.ExportProcessNewTask(context.TODO(), &task) @@ -111,9 +100,9 @@ func TestDeliverHTTP_ForwardQuery(t *testing.T) { )) t.Cleanup(ts.Close) - result := deliverWithQuery(t, httpTargetConfig( + result := deliverWithQuery( t, ts.URL+tc.path, tc.forwardQuery, - )) + ) assert.True(t, result.Success) require.Len(t, received, 1) @@ -144,12 +133,13 @@ func TestDeliverHTTP_ForwardedQueryKeepsTheTargetURLMasked(t *testing.T) { t.Cleanup(ts.Close) target := &database.Target{ - Type: database.TargetTypeHTTP, - Config: httpTargetConfig(t, ts.URL+"/in?token="+secret, true), - MaxAttempts: 1, + Type: database.TargetTypeHTTP, + URL: ts.URL + "/in?token=" + secret, + ForwardQuery: true, + MaxAttempts: 1, } - deliverWithQuery(t, target.Config) + deliverWithQuery(t, target.URL, target.ForwardQuery) require.Len(t, received, 1) sent := <-received @@ -164,9 +154,9 @@ func TestDeliverHTTP_ForwardedQueryKeepsTheTargetURLMasked(t *testing.T) { } // Nothing listens on port 1. - failed := deliverWithQuery(t, httpTargetConfig( + failed := deliverWithQuery( t, "http://127.0.0.1:1/in?token="+secret, true, - )) + ) require.NotEmpty(t, failed.Error) assert.NotContains(t, failed.Error, secret) assert.NotContains(t, failed.Error, eventQuery) diff --git a/internal/delivery/target_redact.go b/internal/delivery/target_redact.go index 31d4867..e9dd06b 100644 --- a/internal/delivery/target_redact.go +++ b/internal/delivery/target_redact.go @@ -18,7 +18,7 @@ const RedactionMarker = "(redacted)" // line behind the delivery path's own URL masking. // // It removes byte-identical echoes of strings taken from the -// target's stored configuration, and nothing else. Anything +// target's URL and headers, and nothing else. Anything // the remote re-encodes survives: JSON "\/" escaping (what // PHP's json_encode emits by default), percent-encoding, HTML // entities, and an echo of only part of a path. It cannot @@ -115,7 +115,7 @@ func (r Redactor) secretPrefixSuffix(s string) int { } // targetSecrets returns the credential-bearing strings a -// target's configuration carries. +// target's settings carry. // // The destination URL contributes. Its path, query and // userinfo are the credential for both target types that have @@ -129,36 +129,15 @@ func (r Redactor) secretPrefixSuffix(s string) int { // picks out. That is the same class-based rule applied to // URLs: an echoed Accept or User-Agent still renders, an // echoed Authorization does not. +// +// A setting a type does not use is empty and contributes +// nothing, so a database or log target has nothing to redact. func targetSecrets(t *database.Target) []string { if t == nil { return nil } - switch t.Type { - case database.TargetTypeSlack: - cfg, err := parseSlackConfig(t.Config) - if err != nil { - return nil - } - - return urlSecrets(cfg.WebhookURL) - case database.TargetTypeHTTP: - cfg, err := parseHTTPConfig(t.Config) - if err != nil { - return nil - } - - return append( - urlSecrets(cfg.URL), - headerSecrets(cfg.Headers)..., - ) - case database.TargetTypeDatabase, database.TargetTypeLog: - // Neither has a destination URL, so neither has - // anything to redact. - return nil - default: - return nil - } + return append(urlSecrets(t.URL), headerSecrets(t.Headers)...) } // urlSecrets returns the substrings of a destination URL that diff --git a/internal/delivery/target_redact_test.go b/internal/delivery/target_redact_test.go index a4c010f..70c4d73 100644 --- a/internal/delivery/target_redact_test.go +++ b/internal/delivery/target_redact_test.go @@ -23,7 +23,7 @@ func TestRedactor_RemovesSlackWebhookURL(t *testing.T) { r := delivery.NewRedactor(&database.Target{ Type: database.TargetTypeSlack, - Config: `{"webhookUrl":"` + redactWebhookURL + `"}`, + URL: redactWebhookURL, MaxAttempts: 1, }) @@ -54,7 +54,7 @@ func TestRedactor_RemovesSecretSeveredByACut(t *testing.T) { r := delivery.NewRedactor(&database.Target{ Type: database.TargetTypeSlack, - Config: `{"webhookUrl":"` + redactWebhookURL + `"}`, + URL: redactWebhookURL, MaxAttempts: 1, }) @@ -86,18 +86,19 @@ func TestRedactor_RedactsCredentialShapedHeaderValues( r := delivery.NewRedactor(&database.Target{ Type: database.TargetTypeHTTP, - Config: `{"url":"https://example.com/in",` + - `"headers":{` + - `"Authorization":"Bearer AAAAAAAAAAAA",` + - `"Cookie":"session=BBBBBBBBBBBB",` + - `"X-Api-Key":"CCCCCCCCCCCC",` + - `"X-Hub-Signature":"sha256=DDDDDDDDDDDD",` + - `"X-Sig":"EEEEEEEEEEEE",` + - `"X-Pass":"FFFFFFFFFFFF",` + - `"X-HMAC":"GGGGGGGGGGGG",` + - `"X-Credential":"HHHHHHHHHHHH",` + - `"Accept":"application/json",` + - `"User-Agent":"webhooker/1.0"}}`, + URL: "https://example.com/in", + Headers: map[string]string{ + headerAuthorization: "Bearer AAAAAAAAAAAA", + "Cookie": "session=BBBBBBBBBBBB", + probeHeaderName: "CCCCCCCCCCCC", + "X-Hub-Signature": "sha256=DDDDDDDDDDDD", + "X-Sig": "EEEEEEEEEEEE", + "X-Pass": "FFFFFFFFFFFF", + "X-HMAC": "GGGGGGGGGGGG", + "X-Credential": "HHHHHHHHHHHH", + "Accept": "application/json", + headerUserAgent: "webhooker/1.0", + }, MaxAttempts: 1, }) @@ -136,9 +137,9 @@ func TestRedactor_IgnoresVeryShortHeaderValues(t *testing.T) { t.Parallel() r := delivery.NewRedactor(&database.Target{ - Type: database.TargetTypeHTTP, - Config: `{"url":"https://example.com/in",` + - `"headers":{"X-Api-Key":"ab"}}`, + Type: database.TargetTypeHTTP, + URL: "https://example.com/in", + Headers: map[string]string{probeHeaderName: "ab"}, MaxAttempts: 1, }) @@ -155,7 +156,7 @@ func TestRedactor_RemovesBarePath(t *testing.T) { r := delivery.NewRedactor(&database.Target{ Type: database.TargetTypeSlack, - Config: `{"webhookUrl":"` + redactWebhookURL + `"}`, + URL: redactWebhookURL, MaxAttempts: 1, }) @@ -189,7 +190,7 @@ func TestRedactor_RemovesHTTPURLQueryAndUserinfo(t *testing.T) { r := delivery.NewRedactor(&database.Target{ Type: database.TargetTypeHTTP, - Config: `{"url":"` + raw + `"}`, + URL: raw, MaxAttempts: 1, }) @@ -239,7 +240,7 @@ func TestRedactor_RemovesEchoedQueryOfURLWithoutPath(t *testing.T) { } { r := delivery.NewRedactor(&database.Target{ Type: database.TargetTypeHTTP, - Config: `{"url":"` + dest + `"}`, + URL: dest, MaxAttempts: 1, }) @@ -261,7 +262,7 @@ func TestRedactor_LeavesUnrelatedTextAlone(t *testing.T) { r := delivery.NewRedactor(&database.Target{ Type: database.TargetTypeSlack, - Config: `{"webhookUrl":"` + redactWebhookURL + `"}`, + URL: redactWebhookURL, MaxAttempts: 1, }) @@ -288,7 +289,7 @@ func TestRedactor_EmptyUserinfoDoesNotShredTheBody(t *testing.T) { r := delivery.NewRedactor(&database.Target{ Type: database.TargetTypeHTTP, - Config: `{"url":"` + dest + `"}`, + URL: dest, MaxAttempts: 1, }) @@ -306,11 +307,10 @@ func TestRedactor_EmptyUserinfoDoesNotShredTheBody(t *testing.T) { ) } -// TestRedactor_ZeroValueAndConfiglessTargets pins that a -// caller with no target, an unparseable config, or a target -// type with no destination URL gets a redactor that changes -// nothing rather than one that panics. -func TestRedactor_ZeroValueAndConfiglessTargets(t *testing.T) { +// TestRedactor_ZeroValueAndURLlessTargets pins that a caller +// with no target, or a target with no destination URL, gets a +// redactor that changes nothing rather than one that panics. +func TestRedactor_ZeroValueAndURLlessTargets(t *testing.T) { t.Parallel() const text = "some response body" @@ -323,8 +323,8 @@ func TestRedactor_ZeroValueAndConfiglessTargets(t *testing.T) { for _, tgt := range []database.Target{ {Type: database.TargetTypeLog}, {Type: database.TargetTypeDatabase}, - {Type: database.TargetTypeSlack, Config: "not json", MaxAttempts: 1}, - {Type: database.TargetTypeHTTP, Config: "", MaxAttempts: 1}, + {Type: database.TargetTypeSlack, MaxAttempts: 1}, + {Type: database.TargetTypeHTTP, MaxAttempts: 1}, } { assert.Equal( t, text, diff --git a/internal/delivery/target_slack.go b/internal/delivery/target_slack.go index 2b9129d..d7a6876 100644 --- a/internal/delivery/target_slack.go +++ b/internal/delivery/target_slack.go @@ -4,7 +4,6 @@ import ( "bytes" "context" "encoding/json" - "errors" "fmt" "io" "net/http" @@ -15,18 +14,6 @@ import ( "sneak.berlin/go/webhooker/internal/database" ) -// errMissingWebhookURL is returned when a Slack target config -// omits its webhook URL. -var errMissingWebhookURL = errors.New( - "webhook_url is required", -) - -// SlackTargetConfig holds configuration for slack target -// types. -type SlackTargetConfig struct { - WebhookURL string `json:"webhookUrl"` -} - // slackTarget delivers events to Slack incoming webhooks. It // formats the event into a Slack message and posts it as // JSON. It shares the retry core with the HTTP target: a @@ -46,19 +33,6 @@ func (t *slackTarget) Deliver( task *Task, sched Scheduler, ) { - cfg, err := parseSlackConfig(d.Target.Config) - if err != nil { - t.eng.log.Error( - "invalid Slack target config", - "target_id", d.TargetID, - "error", err, - ) - - t.failConfig(webhookDB, d, err) - - return - } - msg := FormatSlackMessage(&d.Event) payload, err := json.Marshal( @@ -71,13 +45,26 @@ func (t *slackTarget) Deliver( "error", err, ) - t.failConfig(webhookDB, d, err) + recErr := t.eng.recordResult( + webhookDB, d, 1, + false, 0, "", err.Error(), 0, + ) + if recErr != nil { + t.eng.bookkeepingFailed(d, recErr) + + return + } + + t.eng.settleStatus( + webhookDB, d, d.Target.Type, + database.DeliveryStatusFailed, + ) return } attempt := func() attemptResult { - return t.attempt(ctx, cfg, payload) + return t.attempt(ctx, d.Target.URL, payload) } t.deliver( @@ -86,37 +73,13 @@ func (t *slackTarget) Deliver( ) } -// failConfig records a first-attempt failure for a delivery -// that could not be prepared (bad config or unmarshalable -// payload) and marks it failed. -func (t *slackTarget) failConfig( - webhookDB *gorm.DB, - d *database.Delivery, - err error, -) { - recErr := t.eng.recordResult( - webhookDB, d, 1, - false, 0, "", err.Error(), 0, - ) - if recErr != nil { - t.eng.bookkeepingFailed(d, recErr) - - return - } - - t.eng.settleStatus( - webhookDB, d, d.Target.Type, - database.DeliveryStatusFailed, - ) -} - -// attempt performs a single Slack POST and derives its -// outcome, preserving the engine's original semantics: a +// attempt performs a single Slack POST to targetURL and derives +// its outcome, preserving the engine's original semantics: a // non-2xx response records an "HTTP " error string and // a transport error records a "sending request" error. func (t *slackTarget) attempt( ctx context.Context, - cfg *SlackTargetConfig, + targetURL string, payload []byte, ) attemptResult { start := time.Now() @@ -124,7 +87,7 @@ func (t *slackTarget) attempt( req, err := http.NewRequestWithContext( ctx, http.MethodPost, - cfg.WebhookURL, + targetURL, bytes.NewReader(payload), ) if err != nil { @@ -186,31 +149,6 @@ func (t *slackTarget) readSlackResponse( } } -func parseSlackConfig( - configJSON string, -) (*SlackTargetConfig, error) { - if configJSON == "" { - return nil, errEmptyTargetConfig - } - - var cfg SlackTargetConfig - - err := json.Unmarshal( - []byte(configJSON), &cfg, - ) - if err != nil { - return nil, fmt.Errorf( - "parsing config JSON: %w", err, - ) - } - - if cfg.WebhookURL == "" { - return nil, errMissingWebhookURL - } - - return &cfg, nil -} - // FormatSlackMessage builds a Slack-compatible message // string from a webhook event. func FormatSlackMessage( diff --git a/internal/delivery/terminal_state_test.go b/internal/delivery/terminal_state_test.go index 7f49ab5..bf23143 100644 --- a/internal/delivery/terminal_state_test.go +++ b/internal/delivery/terminal_state_test.go @@ -45,7 +45,7 @@ func tSeedDeletedTarget( iCreateTarget( t, s.MainDB, targetID, s.WebhookID, name, - database.TargetTypeHTTP, iHTTPConfig(url), 5, + database.TargetTypeHTTP, url, 5, ) event := iSeedEvent( @@ -122,9 +122,9 @@ func TestProcessDelivery_UnknownTargetType_RecordsWhy( ) target := database.Target{ - Name: "mystery", - Type: tUnknownType, - Config: iHTTPConfig("http://example.com/hook"), + Name: "mystery", + Type: tUnknownType, + URL: "http://example.com/hook", } target.ID = targetID @@ -140,7 +140,7 @@ func TestProcessDelivery_UnknownTargetType_RecordsWhy( body := event.Body task := iTask( seeded, event, s.WebhookID, targetID, "mystery", - target.Config, 0, 1, &body, + target.URL, 0, 1, &body, ) task.TargetType = tUnknownType @@ -346,11 +346,10 @@ func tRetryChainSetup( iCreateWebhook(t, s.MainDB, s.WebhookID, name) targetID := uuid.New().String() - cfg := iHTTPConfig(ts.URL) iCreateTarget( t, s.MainDB, targetID, s.WebhookID, name, - database.TargetTypeHTTP, cfg, 5, + database.TargetTypeHTTP, ts.URL, 5, ) event := iSeedEvent( @@ -367,7 +366,7 @@ func tRetryChainSetup( body := event.Body return iTask( - d, event, s.WebhookID, targetID, name, cfg, 5, 2, &body, + d, event, s.WebhookID, targetID, name, ts.URL, 5, 2, &body, ), targetID } @@ -528,7 +527,7 @@ func TestRecoverSingleRetry_TargetUnreadable_LeavesDeliveryAlone( iCreateTarget( t, s.MainDB, targetID, s.WebhookID, "healthy", database.TargetTypeHTTP, - iHTTPConfig("http://example.com/hook"), 5, + "http://example.com/hook", 5, ) event := iSeedEvent( diff --git a/internal/delivery/url_mask_test.go b/internal/delivery/url_mask_test.go index da5327c..bd17a04 100644 --- a/internal/delivery/url_mask_test.go +++ b/internal/delivery/url_mask_test.go @@ -2,7 +2,6 @@ package delivery_test import ( "context" - "encoding/json" "net/http" "net/http/httptest" "testing" @@ -71,13 +70,6 @@ func deliverSlackTo( e := testEngine(t, 1) targetID := uuid.New().String() - slackCfg, err := json.Marshal( - delivery.SlackTargetConfig{ - WebhookURL: webhookURL, - }, - ) - require.NoError(t, err) - event := seedEvent(t, db, `{"test":true}`) dlv := seedDelivery( @@ -87,7 +79,7 @@ func deliverSlackTo( d := buildSlackDelivery( dlv, event, targetID, - "test-slack-mask", string(slackCfg), + "test-slack-mask", webhookURL, ) e.ExportDeliverSlack(context.TODO(), db, d) @@ -160,13 +152,9 @@ func TestDoHTTPRequest_TransportErrorMasksURL(t *testing.T) { e := testEngine(t, 1) - cfg, err := e.ExportParseHTTPConfig( - newHTTPTargetConfig(host + maskSecretPath), - ) - require.NoError(t, err) - statusCode, _, _, reqErr := e.ExportDoHTTPRequest( - context.TODO(), cfg, + context.TODO(), + &database.Target{URL: host + maskSecretPath}, &database.Event{Body: `{"test":true}`}, ) require.Error(t, reqErr) @@ -188,7 +176,7 @@ func TestDoHTTPRequest_UnparsableURLIsMasked(t *testing.T) { statusCode, _, _, reqErr := e.ExportDoHTTPRequest( context.TODO(), - &delivery.HTTPTargetConfig{ + &database.Target{ URL: "https://hooks.example.com" + maskSecretPath + "\n", }, &database.Event{}, diff --git a/internal/delivery/user_agent_test.go b/internal/delivery/user_agent_test.go index 2ff6f3c..0f2d930 100644 --- a/internal/delivery/user_agent_test.go +++ b/internal/delivery/user_agent_test.go @@ -2,7 +2,6 @@ package delivery_test import ( "context" - "encoding/json" "net/http" "net/http/httptest" "net/netip" @@ -59,9 +58,9 @@ func TestUserAgent_IsTheBuildVersion(t *testing.T) { statusCode, _, _, err := e.ExportDoHTTPRequest( context.Background(), - &delivery.HTTPTargetConfig{ + &database.Target{ URL: ts.URL, - Headers: map[string]string{"User-Agent": "configured/1"}, + Headers: map[string]string{headerUserAgent: "configured/1"}, }, &database.Event{Headers: `{"User-Agent":["curl/8"]}`}, ) @@ -73,18 +72,13 @@ func TestUserAgent_IsTheBuildVersion(t *testing.T) { db := testWebhookDB(t) targetID := uuid.New().String() - slackCfg, err := json.Marshal( - delivery.SlackTargetConfig{WebhookURL: ts.URL}, - ) - require.NoError(t, err) - event := seedEvent(t, db, `{"action":"test"}`) dlv := seedDelivery( t, db, event.ID, targetID, database.DeliveryStatusPending, ) e.ExportDeliverSlack(context.Background(), db, buildSlackDelivery( - dlv, event, targetID, "test-slack", string(slackCfg), + dlv, event, targetID, "test-slack", ts.URL, )) require.Len(t, userAgents, 1, "the slack target sent no request") assert.Equal(t, want, <-userAgents, "slack target") diff --git a/internal/handlers/archive_expiry.go b/internal/handlers/archive_expiry.go index 38a328b..072c638 100644 --- a/internal/handlers/archive_expiry.go +++ b/internal/handlers/archive_expiry.go @@ -1,8 +1,14 @@ package handlers +import ( + "strconv" + "time" +) + const ( // archiveExpiryNever is the archive expiry that keeps archived - // events forever. A stored empty expiry means the same. + // events forever. It is stored as an ArchiveExpiry of 0, and an + // empty expiry on a form means the same. archiveExpiryNever = "never" // tmplKeyArchiveExpiryChoices is the template data key for the @@ -56,3 +62,19 @@ func archiveExpiryOptions(expiry string) []archiveChoice { return append([]archiveChoice{own}, options...) } + +// formatArchiveExpiry writes a stored ArchiveExpiry the way the +// expiry select spells its values, so the target edit form starts on +// the stored choice: never for 0, a whole number of hours as "720h", +// and anything else as Go writes a duration, which the form accepts +// back unchanged. +func formatArchiveExpiry(expiry time.Duration) string { + switch { + case expiry == 0: + return archiveExpiryNever + case expiry%time.Hour == 0: + return strconv.FormatInt(int64(expiry/time.Hour), 10) + "h" + default: + return expiry.String() + } +} diff --git a/internal/handlers/archive_expiry_test.go b/internal/handlers/archive_expiry_test.go index d740f2d..b0f0dbc 100644 --- a/internal/handlers/archive_expiry_test.go +++ b/internal/handlers/archive_expiry_test.go @@ -7,6 +7,7 @@ import ( "regexp" "strings" "testing" + "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -94,14 +95,18 @@ func TestArchiveExpiryChoices(t *testing.T) { env := setupSourceTest(t) - choices := []struct{ value, shown string }{ - {expiryNever, expiryNever}, - {"1h", "1 hour"}, - {"12h", "12 hours"}, - {"24h", "1 day"}, - {"720h", "30 days"}, - {"2160h", "90 days"}, - {"8760h", "365 days"}, + choices := []struct { + value string + stored time.Duration + shown string + }{ + {expiryNever, 0, expiryNever}, + {"1h", time.Hour, "1 hour"}, + {"12h", 12 * time.Hour, "12 hours"}, + {"24h", 24 * time.Hour, "1 day"}, + {"720h", 720 * time.Hour, "30 days"}, + {"2160h", 2160 * time.Hour, "90 days"}, + {"8760h", 8760 * time.Hour, "365 days"}, } for _, choice := range choices { @@ -122,9 +127,7 @@ func TestArchiveExpiryChoices(t *testing.T) { targets := targetsForWebhook(t, env.db, webhook.ID) require.Len(t, targets, 1) - assert.JSONEq( - t, `{"expiry":"`+choice.value+`"}`, targets[0].Config, - ) + assert.Equal(t, choice.stored, targets[0].ArchiveExpiry) assert.Equal( t, []string{choice.shown}, @@ -138,7 +141,7 @@ func TestArchiveExpiryChoices(t *testing.T) { } // TestArchiveExpiryEditStartsOnStoredValue checks the edit form of a -// database target whose stored expiry is empty, which selects never, +// database target whose stored expiry is 0, which selects never, // and of one whose expiry is not one of the choices, which is listed // first as its own selected entry and saved unchanged. func TestArchiveExpiryEditStartsOnStoredValue(t *testing.T) { @@ -148,7 +151,8 @@ func TestArchiveExpiryEditStartsOnStoredValue(t *testing.T) { webhook := seedWebhookWithRetention(t, env.db, 30) empty := seedConfiguredTarget( - t, env.db, webhook.ID, database.TargetTypeDatabase, "", + t, env.db, webhook.ID, database.TargetTypeDatabase, + database.Target{}, ) _, selected := expirySelected(t, env, webhook.ID, empty.ID) @@ -157,7 +161,7 @@ func TestArchiveExpiryEditStartsOnStoredValue(t *testing.T) { webhook = seedWebhookWithRetention(t, env.db, 30) unlisted := seedConfiguredTarget( t, env.db, webhook.ID, database.TargetTypeDatabase, - `{"expiry":"36h"}`, + database.Target{ArchiveExpiry: 36 * time.Hour}, ) assert.Equal(t, []string{"36 hours"}, expiryShown(t, env, webhook.ID)) @@ -179,7 +183,7 @@ func TestArchiveExpiryEditStartsOnStoredValue(t *testing.T) { w := submitTargetEdit(env, webhook.ID, unlisted.ID, form) require.Equal(t, http.StatusSeeOther, w.Code, w.Body.String()) - assert.JSONEq( - t, `{"expiry":"36h"}`, storedTarget(t, env, unlisted.ID).Config, + assert.Equal( + t, 36*time.Hour, storedTarget(t, env, unlisted.ID).ArchiveExpiry, ) } diff --git a/internal/handlers/archive_rotation_test.go b/internal/handlers/archive_rotation_test.go index 94b27bc..a2cfb8a 100644 --- a/internal/handlers/archive_rotation_test.go +++ b/internal/handlers/archive_rotation_test.go @@ -16,8 +16,12 @@ import ( "sneak.berlin/go/webhooker/internal/database" ) -// rotationNone is the archive rotation that keeps one file. -const rotationNone = "none" +// The archive rotations these tests store: none keeps one file. +const ( + rotationNone = "none" + rotationDaily = "daily" + rotationHourly = "hourly" +) // rotationShown returns the archive rotations the webhook page's // target list shows. @@ -41,7 +45,9 @@ func TestArchiveRotationChoices(t *testing.T) { env := setupSourceTest(t) - for _, rotation := range []string{rotationNone, "monthly", "daily", "hourly"} { + for _, rotation := range []string{ + rotationNone, "monthly", rotationDaily, rotationHourly, + } { t.Run(rotation, func(t *testing.T) { t.Parallel() @@ -60,10 +66,8 @@ func TestArchiveRotationChoices(t *testing.T) { targets := targetsForWebhook(t, env.db, webhook.ID) require.Len(t, targets, 1) - assert.JSONEq(t, - `{"expiry":"720h","rotation":"`+rotation+`"}`, - targets[0].Config, - ) + assert.Equal(t, 720*time.Hour, targets[0].ArchiveExpiry) + assert.Equal(t, rotation, targets[0].ArchiveRotation) assert.Equal(t, []string{rotation}, rotationShown(t, env, webhook.ID)) @@ -78,26 +82,37 @@ func TestArchiveRotationChoices(t *testing.T) { w = submitTargetEdit(env, webhook.ID, targets[0].ID, form) require.Equal(t, http.StatusSeeOther, w.Code, w.Body.String()) - assert.JSONEq(t, - `{"expiry":"720h","rotation":"hourly"}`, - storedTarget(t, env, targets[0].ID).Config, - ) + + stored := storedTarget(t, env, targets[0].ID) + assert.Equal(t, 720*time.Hour, stored.ArchiveExpiry) + assert.Equal(t, rotationHourly, stored.ArchiveRotation) }) } } -// TestArchiveRotationEditStartsOnNone checks the edit form of a -// database target with no rotation stored starts on none. +// TestArchiveRotationEditStartsOnNone checks that a database target +// added with no rotation is stored with none and shown with none, and +// that its edit form starts on none. func TestArchiveRotationEditStartsOnNone(t *testing.T) { t.Parallel() env := setupSourceTest(t) webhook := seedWebhookWithRetention(t, env.db, 30) - target := seedConfiguredTarget( - t, env.db, webhook.ID, database.TargetTypeDatabase, "", - ) - page := targetEditPage(t, env, webhook.ID, target.ID) + form := url.Values{} + form.Set("name", "archive") + form.Set("type", string(database.TargetTypeDatabase)) + + 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, rotationNone, targets[0].ArchiveRotation) + + page := targetEditPage(t, env, webhook.ID, targets[0].ID) assert.Equal(t, []string{rotationNone}, selectedIn(page, "rotation")) assert.Equal(t, []string{rotationNone}, rotationShown(t, env, webhook.ID)) } @@ -125,7 +140,7 @@ func TestArchiveRotationRefused(t *testing.T) { target := seedConfiguredTarget( t, env.db, webhook.ID, database.TargetTypeDatabase, - `{"rotation":"daily"}`, + database.Target{ArchiveRotation: rotationDaily}, ) form.Del("type") @@ -133,8 +148,8 @@ func TestArchiveRotationRefused(t *testing.T) { w = submitTargetEdit(env, webhook.ID, target.ID, form) assert.Equal(t, http.StatusBadRequest, w.Code) assert.Contains(t, w.Body.String(), "Invalid archive rotation") - assert.JSONEq(t, - `{"rotation":"daily"}`, storedTarget(t, env, target.ID).Config, + assert.Equal( + t, rotationDaily, storedTarget(t, env, target.ID).ArchiveRotation, ) } @@ -161,9 +176,8 @@ func TestHandleSourceCreateSubmit_ArchiveRotation(t *testing.T) { targets := targetsForWebhook(t, env.db, webhook.ID) require.Len(t, targets, 1) - assert.JSONEq(t, - `{"expiry":"720h","rotation":"daily"}`, targets[0].Config, - ) + assert.Equal(t, 720*time.Hour, targets[0].ArchiveExpiry) + assert.Equal(t, rotationDaily, targets[0].ArchiveRotation) } // TestArchiveFileView_Rotated describes a daily target's archive files @@ -178,7 +192,7 @@ func TestArchiveFileView_Rotated(t *testing.T) { webhook := seedWebhookWithRetention(t, env.db, 30) target := seedConfiguredTarget( t, env.db, webhook.ID, database.TargetTypeDatabase, - `{"rotation":"daily"}`, + database.Target{ArchiveRotation: rotationDaily}, ) path := archive.Path(env.dbMgr, &webhook, target) diff --git a/internal/handlers/delivery_replay.go b/internal/handlers/delivery_replay.go index 091ba4f..9a2c0a3 100644 --- a/internal/handlers/delivery_replay.go +++ b/internal/handlers/delivery_replay.go @@ -300,21 +300,26 @@ func createReplayDelivery( } return delivery.Task{ - DeliveryID: dlv.ID, - EventID: event.ID, - WebhookID: webhookID, - EntrypointID: event.EntrypointID, - TargetID: target.ID, - TargetName: target.Name, - TargetType: target.Type, - TargetConfig: target.Config, - MaxAttempts: target.MaxAttempts, - Method: event.Method, - RawQuery: event.RawQuery, - Headers: event.Headers, - ContentType: event.ContentType, - Body: replayBody(event.Body), - AttemptNum: 1, + DeliveryID: dlv.ID, + EventID: event.ID, + WebhookID: webhookID, + EntrypointID: event.EntrypointID, + TargetID: target.ID, + TargetName: target.Name, + TargetType: target.Type, + TargetURL: target.URL, + TargetHeaders: target.Headers, + TimeoutSeconds: target.TimeoutSeconds, + ForwardQuery: target.ForwardQuery, + ArchiveExpiry: target.ArchiveExpiry, + ArchiveRotation: target.ArchiveRotation, + MaxAttempts: target.MaxAttempts, + Method: event.Method, + RawQuery: event.RawQuery, + Headers: event.Headers, + ContentType: event.ContentType, + Body: replayBody(event.Body), + AttemptNum: 1, }, nil } diff --git a/internal/handlers/delivery_replay_test.go b/internal/handlers/delivery_replay_test.go index b9ed423..a4dd6d2 100644 --- a/internal/handlers/delivery_replay_test.go +++ b/internal/handlers/delivery_replay_test.go @@ -192,7 +192,7 @@ func TestHandleDeliveryReplay_AppendsDeliveryAndLeavesOriginal( wh := seedWebhook(t, db) tgt := seedConfiguredTarget( t, db, wh.ID, database.TargetTypeHTTP, - `{"url":"`+replayTargetURL+`"}`, + database.Target{URL: replayTargetURL}, ) event, original := seedFailedDelivery( @@ -205,13 +205,13 @@ func TestHandleDeliveryReplay_AppendsDeliveryAndLeavesOriginal( before := loadDelivery(t, webhookDB, original.ID) // The operator fixes the destination, which is the whole reason - // to replay. The replay must use this, not the config the - // original delivery ran against. - const fixedConfig = `{"url":"http://93.184.216.34/fixed"}` + // to replay. The replay must use this, not the URL the original + // delivery ran against. + const fixedURL = "http://93.184.216.34/fixed" require.NoError(t, db.DB().Model(&database.Target{}). Where("id = ?", tgt.ID). - Update("config", fixedConfig).Error) + Update("url", fixedURL).Error) w := postReplay(t, h, sess, wh.ID, original.ID) @@ -240,7 +240,7 @@ func TestHandleDeliveryReplay_AppendsDeliveryAndLeavesOriginal( tasks := notif.Tasks() require.Len(t, tasks, 1) assertReplayTask( - t, tasks[0], wh.ID, event, tgt, replayed.ID, fixedConfig, + t, tasks[0], wh.ID, event, tgt, replayed.ID, fixedURL, ) assertNoLeakedTarget(t, webhookDB) } @@ -277,15 +277,15 @@ func assertDeliveryUntouched( // assertReplayTask proves the task handed to the delivery engine is // the one the receiver would build for this event and this target, and -// that it carries wantConfig — the target's configuration as it stands -// now rather than as the original delivery ran against it. +// that it carries wantURL — the target's URL as it stands now rather +// than as the original delivery ran against it. func assertReplayTask( t *testing.T, task delivery.Task, webhookID string, event *database.Event, target *database.Target, - wantDeliveryID, wantConfig string, + wantDeliveryID, wantURL string, ) { t.Helper() @@ -295,9 +295,9 @@ func assertReplayTask( assert.Equal(t, event.EntrypointID, task.EntrypointID) assert.Equal(t, target.ID, task.TargetID) assert.Equal(t, target.Type, task.TargetType) - assert.JSONEq( - t, wantConfig, task.TargetConfig, - "replay must use the target's current configuration", + assert.Equal( + t, wantURL, task.TargetURL, + "replay must use the target's current URL", ) assert.Equal(t, event.Method, task.Method) assert.Equal( @@ -327,6 +327,43 @@ func assertNoLeakedTarget(t *testing.T, webhookDB *gorm.DB) { ) } +// TestHandleDeliveryReplay_TaskCarriesEveryTargetSetting proves the +// task a replay hands the delivery engine carries every one of the +// target's settings. +func TestHandleDeliveryReplay_TaskCarriesEveryTargetSetting( + t *testing.T, +) { + t.Parallel() + + var ( + h *handlers.Handlers + sess *session.Session + db *database.Database + dbMgr *database.WebhookDBManager + notif *recordingNotifier + ) + + app := newTestApp(t, &h, &sess, &db, &dbMgr, ¬if) + app.RequireStart() + + t.Cleanup(app.RequireStop) + + wh := seedWebhook(t, db) + tgt := seedConfiguredTarget( + t, db, wh.ID, database.TargetTypeHTTP, + targetWithEverySetting(), + ) + + _, original := seedFailedDelivery(t, dbMgr, wh.ID, tgt.ID) + + w := postReplay(t, h, sess, wh.ID, original.ID) + require.Equal(t, http.StatusSeeOther, w.Code) + + tasks := notif.Tasks() + require.Len(t, tasks, 1) + assertTaskCarriesTargetSettings(t, tasks[0], tgt) +} + // TestHandleDeliveryReplay_RefusesDeletedTarget proves the required // refusal: a target deleted since the delivery ran is reported as // deleted rather than erroring, and nothing is created or queued. @@ -349,7 +386,7 @@ func TestHandleDeliveryReplay_RefusesDeletedTarget(t *testing.T) { wh := seedWebhook(t, db) tgt := seedConfiguredTarget( t, db, wh.ID, database.TargetTypeHTTP, - `{"url":"`+replayTargetURL+`"}`, + database.Target{URL: replayTargetURL}, ) event, original := seedFailedDelivery( @@ -424,7 +461,7 @@ func TestHandleDeliveryReplay_RefusesWhileEarlierReplayInFlight( wh := seedWebhook(t, db) tgt := seedConfiguredTarget( t, db, wh.ID, database.TargetTypeHTTP, - `{"url":"`+replayTargetURL+`"}`, + database.Target{URL: replayTargetURL}, ) event, original := seedFailedDelivery( @@ -498,7 +535,7 @@ func TestHandleDeliveryReplay_ReturnsToTheListItWasPressedIn( wh := seedWebhook(t, db) tgt := seedConfiguredTarget( t, db, wh.ID, database.TargetTypeHTTP, - `{"url":"`+replayTargetURL+`"}`, + database.Target{URL: replayTargetURL}, ) _, original := seedFailedDelivery(t, dbMgr, wh.ID, tgt.ID) @@ -556,7 +593,7 @@ func TestHandleSourceLogs_RendersReplayControlAndBanner(t *testing.T) { wh := seedWebhook(t, db) tgt := seedConfiguredTarget( t, db, wh.ID, database.TargetTypeHTTP, - `{"url":"`+replayTargetURL+`"}`, + database.Target{URL: replayTargetURL}, ) _, original := seedFailedDelivery(t, dbMgr, wh.ID, tgt.ID) @@ -615,7 +652,7 @@ func TestHandleDeliveryReplay_LabelsTheReplay(t *testing.T) { wh := seedWebhook(t, db) tgt := seedConfiguredTarget( t, db, wh.ID, database.TargetTypeHTTP, - `{"url":"`+replayTargetURL+`"}`, + database.Target{URL: replayTargetURL}, ) event, original := seedFailedDelivery(t, dbMgr, wh.ID, tgt.ID) diff --git a/internal/handlers/delivery_result_view_test.go b/internal/handlers/delivery_result_view_test.go index 37a9e41..cb90369 100644 --- a/internal/handlers/delivery_result_view_test.go +++ b/internal/handlers/delivery_result_view_test.go @@ -86,12 +86,12 @@ func seedFailedDeliveryWithResponse( } // seedFailureAndRender seeds a failed delivery against a -// target of the given type and config, and returns the +// target of the given type and URL, and returns the // rendered event log page. func seedFailureAndRender( t *testing.T, targetType database.TargetType, - config, responseBody string, + targetURL, responseBody string, ) string { t.Helper() @@ -109,7 +109,7 @@ func seedFailureAndRender( wh := seedWebhook(t, db) tgt := seedConfiguredTarget( - t, db, wh.ID, targetType, config, + t, db, wh.ID, targetType, database.Target{URL: targetURL}, ) seedFailedDeliveryWithResponse( @@ -129,7 +129,7 @@ func TestHandleSourceLogs_RendersFailedAttempt(t *testing.T) { body := seedFailureAndRender( t, database.TargetTypeHTTP, - `{"url":"https://example.com/hook/abc"}`, + "https://example.com/hook/abc", "upstream exploded", ) @@ -167,7 +167,7 @@ func TestHandleSourceLogs_EscapesResponseBody(t *testing.T) { body := seedFailureAndRender( t, database.TargetTypeHTTP, - `{"url":"https://example.com/hook/abc"}`, + "https://example.com/hook/abc", payload, ) @@ -189,7 +189,7 @@ func TestHandleSourceLogs_RedactsCredentialEchoedInResponse( body := seedFailureAndRender( t, database.TargetTypeSlack, - `{"webhookUrl":"`+slackWebhookURL+`"}`, + slackWebhookURL, "no_service: "+slackWebhookURL, ) @@ -228,7 +228,7 @@ func TestHandleSourceLogs_RedactsCredentialEchoedInError( tgt := seedConfiguredTarget( t, db, wh.ID, database.TargetTypeSlack, - `{"webhookUrl":"`+slackWebhookURL+`"}`, + database.Target{URL: slackWebhookURL}, ) dlv := seedFailedDeliveryWithResponse(t, dbMgr, wh.ID, tgt.ID, "") @@ -300,7 +300,7 @@ func TestHandleSourceLogs_RedactsCredentialSeveredByTheEngineCut( body := seedFailureAndRender( t, database.TargetTypeSlack, - `{"webhookUrl":"`+slackWebhookURL+`"}`, + slackWebhookURL, stored, ) @@ -335,7 +335,7 @@ func TestHandleSourceLogs_RedactsCredentialSeveredBySQLCut( body := seedFailureAndRender( t, database.TargetTypeSlack, - `{"webhookUrl":"`+slackWebhookURL+`"}`, + slackWebhookURL, stored, ) @@ -373,7 +373,7 @@ func TestHandleSourceLogs_RedactsForSoftDeletedTarget( tgt := seedConfiguredTarget( t, db, wh.ID, database.TargetTypeSlack, - `{"webhookUrl":"`+slackWebhookURL+`"}`, + database.Target{URL: slackWebhookURL}, ) seedFailedDeliveryWithResponse( @@ -413,7 +413,7 @@ func TestHandleSourceLogs_BoundsRenderedAttempts(t *testing.T) { wh := seedWebhook(t, db) tgt := seedConfiguredTarget( - t, db, wh.ID, database.TargetTypeLog, "", + t, db, wh.ID, database.TargetTypeLog, database.Target{}, ) dlv := seedFailedDeliveryWithResponse(t, dbMgr, wh.ID, tgt.ID, "") @@ -480,7 +480,7 @@ func TestHandleSourceLogs_BoundsOversizeResponse(t *testing.T) { wh := seedWebhook(t, db) tgt := seedConfiguredTarget( - t, db, wh.ID, database.TargetTypeLog, "", + t, db, wh.ID, database.TargetTypeLog, database.Target{}, ) stored := strings.Repeat("A", responseCap*4) + tail diff --git a/internal/handlers/event_log.go b/internal/handlers/event_log.go index 52f17bf..6c845cc 100644 --- a/internal/handlers/event_log.go +++ b/internal/handlers/event_log.go @@ -13,8 +13,8 @@ import ( // DeliveryView is the display-safe projection of a delivery // for the event log page. Its target is a TargetView, so the -// stored configuration blob — which holds the target's -// credential — has no path to the template. +// target's URL and headers — which hold the target's +// credential — have no path to the template. type DeliveryView struct { ID string Status database.DeliveryStatus @@ -126,7 +126,7 @@ func (h *Handlers) HandleSourceLogs() http.HandlerFunc { // loadTargetMap loads targets into a map of display-safe // views keyed by target ID, each paired with its redactor. // The projection happens here so that no caller can hand a -// raw target, configuration blob and all, to a template: the +// raw target, URL and headers and all, to a template: the // raw rows do not leave this function. // // The load is Unscoped because deleting a target only soft diff --git a/internal/handlers/event_resubmit_test.go b/internal/handlers/event_resubmit_test.go index 7ba344e..da48546 100644 --- a/internal/handlers/event_resubmit_test.go +++ b/internal/handlers/event_resubmit_test.go @@ -151,7 +151,7 @@ func TestHandleEventResubmit_DeliversToTargetCreatedAfterTheEvent( // captured. It has no prior delivery of anything. tgt := seedConfiguredTarget( t, db, wh.ID, database.TargetTypeHTTP, - `{"url":"`+resubmitTargetURL+`"}`, + database.Target{URL: resubmitTargetURL}, ) w := postResubmit(t, h, sess, wh.ID, original.ID) @@ -274,7 +274,7 @@ func TestHandleEventResubmit_IsRepeatable(t *testing.T) { wh := seedWebhook(t, db) tgt := seedConfiguredTarget( t, db, wh.ID, database.TargetTypeHTTP, - `{"url":"`+resubmitTargetURL+`"}`, + database.Target{URL: resubmitTargetURL}, ) original := seedStoredEvent(t, dbMgr, wh.ID, `{"fire":"again"}`) @@ -368,7 +368,7 @@ func TestHandleEventResubmit_OversizeBodySurvivesIntact(t *testing.T) { wh := seedWebhook(t, db) seedConfiguredTarget( t, db, wh.ID, database.TargetTypeHTTP, - `{"url":"`+resubmitTargetURL+`"}`, + database.Target{URL: resubmitTargetURL}, ) original := seedStoredEvent(t, dbMgr, wh.ID, stored) @@ -425,11 +425,11 @@ func TestHandleEventResubmit_SkipsInactiveTarget(t *testing.T) { wh := seedWebhook(t, db) active := seedConfiguredTarget( t, db, wh.ID, database.TargetTypeHTTP, - `{"url":"`+resubmitTargetURL+`"}`, + database.Target{URL: resubmitTargetURL}, ) off := seedConfiguredTarget( t, db, wh.ID, database.TargetTypeHTTP, - `{"url":"`+resubmitTargetURL+`/off"}`, + database.Target{URL: resubmitTargetURL + "/off"}, ) require.NoError(t, db.DB().Model(&database.Target{}). @@ -528,7 +528,7 @@ func TestHandleEventResubmit_RefusesEventOfAnotherWebhook( mine := seedWebhook(t, db) seedConfiguredTarget( t, db, mine.ID, database.TargetTypeHTTP, - `{"url":"`+resubmitTargetURL+`"}`, + database.Target{URL: resubmitTargetURL}, ) seedStoredEvent(t, dbMgr, mine.ID, `{"mine":true}`) @@ -575,7 +575,7 @@ func TestHandleSourceLogs_ShowsResubmitProvenance(t *testing.T) { wh := seedWebhook(t, db) seedConfiguredTarget( t, db, wh.ID, database.TargetTypeHTTP, - `{"url":"`+resubmitTargetURL+`"}`, + database.Target{URL: resubmitTargetURL}, ) original := seedStoredEvent(t, dbMgr, wh.ID, `{"trace":"me"}`) diff --git a/internal/handlers/export_test.go b/internal/handlers/export_test.go index 6357e46..73a832e 100644 --- a/internal/handlers/export_test.go +++ b/internal/handlers/export_test.go @@ -133,36 +133,28 @@ func (s *Handlers) RenderTemplateForTest( s.renderTemplate(w, r, pageTemplate, data) } -// BuildSlackTargetConfigForTest exposes -// buildSlackTargetConfig for use in the handlers_test package. -func (s *Handlers) BuildSlackTargetConfigForTest( +// SetSlackTargetSettingsForTest exposes setSlackTargetSettings for use +// in the handlers_test package. +func (s *Handlers) SetSlackTargetSettingsForTest( ctx context.Context, + target *database.Target, targetURL string, -) (string, string, error) { - return s.buildSlackTargetConfig(ctx, targetURL) +) string { + return s.setSlackTargetSettings(ctx, target, targetURL) } -// BuildHTTPTargetConfigForTest exposes buildHTTPTargetConfig -// for use in the handlers_test package, taking the form fields -// an HTTP target's configuration is built from. -func (s *Handlers) BuildHTTPTargetConfigForTest( - ctx context.Context, - targetURL, headers, timeout string, -) (string, string, error) { - return s.buildHTTPTargetConfig(ctx, targetFormInput{ - URL: targetURL, - Headers: headers, - Timeout: timeout, - }) -} - -// BuildDatabaseTargetConfigForTest exposes -// buildDatabaseTargetConfig for use in the handlers_test -// package. -func BuildDatabaseTargetConfigForTest( +// SetDatabaseTargetSettingsForTest exposes setDatabaseTargetSettings +// for use in the handlers_test package. +func SetDatabaseTargetSettingsForTest( + target *database.Target, expiry, rotation string, -) (string, string, error) { - return buildDatabaseTargetConfig(expiry, rotation) +) string { + return setDatabaseTargetSettings(target, expiry, rotation) +} + +// FormatArchiveExpiryForTest exposes formatArchiveExpiry. +func FormatArchiveExpiryForTest(expiry time.Duration) string { + return formatArchiveExpiry(expiry) } // ArchiveFileViewForTest exposes archiveFileView, which describes a diff --git a/internal/handlers/handlers_test.go b/internal/handlers/handlers_test.go index be87aad..3d476de 100644 --- a/internal/handlers/handlers_test.go +++ b/internal/handlers/handlers_test.go @@ -8,6 +8,7 @@ import ( "log/slog" "net/http" "net/http/httptest" + "strconv" "sync" "testing" "time" @@ -354,7 +355,7 @@ func TestHandleIndex_Authenticated(t *testing.T) { ) } -func TestBuildSlackTargetConfig_AcceptsPublicURL(t *testing.T) { +func TestSetSlackTargetSettings_AcceptsPublicURL(t *testing.T) { t.Parallel() var h *handlers.Handlers @@ -364,16 +365,17 @@ func TestBuildSlackTargetConfig_AcceptsPublicURL(t *testing.T) { t.Cleanup(app.RequireStop) - cfg, errMsg, err := h.BuildSlackTargetConfigForTest( - t.Context(), "http://93.184.216.34/services/T00/B00/xxx", - ) + const hookURL = "http://93.184.216.34/services/T00/B00/xxx" + + var target database.Target + + errMsg := h.SetSlackTargetSettingsForTest(t.Context(), &target, hookURL) - require.NoError(t, err) assert.Empty(t, errMsg) - assert.Contains(t, cfg, "webhookUrl") + assert.Equal(t, hookURL, target.URL) } -func TestBuildSlackTargetConfig_RejectsReservedURL(t *testing.T) { +func TestSetSlackTargetSettings_RejectsReservedURL(t *testing.T) { t.Parallel() var h *handlers.Handlers @@ -383,13 +385,14 @@ func TestBuildSlackTargetConfig_RejectsReservedURL(t *testing.T) { t.Cleanup(app.RequireStop) - cfg, errMsg, err := h.BuildSlackTargetConfigForTest( - t.Context(), "http://169.254.169.254/latest/meta-data/", + var target database.Target + + errMsg := h.SetSlackTargetSettingsForTest( + t.Context(), &target, "http://169.254.169.254/latest/meta-data/", ) - require.NoError(t, err) assert.Contains(t, errMsg, "Invalid target URL") - assert.Empty(t, cfg) + assert.Empty(t, target.URL) } func TestRenderTemplate(t *testing.T) { @@ -483,72 +486,100 @@ func TestRenderTemplateMidRenderErrorSendsNoPartialBody(t *testing.T) { ) } -func TestBuildDatabaseTargetConfig_Valid(t *testing.T) { +func TestSetDatabaseTargetSettings_Valid(t *testing.T) { t.Parallel() - // Empty expiry and rotation: the keep-forever, one-file default, - // empty config. - cfg, errMsg, err := handlers.BuildDatabaseTargetConfigForTest("", "") - require.NoError(t, err) - assert.Empty(t, errMsg) - assert.Empty(t, cfg) + cases := []struct { + expiry, rotation string + wantExpiry time.Duration + wantRotation string + }{ + // Empty expiry and rotation: the keep-forever, one-file + // default. + {"", "", 0, rotationNone}, + {expiryNever, "", 0, rotationNone}, + {" 36h ", "", 36 * time.Hour, rotationNone}, + {"", rotationDaily, 0, rotationDaily}, + {"90m", rotationHourly, 90 * time.Minute, rotationHourly}, + } - // Explicit never is stored as config. - cfg, errMsg, err = handlers.BuildDatabaseTargetConfigForTest("never", "") - require.NoError(t, err) - assert.Empty(t, errMsg) - assert.JSONEq(t, `{"expiry":"never"}`, cfg) + for _, tc := range cases { + var target database.Target - // A positive duration is stored as config. - cfg, errMsg, err = handlers.BuildDatabaseTargetConfigForTest("720h", "") - require.NoError(t, err) - assert.Empty(t, errMsg) - assert.JSONEq(t, `{"expiry":"720h"}`, cfg) + errMsg := handlers.SetDatabaseTargetSettingsForTest( + &target, tc.expiry, tc.rotation, + ) - // A rotation is stored as config, with or without an expiry. - cfg, errMsg, err = handlers.BuildDatabaseTargetConfigForTest("", "daily") - require.NoError(t, err) - assert.Empty(t, errMsg) - assert.JSONEq(t, `{"rotation":"daily"}`, cfg) - - cfg, errMsg, err = handlers.BuildDatabaseTargetConfigForTest( - "720h", "hourly", - ) - require.NoError(t, err) - assert.Empty(t, errMsg) - assert.JSONEq(t, `{"expiry":"720h","rotation":"hourly"}`, cfg) + assert.Empty(t, errMsg, tc) + assert.Equal(t, tc.wantExpiry, target.ArchiveExpiry, tc) + assert.Equal(t, tc.wantRotation, target.ArchiveRotation, tc) + } } -func TestBuildDatabaseTargetConfig_RejectsBadExpiry( +// TestSetDatabaseTargetSettings_RejectsBadExpiry pins each refusal's +// whole message, as the form shows it. +func TestSetDatabaseTargetSettings_RejectsBadExpiry( t *testing.T, ) { t.Parallel() - for _, bad := range []string{"nonsense", "7d", "-5h"} { - cfg, errMsg, err := handlers.BuildDatabaseTargetConfigForTest(bad, "") + cases := map[string]string{ + "nonsense": `Invalid archive expiry: expiry must be "never" or ` + + `a Go duration such as "720h": time: invalid duration ` + + `"nonsense"`, + "7d": `Invalid archive expiry: expiry must be "never" or a Go ` + + `duration such as "720h": time: unknown unit "d" in ` + + `duration "7d"`, + "-5h": `Invalid archive expiry: expiry must be a positive ` + + `duration or "never": "-5h"`, + } - require.NoError(t, err) - assert.Contains( - t, errMsg, "Invalid archive expiry", - "expiry %q should be refused", bad, + for bad, want := range cases { + var target database.Target + + errMsg := handlers.SetDatabaseTargetSettingsForTest( + &target, bad, "", ) - assert.Empty(t, cfg) + + assert.Equal(t, want, errMsg, "expiry %q", bad) } } -func TestBuildDatabaseTargetConfig_RejectsBadRotation( +func TestSetDatabaseTargetSettings_RejectsBadRotation( t *testing.T, ) { t.Parallel() for _, bad := range []string{"weekly", "Daily", " none"} { - cfg, errMsg, err := handlers.BuildDatabaseTargetConfigForTest("", bad) + var target database.Target - require.NoError(t, err) - assert.Contains( - t, errMsg, "Invalid archive rotation", - "rotation %q should be refused", bad, + errMsg := handlers.SetDatabaseTargetSettingsForTest( + &target, "", bad, + ) + + assert.Equal( + t, + "Invalid archive rotation: rotation must be none, "+ + "monthly, daily or hourly: "+strconv.Quote(bad), + errMsg, ) - assert.Empty(t, cfg) + } +} + +// TestFormatArchiveExpiry covers the expiries the expiry choices do +// not: TestArchiveExpiryChoices shows the edit form starting on each +// choice. An expiry in whole hours is written in hours, and any other +// as Go writes it, which the form accepts back unchanged. +func TestFormatArchiveExpiry(t *testing.T) { + t.Parallel() + + cases := map[time.Duration]string{ + 0: expiryNever, + 36 * time.Hour: "36h", + 90 * time.Minute: "1h30m0s", + } + + for expiry, want := range cases { + assert.Equal(t, want, handlers.FormatArchiveExpiryForTest(expiry)) } } diff --git a/internal/handlers/source_create_targets_test.go b/internal/handlers/source_create_targets_test.go index 443cded..105c5f6 100644 --- a/internal/handlers/source_create_targets_test.go +++ b/internal/handlers/source_create_targets_test.go @@ -7,6 +7,7 @@ import ( "net/http/httptest" "net/url" "testing" + "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -54,15 +55,26 @@ func TestHandleSourceCreateSubmit_CreatesRequestedTargets(t *testing.T) { env := setupSourceTest(t) - // Each value the archive pruning choice submits, after an empty - // one that stands for the archive checkbox left off. - expiries := []string{ - "", "never", "1h", "12h", "24h", "720h", "2160h", "8760h", + // Each value the archive pruning choice submits and the expiry it + // stores, after an empty one that stands for the archive checkbox + // left off. + expiries := []struct { + value string + stored time.Duration + }{ + {"", 0}, + {expiryNever, 0}, + {"1h", time.Hour}, + {"12h", 12 * time.Hour}, + {"24h", 24 * time.Hour}, + {"720h", 720 * time.Hour}, + {"2160h", 2160 * time.Hour}, + {"8760h", 8760 * time.Hour}, } for _, httpURL := range []string{"", editOriginalURL} { for _, expiry := range expiries { - name := "url=" + httpURL + " archive=" + expiry + name := "url=" + httpURL + " archive=" + expiry.value t.Run(name, func(t *testing.T) { t.Parallel() @@ -72,9 +84,9 @@ func TestHandleSourceCreateSubmit_CreatesRequestedTargets(t *testing.T) { form.Set("http_url", httpURL) form.Set("archive_expiry", "720h") - if expiry != "" { + if expiry.value != "" { form.Set("archive", "on") - form.Set("archive_expiry", expiry) + form.Set("archive_expiry", expiry.value) } w := submitCreateForm(env, form) @@ -96,17 +108,16 @@ func TestHandleSourceCreateSubmit_CreatesRequestedTargets(t *testing.T) { wantCount++ assert.Equal(t, "HTTP", byType[database.TargetTypeHTTP].Name) - assert.JSONEq(t, `{"url":"`+httpURL+`"}`, - byType[database.TargetTypeHTTP].Config) + assert.Equal(t, httpURL, byType[database.TargetTypeHTTP].URL) } - if expiry != "" { + if expiry.value != "" { wantCount++ assert.Equal(t, "Archive", byType[database.TargetTypeDatabase].Name) - assert.JSONEq(t, `{"expiry":"`+expiry+`"}`, - byType[database.TargetTypeDatabase].Config) + assert.Equal(t, expiry.stored, + byType[database.TargetTypeDatabase].ArchiveExpiry) } assert.Len(t, byType, wantCount) diff --git a/internal/handlers/source_detail_test.go b/internal/handlers/source_detail_test.go index 6ccfc13..34c592b 100644 --- a/internal/handlers/source_detail_test.go +++ b/internal/handlers/source_detail_test.go @@ -5,6 +5,7 @@ import ( "net/http" "net/http/httptest" "testing" + "time" "github.com/go-chi/chi" "github.com/stretchr/testify/assert" @@ -25,25 +26,24 @@ const ( slackSecretPath ) -// seedConfiguredTarget inserts a target with a stored config -// blob and returns it. +// seedConfiguredTarget inserts an active target of targetType with +// the settings (URL to ArchiveRotation) that settings carries, and +// returns it. func seedConfiguredTarget( t *testing.T, db *database.Database, webhookID string, targetType database.TargetType, - config string, + settings database.Target, ) *database.Target { t.Helper() - tgt := &database.Target{ - WebhookID: webhookID, - Name: "t-" + string(targetType), - Type: targetType, - Active: true, - Config: config, - MaxAttempts: 1, - } + tgt := &settings + tgt.WebhookID = webhookID + tgt.Name = "t-" + string(targetType) + tgt.Type = targetType + tgt.Active = true + tgt.MaxAttempts = 1 require.NoError( t, @@ -108,89 +108,59 @@ func serveSourceDetailPage( return w } -// TestHandleSourceDetail_MasksSlackWebhookURL is the -// load-bearing regression test for the credential leak: the -// rendered page must show the Slack target without any of the -// secret path segments of its webhook URL. -func TestHandleSourceDetail_MasksSlackWebhookURL(t *testing.T) { +// TestHandleSourceDetail_MasksTargetURL is the load-bearing +// regression test for the credential leak: the rendered page must +// show a Slack target, and an http target, without any of the secret +// path segments of its URL. An http target's destination is routinely +// an incoming-webhook endpoint too, so the same Slack URL serves for +// both. +func TestHandleSourceDetail_MasksTargetURL(t *testing.T) { t.Parallel() - var ( - h *handlers.Handlers - sess *session.Session - db *database.Database - ) + labels := map[database.TargetType]string{ + database.TargetTypeSlack: "Webhook URL", + database.TargetTypeHTTP: "Destination URL", + } - app := newTestApp(t, &h, &sess, &db) - app.RequireStart() + for targetType, label := range labels { + t.Run(string(targetType), func(t *testing.T) { + t.Parallel() - t.Cleanup(app.RequireStop) + var ( + h *handlers.Handlers + sess *session.Session + db *database.Database + ) - wh := seedWebhook(t, db) - seedConfiguredTarget( - t, db, wh.ID, - database.TargetTypeSlack, - `{"webhookUrl":"`+slackWebhookURL+`"}`, - ) + app := newTestApp(t, &h, &sess, &db) + app.RequireStart() - body := renderSourceDetailPage(t, h, sess, wh.ID) + t.Cleanup(app.RequireStop) - assert.NotContains(t, body, slackSecretPath) - assert.NotContains(t, body, "T00000000") - assert.NotContains(t, body, "B00000000") - assert.NotContains( - t, body, "XXXXXXXXXXXXXXXXXXXXXXXX", - ) - assert.NotContains(t, body, "webhookUrl") + wh := seedWebhook(t, db) + seedConfiguredTarget( + t, db, wh.ID, targetType, + database.Target{URL: slackWebhookURL}, + ) - assert.Contains(t, body, "Webhook URL") - assert.Contains(t, body, "https://hooks.slack.com/...") -} + body := renderSourceDetailPage(t, h, sess, wh.ID) -// TestHandleSourceDetail_MasksHTTPDestinationURL is the -// regression test for the same leak reached through the http -// target: its destination is routinely an incoming-webhook -// endpoint whose path segments are the credential, so the -// rendered page must not contain them. -func TestHandleSourceDetail_MasksHTTPDestinationURL( - t *testing.T, -) { - t.Parallel() + assert.NotContains(t, body, slackSecretPath) + assert.NotContains(t, body, "T00000000") + assert.NotContains(t, body, "B00000000") + assert.NotContains( + t, body, "XXXXXXXXXXXXXXXXXXXXXXXX", + ) - var ( - h *handlers.Handlers - sess *session.Session - db *database.Database - ) - - app := newTestApp(t, &h, &sess, &db) - app.RequireStart() - - t.Cleanup(app.RequireStop) - - wh := seedWebhook(t, db) - seedConfiguredTarget( - t, db, wh.ID, - database.TargetTypeHTTP, - `{"url":"`+slackWebhookURL+`"}`, - ) - - body := renderSourceDetailPage(t, h, sess, wh.ID) - - assert.NotContains(t, body, slackSecretPath) - assert.NotContains(t, body, "T00000000") - assert.NotContains(t, body, "B00000000") - assert.NotContains( - t, body, "XXXXXXXXXXXXXXXXXXXXXXXX", - ) - - assert.Contains(t, body, "Destination URL") - assert.Contains(t, body, "https://hooks.slack.com/...") + assert.Contains(t, body, label) + assert.Contains(t, body, "https://hooks.slack.com/...") + }) + } } // TestHandleSourceDetail_RendersNamedTargetFields proves the // other target types render labelled fields rather than the -// stored blob. +// stored values. func TestHandleSourceDetail_RendersNamedTargetFields( t *testing.T, ) { @@ -212,18 +182,24 @@ func TestHandleSourceDetail_RendersNamedTargetFields( seedConfiguredTarget( t, db, wh.ID, database.TargetTypeHTTP, - `{"url":"https://example.com/hook","timeout":30,`+ - `"headers":{"Authorization":"Bearer sekrit"}}`, + database.Target{ + URL: "https://example.com/hook", + TimeoutSeconds: 30, + Headers: map[string]string{"X-Api-Key": "sekrit"}, + }, ) seedConfiguredTarget( t, db, wh.ID, database.TargetTypeDatabase, - `{"expiry":"720h"}`, + database.Target{ + ArchiveExpiry: 720 * time.Hour, + ArchiveRotation: rotationNone, + }, ) seedConfiguredTarget( t, db, wh.ID, database.TargetType("carrier-pigeon"), - `{"beak":"sharp"}`, + database.Target{URL: "https://pigeon.example/beak"}, ) body := renderSourceDetailPage(t, h, sess, wh.ID) @@ -242,8 +218,8 @@ func TestHandleSourceDetail_RendersNamedTargetFields( assert.Contains(t, list, "Archive rotation: none") assert.Contains(t, body, ``) - // An unknown type gets the neutral placeholder, never the - // stored blob. + // An unknown type gets the neutral placeholder, never its + // stored URL. assert.Contains(t, body, "(unavailable)") assert.NotContains(t, body, "beak") } diff --git a/internal/handlers/source_logs_deleted_target_test.go b/internal/handlers/source_logs_deleted_target_test.go index e38da68..2c368c7 100644 --- a/internal/handlers/source_logs_deleted_target_test.go +++ b/internal/handlers/source_logs_deleted_target_test.go @@ -161,7 +161,7 @@ func TestHandleSourceLogs_MasksDeletedTargetConfig(t *testing.T) { tgt := seedConfiguredTarget( t, db, wh.ID, database.TargetTypeSlack, - `{"webhookUrl":"`+slackWebhookURL+`"}`, + database.Target{URL: slackWebhookURL}, ) seedDeliveredEvent(t, dbMgr, wh.ID, tgt.ID) @@ -175,7 +175,6 @@ func TestHandleSourceLogs_MasksDeletedTargetConfig(t *testing.T) { assert.NotContains( t, body, "XXXXXXXXXXXXXXXXXXXXXXXX", ) - assert.NotContains(t, body, "webhookUrl") // The name is there; only the credential is not. assert.Contains(t, body, tgt.Name+deletedMarker) diff --git a/internal/handlers/source_logs_test.go b/internal/handlers/source_logs_test.go index 0cbe5fd..b2bd529 100644 --- a/internal/handlers/source_logs_test.go +++ b/internal/handlers/source_logs_test.go @@ -138,7 +138,7 @@ func TestHandleSourceLogs_MasksSlackWebhookURL(t *testing.T) { tgt := seedConfiguredTarget( t, db, wh.ID, database.TargetTypeSlack, - `{"webhookUrl":"`+slackWebhookURL+`"}`, + database.Target{URL: slackWebhookURL}, ) seedDeliveredEvent(t, dbMgr, wh.ID, tgt.ID) @@ -151,7 +151,6 @@ func TestHandleSourceLogs_MasksSlackWebhookURL(t *testing.T) { assert.NotContains( t, body, "XXXXXXXXXXXXXXXXXXXXXXXX", ) - assert.NotContains(t, body, "webhookUrl") // The page still identifies the delivery's target. assert.Contains(t, body, tgt.Name) diff --git a/internal/handlers/target_create.go b/internal/handlers/target_create.go index 4bde6fd..f96c2f8 100644 --- a/internal/handlers/target_create.go +++ b/internal/handlers/target_create.go @@ -2,7 +2,6 @@ package handlers import ( "context" - "encoding/json" "errors" "fmt" "net/http" @@ -65,20 +64,14 @@ func (h *Handlers) processTargetCreate( ) { in := targetFormInputFrom(r) - target, errMsg, err := h.newTarget(r.Context(), webhook.ID, in) - if err != nil { - h.serverError(w, r, "failed to encode target config", err) - - return - } - + target, errMsg := h.newTarget(r.Context(), webhook.ID, in) if errMsg != "" { h.renderSourceDetail(w, r, webhook, in, errMsg) return } - err = h.db.DB().Create(target).Error + err := h.db.DB().Create(target).Error if err != nil { h.serverError(w, r, "failed to create target", err) @@ -93,15 +86,13 @@ 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. +// shows. 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) { +) (*database.Target, string) { target := &database.Target{ WebhookID: webhookID, Type: in.Type, @@ -109,34 +100,33 @@ func (h *Handlers) newTarget( MaxAttempts: 1, } - errMsg, err := h.setTargetFromForm(ctx, target, in) - if err != nil || errMsg != "" { - return nil, errMsg, err + errMsg := h.setTargetFromForm(ctx, target, in) + if errMsg != "" { + return nil, errMsg } - return target, "", nil + return target, "" } // setTargetFromForm validates a target form against the target's type -// and, when it accepts it, sets the target's name, configuration and -// number of delivery attempts from it. It returns the message the form -// shows for anything it refuses, an unknown type among them, and then -// leaves the target unchanged; an error is the server's fault, as for -// newTarget. +// and sets the target's name, settings and number of delivery attempts +// from it. It returns the message the form shows for anything it +// refuses, an unknown type among them; the caller then discards the +// target, which may be partly set. // The add target form and the target edit form both go through here, // so the two cannot come to disagree about what a target may be. func (h *Handlers) setTargetFromForm( ctx context.Context, target *database.Target, in targetFormInput, -) (string, error) { +) string { if in.Name == "" { - return "Name is required", nil + return "Name is required" } - configJSON, errMsg, err := h.buildTargetConfig(ctx, target.Type, in) - if err != nil || errMsg != "" { - return errMsg, err + errMsg := h.setTargetSettings(ctx, target, in) + if errMsg != "" { + return errMsg } // An empty max_attempts keeps the target's count: the default of @@ -147,14 +137,13 @@ func (h *Handlers) setTargetFromForm( // target is delivering with. maxAttempts, err := parseMaxAttempts(in.MaxAttempts, target.MaxAttempts) if err != nil { - return "Invalid delivery attempts: " + attemptsErrorMessage(err), nil + return "Invalid delivery attempts: " + attemptsErrorMessage(err) } target.Name = in.Name - target.Config = configJSON target.MaxAttempts = maxAttempts - return "", nil + return "" } // targetFormInput carries the raw values of a target form. Both the @@ -213,84 +202,81 @@ func targetFormInputFrom(r *http.Request) targetFormInput { } } -// buildTargetConfig builds the JSON config string for a target from -// the submitted form values, or returns the message the form shows -// 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( +// setTargetSettings sets a target's settings from the submitted form +// values, or returns the message the form shows for a value it +// refuses. Which fields of in apply depends on the target type; a +// type without a URL ignores any URL submitted, and the settings a +// type does not use are left empty. +func (h *Handlers) setTargetSettings( ctx context.Context, - targetType database.TargetType, + target *database.Target, in targetFormInput, -) (string, string, error) { - switch targetType { +) string { + switch target.Type { case database.TargetTypeHTTP: - return h.buildHTTPTargetConfig(ctx, in) + return h.setHTTPTargetSettings(ctx, target, in) case database.TargetTypeSlack: - return h.buildSlackTargetConfig(ctx, in.URL) + return h.setSlackTargetSettings(ctx, target, in.URL) case database.TargetTypeDatabase: - return buildDatabaseTargetConfig(in.Expiry, in.Rotation) + return setDatabaseTargetSettings(target, in.Expiry, in.Rotation) case database.TargetTypeLog: - return "", "", nil + return "" default: - return "", "Invalid target type", nil + return "Invalid target type" } } -// buildHTTPTargetConfig builds config JSON for an HTTP target: an -// SSRF-validated destination plus the optional headers, timeout and -// query string setting the delivery path honours. -func (h *Handlers) buildHTTPTargetConfig( +// setHTTPTargetSettings sets an HTTP target's SSRF-validated +// destination plus the optional headers, timeout and query string +// setting the delivery path honours. +func (h *Handlers) setHTTPTargetSettings( ctx context.Context, + target *database.Target, in targetFormInput, -) (string, string, error) { +) string { errMsg := h.validateTargetURL( ctx, in.URL, "URL is required for HTTP targets", ) if errMsg != "" { - return "", errMsg, nil + return errMsg } headers, err := delivery.ParseTargetHeaders(in.Headers) if err != nil { - return "", fmt.Sprintf("Invalid headers: %v", err), nil + return fmt.Sprintf("Invalid headers: %v", err) } timeout, err := delivery.ParseTargetTimeout(in.Timeout) if err != nil { - return "", fmt.Sprintf("Invalid timeout: %v", err), nil + return fmt.Sprintf("Invalid timeout: %v", err) } - configJSON, err := marshalTargetConfig(delivery.HTTPTargetConfig{ - URL: in.URL, - Headers: headers, - Timeout: timeout, - ForwardQuery: in.ForwardQuery, - }) + target.URL = in.URL + target.Headers = headers + target.TimeoutSeconds = timeout + target.ForwardQuery = in.ForwardQuery - return configJSON, "", err + return "" } -// buildSlackTargetConfig builds config JSON for a Slack target, -// whose whole configuration is one SSRF-validated webhook URL. -func (h *Handlers) buildSlackTargetConfig( +// setSlackTargetSettings sets a Slack target's one setting, its +// SSRF-validated webhook URL. +func (h *Handlers) setSlackTargetSettings( ctx context.Context, + target *database.Target, targetURL string, -) (string, string, error) { +) string { errMsg := h.validateTargetURL( ctx, targetURL, "Webhook URL is required for Slack targets", ) if errMsg != "" { - return "", errMsg, nil + return errMsg } - configJSON, err := marshalTargetConfig(delivery.SlackTargetConfig{ - WebhookURL: targetURL, - }) + target.URL = targetURL - return configJSON, "", err + return "" } // validateTargetURL refuses an empty or SSRF-blocked destination, @@ -341,52 +327,31 @@ func (h *Handlers) validateTargetURL( return "" } -// marshalTargetConfig serialises a target configuration for storage. -func marshalTargetConfig(cfg any) (string, error) { - configBytes, err := json.Marshal(cfg) - if err != nil { - return "", err - } - - return string(configBytes), nil -} - -// buildDatabaseTargetConfig builds config JSON for a database -// (archive) target. The optional expiry and rotation are validated -// here, at creation time, so a bad value is refused instead of -// failing every subsequent delivery. Each is stored only when set, -// and with neither the config is empty (the keep-forever, one-file -// default). -func buildDatabaseTargetConfig( +// setDatabaseTargetSettings sets a database (archive) target's +// expiry and rotation. Both are optional: an empty or never expiry +// keeps the archive forever, and an empty rotation is stored as none, +// one file. A bad value is refused here, at the form, instead of +// being stored. +func setDatabaseTargetSettings( + target *database.Target, expiry, rotation string, -) (string, string, error) { - expiry = strings.TrimSpace(expiry) - - err := archive.ValidateExpiry(expiry) +) string { + archiveExpiry, err := archive.ParseExpiry(strings.TrimSpace(expiry)) if err != nil { - return "", fmt.Sprintf("Invalid archive expiry: %v", err), nil + return fmt.Sprintf("Invalid archive expiry: %v", err) } err = archive.ValidateRotation(rotation) if err != nil { - return "", fmt.Sprintf("Invalid archive rotation: %v", err), nil + return fmt.Sprintf("Invalid archive rotation: %v", err) } - cfg := map[string]any{} - - if expiry != "" { - cfg["expiry"] = expiry + if rotation == "" { + rotation = archiveRotationNone } - if rotation != "" { - cfg["rotation"] = rotation - } + target.ArchiveExpiry = archiveExpiry + target.ArchiveRotation = rotation - if len(cfg) == 0 { - return "", "", nil - } - - configJSON, err := marshalTargetConfig(cfg) - - return configJSON, "", err + return "" } diff --git a/internal/handlers/target_create_query_test.go b/internal/handlers/target_create_query_test.go index 1d0603b..2adeeac 100644 --- a/internal/handlers/target_create_query_test.go +++ b/internal/handlers/target_create_query_test.go @@ -176,7 +176,7 @@ func TestHandleTargetCreate_BodyURLStillCreatesTheTarget(t *testing.T) { targets := targetsForWebhook(t, env.db, webhook.ID) require.Len(t, targets, 1) - assert.Contains(t, targets[0].Config, targetSecretSegments) + assert.Equal(t, targetSecretURL, targets[0].URL) // The body carried the credential, so the access log must still // not have it: the log records the request line only. diff --git a/internal/handlers/target_create_test.go b/internal/handlers/target_create_test.go index e993d6a..57a218f 100644 --- a/internal/handlers/target_create_test.go +++ b/internal/handlers/target_create_test.go @@ -6,73 +6,114 @@ import ( "net/url" "strings" "testing" + "time" "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. +// TestHandleTargetCreate_EveryType adds a target of each type and +// checks the settings it stores. Each submission carries a url and an +// expiry: only the http and slack types store the url, only the +// database type the expiry, and every setting a type does not use +// stays empty. func TestHandleTargetCreate_EveryType(t *testing.T) { t.Parallel() env := setupSourceTest(t) - // fields is the rest of each submission, as a query string. + // fields is the rest of each submission, as a query string, and + // want holds the settings stored. cases := []struct { targetType database.TargetType fields string - wantConfig string + want database.Target wantAttempts int }{ { - database.TargetTypeHTTP, "timeout=12&max_attempts=3", - `{"url":"` + editOriginalURL + `","timeout":12}`, 3, + database.TargetTypeHTTP, + "timeout=12&max_attempts=3&forward_query=on", + database.Target{ + URL: editOriginalURL, + Headers: map[string]string{}, + TimeoutSeconds: 12, + ForwardQuery: true, + }, 3, }, { database.TargetTypeSlack, "max_attempts=4", - `{"webhookUrl":"` + editOriginalURL + `"}`, 4, + database.Target{URL: editOriginalURL}, 4, }, { - database.TargetTypeDatabase, "expiry=720h", - `{"expiry":"720h"}`, 1, + database.TargetTypeDatabase, "rotation=daily", + database.Target{ + ArchiveExpiry: 720 * time.Hour, + ArchiveRotation: rotationDaily, + }, 1, }, - {database.TargetTypeLog, "", "", 1}, + {database.TargetTypeLog, "", database.Target{}, 1}, } 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, + _, got := addTargetThroughForm( + t, env, tc.targetType, + "url="+editOriginalURL+"&expiry=720h&"+tc.fields, ) - 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.wantAttempts, targets[0].MaxAttempts) - - if tc.wantConfig == "" { - assert.Empty(t, targets[0].Config) - } else { - assert.JSONEq(t, tc.wantConfig, targets[0].Config) - } + assert.Equal(t, tc.targetType, got.Type) + assert.Equal(t, tc.wantAttempts, got.MaxAttempts) + assert.Equal(t, tc.want, targetSettingsOf(got)) }) } } +// addTargetThroughForm adds a target of targetType named every-type to +// a new webhook through the add target form, with the rest of the form +// given as a query string, and returns the webhook and the target the +// form stored. +func addTargetThroughForm( + t *testing.T, + env *sourceTestEnv, + targetType database.TargetType, + fields string, +) (database.Webhook, database.Target) { + t.Helper() + + webhook := seedWebhookWithRetention(t, env.db, 30) + + form, err := url.ParseQuery(fields) + require.NoError(t, err) + form.Set("name", "every-type") + form.Set("type", string(targetType)) + + 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) + + return webhook, targets[0] +} + +// targetSettingsOf returns a target's settings, URL to +// ArchiveRotation, and nothing else, for comparing with the settings a +// test expects. +func targetSettingsOf(target database.Target) database.Target { + return database.Target{ + URL: target.URL, + Headers: target.Headers, + TimeoutSeconds: target.TimeoutSeconds, + ForwardQuery: target.ForwardQuery, + ArchiveExpiry: target.ArchiveExpiry, + ArchiveRotation: target.ArchiveRotation, + } +} + // 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. diff --git a/internal/handlers/target_edit.go b/internal/handlers/target_edit.go index 75e8561..ec56f6e 100644 --- a/internal/handlers/target_edit.go +++ b/internal/handlers/target_edit.go @@ -25,22 +25,15 @@ const ( tmplKeyMaxTimeout = "MaxTimeout" ) -// configUnreadableMessage is shown when a target's stored -// configuration does not parse. It says plainly that saving replaces -// the stored value rather than preserving it, because the form -// cannot pre-fill what it could not read. -const configUnreadableMessage = "The stored configuration for this " + - "target could not be read. Enter the values below; saving " + - "replaces the stored configuration." - // targetEditView is the display model for the target edit page: the // target's row fields as stored. The values the form shows, the -// UNMASKED configuration among them, come separately, as a +// UNMASKED URL and headers among them, come separately, as a // targetFormInput. // -// It deliberately omits database.Target's raw Config blob: the form -// renders named fields, and giving the template the blob as well -// would put an unreviewed second path to the credential on the page. +// It deliberately omits database.Target's settings: the form renders +// them from the targetFormInput, and giving the template the target's +// URL and headers as well would put an unreviewed second path to the +// credential on the page. type targetEditView struct { ID string Name string @@ -48,13 +41,16 @@ type targetEditView struct { Active bool } -// HandleTargetEdit shows the form to edit a target. +// HandleTargetEdit shows the form to edit a target, filled with the +// target's settings as stored. // // This page is the one place the full destination URL and header -// values are shown. It is reachable only through the -// /hook/{sourceID} route group, which supplies RequireAuth and -// NoCache, and only for a target of a webhook the session's user -// owns; masking (delivery.TargetView) is unchanged everywhere else. +// values are shown: an operator cannot correct a value they cannot +// see. It is reachable only through the /hook/{sourceID} route +// group, which supplies RequireAuth and NoCache, and only for a +// target of a webhook the session's user owns; masking +// (delivery.TargetView) is unchanged everywhere else. Do not fill a +// form like this one on any other page. func (h *Handlers) HandleTargetEdit() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { webhook, target, ok := h.ownedTarget(w, r) @@ -62,34 +58,19 @@ func (h *Handlers) HandleTargetEdit() http.HandlerFunc { return } - cfg, err := delivery.NewTargetConfigForm(target) - msg := "" - - if err != nil { - // The error carries the parse failure, never the - // blob, so it is safe to log against the target id. - h.log.Warn( - "stored target config could not be read for editing", - "target_id", target.ID, - "error", err, - ) - - msg = configUnreadableMessage - } - form := targetFormInput{ Name: target.Name, - URL: cfg.URL, - Headers: cfg.Headers, - Timeout: cfg.Timeout, - ForwardQuery: cfg.ForwardQuery, + URL: target.URL, + Headers: delivery.FormatTargetHeaders(target.Headers), + Timeout: delivery.FormatTargetTimeout(target.TimeoutSeconds), + ForwardQuery: target.ForwardQuery, MaxAttempts: strconv.Itoa(target.MaxAttempts), - Expiry: cfg.Expiry, - Rotation: cfg.Rotation, + Expiry: formatArchiveExpiry(target.ArchiveExpiry), + Rotation: target.ArchiveRotation, } h.renderTargetEdit( - w, r, webhook, target, form, msg, http.StatusOK, + w, r, webhook, target, form, "", http.StatusOK, ) } } @@ -125,11 +106,11 @@ func (h *Handlers) HandleTargetEditSubmit() http.HandlerFunc { // does, so an edited destination is SSRF-validated exactly as a new // one is. // -// The target's type is not editable. Each type stores a different -// configuration shape and its delivery history is recorded against -// the target row, so changing the type of an existing target is -// really the creation of a different one. The stored type decides -// which fields the form offers and which builder runs. +// The target's type is not editable. Each type uses different +// settings and its delivery history is recorded against the target +// row, so changing the type of an existing target is really the +// creation of a different one. The stored type decides which fields +// the form offers and which settings are set. func (h *Handlers) applyTargetEdit( w http.ResponseWriter, r *http.Request, @@ -142,13 +123,7 @@ func (h *Handlers) applyTargetEdit( // as stored, for the page shown again when the save is refused. edited := *target - errMsg, err := h.setTargetFromForm(r.Context(), &edited, in) - if err != nil { - h.serverError(w, r, "failed to encode target config", err) - - return - } - + errMsg := h.setTargetFromForm(r.Context(), &edited, in) if errMsg != "" { h.renderTargetEdit( w, r, webhook, target, in, errMsg, http.StatusBadRequest, @@ -160,7 +135,7 @@ func (h *Handlers) applyTargetEdit( // A new name renames the archive file before it is saved (see // archive.Writers.Rename). If either step fails, it goes back to // the name that is still stored. - err = h.renameTargetArchive( + err := h.renameTargetArchive( target, webhook.Name, target.Name, edited.Name, ) if err == nil { diff --git a/internal/handlers/target_edit_test.go b/internal/handlers/target_edit_test.go index 57c114f..7ef8109 100644 --- a/internal/handlers/target_edit_test.go +++ b/internal/handlers/target_edit_test.go @@ -2,19 +2,18 @@ package handlers_test import ( "context" - "encoding/json" "net/http" "net/http/httptest" "net/url" "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/delivery" ) // The destinations the target edit tests configure. Both are literal @@ -150,27 +149,6 @@ func storedTarget( return target } -// storedHTTPConfig reloads a target and parses its stored HTTP -// configuration. -func storedHTTPConfig( - t *testing.T, - env *sourceTestEnv, - targetID string, -) delivery.HTTPTargetConfig { - t.Helper() - - var cfg delivery.HTTPTargetConfig - - require.NoError( - t, - json.Unmarshal( - []byte(storedTarget(t, env, targetID).Config), &cfg, - ), - ) - - return cfg -} - // editForm is the fully populated edit submission for an HTTP // target. func editForm(targetURL, headers, timeout string) url.Values { @@ -197,12 +175,9 @@ func submitTargetEdit( ) } -// TestHandleTargetCreate_Configuration covers the half of the gap -// that is not about editing at all: HTTPTargetConfig has carried -// Headers and Timeout, and the delivery path has honoured them, but -// the create form wrote {"url":...} and nothing else, so a -// destination needing an Authorization header could not be -// configured through the UI at all. +// TestHandleTargetCreate_Configuration proves the add target form +// stores an HTTP target's headers and timeout, and leaves them empty +// when the form leaves them empty. func TestHandleTargetCreate_Configuration(t *testing.T) { t.Parallel() @@ -213,9 +188,9 @@ func TestHandleTargetCreate_Configuration(t *testing.T) { assertCreateStoresHeadersAndTimeout(t, env) }) - t.Run("without them keeps a url-only config", func(t *testing.T) { + t.Run("without them stores only the url", func(t *testing.T) { t.Parallel() - assertCreateKeepsURLOnlyConfig(t, env) + assertCreateStoresOnlyTheURL(t, env) }) } @@ -228,33 +203,31 @@ func assertCreateStoresHeadersAndTimeout( t, env, editAuthHeader+"\nX-Tenant: acme\n", "12", ) - cfg := storedHTTPConfig(t, env, target.ID) + stored := storedTarget(t, env, target.ID) - assert.Equal(t, editOriginalURL, cfg.URL) - assert.Equal(t, 12, cfg.Timeout) + assert.Equal(t, editOriginalURL, stored.URL) + assert.Equal(t, 12, stored.TimeoutSeconds) assert.Equal( t, map[string]string{ "Authorization": "Bearer " + editBearerSecret, "X-Tenant": "acme", }, - cfg.Headers, + stored.Headers, ) } -// Without the new fields the stored shape must be the same -// {"url":...} the create form wrote before they existed, so no -// existing target's configuration is rewritten by this change. -func assertCreateKeepsURLOnlyConfig( +func assertCreateStoresOnlyTheURL( t *testing.T, env *sourceTestEnv, ) { t.Helper() _, target := seedHTTPTarget(t, env, "", "") - assert.JSONEq( - t, `{"url":"`+editOriginalURL+`"}`, target.Config, - ) + assert.Equal(t, editOriginalURL, target.URL) + assert.Empty(t, target.Headers) + assert.Zero(t, target.TimeoutSeconds) + assert.False(t, target.ForwardQuery) } // TestHandleTargetEditSubmit_Saves is the round trip the issue asks @@ -299,13 +272,8 @@ func assertEditChangesDestination( ) require.Equal(t, http.StatusSeeOther, w.Code, w.Body.String()) - assert.Equal( - t, - editReplacedURL, - storedHTTPConfig(t, env, target.ID).URL, - ) - reloaded := storedTarget(t, env, target.ID) + assert.Equal(t, editReplacedURL, reloaded.URL) assert.Equal(t, "edited-name", reloaded.Name) assert.Equal(t, 5, reloaded.MaxAttempts) assert.Equal( @@ -333,16 +301,16 @@ func assertEditRoundTripsHeadersAndTimeout( ) require.Equal(t, http.StatusSeeOther, w.Code, w.Body.String()) - cfg := storedHTTPConfig(t, env, target.ID) + stored := storedTarget(t, env, target.ID) - assert.Equal(t, 21, cfg.Timeout) + assert.Equal(t, 21, stored.TimeoutSeconds) assert.Equal( t, map[string]string{ "Authorization": "Bearer rotated", "X-Trace": "on", }, - cfg.Headers, + stored.Headers, ) } @@ -362,10 +330,10 @@ func assertEditClearingRemovesThem( ) require.Equal(t, http.StatusSeeOther, w.Code, w.Body.String()) - cfg := storedHTTPConfig(t, env, target.ID) + stored := storedTarget(t, env, target.ID) - assert.Empty(t, cfg.Headers) - assert.Zero(t, cfg.Timeout) + assert.Empty(t, stored.Headers) + assert.Zero(t, stored.TimeoutSeconds) } // Delivery attempts are offered only by the forms for target types @@ -442,6 +410,90 @@ func TestHandleTargetEdit_CallsTheDatabaseTypeArchive(t *testing.T) { assert.Contains(t, page, `class="label">Archive rotation`) } +// TestHandleTargetEdit_EveryType edits a target of each type: the edit +// form starts on the settings the add target form stored, and saving +// it stores the settings submitted. +func TestHandleTargetEdit_EveryType(t *testing.T) { + t.Parallel() + + env := setupSourceTest(t) + + // created and edited are the add target form's and the edit + // form's fields, as query strings; shown is what the edit form + // must show of created; want is the settings edited stores. + cases := []struct { + targetType database.TargetType + created, edited string + shown []string + want database.Target + }{ + { + database.TargetTypeHTTP, + "url=" + editOriginalURL + + "&headers=X-Tenant:+acme&timeout=7&forward_query=on", + "url=" + editReplacedURL + "&timeout=9", + []string{ + `value="` + editOriginalURL + `"`, + "X-Tenant: acme", + `value="7"`, + `name="forward_query" value="on" checked`, + }, + database.Target{ + URL: editReplacedURL, + Headers: map[string]string{}, + TimeoutSeconds: 9, + }, + }, + { + database.TargetTypeSlack, + "url=" + editOriginalURL, + "url=" + editReplacedURL, + []string{`value="` + editOriginalURL + `"`}, + database.Target{URL: editReplacedURL}, + }, + { + database.TargetTypeDatabase, + "expiry=720h&rotation=daily", + "expiry=24h&rotation=hourly", + []string{ + `