Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eda8e80844 | ||
|
|
e584718eb5 | ||
|
|
a8d226942e | ||
|
|
6df1fc7875 |
No files matched your search
+1
-3
@@ -72,10 +72,8 @@
|
||||
**/*.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).
|
||||
# writes.
|
||||
/bin
|
||||
/static/js/alpine.min.js
|
||||
|
||||
# SQLite databases, which hold the session key and webhook payloads, at
|
||||
# any depth.
|
||||
|
||||
@@ -77,7 +77,3 @@ data/
|
||||
# Temporary files
|
||||
tmp/
|
||||
temp/
|
||||
|
||||
# Alpine.js, extracted by `make assets` from its tarball in 3p/, which is
|
||||
# what is committed.
|
||||
/static/js/alpine.min.js
|
||||
@@ -1,2 +1,3 @@
|
||||
node_modules/
|
||||
yarn.lock
|
||||
static/js/alpine.min.js
|
||||
Binary file not shown.
+2
-11
@@ -92,10 +92,6 @@ COPY . .
|
||||
# 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
|
||||
@@ -130,9 +126,6 @@ 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
|
||||
@@ -211,10 +204,8 @@ RUN version="$(make version VERSION="$VERSION")"; \
|
||||
esac; \
|
||||
fi
|
||||
|
||||
# Builds through the Makefile's build target, which runs script/assets
|
||||
# (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.
|
||||
# The Makefile's build target builds with cgo off, so the binary is
|
||||
# statically linked and runs on the Alpine runtime image below.
|
||||
RUN make build VERSION="$VERSION"
|
||||
|
||||
# Runtime stage
|
||||
|
||||
+1
-1
@@ -17,7 +17,7 @@ COPY . .
|
||||
# The test binary embeds the templates and static files, so the browser
|
||||
# stage needs nothing else. It is built with cgo off and -p 4 keeps the
|
||||
# compile's memory down, both as in the test phase of Dockerfile.
|
||||
RUN make assets && CGO_ENABLED=0 go test -c -p 4 -tags browser -o /browser.test ./internal/server
|
||||
RUN CGO_ENABLED=0 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
|
||||
# browser is on PATH as headless-shell, where the test's browser library
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
.PHONY: bootstrap setup assets test test-browser lint fmt fmt-check check build run dev deps docker clean hooks css css-check version
|
||||
.PHONY: bootstrap setup test test-browser lint fmt fmt-check check build run dev deps docker clean hooks css css-check version
|
||||
|
||||
# Default target
|
||||
.DEFAULT_GOAL := check
|
||||
@@ -22,9 +22,6 @@ bootstrap:
|
||||
setup:
|
||||
@script/setup
|
||||
|
||||
assets:
|
||||
@script/assets
|
||||
|
||||
test:
|
||||
@script/test
|
||||
|
||||
@@ -47,13 +44,13 @@ check:
|
||||
# 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
|
||||
build:
|
||||
CGO_ENABLED=0 go build -trimpath -ldflags '-s -w -X main.version=$(VERSION)' -o bin/webhooker ./cmd/webhooker
|
||||
|
||||
run: build
|
||||
./bin/webhooker
|
||||
|
||||
dev: assets
|
||||
dev:
|
||||
go run ./cmd/webhooker
|
||||
|
||||
deps:
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# webhooker
|
||||
|
||||
webhooker is an MIT-licensed web service, written in [Go](https://golang.org) by
|
||||
[@sneak](https://sneak.berlin), that receives the HTTP requests other services
|
||||
send when something happens and forwards them to your own systems.
|
||||
webhooker is an MIT-licensed [Go](https://golang.org) web service by
|
||||
[@sneak](https://sneak.berlin) that receives the HTTP requests other services
|
||||
send and forwards them to your own systems.
|
||||
|
||||
Many services send an HTTP request to a URL you give them when something
|
||||
happens: a code push, a payment, a new ticket. webhooker receives those
|
||||
@@ -30,8 +30,8 @@ Five words name the moving parts.
|
||||
it. The UUID is a random 36-character identifier, and it is the only thing
|
||||
that authorises a sender.
|
||||
- A **target** is a place events are delivered to. There are four kinds: an HTTP
|
||||
URL, a Slack or Mattermost message-posting URL, an archive file, and the
|
||||
application log.
|
||||
URL, the message-posting URL of a chat service (Slack or Mattermost), an
|
||||
archive file, and the application log.
|
||||
- An **event** is one stored request: its method, query string, headers and
|
||||
body.
|
||||
- A **delivery** is one event paired with one target. It records every attempt.
|
||||
@@ -39,15 +39,15 @@ Five words name the moving parts.
|
||||
The part of webhooker that answers requests to `/h/{uuid}` is the receiver. It
|
||||
stores the event and one pending delivery per active target, then answers `200`.
|
||||
|
||||
Delivery is at least once, not exactly once. If a delivery reaches the target
|
||||
but webhooker cannot record that outcome, it sends the delivery again later. A
|
||||
delivery in flight when the service stops is also sent again at the next start.
|
||||
webhooker adds no delivery identifier of its own to the request, so make your
|
||||
targets tolerate a duplicate.
|
||||
Delivery is at least once, not exactly once. A delivery can reach its target
|
||||
more than once. If a delivery reaches the target but webhooker cannot record
|
||||
that outcome, it sends the delivery again later. A delivery in flight when the
|
||||
service stops is also sent again at the next start. webhooker adds no delivery
|
||||
identifier of its own to the request, so make your targets tolerate a duplicate.
|
||||
|
||||
## Getting Started
|
||||
|
||||
You need the Go version named in `go.mod`, and Docker for the checks.
|
||||
You need the Go version named in `go.mod`.
|
||||
|
||||
```bash
|
||||
git clone https://git.eeqj.de/sneak/webhooker.git
|
||||
@@ -57,7 +57,8 @@ DATA_DIR=../webhooker-data make dev
|
||||
```
|
||||
|
||||
`DATA_DIR` is where webhooker keeps its databases. It defaults to
|
||||
`/var/lib/webhooker`, so point it at a directory you can write to.
|
||||
`/var/lib/webhooker`, so point it at a directory you can write to. `make build`
|
||||
writes the program to `bin/webhooker`. Running that file starts webhooker.
|
||||
|
||||
The first start creates the `admin` account and prints its password once, in a
|
||||
ruled box on standard output:
|
||||
@@ -87,6 +88,9 @@ docker run -d \
|
||||
webhooker
|
||||
```
|
||||
|
||||
The first-boot password goes to the container's log. Read it with `docker logs`
|
||||
and the container's name.
|
||||
|
||||
In production, put a reverse proxy in front of webhooker: a server that accepts
|
||||
the public connections and passes them on. Read
|
||||
[Behind a reverse proxy](#behind-a-reverse-proxy) first.
|
||||
@@ -99,12 +103,12 @@ sooner after 24 hours without use, unless `SESSION_IDLE_TIMEOUT` says otherwise.
|
||||
|
||||
### Webhooks
|
||||
|
||||
**New Webhook** on the webhook list at `/hooks` asks for **Name**, an optional
|
||||
On the webhook list at `/hooks`, **New Webhook** asks for **Name**, an optional
|
||||
**Description** and **Retention (days)**. Events older than the retention period
|
||||
are deleted, along with their deliveries. The default is 30 days. `0` keeps
|
||||
events forever. The form also has an **HTTP target URL** field and an
|
||||
**Archive** checkbox; each creates that target with the webhook. Every new
|
||||
webhook gets one entrypoint, described as `Default entrypoint`.
|
||||
events forever. Filling in **HTTP target URL** creates an `http` target with the
|
||||
webhook. Ticking **Archive** creates an archive target. Every new webhook gets
|
||||
one entrypoint, described as `Default entrypoint`.
|
||||
|
||||
### Entrypoint URLs
|
||||
|
||||
@@ -120,7 +124,7 @@ deactivated entrypoint answers `410 Gone`. A deleted or unknown one answers
|
||||
|
||||
### Targets
|
||||
|
||||
**Add** under **Targets** asks for the type first, then that type's settings.
|
||||
Under **Targets**, **Add** asks for the type first, then that type's settings.
|
||||
|
||||
- An `http` target posts each event to a URL, with optional **Headers** and a
|
||||
**Timeout**.
|
||||
@@ -135,6 +139,8 @@ deactivated entrypoint answers `410 Gone`. A deleted or unknown one answers
|
||||
`http` and `slack` targets also take **Delivery attempts**: how many times each
|
||||
delivery is tried in all. The default is 1. The most is 20. A target's type
|
||||
cannot be changed. Deleting an archive target leaves its files on disk.
|
||||
**Deactivate** and **Activate** on the webhook page switch a target off and on,
|
||||
and new events skip a target that is off.
|
||||
|
||||
### Events
|
||||
|
||||
@@ -144,11 +150,11 @@ body** link. **Full Event Log** shows the 50 newest events with their deliveries
|
||||
and every attempt. **Failed** and **Pending** narrow it to events with a failed
|
||||
delivery, or with one still pending or retrying.
|
||||
|
||||
**Replay** sits beside each finished delivery. It creates a new delivery of the
|
||||
same event to the same target, with the target's settings as they stand now.
|
||||
**Resubmit** sits beside each event. It stores a copy of the event as a new
|
||||
event and delivers it to every target that is active now. Both actions are
|
||||
limited to 30 per minute per client.
|
||||
A finished delivery has a **Replay** button while its target exists. **Replay**
|
||||
creates a new delivery of the same event to the same target, with the target's
|
||||
settings as they stand now. **Resubmit** sits beside each event. It stores a
|
||||
copy of the event as a new event and delivers it to every target that is active
|
||||
now. Both actions are limited to 30 per minute per client.
|
||||
|
||||
### Profile and settings
|
||||
|
||||
@@ -171,45 +177,51 @@ Do not set `BIND_ADDRESS` to an empty value in a container. An empty value means
|
||||
unset, and unset falls back to `127.0.0.1`, so the container answers nothing on
|
||||
its published port.
|
||||
|
||||
The app runs as user `webhooker`. The image's start-up script,
|
||||
`deploy/docker-entrypoint.sh`, gives `DATA_DIR` and its contents to that user
|
||||
The app runs as user `webhooker`. The image's start-up script is
|
||||
`deploy/docker-entrypoint.sh`. It gives `DATA_DIR` and its contents to that user
|
||||
before the app starts. So a host directory can be mounted as it is. webhooker
|
||||
creates every SQLite file with mode `0600`.
|
||||
creates every SQLite file readable and writable by that user alone (mode
|
||||
`0600`).
|
||||
|
||||
### Running under upaas
|
||||
|
||||
[upaas](https://git.eeqj.de/sneak/upaas), a deployment tool, builds the image
|
||||
from this repository's `Dockerfile` and runs it. Add no port mapping, because
|
||||
upaas publishes every mapped port on all interfaces of the host. Put the app on
|
||||
your reverse proxy's Docker network instead, and mount one host directory at
|
||||
`/var/lib/webhooker`.
|
||||
[upaas](https://git.eeqj.de/sneak/upaas) is a deployment tool. It builds the
|
||||
image from this repository's `Dockerfile` and runs it. Add no port mapping,
|
||||
because upaas publishes every mapped port on all interfaces of the host. Put the
|
||||
app on your reverse proxy's Docker network instead, and mount one host directory
|
||||
at `/var/lib/webhooker`. The first-boot password goes to the container's log.
|
||||
Read it with `docker logs` and the name upaas gave the container.
|
||||
|
||||
### Behind a reverse proxy
|
||||
|
||||
webhooker speaks plain HTTP. It does not handle TLS, the encryption behind
|
||||
HTTPS: it holds no certificate and accepts no HTTPS connections. The reverse
|
||||
proxy holds the certificate and reaches webhooker over a private address. On a
|
||||
bare host that is loopback, the address only this host can reach. For the image
|
||||
it is the Docker bridge.
|
||||
proxy holds the certificate and reaches webhooker over a private address. A bare
|
||||
host is a host without Docker. On a bare host the private address is loopback
|
||||
(`127.0.0.1`), which only this host can reach. For the image it is the Docker
|
||||
bridge, the private network Docker puts containers on.
|
||||
|
||||
Four things must be right.
|
||||
|
||||
1. **Keep the app port private.** Only the proxy may reach it: `127.0.0.1` on a
|
||||
bare host, or a port published to `127.0.0.1` for the image.
|
||||
2. **Send `X-Forwarded-Proto: https`.** This header tells webhooker the request
|
||||
arrived over TLS. It decides the `Secure` flag on cookies, and whether a form
|
||||
submission must carry a matching `Origin` or `Referer`. webhooker reads it
|
||||
from any connecting machine, so the proxy must overwrite whatever the client
|
||||
sent.
|
||||
arrived over TLS. It decides the `Secure` flag on cookies, the flag that
|
||||
makes a browser send them only over HTTPS. It also decides whether a form
|
||||
submission must carry a matching `Origin` or `Referer` header. A browser
|
||||
sends those two headers to say which site or page a request came from.
|
||||
webhooker reads `X-Forwarded-Proto` from any connecting machine, so the proxy
|
||||
must overwrite whatever the client sent.
|
||||
3. **Cover the proxy in `TRUSTED_PROXIES`.** Otherwise every client shares one
|
||||
rate-limit bucket, the count a limit keeps per client. The default list
|
||||
covers the Docker bridge address, such as `172.17.0.1`, that the image sees a
|
||||
proxy at. It does not cover loopback, so on a bare host set
|
||||
`TRUSTED_PROXIES=127.0.0.1`.
|
||||
4. **Pass on the client's `Host` header with its port.** webhooker checks a
|
||||
form's `Origin` against the `Host` header it receives. A `Host` without the
|
||||
port makes every form submission fail with `403` on any port other than 443.
|
||||
In nginx that means `$http_host`, not `$host`.
|
||||
rate-limit bucket, the count a limit keeps per client. A proxy on the host
|
||||
reaches the container from the Docker bridge address, such as `172.17.0.1`.
|
||||
The default list covers it. The list does not cover loopback, so on a bare
|
||||
host set `TRUSTED_PROXIES=127.0.0.1`.
|
||||
4. **Pass on the client's `Host` header with its port.** `Host` is the header
|
||||
that names the site the client asked for. webhooker checks a form's `Origin`
|
||||
against the `Host` header it receives. A `Host` without the port makes every
|
||||
form submission fail with `403` on any port other than `443`. For the nginx
|
||||
reverse proxy that means `$http_host`, not `$host`.
|
||||
|
||||
Protect the proxy's access log: its request paths hold the entrypoint UUIDs.
|
||||
|
||||
@@ -233,14 +245,19 @@ server {
|
||||
}
|
||||
```
|
||||
|
||||
The block matches webhooker's 1 MB body cap and its 60 second request timeout.
|
||||
The block matches webhooker's 1 MB body cap. Its `proxy_read_timeout` is longer
|
||||
than webhooker's 60 second request timeout, so webhooker's own `504` reaches the
|
||||
client first.
|
||||
|
||||
### HSTS
|
||||
|
||||
HSTS is a response header that tells browsers to use HTTPS only for this
|
||||
hostname and every name under it, for `max-age` seconds. Every response carries
|
||||
it unless `HSTS_MAX_AGE` is `0`. A browser keeps the rule until `max-age` runs
|
||||
out, so serve webhooker on a hostname you will keep on HTTPS.
|
||||
out, so serve webhooker on a hostname you will keep on HTTPS. The header always
|
||||
carries `preload`. On a whole domain such as `example.com`, that lets the domain
|
||||
be added to the HTTPS-only list that browsers ship with. That listing is hard to
|
||||
undo.
|
||||
|
||||
## Recovering a lost admin password
|
||||
|
||||
@@ -252,10 +269,11 @@ the server does and refuses while a running instance holds it.
|
||||
DATA_DIR=/var/lib/webhooker webhooker resetpw -generate admin
|
||||
```
|
||||
|
||||
Without `-generate`, the new password is read from standard input. It must be at
|
||||
least 8 characters. It is never taken as a command-line argument, because other
|
||||
accounts on the host can read those. The command creates nothing: a missing
|
||||
`DATA_DIR`, a missing `webhooker.db` or an unknown username is an error.
|
||||
`-generate` makes up a random password and prints it. Without `-generate`, the
|
||||
new password is read from standard input. It must be at least 8 bytes long. It
|
||||
is never taken as a command-line argument, because other accounts on the host
|
||||
can read those. The command creates nothing: a missing `DATA_DIR`, a missing
|
||||
`webhooker.db` or an unknown username is an error.
|
||||
|
||||
In a container, run the binary at `/app/webhooker` with the stopped container's
|
||||
volume mounted:
|
||||
@@ -269,18 +287,19 @@ docker run --rm -v /path/to/data:/var/lib/webhooker \
|
||||
|
||||
All state lives in `DATA_DIR`. Back up the whole directory. It holds:
|
||||
|
||||
- the main database, `webhooker.db`: the session key, the `admin` account's
|
||||
password hash, and the webhooks, entrypoints and targets;
|
||||
- the main database, `webhooker.db`: the session key (which signs the session
|
||||
cookies), the `admin` account's password hash, and the webhooks, entrypoints
|
||||
and targets;
|
||||
- one event database per webhook, `events-{webhook_uuid}.db`: its events,
|
||||
deliveries and attempts;
|
||||
- the files of each archive target;
|
||||
- the lock file, `webhooker.lock`, which is always empty.
|
||||
|
||||
Every database runs in WAL mode. WAL is SQLite's write-ahead log: finished
|
||||
Every database runs in WAL mode. WAL is SQLite's write-ahead log. Finished
|
||||
writes land in a `-wal` file beside the database before they reach the `.db`
|
||||
itself, and a `-shm` file beside it indexes that log. A copy of the `.db`
|
||||
without its `-wal` is missing data. A clean shutdown removes the `-wal` and
|
||||
`-shm` files; a crash leaves them, and they must be carried with the `.db`. Keep
|
||||
itself. A `-shm` file beside it indexes that log. A copy of the `.db` without
|
||||
its `-wal` is missing data. A clean shutdown removes the `-wal` and `-shm`
|
||||
files; a crash leaves them, and they must be carried with the `.db`. Keep
|
||||
`DATA_DIR` on a local disk: WAL mode is not reliable on network filesystems, and
|
||||
webhooker does not check for one.
|
||||
|
||||
@@ -296,7 +315,7 @@ done
|
||||
```
|
||||
|
||||
Each file is captured at its own instant. A webhook created during the backup
|
||||
can be in the copy of `webhooker.db` with no event database in the backup; a
|
||||
can be in the copy of `webhooker.db` with no event database in the backup. A
|
||||
restore then creates that event database empty.
|
||||
|
||||
To restore, stop the service and put back every file together, with any `-wal`
|
||||
@@ -314,11 +333,11 @@ plain text. Protect it accordingly.
|
||||
|
||||
### The entrypoint URL is the credential
|
||||
|
||||
Each entrypoint is a random version 4 UUID served at `/h/{uuid}`. Holding that
|
||||
URL is what authorises a sender. The receiver checks nothing else: no shared
|
||||
secret, no HMAC signature (a hash of the body made with a shared key), no token
|
||||
header. Signature headers a sender adds anyway are stored and forwarded as
|
||||
ordinary headers.
|
||||
Each entrypoint is a random UUID served at `/h/{uuid}`. Holding that URL is what
|
||||
authorises a sender. The receiver checks nothing else: no shared secret, no HMAC
|
||||
signature (a hash of the body made with a shared key), no token header. If a
|
||||
sender adds signature headers anyway, they are stored and forwarded as ordinary
|
||||
headers.
|
||||
|
||||
Keep the URL out of tickets, chat and screenshots. webhooker's own log never
|
||||
writes it. The reverse proxy's access log does hold it, in the request path.
|
||||
@@ -327,33 +346,37 @@ writes it. The reverse proxy's access log does hold it, in the request path.
|
||||
|
||||
- Passwords are hashed with Argon2id, a slow password hashing function.
|
||||
- Session cookies are signed, `HttpOnly` (hidden from page scripts) and
|
||||
`SameSite=Lax` (not sent with cross-site form posts). They carry `Secure` when
|
||||
the request carries `X-Forwarded-Proto: https`.
|
||||
`SameSite=Lax` (not sent with cross-site form posts). They carry `Secure`
|
||||
(sent only over HTTPS) when the request carries `X-Forwarded-Proto: https`.
|
||||
- Every form is protected against cross-site request forgery (CSRF). CSRF is
|
||||
when another site makes your browser submit a form here. Each form carries a
|
||||
token tied to a cookie, and a submission's `Origin` must name this host.
|
||||
- Form bodies are capped at 1 MB.
|
||||
- Every response carries these headers:
|
||||
- `Strict-Transport-Security`, unless `HSTS_MAX_AGE` is `0`;
|
||||
- `X-Content-Type-Options: nosniff`;
|
||||
- `X-Frame-Options: DENY`;
|
||||
- a Content-Security-Policy (CSP), the header that tells the browser where
|
||||
scripts and styles may come from, set to
|
||||
`default-src 'self'; script-src 'self'; style-src 'self'`;
|
||||
- `Referrer-Policy: strict-origin-when-cross-origin`;
|
||||
- `Permissions-Policy: camera=(), microphone=(), geolocation=()`.
|
||||
- `Strict-Transport-Security`, the HSTS header, unless `HSTS_MAX_AGE` is
|
||||
`0`;
|
||||
- `X-Content-Type-Options: nosniff`, which stops the browser guessing a
|
||||
file's type;
|
||||
- `X-Frame-Options: DENY`, which stops other sites showing these pages in a
|
||||
frame;
|
||||
- `Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self'`,
|
||||
which tells the browser where scripts and styles may come from;
|
||||
- `Referrer-Policy: strict-origin-when-cross-origin`, which tells another
|
||||
site only which site a visitor came from, not which page;
|
||||
- `Permissions-Policy: camera=(), microphone=(), geolocation=()`, which
|
||||
denies the pages the camera, microphone and location.
|
||||
- `/metrics` sits behind HTTP basic authentication, a username and password sent
|
||||
with each request.
|
||||
|
||||
### Sign-in limits
|
||||
|
||||
The password is checked first, and only a failed sign-in is counted. A correct
|
||||
password is never answered `429`. Failures are counted per client and submitted
|
||||
username. From the fifth failure within a minute, each failure is answered
|
||||
`429`. At most two password checks run at once; a request that gets no turn
|
||||
within 5 seconds is answered `503`. So those two checks, not the failure count,
|
||||
bound how fast passwords can be guessed. Choose a long random admin password, or
|
||||
rate-limit `POST /pages/login` at the reverse proxy.
|
||||
From the fifth failure within a minute, each failure is answered `429`. Only
|
||||
failures are counted. A correct password is never answered `429`. Failures are
|
||||
counted per client and submitted username. At most two password checks run at
|
||||
once; a request that gets no turn within 5 seconds is answered `503`. So
|
||||
guessing speed is bounded by the limit of two checks at a time, not by the
|
||||
failure count. Choose a long random `admin` password, or rate-limit
|
||||
`POST /pages/login` at the reverse proxy.
|
||||
|
||||
### Delivery destinations
|
||||
|
||||
@@ -361,8 +384,9 @@ A delivery target could be used to make webhooker send requests into the network
|
||||
it sits in and read the answers back. This is called server-side request forgery
|
||||
(SSRF). webhooker refuses it with the SSRF guard.
|
||||
|
||||
Every target URL must use `http` or `https` and resolve to a public address. The
|
||||
SSRF guard refuses these ranges:
|
||||
Every target URL must use `http` or `https`. By default its hostname must also
|
||||
point at a public address, one reachable from the internet. The SSRF guard
|
||||
refuses these ranges:
|
||||
|
||||
- loopback, `127.0.0.0/8` and `::1`;
|
||||
- the RFC 1918 private ranges, `10.0.0.0/8`, `172.16.0.0/12` and
|
||||
@@ -370,17 +394,20 @@ SSRF guard refuses these ranges:
|
||||
- link-local space, which is valid only on one network segment;
|
||||
- carrier-grade NAT space, which internet providers use inside their own
|
||||
networks;
|
||||
- multicast, the IPv6 unique local range, and the other reserved ranges.
|
||||
- multicast, the addresses that reach a group of hosts at once;
|
||||
- the IPv6 unique local range, `fc00::/7`, the private space of IPv6;
|
||||
- the other reserved ranges.
|
||||
|
||||
The SSRF guard runs when a target is saved and again when each delivery
|
||||
connects, so a hostname that changes its address later is still checked.
|
||||
connects. So a hostname that changes its address later is still checked.
|
||||
Redirects are followed up to ten hops, and each hop is checked. A hop to another
|
||||
host or port, or from `https` to `http`, carries none of the configured or
|
||||
forwarded headers, so a redirect cannot pass a credential to another host.
|
||||
host or port, or from `https` to `http`, drops every configured or forwarded
|
||||
header except `Content-Type`, which names the body's format. So a redirect
|
||||
cannot pass a credential to another host.
|
||||
|
||||
Egress means the connections webhooker opens outward to deliver.
|
||||
[`ALLOWED_EGRESS_CIDRS`](#allowing-egress-to-your-own-network) opens named
|
||||
networks to it.
|
||||
[`ALLOWED_EGRESS_CIDRS`](#allowing-egress-to-your-own-network) lets deliveries
|
||||
reach networks you name.
|
||||
|
||||
### What the logs contain
|
||||
|
||||
@@ -394,8 +421,9 @@ The `admin` password is printed in the clear on the first boot and by
|
||||
query string, headers and body.
|
||||
|
||||
When `SENTRY_DSN` is set, error reports go to Sentry, an error reporting
|
||||
service. A report carries no query string, body or cookies, and the route
|
||||
pattern (the route as registered, such as `/h/{uuid}`) in place of the path.
|
||||
service. A report carries no query string, body or cookies. It carries the route
|
||||
pattern in place of the path. A route pattern is the path with its variable part
|
||||
left as a name, such as `/h/{uuid}`.
|
||||
|
||||
## Design
|
||||
|
||||
@@ -404,21 +432,23 @@ sends them on in the background, and SQLite holds everything in between.
|
||||
|
||||
The receiver stores the event and one pending delivery per active target in one
|
||||
database write, then queues the deliveries. Background workers run them and
|
||||
record each attempt. At start and then every minute, a recovery routine queues
|
||||
again any delivery left `pending` or `retrying`, so a stop or a crash loses no
|
||||
delivery.
|
||||
record each attempt. At start, a recovery routine resumes every delivery left
|
||||
`pending` or `retrying`. It then runs every minute. Each run resumes every
|
||||
`retrying` delivery that the engine is not already handling. It also resumes a
|
||||
`pending` delivery that has sat untouched for 15 minutes. So a stop or a crash
|
||||
loses no delivery.
|
||||
|
||||
The main database holds configuration. Each webhook has its own event database,
|
||||
so writes for one webhook never wait on another webhook's database. The program
|
||||
starts in `cmd/webhooker/main.go`, and its packages are under `internal/`. The
|
||||
pages' HTML, CSS and JavaScript are embedded in the binary.
|
||||
starts in `cmd/webhooker/main.go`, and its logic is under `internal/`. The
|
||||
`static` and `templates` packages embed the pages' HTML, CSS and JavaScript in
|
||||
the binary.
|
||||
|
||||
## Configuration reference
|
||||
|
||||
All configuration comes from environment variables. For development, variables
|
||||
can also go in a `.env` file in the working directory. A variable set in the
|
||||
real environment wins over the file. A `.env` file that cannot be parsed aborts
|
||||
startup.
|
||||
All configuration comes from environment variables. Variables can also go in a
|
||||
`.env` file in the working directory. A variable set in the real environment
|
||||
wins over the file. A `.env` file that cannot be parsed aborts startup.
|
||||
|
||||
A variable that is unset or empty takes its default. A variable that is set but
|
||||
cannot be parsed aborts startup with a message naming it. A Go duration is a
|
||||
@@ -427,16 +457,20 @@ number with a unit, such as `30s`, `15m` or `24h`.
|
||||
### `WEBHOOKER_ENVIRONMENT`
|
||||
|
||||
`dev` or `prod`. Default `prod`. This controls one thing: CORS, the headers that
|
||||
let a page on another site call this one from a browser. In `dev`, every origin
|
||||
is allowed. In `prod`, no CORS headers are sent.
|
||||
let a page on another site call this one from a browser. An origin is a site as
|
||||
a browser names it: scheme, host and port. In `dev`, every origin is allowed. In
|
||||
`prod`, no CORS headers are sent.
|
||||
|
||||
### `PORT`
|
||||
|
||||
The TCP port the listener binds. Default `8080`.
|
||||
The port webhooker listens on. Default `8080`. Docker marks the image's
|
||||
container healthy by a check every 30 seconds. The check requests port `8080`
|
||||
whatever `PORT` says, so a container started with another `PORT` reports
|
||||
unhealthy.
|
||||
|
||||
### `BIND_ADDRESS`
|
||||
|
||||
The IP address the listener binds. Default `127.0.0.1`. The Docker image sets
|
||||
The IP address webhooker listens on. Default `127.0.0.1`. The Docker image sets
|
||||
`0.0.0.0`. It must be an IP address literal: `localhost` and a value with a port
|
||||
are refused. `0.0.0.0` listens on IPv4 only; `::` listens on IPv6 and IPv4.
|
||||
|
||||
@@ -445,17 +479,17 @@ are refused. `0.0.0.0` listens on IPv4 only; `::` listens on IPv6 and IPv4.
|
||||
The directory for every SQLite database and the lock file. Default
|
||||
`/var/lib/webhooker`. It is created if it is missing.
|
||||
|
||||
Exactly one webhooker process may use a `DATA_DIR` at a time; two would both run
|
||||
the recovery routine and send every pending delivery twice. At startup the
|
||||
process locks `{DATA_DIR}/webhooker.lock` with `flock`, the kernel's file lock,
|
||||
and a second process pointed at the same directory exits with a message naming
|
||||
it.
|
||||
Exactly one webhooker process may use a `DATA_DIR` at a time. Two would both run
|
||||
the recovery routine and could send one delivery twice. At startup the process
|
||||
locks `{DATA_DIR}/webhooker.lock` with `flock`, the kernel's file lock. A second
|
||||
process pointed at the same directory exits with a message that names the
|
||||
directory and the lock file.
|
||||
|
||||
### `DEBUG`
|
||||
|
||||
`true` or `false`. Default `false`. It accepts the spellings Go's
|
||||
`strconv.ParseBool` accepts; `yes` and `on` are refused. `DEBUG=true` lowers the
|
||||
log level to `DEBUG`, which adds every SQL statement, with `?` in place of each
|
||||
log level to `DEBUG`, which adds SQL statements, with `?` in place of each
|
||||
value, and other diagnostic lines.
|
||||
|
||||
### `METRICS_USERNAME` and `METRICS_PASSWORD`
|
||||
@@ -504,11 +538,11 @@ private ranges. A set value replaces the default.
|
||||
|
||||
The list decides whose `X-Forwarded-For` header is believed. `X-Forwarded-For`
|
||||
is the header a proxy uses to pass on the address of the client it is serving.
|
||||
It is read only when the connecting machine is inside the list. `X-Real-IP` and
|
||||
`True-Client-IP` are never read. If any client can reach webhooker, or the proxy
|
||||
in front of it, from a private address, set the list to the proxy's address
|
||||
alone. Otherwise those clients can choose their own `X-Forwarded-For` and bypass
|
||||
every rate limit.
|
||||
It is read only when the connecting machine is inside the list. webhooker never
|
||||
reads `X-Real-IP` or `True-Client-IP`, two other headers with the same job. Some
|
||||
clients may reach webhooker, or the proxy in front of it, from a private
|
||||
address. If so, set the list to the proxy's address alone. Otherwise those
|
||||
clients can choose their own `X-Forwarded-For` and bypass every rate limit.
|
||||
|
||||
### Allowing egress to your own network
|
||||
|
||||
@@ -517,13 +551,15 @@ that delivery targets may reach although the SSRF guard refuses them by default.
|
||||
Default empty. Example: `10.0.0.0/8, 172.17.0.0/16`. When the list is set,
|
||||
startup logs it at `WARN`.
|
||||
|
||||
Anyone with the admin account can make webhooker send requests to every network
|
||||
you list, and read the responses in the event log. List the smallest blocks that
|
||||
cover your destinations, and never `0.0.0.0/0` or `::/0`.
|
||||
Anyone with the `admin` account can make webhooker send requests to every
|
||||
network you list, and read the responses in **Full Event Log**. List the
|
||||
smallest networks that cover your destinations, and never `0.0.0.0/0` or `::/0`.
|
||||
|
||||
These addresses stay blocked whatever you list. They are link-local space, the
|
||||
unspecified addresses, and cloud metadata endpoints, the addresses a cloud gives
|
||||
each virtual machine for its own credentials:
|
||||
unspecified addresses and cloud metadata endpoints. The unspecified addresses
|
||||
are `0.0.0.0` and `::`, which name no host. A cloud metadata endpoint is the
|
||||
address a cloud gives each virtual machine for its own credentials. The blocked
|
||||
addresses are:
|
||||
|
||||
- `169.254.0.0/16` and `fe80::/10`;
|
||||
- `fd00:ec2::254/128` and `fd00:ec2::23/128`;
|
||||
@@ -536,25 +572,25 @@ each virtual machine for its own credentials:
|
||||
- `0.0.0.0/32` and `::/128`;
|
||||
- `::a9fe:a9fe/128` and `64:ff9b::a9fe:a9fe/128`.
|
||||
|
||||
This list is not every cloud's metadata address. If yours is not here, do not
|
||||
list the block that contains it. Azure's public `168.63.129.16` is refused by
|
||||
default but is reopened by a list that covers it.
|
||||
Some clouds' metadata addresses are not on this list. If yours is not here, do
|
||||
not list the network that contains it. Azure's public metadata address,
|
||||
`168.63.129.16`, is refused by default but is reopened by a list that covers it.
|
||||
|
||||
## Delivery reference
|
||||
|
||||
An `http` target posts the event body to its URL with the event's `Content-Type`
|
||||
and headers, then the target's configured **Headers**, which replace event
|
||||
headers of the same name. Every `http` and `slack` request carries
|
||||
`User-Agent: webhooker/{version}`, replacing the sender's `User-Agent` and any
|
||||
configured one. A `2xx` response is a success. The first 4 KiB of the response
|
||||
body is stored with the attempt. **Timeout** is at most 300 seconds, and blank
|
||||
means the 30 second default. **Pass the query string on to this target** appends
|
||||
the event's query string to the URL.
|
||||
An `http` target posts the event body to its URL. The request carries the
|
||||
event's `Content-Type` and headers. The target's configured **Headers** are
|
||||
added next and replace event headers of the same name. Every `http` and `slack`
|
||||
request carries `User-Agent: webhooker/{version}`. `User-Agent` is the header
|
||||
that names the sending program. The **Headers** field refuses `User-Agent`. A
|
||||
response with a status from `200` to `299` is a success. The first 4 KiB of the
|
||||
response body is stored with the attempt. **Timeout** is at most 300 seconds,
|
||||
and blank means the 30 second default. **Pass the query string on to this
|
||||
target** appends the event's query string to the URL.
|
||||
|
||||
A `slack` target posts a message that describes the event and shows its body in
|
||||
a code block, cut at 3500 bytes. An archive target, whose stored type is
|
||||
`database`, appends the event as a row to its own SQLite file. A `log` target
|
||||
writes the event as one `INFO` line.
|
||||
a code block, cut at 3500 bytes. An archive target appends the event as a row to
|
||||
its own SQLite file. A `log` target writes the event as one `INFO` line.
|
||||
|
||||
**Delivery attempts** is from 1 to 20. A failed attempt marks the delivery
|
||||
`retrying` and schedules the next attempt; the wait doubles each time, starting
|
||||
@@ -572,11 +608,12 @@ paused.
|
||||
An archive target writes to
|
||||
`archive-{webhook_name}-{target_name}-{target_uuid}.db` in `DATA_DIR`, the two
|
||||
names made safe for a file name. A rotation of `monthly`, `daily` or `hourly`
|
||||
starts a new file for each period, in UTC, with the period before `.db`, such as
|
||||
`-2026-10-01.db`. Rows older than the expiry are deleted on each write and by
|
||||
the archive sweep. Renaming the webhook or the target renames the files.
|
||||
**Download** on the webhook page returns the target's whole archive as one
|
||||
gzipped JSON file.
|
||||
starts a new file for each period. Periods follow UTC, the universal time that
|
||||
ignores local time zones. The period goes before `.db`, such as
|
||||
`-2026-10-01.db`. Rows older than the expiry are deleted each time a file is
|
||||
opened and by the archive sweep. Renaming the webhook or the target renames the
|
||||
files. **Download** on the webhook page returns the target's whole archive as
|
||||
one JSON file compressed with gzip.
|
||||
|
||||
## HTTP routes reference
|
||||
|
||||
@@ -587,6 +624,8 @@ gzipped JSON file.
|
||||
- an unknown UUID gets `404`;
|
||||
- a deactivated entrypoint gets `410`;
|
||||
- a body over 1 MB gets `413`;
|
||||
- a body that cannot be read in full gets `400`, for example one still
|
||||
arriving after the server's 10 second read limit;
|
||||
- a failure to store the event gets `500`;
|
||||
- a stored event gets `200` with `{"status":"ok"}`.
|
||||
- `GET /pages/login` shows the sign-in form, `POST /pages/login` signs in, and
|
||||
@@ -600,9 +639,9 @@ gzipped JSON file.
|
||||
## Metrics reference
|
||||
|
||||
`/metrics` serves these metrics for Prometheus, the monitoring system, plus the
|
||||
Go runtime and process metrics the Prometheus library provides. Each delivery
|
||||
metric carries one label, `target_type`, which is `http`, `slack`, `database`,
|
||||
`log` or `unknown`.
|
||||
Go runtime and process metrics the Prometheus library provides. A label is a
|
||||
name and value attached to a metric. Each delivery metric carries one label,
|
||||
`target_type`, which is `http`, `slack`, `database`, `log` or `unknown`.
|
||||
|
||||
- `webhooker_events_received_total` and `webhooker_events_resubmitted_total`,
|
||||
two counters (totals that only grow) with no label;
|
||||
@@ -616,17 +655,17 @@ metric carries one label, `target_type`, which is `http`, `slack`, `database`,
|
||||
(current values) sampled every 30 seconds;
|
||||
- `webhooker_circuit_breakers_open`.
|
||||
|
||||
Two inbound HTTP metrics, `http_request_duration_seconds` and
|
||||
`http_response_size_bytes`, carry the route pattern as their `handler` label,
|
||||
never the real path. A third, `http_requests_inflight`, counts all routes
|
||||
together.
|
||||
`http_request_duration_seconds` and `http_response_size_bytes` measure inbound
|
||||
requests. Their `handler` label is the route pattern, never the real path.
|
||||
`http_requests_inflight` counts all routes together.
|
||||
|
||||
## Development
|
||||
|
||||
You need the Go version named in `go.mod`, and Docker. The tests, the linters,
|
||||
prettier and the stylesheet build run in Docker, at versions pinned (fixed to
|
||||
one exact release) in the `Dockerfile`, `package.json` and `yarn.lock`. `gofmt`
|
||||
and `goimports` run on the host.
|
||||
`prettier` and the stylesheet build run in Docker. Their versions are pinned
|
||||
(fixed to one exact release) in the `Dockerfile`, `package.json` and
|
||||
`yarn.lock`. `gofmt` runs on the host. `goimports` runs on the host too, when it
|
||||
is installed. `script/bootstrap` does not install it.
|
||||
|
||||
```bash
|
||||
make bootstrap # Install git, make, Go and the Go modules
|
||||
@@ -639,25 +678,27 @@ make build # Build bin/webhooker, version-stamped
|
||||
make docker # Build the Docker image
|
||||
```
|
||||
|
||||
Each `make` target except `build`, `run`, `dev`, `deps` and `clean` runs a
|
||||
script in `script/`, listed under [Entrypoints](#entrypoints). Every
|
||||
`docker build` in `script/` passes `--no-cache`, so a check that passes has
|
||||
really run.
|
||||
Most `make` targets run a script in `script/`, listed under
|
||||
[Entrypoints](#entrypoints). `build`, `run`, `dev`, `deps` and `clean` run their
|
||||
own commands. Every `docker build` in `script/` passes `--no-cache`, so a check
|
||||
that passes has really run.
|
||||
|
||||
The binary's version comes from `script/version`: `git describe` on the
|
||||
checkout, or `VERSION` when that is set. It is shown at
|
||||
`/.well-known/healthcheck` and in the page footer.
|
||||
|
||||
The pages use one third-party script, Alpine.js, in its CSP build
|
||||
`@alpinejs/csp`, which runs under a Content-Security-Policy that forbids `eval`.
|
||||
The npm registry is where JavaScript packages are published. The package file in
|
||||
`3p/` is byte for byte the one it publishes.
|
||||
The pages use one third-party script, Alpine.js. They load `@alpinejs/csp`, the
|
||||
version of it that works when the Content-Security-Policy forbids running text
|
||||
as code. The npm registry is where JavaScript packages are published.
|
||||
`static/js/alpine.min.js` is committed byte for byte as the npm registry
|
||||
publishes it.
|
||||
|
||||
A browser test in `internal/server` loads the pages in a headless browser, one
|
||||
without a window, under the real Content-Security-Policy. `make test` leaves it
|
||||
out; run `make test-browser` after changing `templates/` or `static/js/`.
|
||||
`static/css/tailwind.css` is generated by Tailwind and committed; `make check`
|
||||
fails when it is out of date, and `make css` regenerates it.
|
||||
A headless browser is a browser without a window. A browser test in
|
||||
`internal/server` loads the pages in one under the real Content-Security-Policy.
|
||||
`make test` leaves it out; run `make test-browser` after changing `templates/`
|
||||
or `static/js/`. `static/css/tailwind.css` is generated by Tailwind, a CSS
|
||||
generator. The file is committed. `make check` fails when it is out of date, and
|
||||
`make css` regenerates it.
|
||||
|
||||
## Entrypoints
|
||||
|
||||
@@ -670,26 +711,25 @@ application are under [Entrypoint URLs](#entrypoint-urls).
|
||||
- `script/bootstrap` installs git, make, Go and the Go modules.
|
||||
- `script/setup` runs `script/bootstrap`, then `script/install-precommit`.
|
||||
- `script/projectname` prints `webhooker`.
|
||||
- `script/assets` extracts Alpine.js from `3p/` to `static/js/alpine.min.js`.
|
||||
- `script/test` runs the tests in Docker.
|
||||
- `script/test-browser` runs the browser test in Docker.
|
||||
- `script/lint` runs `gofmt`, `golangci-lint` and ESLint in Docker.
|
||||
- `script/fmt` formats the Go code with `gofmt` and `goimports` on the host, and
|
||||
the Markdown with prettier in Docker.
|
||||
- `script/fmt-check` fails when `gofmt` or prettier would change a file. It does
|
||||
not run `goimports`, and it writes nothing.
|
||||
- `script/fmt` formats the Go code with `gofmt` on the host and the Markdown
|
||||
with `prettier` in Docker. It also runs `goimports` when that is installed.
|
||||
- `script/fmt-check` fails when `gofmt` or `prettier` would change a file. It
|
||||
does not run `goimports`, and it writes nothing.
|
||||
- `script/css` regenerates `static/css/tailwind.css` in Docker.
|
||||
- `script/css-check` fails when `static/css/tailwind.css` is out of date.
|
||||
- `script/check` runs `script/test`, `script/lint`, `script/fmt-check` and
|
||||
`script/css-check`.
|
||||
- `script/version` prints the version to stamp into the binary.
|
||||
- `script/docker` builds the image tagged `webhooker`.
|
||||
- `script/cibuild` is what CI, the check run on every push, runs:
|
||||
- CI is the check that runs on every push. It runs `script/cibuild`:
|
||||
`script/bootstrap`, `script/check`, then the image build.
|
||||
- `script/precommit` checks that `go mod tidy` changes nothing, then runs
|
||||
`script/check`.
|
||||
- `script/install-precommit` installs the git pre-commit hook that runs
|
||||
`script/precommit`.
|
||||
- `script/install-precommit` installs the git hook that runs `script/precommit`
|
||||
before each commit.
|
||||
|
||||
## TODO
|
||||
|
||||
|
||||
@@ -1,72 +1,77 @@
|
||||
# Workflow
|
||||
|
||||
One issue per unit of work, one branch and one PR per issue:
|
||||
One issue per unit of work, and 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
|
||||
- make sure a tracked issue exists with a definition of done
|
||||
- branch from `next`, never from `main`
|
||||
- do the work and push the branch
|
||||
- open a PR with base `next`, never `main`
|
||||
- pass an independent review
|
||||
- the repository manager squash-merges it into `next`
|
||||
|
||||
`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.
|
||||
The repository manager is the role that merges each issue's PR into `next`.
|
||||
Nothing stays local-only: every commit is pushed.
|
||||
|
||||
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.
|
||||
`next` is the branch for the next milestone. It must stay green and mergeable
|
||||
into `main` at all times. A milestone's work reaches `main` through one PR from
|
||||
`next`. The owner, @sneak, merges that PR. Cutting a release from `main` is a
|
||||
separate step after that.
|
||||
|
||||
The tracker (https://git.eeqj.de/sneak/webhooker/issues) records what is done
|
||||
and what is under way. This file keeps no list of either.
|
||||
|
||||
# Status
|
||||
|
||||
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.
|
||||
(https://git.eeqj.de/sneak/webhooker/milestone/9). `v1.0.0` is not tagged. 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
|
||||
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. When webhooker cannot record the outcome of
|
||||
a delivery, it sends that delivery again. The target can then receive the same
|
||||
event twice. The alternative is a lost delivery that nothing reports. The README
|
||||
says so. Such a duplicate is not a defect to re-file.
|
||||
|
||||
# Next Step
|
||||
|
||||
@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.
|
||||
into `main` is a separate act. It waits on neither the production run nor the
|
||||
tag.
|
||||
|
||||
# Future Steps
|
||||
|
||||
- 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
|
||||
- Per-webhook rate limits, set in the web UI and enforced at the entrypoint.
|
||||
Today `RECEIVER_RATE_LIMIT` is the only rate-limit setting. It is how many
|
||||
requests per minute one client address may send to one entrypoint. The same
|
||||
address is also capped at ten times that across all entrypoints together.
|
||||
- API key authentication for programmatic access. The `APIKey` model and its
|
||||
`api_keys` table exist. Nothing creates a key and nothing checks one.
|
||||
- REST API v1. `/api/v1` is mounted with no routes today.
|
||||
- CRUD for webhooks, entrypoints and targets
|
||||
- event viewing and filtering endpoints
|
||||
- event redelivery endpoint
|
||||
- OpenAPI specification
|
||||
- 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`
|
||||
(https://git.eeqj.de/sneak/webhooker/issues/208)
|
||||
and figures for all webhooks together. Today the webhook page shows event,
|
||||
delivery and failure counts and failure percentages. The target list counts
|
||||
each target's deliveries. The event log shows every attempt's duration.
|
||||
`/metrics` carries server-wide counts and delivery durations.
|
||||
- A remember-me option at login. Today every session survives a browser restart.
|
||||
It lasts up to 7 days. It ends sooner after `SESSION_IDLE_TIMEOUT` (default 24
|
||||
hours) without use.
|
||||
- Password reset over the web for a forgotten password. Today a signed-in user
|
||||
can change their password. A lost password is reset from the command line with
|
||||
`webhooker resetpw` (https://git.eeqj.de/sneak/webhooker/issues/208).
|
||||
- Later, nice to have
|
||||
- email delivery target type
|
||||
- SNS and S3 delivery targets
|
||||
- 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)
|
||||
- data transformations, such as a configurable Slack message format. The
|
||||
Slack target sends one fixed format today.
|
||||
- JSONL file delivery with periodic S3 upload. Today an archive target
|
||||
writes events to SQLite files. An archive target can rotate those files
|
||||
hourly, daily or monthly. 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)
|
||||
- multi-user with role-based access control. Today each webhook belongs to
|
||||
one account. Only the account made at first boot exists. There is no way
|
||||
to add another.
|
||||
+1
-1
@@ -5,7 +5,7 @@
|
||||
// REPO_POLICIES.md states that a linter can check: const for everything,
|
||||
// let only for a variable that is reassigned, never var.
|
||||
export default [
|
||||
// Alpine.js, extracted from 3p/ by make assets; not ours to lint.
|
||||
// Alpine.js, committed as published; not ours to lint.
|
||||
{ ignores: ["static/js/alpine.min.js"] },
|
||||
{
|
||||
// The pages load static/js/app.js as a classic script, not a module.
|
||||
|
||||
@@ -13,9 +13,6 @@ import (
|
||||
|
||||
// TestBaseTemplateScriptsAreServed walks every /s/ script the base
|
||||
// template loads on each page and fetches it through the real router.
|
||||
// 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()
|
||||
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
#!/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 package
|
||||
# is @alpinejs/csp, Alpine's build for pages whose Content-Security-Policy
|
||||
# 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)"
|
||||
|
||||
main() {
|
||||
cd "$ROOT"
|
||||
tar -xzOf 3p/alpinejs-csp-3.14.9.tgz package/dist/cdn.min.js \
|
||||
>static/js/alpine.min.js
|
||||
}
|
||||
|
||||
main "$@"
|
||||
Vendored
+11
@@ -0,0 +1,11 @@
|
||||
(()=>{var it=!1,ot=!1,W=[],st=-1;function Wt(e){Mn(e)}function Mn(e){W.includes(e)||W.push(e),Nn()}function Gt(e){let t=W.indexOf(e);t!==-1&&t>st&&W.splice(t,1)}function Nn(){!ot&&!it&&(it=!0,queueMicrotask(kn))}function kn(){it=!1,ot=!0;for(let e=0;e<W.length;e++)W[e](),st=e;W.length=0,st=-1,ot=!1}var T,N,F,ct,at=!0;function Jt(e){at=!1,e(),at=!0}function Yt(e){T=e.reactive,F=e.release,N=t=>e.effect(t,{scheduler:r=>{at?Wt(r):r()}}),ct=e.raw}function lt(e){N=e}function Xt(e){let t=()=>{};return[n=>{let i=N(n);return e._x_effects||(e._x_effects=new Set,e._x_runEffects=()=>{e._x_effects.forEach(o=>o())}),e._x_effects.add(i),t=()=>{i!==void 0&&(e._x_effects.delete(i),F(i))},i},()=>{t()}]}function Se(e,t){let r=!0,n,i=N(()=>{let o=e();JSON.stringify(o),r?n=o:queueMicrotask(()=>{t(o,n),n=o}),r=!1});return()=>F(i)}var Zt=[],Qt=[],er=[];function tr(e){er.push(e)}function re(e,t){typeof t=="function"?(e._x_cleanups||(e._x_cleanups=[]),e._x_cleanups.push(t)):(t=e,Qt.push(t))}function Oe(e){Zt.push(e)}function Ce(e,t,r){e._x_attributeCleanups||(e._x_attributeCleanups={}),e._x_attributeCleanups[t]||(e._x_attributeCleanups[t]=[]),e._x_attributeCleanups[t].push(r)}function ut(e,t){e._x_attributeCleanups&&Object.entries(e._x_attributeCleanups).forEach(([r,n])=>{(t===void 0||t.includes(r))&&(n.forEach(i=>i()),delete e._x_attributeCleanups[r])})}function rr(e){for(e._x_effects?.forEach(Gt);e._x_cleanups?.length;)e._x_cleanups.pop()()}var ft=new MutationObserver(ht),dt=!1;function de(){ft.observe(document,{subtree:!0,childList:!0,attributes:!0,attributeOldValue:!0}),dt=!0}function pt(){Dn(),ft.disconnect(),dt=!1}var fe=[];function Dn(){let e=ft.takeRecords();fe.push(()=>e.length>0&&ht(e));let t=fe.length;queueMicrotask(()=>{if(fe.length===t)for(;fe.length>0;)fe.shift()()})}function m(e){if(!dt)return e();pt();let t=e();return de(),t}var mt=!1,Ae=[];function nr(){mt=!0}function ir(){mt=!1,ht(Ae),Ae=[]}function ht(e){if(mt){Ae=Ae.concat(e);return}let t=[],r=new Set,n=new Map,i=new Map;for(let o=0;o<e.length;o++)if(!e[o].target._x_ignoreMutationObserver&&(e[o].type==="childList"&&(e[o].removedNodes.forEach(s=>{s.nodeType===1&&s._x_marker&&r.add(s)}),e[o].addedNodes.forEach(s=>{if(s.nodeType===1){if(r.has(s)){r.delete(s);return}s._x_marker||t.push(s)}})),e[o].type==="attributes")){let s=e[o].target,a=e[o].attributeName,c=e[o].oldValue,l=()=>{n.has(s)||n.set(s,[]),n.get(s).push({name:a,value:s.getAttribute(a)})},u=()=>{i.has(s)||i.set(s,[]),i.get(s).push(a)};s.hasAttribute(a)&&c===null?l():s.hasAttribute(a)?(u(),l()):u()}i.forEach((o,s)=>{ut(s,o)}),n.forEach((o,s)=>{Zt.forEach(a=>a(s,o))});for(let o of r)t.some(s=>s.contains(o))||Qt.forEach(s=>s(o));for(let o of t)o.isConnected&&er.forEach(s=>s(o));t=null,r=null,n=null,i=null}function Te(e){return P(k(e))}function D(e,t,r){return e._x_dataStack=[t,...k(r||e)],()=>{e._x_dataStack=e._x_dataStack.filter(n=>n!==t)}}function k(e){return e._x_dataStack?e._x_dataStack:typeof ShadowRoot=="function"&&e instanceof ShadowRoot?k(e.host):e.parentNode?k(e.parentNode):[]}function P(e){return new Proxy({objects:e},Pn)}var Pn={ownKeys({objects:e}){return Array.from(new Set(e.flatMap(t=>Object.keys(t))))},has({objects:e},t){return t==Symbol.unscopables?!1:e.some(r=>Object.prototype.hasOwnProperty.call(r,t)||Reflect.has(r,t))},get({objects:e},t,r){return t=="toJSON"?In:Reflect.get(e.find(n=>Reflect.has(n,t))||{},t,r)},set({objects:e},t,r,n){let i=e.find(s=>Object.prototype.hasOwnProperty.call(s,t))||e[e.length-1],o=Object.getOwnPropertyDescriptor(i,t);return o?.set&&o?.get?o.set.call(n,r)||!0:Reflect.set(i,t,r)}};function In(){return Reflect.ownKeys(this).reduce((t,r)=>(t[r]=Reflect.get(this,r),t),{})}function Re(e){let t=n=>typeof n=="object"&&!Array.isArray(n)&&n!==null,r=(n,i="")=>{Object.entries(Object.getOwnPropertyDescriptors(n)).forEach(([o,{value:s,enumerable:a}])=>{if(a===!1||s===void 0||typeof s=="object"&&s!==null&&s.__v_skip)return;let c=i===""?o:`${i}.${o}`;typeof s=="object"&&s!==null&&s._x_interceptor?n[o]=s.initialize(e,c,o):t(s)&&s!==n&&!(s instanceof Element)&&r(s,c)})};return r(e)}function Me(e,t=()=>{}){let r={initialValue:void 0,_x_interceptor:!0,initialize(n,i,o){return e(this.initialValue,()=>$n(n,i),s=>_t(n,i,s),i,o)}};return t(r),n=>{if(typeof n=="object"&&n!==null&&n._x_interceptor){let i=r.initialize.bind(r);r.initialize=(o,s,a)=>{let c=n.initialize(o,s,a);return r.initialValue=c,i(o,s,a)}}else r.initialValue=n;return r}}function $n(e,t){return t.split(".").reduce((r,n)=>r[n],e)}function _t(e,t,r){if(typeof t=="string"&&(t=t.split(".")),t.length===1)e[t[0]]=r;else{if(t.length===0)throw error;return e[t[0]]||(e[t[0]]={}),_t(e[t[0]],t.slice(1),r)}}var or={};function y(e,t){or[e]=t}function G(e,t){let r=Ln(t);return Object.entries(or).forEach(([n,i])=>{Object.defineProperty(e,`$${n}`,{get(){return i(t,r)},enumerable:!1})}),e}function Ln(e){let[t,r]=gt(e),n={interceptor:Me,...t};return re(e,r),n}function Ne(e,t,r,...n){try{return r(...n)}catch(i){ne(i,e,t)}}function ne(e,t,Line truncated
|
||||
|
||||
${r?'Expression: "'+r+`"
|
||||
|
||||
`:""}`,t),setTimeout(()=>{throw e},0)}var ke=!0;function De(e){let t=ke;ke=!1;let r=e();return ke=t,r}function R(e,t,r={}){let n;return x(e,t)(i=>n=i,r),n}function x(...e){return sr(...e)}var sr=jn;function ar(e){sr=e}function jn(e,t){let r={};G(r,e);let n=[r,...k(e)],i=typeof t=="function"?yt(n,t):Bn(n,t,e);return Ne.bind(null,e,t,i)}function yt(e,t){return(r=()=>{},{scope:n={},params:i=[]}={})=>{let o=t.apply(P([n,...e]),i);ie(r,o)}}var xt={};function Fn(e,t){if(xt[e])return xt[e];let r=Object.getPrototypeOf(async function(){}).constructor,n=/^[\n\s]*if.*\(.*\)/.test(e.trim())||/^(let|const)\s/.test(e.trim())?`(async()=>{ ${e} })()`:e,o=(()=>{try{let s=new r(["__self","scope"],`with (scope) { __self.result = ${n} }; __self.finished = true; return __self.result;`);return Object.defineProperty(s,"name",{value:`[Alpine] ${e}`}),s}catch(s){return ne(s,t,e),Promise.resolve()}})();return xt[e]=o,o}function Bn(e,t,r){let n=Fn(t,r);return(i=()=>{},{scope:o={},params:s=[]}={})=>{n.result=void 0,n.finished=!1;let a=P([o,...e]);if(typeof n=="function"){let c=n(n,a).catch(l=>ne(l,r,t));n.finished?(ie(i,n.result,a,s,r),n.result=void 0):c.then(l=>{ie(i,l,a,s,r)}).catch(l=>ne(l,r,t)).finally(()=>n.result=void 0)}}}function ie(e,t,r,n,i){if(ke&&typeof t=="function"){let o=t.apply(r,n);o instanceof Promise?o.then(s=>ie(e,s,r,n)).catch(s=>ne(s,i,t)):e(o)}else typeof t=="object"&&t instanceof Promise?t.then(o=>e(o)):e(t)}var Et="x-";function C(e=""){return Et+e}function cr(e){Et=e}var Pe={};function d(e,t){return Pe[e]=t,{before(r){if(!Pe[r]){console.warn(String.raw`Cannot find directive \`${r}\`. \`${e}\` will use the default order of execution`);return}let n=J.indexOf(r);J.splice(n>=0?n:J.indexOf("DEFAULT"),0,e)}}}function lr(e){return Object.keys(Pe).includes(e)}function me(e,t,r){if(t=Array.from(t),e._x_virtualDirectives){let o=Object.entries(e._x_virtualDirectives).map(([a,c])=>({name:a,value:c})),s=vt(o);o=o.map(a=>s.find(c=>c.name===a.name)?{name:`x-bind:${a.name}`,value:`"${a.value}"`}:a),t=t.concat(o)}let n={};return t.map(dr((o,s)=>n[o]=s)).filter(mr).map(Kn(n,r)).sort(Hn).map(o=>zn(e,o))}function vt(e){return Array.from(e).map(dr()).filter(t=>!mr(t))}var bt=!1,pe=new Map,ur=Symbol();function fr(e){bt=!0;let t=Symbol();ur=t,pe.set(t,[]);let r=()=>{for(;pe.get(t).length;)pe.get(t).shift()();pe.delete(t)},n=()=>{bt=!1,r()};e(r),n()}function gt(e){let t=[],r=a=>t.push(a),[n,i]=Xt(e);return t.push(i),[{Alpine:K,effect:n,cleanup:r,evaluateLater:x.bind(x,e),evaluate:R.bind(R,e)},()=>t.forEach(a=>a())]}function zn(e,t){let r=()=>{},n=Pe[t.type]||r,[i,o]=gt(e);Ce(e,t.original,o);let s=()=>{e._x_ignore||e._x_ignoreSelf||(n.inline&&n.inline(e,t,i),n=n.bind(n,e,t,i),bt?pe.get(ur).push(n):n())};return s.runCleanups=o,s}var Ie=(e,t)=>({name:r,value:n})=>(r.startsWith(e)&&(r=r.replace(e,t)),{name:r,value:n}),$e=e=>e;function dr(e=()=>{}){return({name:t,value:r})=>{let{name:n,value:i}=pr.reduce((o,s)=>s(o),{name:t,value:r});return n!==t&&e(n,t),{name:n,value:i}}}var pr=[];function oe(e){pr.push(e)}function mr({name:e}){return hr().test(e)}var hr=()=>new RegExp(`^${Et}([^:^.]+)\\b`);function Kn(e,t){return({name:r,value:n})=>{let i=r.match(hr()),o=r.match(/:([a-zA-Z0-9\-_:]+)/),s=r.match(/\.[^.\]]+(?=[^\]]*$)/g)||[],a=t||e[r]||r;return{type:i?i[1]:null,value:o?o[1]:null,modifiers:s.map(c=>c.replace(".","")),expression:n,original:a}}}var wt="DEFAULT",J=["ignore","ref","data","id","anchor","bind","init","for","model","modelable","transition","show","if",wt,"teleport"];function Hn(e,t){let r=J.indexOf(e.type)===-1?wt:e.type,n=J.indexOf(t.type)===-1?wt:t.type;return J.indexOf(r)-J.indexOf(n)}function Y(e,t,r={}){e.dispatchEvent(new CustomEvent(t,{detail:r,bubbles:!0,composed:!0,cancelable:!0}))}function I(e,t){if(typeof ShadowRoot=="function"&&e instanceof ShadowRoot){Array.from(e.children).forEach(i=>I(i,t));return}let r=!1;if(t(e,()=>r=!0),r)return;let n=e.firstElementChild;for(;n;)I(n,t,!1),n=n.nextElementSibling}function E(e,...t){console.warn(`Alpine Warning: ${e}`,...t)}var _r=!1;function gr(){_r&&E("Alpine has already been initialized on this page. Calling Alpine.start() more than once can cause problems."),_r=!0,document.body||E("Unable to initialize. Trying to load Alpine before `<body>` is available. Did you forget to add `defer` in Alpine's `<script>` tag?"),Y(document,"alpine:init"),Y(document,"alpine:initializing"),de(),tr(t=>S(t,I)),re(t=>$(t)),Oe((t,r)=>{me(t,r).forEach(n=>n())});let e=t=>!X(t.parentElement,!0);Array.from(document.querySelectorAll(br().join(","))).filter(e).forEach(t=>{S(t)}),Y(document,"alpine:initialized"),setTimeout(()=>{qn()})}var St=[],xr=[];function yr(){return St.map(e=>e())}function br(){return St.concat(xr).map(e=>e())}function Le(e){St.push(e)}function je(e){xr.push(e)}function X(e,t=!1){return B(e,r=>{if((t?br():yr()).some(i=>r.matches(i)))return!0})}function B(e,t){if(e){if(t(e))return e;if(e._x_teleportBack&&(e=e._x_teleportBack),!!e.parentElement)return B(e.parentElement,t)}}function wr(Line truncated
|
||||
|
||||
"${t}"
|
||||
|
||||
Read more about the Alpine's CSP-friendly build restrictions here: https://alpinejs.dev/advanced/csp
|
||||
|
||||
`,e)}function Mt(e,t){let r=Object.create(null),n=e.split(",");for(let i=0;i<n.length;i++)r[n[i]]=!0;return t?i=>!!r[i.toLowerCase()]:i=>!!r[i]}var mi="itemscope,allowfullscreen,formnovalidate,ismap,nomodule,novalidate,readonly";var qs=Mt(mi+",async,autofocus,autoplay,controls,default,defer,disabled,hidden,loop,open,required,reversed,scoped,seamless,checked,muted,multiple,selected");var Yr=Object.freeze({}),Us=Object.freeze([]);var hi=Object.prototype.hasOwnProperty,be=(e,t)=>hi.call(e,t),V=Array.isArray,ae=e=>Xr(e)==="[object Map]";var _i=e=>typeof e=="string",Ue=e=>typeof e=="symbol",we=e=>e!==null&&typeof e=="object";var gi=Object.prototype.toString,Xr=e=>gi.call(e),Nt=e=>Xr(e).slice(8,-1);var We=e=>_i(e)&&e!=="NaN"&&e[0]!=="-"&&""+parseInt(e,10)===e;var Ge=e=>{let t=Object.create(null);return r=>t[r]||(t[r]=e(r))},xi=/-(\w)/g,Ws=Ge(e=>e.replace(xi,(t,r)=>r?r.toUpperCase():"")),yi=/\B([A-Z])/g,Gs=Ge(e=>e.replace(yi,"-$1").toLowerCase()),kt=Ge(e=>e.charAt(0).toUpperCase()+e.slice(1)),Js=Ge(e=>e?`on${kt(e)}`:""),Dt=(e,t)=>e!==t&&(e===e||t===t);var Pt=new WeakMap,Ee=[],j,ee=Symbol("iterate"),It=Symbol("Map key iterate");function bi(e){return e&&e._isEffect===!0}function nn(e,t=Yr){bi(e)&&(e=e.raw);let r=Ei(e,t);return t.lazy||r(),r}function on(e){e.active&&(sn(e),e.options.onStop&&e.options.onStop(),e.active=!1)}var wi=0;function Ei(e,t){let r=function(){if(!r.active)return e();if(!Ee.includes(r)){sn(r);try{return Si(),Ee.push(r),j=r,e()}finally{Ee.pop(),an(),j=Ee[Ee.length-1]}}};return r.id=wi++,r.allowRecurse=!!t.allowRecurse,r._isEffect=!0,r.active=!0,r.raw=e,r.deps=[],r.options=t,r}function sn(e){let{deps:t}=e;if(t.length){for(let r=0;r<t.length;r++)t[r].delete(e);t.length=0}}var ce=!0,Lt=[];function vi(){Lt.push(ce),ce=!1}function Si(){Lt.push(ce),ce=!0}function an(){let e=Lt.pop();ce=e===void 0?!0:e}function M(e,t,r){if(!ce||j===void 0)return;let n=Pt.get(e);n||Pt.set(e,n=new Map);let i=n.get(r);i||n.set(r,i=new Set),i.has(j)||(i.add(j),j.deps.push(i),j.options.onTrack&&j.options.onTrack({effect:j,target:e,type:t,key:r}))}function U(e,t,r,n,i,o){let s=Pt.get(e);if(!s)return;let a=new Set,c=u=>{u&&u.forEach(p=>{(p!==j||p.allowRecurse)&&a.add(p)})};if(t==="clear")s.forEach(c);else if(r==="length"&&V(e))s.forEach((u,p)=>{(p==="length"||p>=n)&&c(u)});else switch(r!==void 0&&c(s.get(r)),t){case"add":V(e)?We(r)&&c(s.get("length")):(c(s.get(ee)),ae(e)&&c(s.get(It)));break;case"delete":V(e)||(c(s.get(ee)),ae(e)&&c(s.get(It)));break;case"set":ae(e)&&c(s.get(ee));break}let l=u=>{u.options.onTrigger&&u.options.onTrigger({effect:u,target:e,key:r,type:t,newValue:n,oldValue:i,oldTarget:o}),u.options.scheduler?u.options.scheduler(u):u()};a.forEach(l)}var Ai=Mt("__proto__,__v_isRef,__isVue"),cn=new Set(Object.getOwnPropertyNames(Symbol).map(e=>Symbol[e]).filter(Ue)),Oi=ln();var Ci=ln(!0);var Zr=Ti();function Ti(){let e={};return["includes","indexOf","lastIndexOf"].forEach(t=>{e[t]=function(...r){let n=_(this);for(let o=0,s=this.length;o<s;o++)M(n,"get",o+"");let i=n[t](...r);return i===-1||i===!1?n[t](...r.map(_)):i}}),["push","pop","shift","unshift","splice"].forEach(t=>{e[t]=function(...r){vi();let n=_(this)[t].apply(this,r);return an(),n}}),e}function ln(e=!1,t=!1){return function(n,i,o){if(i==="__v_isReactive")return!e;if(i==="__v_isReadonly")return e;if(i==="__v_raw"&&o===(e?t?Vi:pn:t?Hi:dn).get(n))return n;let s=V(n);if(!e&&s&&be(Zr,i))return Reflect.get(Zr,i,o);let a=Reflect.get(n,i,o);return(Ue(i)?cn.has(i):Ai(i))||(e||M(n,"get",i),t)?a:$t(a)?!s||!We(i)?a.value:a:we(a)?e?mn(a):tt(a):a}}var Ri=Mi();function Mi(e=!1){return function(r,n,i,o){let s=r[n];if(!e&&(i=_(i),s=_(s),!V(r)&&$t(s)&&!$t(i)))return s.value=i,!0;let a=V(r)&&We(n)?Number(n)<r.length:be(r,n),c=Reflect.set(r,n,i,o);return r===_(o)&&(a?Dt(i,s)&&U(r,"set",n,i,s):U(r,"add",n,i)),c}}function Ni(e,t){let r=be(e,t),n=e[t],i=Reflect.deleteProperty(e,t);return i&&r&&U(e,"delete",t,void 0,n),i}function ki(e,t){let r=Reflect.has(e,t);return(!Ue(t)||!cn.has(t))&&M(e,"has",t),r}function Di(e){return M(e,"iterate",V(e)?"length":ee),Reflect.ownKeys(e)}var Pi={get:Oi,set:Ri,deleteProperty:Ni,has:ki,ownKeys:Di},Ii={get:Ci,set(e,t){return console.warn(`Set operation on key "${String(t)}" failed: target is readonly.`,e),!0},deleteProperty(e,t){return console.warn(`Delete operation on key "${String(t)}" failed: target is readonly.`,e),!0}};var jt=e=>we(e)?tt(e):e,Ft=e=>we(e)?mn(e):e,Bt=e=>e,et=e=>Reflect.getPrototypeOf(e);function Je(e,t,r=!1,n=!1){e=e.__v_raw;let i=_(e),o=_(t);t!==o&&!r&&M(i,"get",t),!r&&M(i,"get",o);let{has:s}=et(i),a=n?Bt:r?Ft:jt;if(s.call(i,t))return a(e.get(t));if(s.call(i,o))return a(e.get(o));e!==i&&e.get(t)}function Ye(e,t=!1){let r=this.__v_raw,n=_(r),i=_(e);return e!==i&&!t&&M(n,"has",e),!t&&M(n,"has",i),e===i?r.has(e):r.has(e)||r.has(i)}function Xe(e,t=!1){return e=e.__v_raw,!t&&M(_(e),"iterate",ee),Reflect.get(e,"size",e)}function Qr(e){e=_(e);let t=_(this);return et(t).has.call(t,e)||(t.add(e),U(t,"add",e,e)),this}function en(e,t){t
Line truncated
|
||||
+5
-3
@@ -6,9 +6,11 @@ import (
|
||||
)
|
||||
|
||||
// Static holds the CSS and JavaScript files the web UI's pages load. They
|
||||
// are named one by one so that a missing js/alpine.min.js, which make
|
||||
// assets extracts and git does not track, fails the build instead of
|
||||
// leaving the pages without Alpine.js.
|
||||
// are named one by one, so a missing file fails the build instead of
|
||||
// leaving the pages without it.
|
||||
//
|
||||
// js/alpine.min.js is Alpine.js, committed unchanged. It is
|
||||
// package/dist/cdn.min.js from the npm package @alpinejs/csp 3.14.9.
|
||||
//
|
||||
//go:embed css/tailwind.css css/style.css js/app.js js/alpine.min.js
|
||||
var Static embed.FS
|
||||
Reference in new issue
Block a user