mirror of
https://github.com/multipleof4/ntfy.git
synced 2026-10-09 05:15:22 +00:00
Compare commits
221
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e2f4fc7469 | ||
|
|
4f52663dda | ||
|
|
10cb6506f8 | ||
|
|
7826c86f67 | ||
|
|
cae7816e5d | ||
|
|
a0a0a5fde3 | ||
|
|
fbcd4b6290 | ||
|
|
f1bdb6bfe1 | ||
|
|
2f53e15a20 | ||
|
|
4c2b69e059 | ||
|
|
ea6b1ca520 | ||
|
|
423063893d | ||
|
|
143b9fb55c | ||
|
|
3104ad20e2 | ||
|
|
00e3c1351a | ||
|
|
c23dc0b30c | ||
|
|
01af61d228 | ||
|
|
9328f1f3c8 | ||
|
|
61873b593f | ||
|
|
9dc30e4a97 | ||
|
|
cd333f130f | ||
|
|
f42326605e | ||
|
|
7ed7fea081 | ||
|
|
01c13e186a | ||
|
|
0ecba37334 | ||
|
|
fc808db251 | ||
|
|
dc11655153 | ||
|
|
bd96177fdf | ||
|
|
a3d43190c9 | ||
|
|
95ad323d1c | ||
|
|
e7efdaeb3b | ||
|
|
244a9bc06d | ||
|
|
5e13ca05d5 | ||
|
|
fdaf3316a0 | ||
|
|
310a5aa8df | ||
|
|
0ff1cd5bab | ||
|
|
4abdeb8d57 | ||
|
|
7fb1d25740 | ||
|
|
ef121a3f6c | ||
|
|
4a0f66e258 | ||
|
|
53bbd12cd7 | ||
|
|
e620fbe95b | ||
|
|
b703627d7f | ||
|
|
6f4f9e6407 | ||
|
|
f2d5c1ce6c | ||
|
|
7680cb4906 | ||
|
|
706fa3b491 | ||
|
|
2bc145f3ae | ||
|
|
311138ef7b | ||
|
|
f6b03b44dd | ||
|
|
c674985699 | ||
|
|
469d263a5c | ||
|
|
f8d2fcd7a6 | ||
|
|
ac63a2eea0 | ||
|
|
07e1260b89 | ||
|
|
af4d85ec03 | ||
|
|
24bc50b585 | ||
|
|
b55e78a918 | ||
|
|
6638699d48 | ||
|
|
3f56dae54a | ||
|
|
1e4e3b6e36 | ||
|
|
75c687de1c | ||
|
|
432da44dc4 | ||
|
|
703d7bb9de | ||
|
|
98a0daba86 | ||
|
|
ce01b357d8 | ||
|
|
c40a2ce0a7 | ||
|
|
88e598d8b8 | ||
|
|
6869d166ae | ||
|
|
08d81a3645 | ||
|
|
202d858826 | ||
|
|
73e9d46b49 | ||
|
|
9a021aba2d | ||
|
|
812dc4cded | ||
|
|
9f6c4743b3 | ||
|
|
165f012ae6 | ||
|
|
1d69ebaf58 | ||
|
|
a001ac5195 | ||
|
|
bde864756e | ||
|
|
b6d21415bb | ||
|
|
2e3d5babc8 | ||
|
|
b16efd2cb6 | ||
|
|
24f991c6d7 | ||
|
|
b15213e531 | ||
|
|
7b54850c16 | ||
|
|
be3dbdcb48 | ||
|
|
2eabfd2f01 | ||
|
|
d0054ea394 | ||
|
|
97282d6e3d | ||
|
|
ef7aa31881 | ||
|
|
aaa124973b | ||
|
|
69c2c18a9e | ||
|
|
479e406493 | ||
|
|
6429d36708 | ||
|
|
73f771dafa | ||
|
|
5e1f27d709 | ||
|
|
6ed71dd4d0 | ||
|
|
b4adf85805 | ||
|
|
7c0ab6e6b0 | ||
|
|
e39de727e0 | ||
|
|
8a67b5129e | ||
|
|
f5067d295c | ||
|
|
958ce520ec | ||
|
|
edbf600cd7 | ||
|
|
6ed57ec064 | ||
|
|
e28d8aca59 | ||
|
|
a6f0447482 | ||
|
|
f2b22c114f | ||
|
|
c873064cad | ||
|
|
4c0bd70408 | ||
|
|
9078662bdd | ||
|
|
5970f03973 | ||
|
|
6cbcfd95fa | ||
|
|
6b1339ff4a | ||
|
|
7881b973d7 | ||
|
|
3a5458d237 | ||
|
|
f6ab9e7c58 | ||
|
|
a8ac283353 | ||
|
|
86b077a292 | ||
|
|
d44a566528 | ||
|
|
72b7155130 | ||
|
|
aeacd0a3d6 | ||
|
|
3302ad2479 | ||
|
|
c9105dad09 | ||
|
|
5765be7892 | ||
|
|
7a99bfc717 | ||
|
|
156ad4ae92 | ||
|
|
a6dc691672 | ||
|
|
9114d1e996 | ||
|
|
55d871aeb2 | ||
|
|
a5318a4312 | ||
|
|
f77d299aa3 | ||
|
|
af66cc3ddc | ||
|
|
a47eef5006 | ||
|
|
0ac7cbd4fe | ||
|
|
72ac95148b | ||
|
|
817a0ece7e | ||
|
|
e617c5f453 | ||
|
|
a04a12eb5f | ||
|
|
46799db753 | ||
|
|
5e32f05302 | ||
|
|
ad8391dd31 | ||
|
|
e3889746c4 | ||
|
|
df6f1f3ee1 | ||
|
|
047a1258f1 | ||
|
|
2b1f2d6b9a | ||
|
|
87e9dc9da4 | ||
|
|
5d96888f0e | ||
|
|
65e409e9e4 | ||
|
|
8022401728 | ||
|
|
6d86574a38 | ||
|
|
6a4f68897a | ||
|
|
b0411e5c92 | ||
|
|
c5e5be0746 | ||
|
|
ab2966e70f | ||
|
|
3a960b1b89 | ||
|
|
ad45f70fd1 | ||
|
|
4b070546b5 | ||
|
|
e6a201bc11 | ||
|
|
8327047b71 | ||
|
|
c44575d7a1 | ||
|
|
f121f2ba8d | ||
|
|
0c313906ef | ||
|
|
ab1e170a20 | ||
|
|
fb75a65885 | ||
|
|
74240328dc | ||
|
|
3bfb9f334b | ||
|
|
7b37f2a3eb | ||
|
|
d5b13a925e | ||
|
|
50ac2c1925 | ||
|
|
2b30ce9ee0 | ||
|
|
9b3ab5a302 | ||
|
|
4313b02fc6 | ||
|
|
d8666b66ec | ||
|
|
5808c4d4c0 | ||
|
|
954fae44dc | ||
|
|
1979dbc7c3 | ||
|
|
d7dea6d250 | ||
|
|
eab3988304 | ||
|
|
7494d0acf6 | ||
|
|
914bf3b0c4 | ||
|
|
c45744558b | ||
|
|
74332fa302 | ||
|
|
3d02c99394 | ||
|
|
36d7d3bd24 | ||
|
|
8a7b73cc7e | ||
|
|
bb2ca0facf | ||
|
|
2ee8717e0c | ||
|
|
cac3b2986a | ||
|
|
19af0b65cc | ||
|
|
01eabb288a | ||
|
|
99bc803271 | ||
|
|
d8c87d04e7 | ||
|
|
b75d0e582c | ||
|
|
44d5bcf875 | ||
|
|
fc59339f86 | ||
|
|
5834f667b2 | ||
|
|
9fa8550ef6 | ||
|
|
eff808d0f8 | ||
|
|
ca58b885cb | ||
|
|
1215e99098 | ||
|
|
f558935c1e | ||
|
|
4516adea36 | ||
|
|
33ae31055c | ||
|
|
30dd4840a2 | ||
|
|
fd0f0657b9 | ||
|
|
44dac47d76 | ||
|
|
fd716e4807 | ||
|
|
a0775701c1 | ||
|
|
d824f1a11a | ||
|
|
2d7faac0ea | ||
|
|
d1696ac5b6 | ||
|
|
737163ba59 | ||
|
|
26bc28ae24 | ||
|
|
88fff8264b | ||
|
|
9cc6124f18 | ||
|
|
5ff460f1dc | ||
|
|
9d3019004e | ||
|
|
dbcc89ed8b | ||
|
|
46a5338a30 | ||
|
|
fb89b87efb |
@@ -8,13 +8,13 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Install Go
|
||||
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
|
||||
with:
|
||||
go-version: '1.26.x'
|
||||
go-version-file: '.go-version'
|
||||
- name: Install node
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: '24'
|
||||
cache: 'npm'
|
||||
|
||||
@@ -9,10 +9,10 @@ jobs:
|
||||
steps:
|
||||
-
|
||||
name: Checkout ntfy code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
-
|
||||
name: Checkout docs pages code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
repository: binwiederhier/ntfy-docs.github.io
|
||||
path: build/ntfy-docs.github.io
|
||||
|
||||
@@ -25,19 +25,19 @@ jobs:
|
||||
NTFY_TEST_S3_URL: ${{ secrets.NTFY_TEST_S3_URL }}
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Install Go
|
||||
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
|
||||
with:
|
||||
go-version: '1.26.x'
|
||||
go-version-file: '.go-version'
|
||||
- name: Install node
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: '24'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: './web/package-lock.json'
|
||||
- name: Docker login
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||
uses: docker/login-action@371161bbe7024a29a25c5e19bfcbc0804fe9ad2c # v4.5.2
|
||||
with:
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.DOCKER_HUB_TOKEN }}
|
||||
|
||||
@@ -25,13 +25,13 @@ jobs:
|
||||
NTFY_TEST_S3_URL: ${{ secrets.NTFY_TEST_S3_URL }}
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Install Go
|
||||
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
|
||||
with:
|
||||
go-version: '1.26.x'
|
||||
go-version-file: '.go-version'
|
||||
- name: Install node
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: '24'
|
||||
cache: 'npm'
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
1.27.0
|
||||
+1
-1
@@ -60,7 +60,7 @@ representative at an online or offline event.
|
||||
|
||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
||||
reported to the community leaders responsible for enforcement via Discord/Matrix (binwiederhier),
|
||||
or email (ntfy@heckel.io). All complaints will be reviewed and investigated promptly
|
||||
or email (contact@mail.ntfy.sh). All complaints will be reviewed and investigated promptly
|
||||
and fairly.
|
||||
|
||||
All community leaders are obligated to respect the privacy and security of the
|
||||
|
||||
@@ -48,6 +48,9 @@ ADD ./webpush ./webpush
|
||||
ADD ./attachment ./attachment
|
||||
ADD ./mail ./mail
|
||||
ADD ./s3 ./s3
|
||||
ADD ./action ./action
|
||||
ADD ./template/gotext ./template/gotext
|
||||
|
||||
RUN --mount=type=cache,target=/go/pkg/mod --mount=type=cache,target=/root/.cache/go-build make VERSION=$VERSION COMMIT=$COMMIT cli-linux-server
|
||||
|
||||
FROM alpine
|
||||
|
||||
@@ -5,7 +5,9 @@ PIP := pip3
|
||||
VERSION := $(shell git describe --tag)
|
||||
COMMIT := $(shell git rev-parse --short HEAD)
|
||||
|
||||
.PHONY:
|
||||
# FORCE is an always-out-of-date target with no recipe; listing it as a prerequisite
|
||||
# forces that target's recipe to run every time (the classic "FORCE target" idiom).
|
||||
FORCE:
|
||||
|
||||
help:
|
||||
@echo "Typical commands (more see below):"
|
||||
@@ -43,6 +45,7 @@ help:
|
||||
@echo " make web-deps - Install web app dependencies (npm install the universe)"
|
||||
@echo " make web-build - Actually build the web app"
|
||||
@echo " make web-lint - Run eslint on the web app"
|
||||
@echo " make web-test - Run vitest unit tests for the web app"
|
||||
@echo " make web-fmt - Run prettier on the web app"
|
||||
@echo " make web-fmt-check - Run prettier on the web app, but don't change anything"
|
||||
@echo
|
||||
@@ -52,7 +55,9 @@ help:
|
||||
@echo " make docs-build - Actually build the documentation"
|
||||
@echo
|
||||
@echo "Test/check:"
|
||||
@echo " make test - Run tests"
|
||||
@echo " make test - Run all tests (Go + web)"
|
||||
@echo " make cli-test - Run Go tests only"
|
||||
@echo " make web-test - Run web app tests only"
|
||||
@echo " make race - Run tests with -race flag"
|
||||
@echo " make coverage - Run tests and show coverage"
|
||||
@echo " make coverage-html - Run tests and show coverage (as HTML)"
|
||||
@@ -82,12 +87,12 @@ help:
|
||||
|
||||
# Building everything
|
||||
|
||||
clean: .PHONY
|
||||
clean: FORCE
|
||||
rm -rf dist build server/docs server/site
|
||||
|
||||
build: web docs cli
|
||||
|
||||
update: web-deps-update cli-deps-update docs-deps-update
|
||||
update: web-deps-update cli-deps-update docs-deps-update go-check
|
||||
docker pull alpine
|
||||
|
||||
docker-dev:
|
||||
@@ -119,7 +124,7 @@ build-deps-ubuntu:
|
||||
|
||||
docs: docs-deps docs-build
|
||||
|
||||
docs-venv: .PHONY
|
||||
docs-venv: FORCE
|
||||
$(PYTHON) -m venv ./venv
|
||||
|
||||
docs-build: docs-venv
|
||||
@@ -128,7 +133,7 @@ docs-build: docs-venv
|
||||
docs-deps: docs-venv
|
||||
(. venv/bin/activate && $(PIP) install -r requirements.txt)
|
||||
|
||||
docs-deps-update: .PHONY
|
||||
docs-deps-update: FORCE
|
||||
(. venv/bin/activate && $(PIP) install -r requirements.txt --upgrade)
|
||||
|
||||
|
||||
@@ -163,6 +168,9 @@ web-fmt-check:
|
||||
web-lint:
|
||||
cd web && $(NPM) run lint
|
||||
|
||||
web-test:
|
||||
cd web && $(NPM) run test
|
||||
|
||||
# Main server/client build
|
||||
|
||||
cli: cli-deps
|
||||
@@ -265,17 +273,21 @@ cli-build-results:
|
||||
|
||||
# Test/check targets
|
||||
|
||||
check: test web-fmt-check fmt-check vet web-lint lint staticcheck
|
||||
check: test web-fmt-check fmt-check vet web-lint lint staticcheck template-check go-check
|
||||
|
||||
checkv: testv web-fmt-check fmt-check vet web-lint lint staticcheck
|
||||
checkv: testv web-fmt-check fmt-check vet web-lint lint staticcheck template-check go-check
|
||||
|
||||
test: .PHONY
|
||||
test: cli-test web-test
|
||||
|
||||
testv: cli-testv web-test
|
||||
|
||||
cli-test: FORCE
|
||||
go test $(shell go list -f '{{if .TestGoFiles}}{{.ImportPath}}{{end}}' ./... | grep -vE 'ntfy/v2/(test|examples|tools)')
|
||||
|
||||
testv: .PHONY
|
||||
cli-testv: FORCE
|
||||
go test -v $(shell go list -f '{{if .TestGoFiles}}{{.ImportPath}}{{end}}' ./... | grep -vE 'ntfy/v2/(test|examples|tools)')
|
||||
|
||||
race: .PHONY
|
||||
race: FORCE
|
||||
go test -v -race $(shell go list -f '{{if .TestGoFiles}}{{.ImportPath}}{{end}}' ./... | grep -vE 'ntfy/v2/(test|examples|tools)')
|
||||
|
||||
coverage:
|
||||
@@ -305,17 +317,73 @@ vet:
|
||||
|
||||
lint:
|
||||
which golint || go install golang.org/x/lint/golint@latest
|
||||
go list ./... | grep -v /vendor/ | xargs -L1 golint -set_exit_status
|
||||
go list ./... | grep -v /vendor/ | grep -vE 'ntfy/v2/template/gotext' | xargs -L1 golint -set_exit_status
|
||||
|
||||
staticcheck: .PHONY
|
||||
staticcheck: FORCE
|
||||
rm -rf build/staticcheck
|
||||
which staticcheck || go install honnef.co/go/tools/cmd/staticcheck@latest
|
||||
mkdir -p build/staticcheck
|
||||
ln -s "go" build/staticcheck/go
|
||||
PATH="$(PWD)/build/staticcheck:$(PATH)" staticcheck ./...
|
||||
PATH="$(PWD)/build/staticcheck:$(PATH)" staticcheck $$(go list ./... | grep -vE 'ntfy/v2/template/gotext')
|
||||
rm -rf build/staticcheck
|
||||
|
||||
|
||||
# Vendored template targets (see template/README.md)
|
||||
|
||||
TEMPLATE_GO_VERSION := go$(shell cat .go-version 2>/dev/null)
|
||||
|
||||
update-template:
|
||||
@if [ "$$(go env GOVERSION)" != "$(TEMPLATE_GO_VERSION)" ]; then \
|
||||
echo "ERROR: local Go $$(go env GOVERSION) != $(TEMPLATE_GO_VERSION) pinned in .go-version."; \
|
||||
echo "Bump .go-version and install that toolchain first: go install golang.org/dl/$(TEMPLATE_GO_VERSION)@latest && $(TEMPLATE_GO_VERSION) download"; \
|
||||
exit 1; \
|
||||
fi
|
||||
src="$$(go env GOROOT)/src"; \
|
||||
rm -f template/gotext/*.go template/gotext/fmtsort/*.go; \
|
||||
for f in $$(go list -f '{{range .GoFiles}}{{.}} {{end}}' text/template); do cp "$$src/text/template/$$f" template/gotext/; done; \
|
||||
for f in $$(go list -f '{{range .GoFiles}}{{.}} {{end}}' internal/fmtsort); do cp "$$src/internal/fmtsort/$$f" template/gotext/fmtsort/; done; \
|
||||
sed -i 's/^package template$$/package gotext/' template/gotext/*.go; \
|
||||
sed -i 's#"internal/fmtsort"#"heckel.io/ntfy/v2/template/gotext/fmtsort"#' template/gotext/*.go; \
|
||||
( cd template/gotext && for p in patches/*.patch; do echo "Applying $$p"; git apply "$$p" || exit 1; done )
|
||||
go env GOVERSION > template/gotext/GENERATED_FROM
|
||||
@echo "Regenerated template/gotext/ from $(TEMPLATE_GO_VERSION) (files enumerated via 'go list'); review with 'git diff'."
|
||||
|
||||
template-check: FORCE
|
||||
@if [ "$$(cat template/gotext/GENERATED_FROM)" != "$(TEMPLATE_GO_VERSION)" ]; then \
|
||||
echo "ERROR: template/gotext was generated from $$(cat template/gotext/GENERATED_FROM), but .go-version pins $(TEMPLATE_GO_VERSION). Run 'make update-template' on the pinned Go."; \
|
||||
exit 1; \
|
||||
fi
|
||||
@if [ "$$(go env GOVERSION)" != "$(TEMPLATE_GO_VERSION)" ]; then \
|
||||
echo "SKIP: local Go $$(go env GOVERSION) != pinned $(TEMPLATE_GO_VERSION); skipping vendored template content check (version marker already verified)."; \
|
||||
exit 0; \
|
||||
fi
|
||||
@tmp=$$(mktemp -d); src="$$(go env GOROOT)/src"; \
|
||||
mkdir -p "$$tmp/gotext/fmtsort"; \
|
||||
for f in $$(go list -f '{{range .GoFiles}}{{.}} {{end}}' text/template); do cp "$$src/text/template/$$f" "$$tmp/gotext/"; done; \
|
||||
for f in $$(go list -f '{{range .GoFiles}}{{.}} {{end}}' internal/fmtsort); do cp "$$src/internal/fmtsort/$$f" "$$tmp/gotext/fmtsort/"; done; \
|
||||
sed -i 's/^package template$$/package gotext/' "$$tmp/gotext/"*.go; \
|
||||
sed -i 's#"internal/fmtsort"#"heckel.io/ntfy/v2/template/gotext/fmtsort"#' "$$tmp/gotext/"*.go; \
|
||||
cp template/gotext/patches/*.patch "$$tmp/"; \
|
||||
( cd "$$tmp/gotext" && for p in "$$tmp"/*.patch; do git apply "$$p" || exit 1; done ); \
|
||||
if diff -rq -x 'README.md' -x 'GENERATED_FROM' -x 'patches' "$$tmp/gotext" template/gotext >/dev/null 2>&1; then \
|
||||
rm -rf "$$tmp"; \
|
||||
else \
|
||||
echo "ERROR: template/gotext/ drifted from GOROOT+patches (or its file set changed). Run 'make update-template' on Go $(TEMPLATE_GO_VERSION):"; \
|
||||
diff -rq -x 'README.md' -x 'GENERATED_FROM' -x 'patches' "$$tmp/gotext" template/gotext; \
|
||||
rm -rf "$$tmp"; exit 1; \
|
||||
fi
|
||||
|
||||
# go-check is advisory only (never fails): it warns when the pinned Go (.go-version) is behind the
|
||||
# latest upstream release, so template/gotext doesn't silently fall behind on text/template fixes.
|
||||
go-check: FORCE
|
||||
@latest=$$(curl -s --max-time 10 'https://go.dev/VERSION?m=text' 2>/dev/null | head -1); \
|
||||
if [ -n "$$latest" ] && [ "$$latest" != "$(TEMPLATE_GO_VERSION)" ]; then \
|
||||
echo ""; \
|
||||
echo "note: latest Go is $$latest, but template/gotext is pinned to $(TEMPLATE_GO_VERSION) (.go-version)."; \
|
||||
echo " to bump: install $$latest, set .go-version to $${latest#go}, then run 'make update-template'."; \
|
||||
fi
|
||||
|
||||
|
||||
# Releasing targets
|
||||
|
||||
release: clean cli-deps release-checks docs web check
|
||||
@@ -326,6 +394,10 @@ release-snapshot: clean cli-deps docs web check
|
||||
|
||||
release-checks:
|
||||
$(eval LATEST_TAG := $(shell git describe --abbrev=0 --tags | cut -c2-))
|
||||
if [ "$$(go env GOVERSION)" != "go$$(cat .go-version)" ]; then\
|
||||
echo "ERROR: releases must use the pinned Go toolchain (go$$(cat .go-version) from .go-version), but this is $$(go env GOVERSION). This also ensures 'make check' enforces (not skips) the template/gotext drift check.";\
|
||||
exit 1;\
|
||||
fi
|
||||
if ! grep -q $(LATEST_TAG) docs/install.md; then\
|
||||
echo "ERROR: Must update docs/install.md with latest tag first.";\
|
||||
exit 1;\
|
||||
|
||||
@@ -84,8 +84,6 @@ Thank you to our commercial sponsors, who help keep the service running and the
|
||||
|
||||
<a href="https://m.do.co/c/442b929528db"><img src="https://opensource.nyc3.cdn.digitaloceanspaces.com/attribution/assets/SVG/DO_Logo_horizontal_blue.svg" width="201px"></a>
|
||||
|
||||
<a href="https://www.magicbell.com/?utm_source=ntfy"><img src="assets/sponsors/magicbell.png" width="180px"></a>
|
||||
|
||||
<a href="https://go.warp.dev/ntfy"><img src="https://raw.githubusercontent.com/warpdotdev/brand-assets/refs/heads/main/Logos/Warp-Wordmark-Black.png" width="160px"></a>
|
||||
|
||||
And a big fat **Thank You** to the individuals who have sponsored ntfy in the past, or are still sponsoring ntfy:
|
||||
@@ -268,6 +266,7 @@ Third-party libraries and resources:
|
||||
* [github.com/mattn/go-sqlite3](https://github.com/mattn/go-sqlite3) (MIT) is used to provide the persistent message cache
|
||||
* [Firebase Admin SDK](https://github.com/firebase/firebase-admin-go) (Apache 2.0) is used to send FCM messages
|
||||
* [github/gemoji](https://github.com/github/gemoji) (MIT) is used for emoji support (specifically the [emoji.json](https://raw.githubusercontent.com/github/gemoji/master/db/emoji.json) file)
|
||||
* Go's [text/template](https://pkg.go.dev/text/template) (BSD-3-Clause) is vendored under [template/gotext/](template/gotext/) with a small patch adding an execution deadline (see [template/gotext/README.md](template/gotext/README.md))
|
||||
* [Lightbox with vanilla JS](https://yossiabramov.com/blog/vanilla-js-lightbox) as a lightbox on the landing page
|
||||
* [HTTP middleware for gzip compression](https://gist.github.com/CJEnright/bc2d8b8dc0c1389a9feeddb110f822d7) (MIT) is used for serving static files
|
||||
* [Regex for auto-linking](https://github.com/bryanwoods/autolink-js) (MIT) is used to highlight links (the library is not used)
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
package server
|
||||
// Package action parses the "action buttons" that can be attached to a notification, in both the
|
||||
// JSON and the human-readable "simple" format described at https://ntfy.sh/docs/publish/#action-buttons.
|
||||
package action
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
@@ -37,10 +39,10 @@ type actionParser struct {
|
||||
pos int
|
||||
}
|
||||
|
||||
// parseActions parses the actions string as described in https://ntfy.sh/docs/publish/#action-buttons.
|
||||
// Parse parses the actions string as described in https://ntfy.sh/docs/publish/#action-buttons.
|
||||
// It supports both a JSON representation (if the string begins with "[", see parseActionsFromJSON),
|
||||
// and the "simple" format, which is more human-readable, but harder to parse (see parseActionsFromSimple).
|
||||
func parseActions(s string) (actions []*model.Action, err error) {
|
||||
func Parse(s string) (actions []*model.Action, err error) {
|
||||
// Parse JSON or simple format
|
||||
s = strings.TrimSpace(s)
|
||||
if strings.HasPrefix(s, "[") {
|
||||
@@ -1,4 +1,4 @@
|
||||
package server
|
||||
package action
|
||||
|
||||
import (
|
||||
"testing"
|
||||
@@ -7,12 +7,12 @@ import (
|
||||
)
|
||||
|
||||
func TestParseActions(t *testing.T) {
|
||||
actions, err := parseActions("[]")
|
||||
actions, err := Parse("[]")
|
||||
require.Nil(t, err)
|
||||
require.Empty(t, actions)
|
||||
|
||||
// Basic test
|
||||
actions, err = parseActions("action=http, label=Open door, url=https://door.lan/open; view, Show portal, https://door.lan")
|
||||
actions, err = Parse("action=http, label=Open door, url=https://door.lan/open; view, Show portal, https://door.lan")
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 2, len(actions))
|
||||
require.Equal(t, "http", actions[0].Action)
|
||||
@@ -23,7 +23,7 @@ func TestParseActions(t *testing.T) {
|
||||
require.Equal(t, "https://door.lan", actions[1].URL)
|
||||
|
||||
// JSON
|
||||
actions, err = parseActions(`[{"action":"http","label":"Open door","url":"https://door.lan/open"}, {"action":"view","label":"Show portal","url":"https://door.lan"}]`)
|
||||
actions, err = Parse(`[{"action":"http","label":"Open door","url":"https://door.lan/open"}, {"action":"view","label":"Show portal","url":"https://door.lan"}]`)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 2, len(actions))
|
||||
require.Equal(t, "http", actions[0].Action)
|
||||
@@ -34,7 +34,7 @@ func TestParseActions(t *testing.T) {
|
||||
require.Equal(t, "https://door.lan", actions[1].URL)
|
||||
|
||||
// Other params
|
||||
actions, err = parseActions("action=http, label=Open door, url=https://door.lan/open, body=this is a body, method=PUT")
|
||||
actions, err = Parse("action=http, label=Open door, url=https://door.lan/open, body=this is a body, method=PUT")
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 1, len(actions))
|
||||
require.Equal(t, "http", actions[0].Action)
|
||||
@@ -44,7 +44,7 @@ func TestParseActions(t *testing.T) {
|
||||
require.Equal(t, "this is a body", actions[0].Body)
|
||||
|
||||
// Extras with underscores
|
||||
actions, err = parseActions("action=broadcast, label=Do a thing, extras.command=some command, extras.some_param=a parameter")
|
||||
actions, err = Parse("action=broadcast, label=Do a thing, extras.command=some command, extras.some_param=a parameter")
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 1, len(actions))
|
||||
require.Equal(t, "broadcast", actions[0].Action)
|
||||
@@ -54,7 +54,7 @@ func TestParseActions(t *testing.T) {
|
||||
require.Equal(t, "a parameter", actions[0].Extras["some_param"])
|
||||
|
||||
// Broadcast action with intent
|
||||
actions, err = parseActions("action=broadcast, label=Do a thing, intent=io.heckel.ntfy.TEST_INTENT")
|
||||
actions, err = Parse("action=broadcast, label=Do a thing, intent=io.heckel.ntfy.TEST_INTENT")
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 1, len(actions))
|
||||
require.Equal(t, "broadcast", actions[0].Action)
|
||||
@@ -62,7 +62,7 @@ func TestParseActions(t *testing.T) {
|
||||
require.Equal(t, "io.heckel.ntfy.TEST_INTENT", actions[0].Intent)
|
||||
|
||||
// Headers with dashes
|
||||
actions, err = parseActions("action=http, label=Send request, url=http://example.com, method=GET, headers.Content-Type=application/json, headers.Authorization=Basic sdasffsf")
|
||||
actions, err = Parse("action=http, label=Send request, url=http://example.com, method=GET, headers.Content-Type=application/json, headers.Authorization=Basic sdasffsf")
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 1, len(actions))
|
||||
require.Equal(t, "http", actions[0].Action)
|
||||
@@ -72,7 +72,7 @@ func TestParseActions(t *testing.T) {
|
||||
require.Equal(t, "Basic sdasffsf", actions[0].Headers["Authorization"])
|
||||
|
||||
// Quotes
|
||||
actions, err = parseActions(`action=http, "Look ma, \"quotes\"; and semicolons", url=http://example.com`)
|
||||
actions, err = Parse(`action=http, "Look ma, \"quotes\"; and semicolons", url=http://example.com`)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 1, len(actions))
|
||||
require.Equal(t, "http", actions[0].Action)
|
||||
@@ -80,7 +80,7 @@ func TestParseActions(t *testing.T) {
|
||||
require.Equal(t, `http://example.com`, actions[0].URL)
|
||||
|
||||
// Single quotes
|
||||
actions, err = parseActions(`action=http, '"quotes" and \'single quotes\'', url=http://example.com`)
|
||||
actions, err = Parse(`action=http, '"quotes" and \'single quotes\'', url=http://example.com`)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 1, len(actions))
|
||||
require.Equal(t, "http", actions[0].Action)
|
||||
@@ -88,7 +88,7 @@ func TestParseActions(t *testing.T) {
|
||||
require.Equal(t, `http://example.com`, actions[0].URL)
|
||||
|
||||
// Single quotes (JSON)
|
||||
actions, err = parseActions(`action=http, Post it, url=http://example.com, body='{"temperature": 65}'`)
|
||||
actions, err = Parse(`action=http, Post it, url=http://example.com, body='{"temperature": 65}'`)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 1, len(actions))
|
||||
require.Equal(t, "http", actions[0].Action)
|
||||
@@ -97,7 +97,7 @@ func TestParseActions(t *testing.T) {
|
||||
require.Equal(t, `{"temperature": 65}`, actions[0].Body)
|
||||
|
||||
// Out of order
|
||||
actions, err = parseActions(`label="Out of order!" , action="http", url=http://example.com`)
|
||||
actions, err = Parse(`label="Out of order!" , action="http", url=http://example.com`)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 1, len(actions))
|
||||
require.Equal(t, "http", actions[0].Action)
|
||||
@@ -105,7 +105,7 @@ func TestParseActions(t *testing.T) {
|
||||
require.Equal(t, `http://example.com`, actions[0].URL)
|
||||
|
||||
// Spaces
|
||||
actions, err = parseActions(`action = http, label = 'this is a label', url = "http://google.com"`)
|
||||
actions, err = Parse(`action = http, label = 'this is a label', url = "http://google.com"`)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 1, len(actions))
|
||||
require.Equal(t, "http", actions[0].Action)
|
||||
@@ -113,7 +113,7 @@ func TestParseActions(t *testing.T) {
|
||||
require.Equal(t, `http://google.com`, actions[0].URL)
|
||||
|
||||
// Non-ASCII
|
||||
actions, err = parseActions(`action = http, 'Кохайтеся а не воюйте, 💙🫤', url = "http://google.com"`)
|
||||
actions, err = Parse(`action = http, 'Кохайтеся а не воюйте, 💙🫤', url = "http://google.com"`)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 1, len(actions))
|
||||
require.Equal(t, "http", actions[0].Action)
|
||||
@@ -121,7 +121,7 @@ func TestParseActions(t *testing.T) {
|
||||
require.Equal(t, `http://google.com`, actions[0].URL)
|
||||
|
||||
// Multiple actions, awkward spacing
|
||||
actions, err = parseActions(`http , 'Make love, not war 💙🫤' , https://ntfy.sh ; view, " yo ", https://x.org, clear=true`)
|
||||
actions, err = Parse(`http , 'Make love, not war 💙🫤' , https://ntfy.sh ; view, " yo ", https://x.org, clear=true`)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 2, len(actions))
|
||||
require.Equal(t, "http", actions[0].Action)
|
||||
@@ -134,7 +134,7 @@ func TestParseActions(t *testing.T) {
|
||||
require.Equal(t, true, actions[1].Clear)
|
||||
|
||||
// Copy action (simple format)
|
||||
actions, err = parseActions("copy, Copy code, 1234")
|
||||
actions, err = Parse("copy, Copy code, 1234")
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 1, len(actions))
|
||||
require.Equal(t, "copy", actions[0].Action)
|
||||
@@ -142,7 +142,7 @@ func TestParseActions(t *testing.T) {
|
||||
require.Equal(t, "1234", actions[0].Value)
|
||||
|
||||
// Copy action (JSON)
|
||||
actions, err = parseActions(`[{"action":"copy","label":"Copy OTP","value":"567890"}]`)
|
||||
actions, err = Parse(`[{"action":"copy","label":"Copy OTP","value":"567890"}]`)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 1, len(actions))
|
||||
require.Equal(t, "copy", actions[0].Action)
|
||||
@@ -150,7 +150,7 @@ func TestParseActions(t *testing.T) {
|
||||
require.Equal(t, "567890", actions[0].Value)
|
||||
|
||||
// Copy action with clear
|
||||
actions, err = parseActions("copy, Copy code, 1234, clear=true")
|
||||
actions, err = Parse("copy, Copy code, 1234, clear=true")
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 1, len(actions))
|
||||
require.Equal(t, "copy", actions[0].Action)
|
||||
@@ -159,7 +159,7 @@ func TestParseActions(t *testing.T) {
|
||||
require.Equal(t, true, actions[0].Clear)
|
||||
|
||||
// Copy action with explicit value key
|
||||
actions, err = parseActions("action=copy, label=Copy token, clear=true, value=abc-123-def")
|
||||
actions, err = Parse("action=copy, label=Copy token, clear=true, value=abc-123-def")
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 1, len(actions))
|
||||
require.Equal(t, "copy", actions[0].Action)
|
||||
@@ -168,56 +168,56 @@ func TestParseActions(t *testing.T) {
|
||||
require.True(t, actions[0].Clear)
|
||||
|
||||
// Copy action without value (error)
|
||||
_, err = parseActions("copy, Copy code")
|
||||
_, err = Parse("copy, Copy code")
|
||||
require.EqualError(t, err, "parameter 'value' is required for action 'copy'")
|
||||
|
||||
// Invalid syntax
|
||||
_, err = parseActions(`label="Out of order!" x, action="http", url=http://example.com`)
|
||||
_, err = Parse(`label="Out of order!" x, action="http", url=http://example.com`)
|
||||
require.EqualError(t, err, "unexpected character 'x' at position 22")
|
||||
|
||||
_, err = parseActions(`label="", action="http", url=http://example.com`)
|
||||
_, err = Parse(`label="", action="http", url=http://example.com`)
|
||||
require.EqualError(t, err, "parameter 'label' is required")
|
||||
|
||||
_, err = parseActions(`label=, action="http", url=http://example.com`)
|
||||
_, err = Parse(`label=, action="http", url=http://example.com`)
|
||||
require.EqualError(t, err, "parameter 'label' is required")
|
||||
|
||||
_, err = parseActions(`label="xx", action="http", url=http://example.com, what is this anyway`)
|
||||
_, err = Parse(`label="xx", action="http", url=http://example.com, what is this anyway`)
|
||||
require.EqualError(t, err, "term 'what is this anyway' unknown")
|
||||
|
||||
_, err = parseActions(`fdsfdsf`)
|
||||
_, err = Parse(`fdsfdsf`)
|
||||
require.EqualError(t, err, "parameter 'action' cannot be 'fdsfdsf', valid values are 'view', 'broadcast', 'http' and 'copy'")
|
||||
|
||||
_, err = parseActions(`aaa=a, "bbb, 'ccc, ddd, eee "`)
|
||||
_, err = Parse(`aaa=a, "bbb, 'ccc, ddd, eee "`)
|
||||
require.EqualError(t, err, "key 'aaa' unknown")
|
||||
|
||||
_, err = parseActions(`action=http, label="omg the end quote is missing`)
|
||||
_, err = Parse(`action=http, label="omg the end quote is missing`)
|
||||
require.EqualError(t, err, "unexpected end of input, quote started at position 20")
|
||||
|
||||
_, err = parseActions(`;;;;`)
|
||||
_, err = Parse(`;;;;`)
|
||||
require.EqualError(t, err, "only 3 actions allowed")
|
||||
|
||||
_, err = parseActions(`,,,,,,;;`)
|
||||
_, err = Parse(`,,,,,,;;`)
|
||||
require.EqualError(t, err, "term '' unknown")
|
||||
|
||||
_, err = parseActions(`''";,;"`)
|
||||
_, err = Parse(`''";,;"`)
|
||||
require.EqualError(t, err, "unexpected character '\"' at position 2")
|
||||
|
||||
_, err = parseActions(`action=http, label=a label, body=somebody`)
|
||||
_, err = Parse(`action=http, label=a label, body=somebody`)
|
||||
require.EqualError(t, err, "parameter 'url' is required for action 'http'")
|
||||
|
||||
_, err = parseActions(`action=http, label=a label, url=http://ntfy.sh, method=HEAD, body=somebody`)
|
||||
_, err = Parse(`action=http, label=a label, url=http://ntfy.sh, method=HEAD, body=somebody`)
|
||||
require.EqualError(t, err, "parameter 'body' cannot be set if method is HEAD")
|
||||
|
||||
_, err = parseActions(`[ invalid json ]`)
|
||||
_, err = Parse(`[ invalid json ]`)
|
||||
require.EqualError(t, err, "JSON error: invalid character 'i' looking for beginning of value")
|
||||
|
||||
_, err = parseActions(`[ { "some": "object" } ]`)
|
||||
_, err = Parse(`[ { "some": "object" } ]`)
|
||||
require.EqualError(t, err, "parameter 'action' cannot be '', valid values are 'view', 'broadcast', 'http' and 'copy'")
|
||||
|
||||
_, err = parseActions("\x00\x01\xFFx\xFE")
|
||||
_, err = Parse("\x00\x01\xFFx\xFE")
|
||||
require.EqualError(t, err, "invalid utf-8 string")
|
||||
|
||||
_, err = parseActions(`http, label, http://x.org, clear=x`)
|
||||
_, err = Parse(`http, label, http://x.org, clear=x`)
|
||||
require.EqualError(t, err, "parameter 'clear' cannot be 'x', only boolean values are allowed (true/yes/1/false/no/0)")
|
||||
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 12 KiB |
+185
@@ -0,0 +1,185 @@
|
||||
// Package ban implements the abuse ban-feed: it tracks per-prefix weighted "strikes" from rejected
|
||||
// requests and appends breaching prefixes to a ban file that fail2ban tails. Keying by prefix (not
|
||||
// by visitor) makes the accounting match the unit fail2ban bans, even for shared account visitors.
|
||||
package ban
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/time/rate"
|
||||
"heckel.io/ntfy/v2/log"
|
||||
)
|
||||
|
||||
const (
|
||||
tag = "ban"
|
||||
pruneInterval = 10 * time.Minute
|
||||
writeInterval = 3 * time.Second
|
||||
)
|
||||
|
||||
// Config is the Service's config, kept separate from server.Config to avoid an import cycle.
|
||||
type Config struct {
|
||||
File string // Ban file that fail2ban tails (must be non-empty; the caller decides whether the feature is enabled)
|
||||
Window time.Duration // Rolling window over which weighted strikes are counted
|
||||
Threshold int // Weighted strikes per Window before a prefix is banned
|
||||
Weights Weights // Code matcher -> strike weight (0 = exempt)
|
||||
PrefixBitsIPv4 int // Mask width for the ban unit, e.g. 32 (matches rate-limiting granularity)
|
||||
PrefixBitsIPv6 int // Mask width for the ban unit, e.g. 64
|
||||
}
|
||||
|
||||
// tracker is the per-prefix strike state: a weighted breach detector plus timestamps for pruning and throttling.
|
||||
type tracker struct {
|
||||
limiter *rate.Limiter
|
||||
seen time.Time // Last strike, for pruning idle prefixes
|
||||
emitted time.Time // Last ban-line write for this prefix, throttles re-emits to once per Window
|
||||
}
|
||||
|
||||
// Service owns the ban-feed: per-prefix strike accounting, buffered file writes, and idle-prefix
|
||||
// pruning. The caller owns the enable/disable decision -- only construct a Service when the feature
|
||||
// is on (see server.New, which builds one only when a ban file is configured).
|
||||
type Service struct {
|
||||
conf *Config
|
||||
mu sync.Mutex // Guards trackers and pending
|
||||
trackers map[netip.Prefix]*tracker
|
||||
pending []string // Formatted ban lines buffered by Record, flushed to the ban file by runWriteLoop
|
||||
writeDone chan struct{} // Closed when runWriteLoop exits after its final flush
|
||||
closeChan chan struct{}
|
||||
closeOnce sync.Once
|
||||
}
|
||||
|
||||
// NewService builds a Service and starts its background loops. The caller must only call it when the
|
||||
// feature is enabled (conf non-nil, File non-empty); the Service does not model a disabled state.
|
||||
func NewService(conf *Config) *Service {
|
||||
s := &Service{
|
||||
conf: conf,
|
||||
trackers: make(map[netip.Prefix]*tracker),
|
||||
closeChan: make(chan struct{}),
|
||||
writeDone: make(chan struct{}),
|
||||
}
|
||||
go s.runPruneLoop()
|
||||
go s.runWriteLoop()
|
||||
return s
|
||||
}
|
||||
|
||||
// Record counts one rejection against the IP's prefix bucket and, on breach, buffers a ban line
|
||||
// (throttled to once per Window per prefix). No-ops for a non-4xx/5xx status or a zero-weight code.
|
||||
func (s *Service) Record(ip netip.Addr, httpCode, errorCode int) {
|
||||
if httpCode < 400 {
|
||||
return
|
||||
}
|
||||
weight := s.conf.Weights.WeightFor(errorCode)
|
||||
if weight == 0 {
|
||||
return // Weight 0: exempt, no strike
|
||||
}
|
||||
prefix := s.prefix(ip)
|
||||
now := time.Now()
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
t := s.trackers[prefix]
|
||||
if t == nil {
|
||||
t = &tracker{limiter: rate.NewLimiter(rate.Limit(float64(s.conf.Threshold)/s.conf.Window.Seconds()), s.conf.Threshold)}
|
||||
s.trackers[prefix] = t
|
||||
}
|
||||
t.seen = now
|
||||
if t.limiter.AllowN(now, weight) {
|
||||
return // Within the strike budget, no breach
|
||||
}
|
||||
if !t.emitted.IsZero() && now.Sub(t.emitted) < s.conf.Window {
|
||||
return // Already emitted this prefix within the window (one ban line per prefix per window)
|
||||
}
|
||||
t.emitted = now
|
||||
s.pending = append(s.pending, formatBanLine(now, ip, prefix, httpCode, errorCode))
|
||||
}
|
||||
|
||||
// prefix masks ip to the ban unit (PrefixBitsIPv4/IPv6) -- what fail2ban bans, e.g. a whole /64.
|
||||
func (s *Service) prefix(ip netip.Addr) netip.Prefix {
|
||||
if ip.Is4() {
|
||||
return netip.PrefixFrom(ip, s.conf.PrefixBitsIPv4).Masked()
|
||||
}
|
||||
return netip.PrefixFrom(ip, s.conf.PrefixBitsIPv6).Masked()
|
||||
}
|
||||
|
||||
// formatBanLine builds the "<RFC3339-UTC> <ip> <prefix> <http> <ntfy>" line the fail2ban filter
|
||||
// parses. The timestamp is captured at breach time, not flush time.
|
||||
func formatBanLine(t time.Time, ip netip.Addr, prefix netip.Prefix, httpCode, errorCode int) string {
|
||||
return fmt.Sprintf("%s %s %s %d %d\n", t.UTC().Format(time.RFC3339), ip.String(), prefix.String(), httpCode, errorCode)
|
||||
}
|
||||
|
||||
// runPruneLoop prunes idle prefixes until Close.
|
||||
func (s *Service) runPruneLoop() {
|
||||
ticker := time.NewTicker(pruneInterval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
s.prune()
|
||||
case <-s.closeChan:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// runWriteLoop flushes buffered ban lines every writeInterval, plus a final flush on Close.
|
||||
func (s *Service) runWriteLoop() {
|
||||
defer close(s.writeDone)
|
||||
ticker := time.NewTicker(writeInterval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
s.flush()
|
||||
case <-s.closeChan:
|
||||
s.flush()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// flush appends the buffered lines to the file in one open/write. Best-effort: a batch is dropped on
|
||||
// error. Concurrent calls are safe -- pending is drained under mu, so only one flush writes a batch.
|
||||
func (s *Service) flush() {
|
||||
s.mu.Lock()
|
||||
lines := s.pending
|
||||
s.pending = nil
|
||||
s.mu.Unlock()
|
||||
if len(lines) == 0 {
|
||||
return
|
||||
}
|
||||
f, err := os.OpenFile(s.conf.File, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
|
||||
if err != nil {
|
||||
log.Tag(tag).Err(err).Warn("Cannot open ban file %s, dropped %d ban(s)", s.conf.File, len(lines))
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
for i, line := range lines {
|
||||
if _, err := f.WriteString(line); err != nil {
|
||||
log.Tag(tag).Err(err).Warn("Cannot write to ban file %s, dropped %d ban(s)", s.conf.File, len(lines)-i)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// prune drops prefixes idle for a full Window -- their bucket has refilled, so forgetting them is a
|
||||
// no-op that bounds memory under a flood of distinct IPs.
|
||||
func (s *Service) prune() {
|
||||
now := time.Now()
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
for prefix, t := range s.trackers {
|
||||
if now.Sub(t.seen) >= s.conf.Window {
|
||||
delete(s.trackers, prefix)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Close stops the loops and blocks until the final flush completes. Idempotent.
|
||||
func (s *Service) Close() {
|
||||
s.closeOnce.Do(func() {
|
||||
close(s.closeChan)
|
||||
<-s.writeDone
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
package ban
|
||||
|
||||
import (
|
||||
"net/netip"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
var testIP = netip.MustParseAddr("1.2.3.4")
|
||||
|
||||
// newTestService creates a Service wired for testing, with the given ban file, weighted-bucket
|
||||
// threshold, and weights, plus a 1-minute window (so the emit throttle only fires once per test).
|
||||
func newTestService(t *testing.T, banFile string, threshold int, weights map[string]int) *Service {
|
||||
t.Helper()
|
||||
s := NewService(&Config{
|
||||
File: banFile,
|
||||
Window: time.Minute,
|
||||
Threshold: threshold,
|
||||
Weights: weights,
|
||||
PrefixBitsIPv4: 32,
|
||||
PrefixBitsIPv6: 64,
|
||||
})
|
||||
t.Cleanup(s.Close)
|
||||
return s
|
||||
}
|
||||
|
||||
// flushAndRead forces a synchronous flush of the buffered bans (writes are otherwise async, on the
|
||||
// runWriteLoop ticker) and returns the ban file's lines.
|
||||
func flushAndRead(t *testing.T, s *Service, path string) []string {
|
||||
t.Helper()
|
||||
s.flush()
|
||||
data, err := os.ReadFile(path)
|
||||
require.NoError(t, err)
|
||||
return strings.Split(strings.TrimRight(string(data), "\n"), "\n")
|
||||
}
|
||||
|
||||
func TestService_Record_Weight2BansAtHalfThreshold(t *testing.T) {
|
||||
banFile := filepath.Join(t.TempDir(), "ban.log")
|
||||
// Threshold 10, code weight 2 -> the budget covers exactly 5 hits, so the 6th breaches.
|
||||
s := newTestService(t, banFile, 10, map[string]int{"*": 2})
|
||||
for i := 0; i < 5; i++ {
|
||||
s.Record(testIP, 400, 40001)
|
||||
}
|
||||
s.flush()
|
||||
require.NoFileExists(t, banFile) // 5 hits * weight 2 = 10 == budget, exactly at the limit, not over
|
||||
s.Record(testIP, 400, 40001) // 6th hit cannot be covered -> breach
|
||||
lines := flushAndRead(t, s, banFile)
|
||||
require.Len(t, lines, 1)
|
||||
require.True(t, strings.HasSuffix(lines[0], " 1.2.3.4 1.2.3.4/32 400 40001")) // <ip> <prefix> <http> <ntfy-code>
|
||||
}
|
||||
|
||||
func TestService_Record_Weight10BansFast(t *testing.T) {
|
||||
banFile := filepath.Join(t.TempDir(), "ban.log")
|
||||
// Threshold 10, code weight 10 -> a single hit drains the whole budget, so the 2nd breaches.
|
||||
s := newTestService(t, banFile, 10, map[string]int{"42909": 10, "*": 1})
|
||||
s.Record(testIP, 429, 42909)
|
||||
s.flush()
|
||||
require.NoFileExists(t, banFile)
|
||||
s.Record(testIP, 429, 42909) // 2nd hit cannot be covered -> breach
|
||||
lines := flushAndRead(t, s, banFile)
|
||||
require.Len(t, lines, 1)
|
||||
require.True(t, strings.HasSuffix(lines[0], " 1.2.3.4 1.2.3.4/32 429 42909"))
|
||||
}
|
||||
|
||||
func TestService_Record_Weight0NeverBans(t *testing.T) {
|
||||
banFile := filepath.Join(t.TempDir(), "ban.log")
|
||||
// The legit-quota code is exempt (weight 0), so no number of hits ever bans.
|
||||
s := newTestService(t, banFile, 10, map[string]int{"42908": 0, "*": 1})
|
||||
for i := 0; i < 100; i++ {
|
||||
s.Record(testIP, 429, 42908)
|
||||
}
|
||||
s.flush()
|
||||
require.NoFileExists(t, banFile)
|
||||
}
|
||||
|
||||
func TestService_Record_SingleBucketNoRelaxation(t *testing.T) {
|
||||
banFile := filepath.Join(t.TempDir(), "ban.log")
|
||||
// One shared bucket per prefix: different codes draw down the SAME budget, so mixing them creates
|
||||
// no extra headroom (unlike per-code buckets, which would relax the effective limit for a mixed
|
||||
// offender).
|
||||
s := newTestService(t, banFile, 10, map[string]int{"403*": 2, "*": 1})
|
||||
s.Record(testIP, 403, 40301) // weight 2 -> 8 left
|
||||
s.Record(testIP, 403, 40301) // weight 2 -> 6 left
|
||||
s.Record(testIP, 403, 40301) // weight 2 -> 4 left
|
||||
s.flush()
|
||||
require.NoFileExists(t, banFile)
|
||||
for i := 0; i < 4; i++ {
|
||||
s.Record(testIP, 400, 40001) // weight 1 each -> drains the remaining 4 -> 0 left
|
||||
}
|
||||
s.flush()
|
||||
require.NoFileExists(t, banFile) // 6 + 4 = 10 == budget exactly, still not over
|
||||
s.Record(testIP, 400, 40001) // one more cannot be covered -> breach
|
||||
lines := flushAndRead(t, s, banFile)
|
||||
require.Len(t, lines, 1)
|
||||
}
|
||||
|
||||
func TestService_Record_ExactLineFormat(t *testing.T) {
|
||||
banFile := filepath.Join(t.TempDir(), "ban.log")
|
||||
s := newTestService(t, banFile, 1, map[string]int{"*": 1})
|
||||
before := time.Now().UTC().Truncate(time.Second)
|
||||
for i := 0; i < 3; i++ {
|
||||
s.Record(testIP, 429, 42901)
|
||||
}
|
||||
after := time.Now().UTC()
|
||||
lines := flushAndRead(t, s, banFile)
|
||||
require.Len(t, lines, 1)
|
||||
parts := strings.Split(lines[0], " ")
|
||||
require.Len(t, parts, 5) // "<timestamp> <ip> <prefix> <http-code> <ntfy-code>"
|
||||
ts, err := time.Parse(time.RFC3339, parts[0])
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, time.UTC, ts.Location())
|
||||
require.False(t, ts.Before(before))
|
||||
require.False(t, ts.After(after.Add(time.Second)))
|
||||
require.Equal(t, "1.2.3.4", parts[1]) // full IP
|
||||
require.Equal(t, "1.2.3.4/32", parts[2]) // masked to the default IPv4 prefix (/32)
|
||||
require.Equal(t, "429", parts[3]) // HTTP status
|
||||
require.Equal(t, "42901", parts[4]) // ntfy code
|
||||
}
|
||||
|
||||
func TestService_Record_IPv6MaskedToPrefix(t *testing.T) {
|
||||
banFile := filepath.Join(t.TempDir(), "ban.log")
|
||||
s := newTestService(t, banFile, 1, map[string]int{"*": 1})
|
||||
ip := netip.MustParseAddr("2001:db8::abcd")
|
||||
for i := 0; i < 3; i++ {
|
||||
s.Record(ip, 429, 42901)
|
||||
}
|
||||
parts := strings.Split(flushAndRead(t, s, banFile)[0], " ")
|
||||
require.Len(t, parts, 5)
|
||||
require.Equal(t, "2001:db8::abcd", parts[1]) // full IPv6 address
|
||||
require.Equal(t, "2001:db8::/64", parts[2]) // masked to the default IPv6 prefix (/64)
|
||||
}
|
||||
|
||||
func TestService_Record_PerPrefixIsolation(t *testing.T) {
|
||||
// Each source prefix gets its own bucket: one IP hammering to a breach must not push a different,
|
||||
// quiet IP over the edge. This is the whole point of keying by prefix instead of by visitor.
|
||||
banFile := filepath.Join(t.TempDir(), "ban.log")
|
||||
s := newTestService(t, banFile, 2, map[string]int{"*": 1})
|
||||
noisy := netip.MustParseAddr("1.1.1.1")
|
||||
quiet := netip.MustParseAddr("2.2.2.2")
|
||||
for i := 0; i < 5; i++ {
|
||||
s.Record(noisy, 429, 42901) // breaches its own bucket
|
||||
}
|
||||
s.Record(quiet, 429, 42901) // single hit, well under threshold
|
||||
lines := flushAndRead(t, s, banFile)
|
||||
require.Len(t, lines, 1) // only the noisy prefix is written
|
||||
require.True(t, strings.HasSuffix(lines[0], " 1.1.1.1 1.1.1.1/32 429 42901"))
|
||||
}
|
||||
|
||||
func TestService_Record_OncePerWindowThrottle(t *testing.T) {
|
||||
// Once a prefix has been written, further breaches within the window must not re-append it, so a
|
||||
// persistent offender produces exactly one line per window (mirrors the old per-visitor banEmit).
|
||||
banFile := filepath.Join(t.TempDir(), "ban.log")
|
||||
s := newTestService(t, banFile, 1, map[string]int{"*": 1})
|
||||
for i := 0; i < 50; i++ {
|
||||
s.Record(testIP, 429, 42901)
|
||||
}
|
||||
lines := flushAndRead(t, s, banFile)
|
||||
require.Len(t, lines, 1)
|
||||
}
|
||||
|
||||
func TestService_Record_BansPassedIP(t *testing.T) {
|
||||
// The Service bans the exact IP passed to Record -- the caller passes the offending request's IP,
|
||||
// which for an account-keyed visitor is not the visitor's stored IP.
|
||||
banFile := filepath.Join(t.TempDir(), "ban.log")
|
||||
s := newTestService(t, banFile, 1, map[string]int{"*": 1})
|
||||
offender := netip.MustParseAddr("5.6.7.8")
|
||||
for i := 0; i < 3; i++ {
|
||||
s.Record(offender, 429, 42901)
|
||||
}
|
||||
parts := strings.Split(flushAndRead(t, s, banFile)[0], " ")
|
||||
require.Equal(t, "5.6.7.8", parts[1]) // the IP passed to Record
|
||||
require.Equal(t, "5.6.7.8/32", parts[2]) // its prefix
|
||||
}
|
||||
|
||||
func TestService_Record_Ignores2xx3xx(t *testing.T) {
|
||||
banFile := filepath.Join(t.TempDir(), "ban.log")
|
||||
s := newTestService(t, banFile, 3, map[string]int{"*": 1})
|
||||
// Success and redirects must never count toward a ban, even over the threshold -- otherwise a
|
||||
// legit high-volume publisher (lots of 200s) would get banned.
|
||||
for i := 0; i < 20; i++ {
|
||||
s.Record(testIP, 200, 20000)
|
||||
s.Record(testIP, 302, 30000)
|
||||
}
|
||||
s.flush()
|
||||
require.NoFileExists(t, banFile)
|
||||
// A 4xx over the same budget still gets written.
|
||||
for i := 0; i < 5; i++ {
|
||||
s.Record(testIP, 400, 40001)
|
||||
}
|
||||
lines := flushAndRead(t, s, banFile)
|
||||
require.Len(t, lines, 1)
|
||||
require.True(t, strings.HasSuffix(lines[0], " 1.2.3.4 1.2.3.4/32 400 40001"))
|
||||
}
|
||||
|
||||
func TestService_Record_BuffersUntilFlush(t *testing.T) {
|
||||
// Writes are async: a breach buffers the ban line rather than writing it synchronously on the
|
||||
// request path. The line only reaches the file when runWriteLoop (or an explicit flush) runs.
|
||||
banFile := filepath.Join(t.TempDir(), "ban.log")
|
||||
s := newTestService(t, banFile, 1, map[string]int{"*": 1})
|
||||
for i := 0; i < 3; i++ {
|
||||
s.Record(testIP, 429, 42901)
|
||||
}
|
||||
require.NoFileExists(t, banFile) // not written synchronously
|
||||
s.mu.Lock()
|
||||
require.Len(t, s.pending, 1) // one line buffered (throttled to once per window)
|
||||
s.mu.Unlock()
|
||||
lines := flushAndRead(t, s, banFile)
|
||||
require.Len(t, lines, 1)
|
||||
require.True(t, strings.HasSuffix(lines[0], " 1.2.3.4 1.2.3.4/32 429 42901"))
|
||||
}
|
||||
|
||||
func TestService_Close_FlushesPending(t *testing.T) {
|
||||
// Close must flush buffered bans so nothing is lost on shutdown, and must block until it has.
|
||||
banFile := filepath.Join(t.TempDir(), "ban.log")
|
||||
s := NewService(&Config{File: banFile, Window: time.Minute, Threshold: 1, Weights: Weights{"*": 1}, PrefixBitsIPv4: 32, PrefixBitsIPv6: 64})
|
||||
for i := 0; i < 3; i++ {
|
||||
s.Record(testIP, 429, 42901)
|
||||
}
|
||||
require.NoFileExists(t, banFile) // still buffered
|
||||
s.Close() // blocks until the final flush completes
|
||||
data, err := os.ReadFile(banFile)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, strings.Split(strings.TrimRight(string(data), "\n"), "\n"), 1)
|
||||
}
|
||||
|
||||
func TestService_Prune_DropsIdlePrefixes(t *testing.T) {
|
||||
// A prefix idle for a full window has a refilled bucket, so prune drops it to bound memory. An
|
||||
// active prefix (seen within the window) is kept.
|
||||
banFile := filepath.Join(t.TempDir(), "ban.log")
|
||||
s := newTestService(t, banFile, 10, map[string]int{"*": 1})
|
||||
idle := netip.MustParseAddr("9.9.9.9")
|
||||
s.Record(idle, 400, 40001)
|
||||
s.Record(testIP, 400, 40001)
|
||||
require.Len(t, s.trackers, 2)
|
||||
|
||||
// Backdate the idle prefix past the window, then prune.
|
||||
idlePrefix := s.prefix(idle)
|
||||
s.mu.Lock()
|
||||
s.trackers[idlePrefix].seen = time.Now().Add(-2 * time.Minute)
|
||||
s.mu.Unlock()
|
||||
s.prune()
|
||||
|
||||
require.Len(t, s.trackers, 1)
|
||||
_, ok := s.trackers[idlePrefix]
|
||||
require.False(t, ok) // idle prefix dropped
|
||||
_, ok = s.trackers[s.prefix(testIP)]
|
||||
require.True(t, ok) // active prefix kept
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package ban
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Weights maps a matcher key to a strike weight for the abuse ban-feed (see ParseWeights, WeightFor).
|
||||
type Weights map[string]int
|
||||
|
||||
// ParseWeights normalizes a list like ["42909:10","403:2","*:1"] into a Weights map. A key is an
|
||||
// exact ntfy code, a family ("429*"), a bare HTTP status ("403" -> "403*"), or "*"; weights are ints
|
||||
// >= 0 (0 = exempt). Malformed entries are rejected so misconfiguration fails at startup.
|
||||
func ParseWeights(entries []string) (Weights, error) {
|
||||
out := make(Weights, len(entries))
|
||||
for _, entry := range entries {
|
||||
key, weightStr, ok := strings.Cut(entry, ":")
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("invalid ban-weight %q, want KEY:WEIGHT", entry)
|
||||
}
|
||||
weight, err := strconv.Atoi(strings.TrimSpace(weightStr))
|
||||
if err != nil || weight < 0 {
|
||||
return nil, fmt.Errorf("invalid ban-weight value in %q, want a non-negative integer", entry)
|
||||
}
|
||||
key = strings.TrimSpace(key)
|
||||
if !validWeightKey(key) {
|
||||
return nil, fmt.Errorf("invalid ban-weight key in %q, want %q, an ntfy code, an HTTP status, or a PREFIX*", entry, "*")
|
||||
}
|
||||
// A bare 3-digit HTTP status is shorthand for the whole family (e.g. "403" -> "403*").
|
||||
if len(key) == 3 && isAllDigits(key) {
|
||||
key += "*"
|
||||
}
|
||||
out[key] = weight
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// WeightFor returns the strike weight for an ntfy error code, longest-match-wins (exact > family > "*").
|
||||
// If nothing matches (no "*" catch-all) it returns the implied default 1, so a forgotten "*" still
|
||||
// bans; use "*:0" to exempt everything not explicitly weighted.
|
||||
func (w Weights) WeightFor(errorCode int) int {
|
||||
code := strconv.Itoa(errorCode)
|
||||
weight, bestLen, matched := 0, -1, false
|
||||
for key, wt := range w {
|
||||
matchLen := -1
|
||||
switch {
|
||||
case key == "*":
|
||||
matchLen = 0
|
||||
case strings.HasSuffix(key, "*"):
|
||||
if prefix := strings.TrimSuffix(key, "*"); strings.HasPrefix(code, prefix) {
|
||||
matchLen = len(prefix)
|
||||
}
|
||||
case key == code:
|
||||
matchLen = len(code)
|
||||
}
|
||||
if matchLen > bestLen {
|
||||
weight, bestLen, matched = wt, matchLen, true
|
||||
}
|
||||
}
|
||||
if !matched {
|
||||
return 1
|
||||
}
|
||||
return weight
|
||||
}
|
||||
|
||||
// validWeightKey reports whether key is a legal matcher: "*", an all-digits code, or DIGITS*.
|
||||
func validWeightKey(key string) bool {
|
||||
if key == "*" {
|
||||
return true
|
||||
}
|
||||
digits := strings.TrimSuffix(key, "*")
|
||||
return digits != "" && isAllDigits(digits)
|
||||
}
|
||||
|
||||
func isAllDigits(s string) bool {
|
||||
for _, r := range s {
|
||||
if r < '0' || r > '9' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return s != ""
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package ban
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestParseWeights(t *testing.T) {
|
||||
// Exact codes, a bare 3-digit HTTP status (normalized to a family), an exempt code, and "*".
|
||||
weights, err := ParseWeights([]string{"42909:10", "403:2", "42908:0", "*:1"})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, Weights{"42909": 10, "403*": 2, "42908": 0, "*": 1}, weights)
|
||||
|
||||
// A bare 3-digit HTTP status normalizes to its family.
|
||||
weights, err = ParseWeights([]string{"429:5"})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, Weights{"429*": 5}, weights)
|
||||
|
||||
// An explicit family key stays as-is.
|
||||
weights, err = ParseWeights([]string{"429*:5"})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, Weights{"429*": 5}, weights)
|
||||
|
||||
// Weight 0 is valid and means exempt.
|
||||
weights, err = ParseWeights([]string{"42908:0"})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, Weights{"42908": 0}, weights)
|
||||
|
||||
_, err = ParseWeights([]string{"401"}) // Missing weight
|
||||
require.Error(t, err)
|
||||
_, err = ParseWeights([]string{"401:-1"}) // Negative weight
|
||||
require.Error(t, err)
|
||||
_, err = ParseWeights([]string{"401:abc"}) // Non-integer weight
|
||||
require.Error(t, err)
|
||||
_, err = ParseWeights([]string{"abc:10"}) // Non-numeric key
|
||||
require.Error(t, err)
|
||||
_, err = ParseWeights([]string{"4*3:10"}) // Star not at the end
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestWeights_WeightFor(t *testing.T) {
|
||||
weights, err := ParseWeights([]string{"42908:0", "42903:0", "42905:0", "42910:0", "42909:10", "429*:1", "403*:2", "4*:1", "5*:1"})
|
||||
require.NoError(t, err)
|
||||
// Longest-match-wins: exact 5-digit beats "429*" beats "4*" beats "*".
|
||||
require.Equal(t, 0, weights.WeightFor(42908))
|
||||
require.Equal(t, 10, weights.WeightFor(42909))
|
||||
require.Equal(t, 1, weights.WeightFor(42901))
|
||||
require.Equal(t, 2, weights.WeightFor(40311))
|
||||
require.Equal(t, 1, weights.WeightFor(40011))
|
||||
require.Equal(t, 1, weights.WeightFor(50312))
|
||||
// No rule matches (this config has 4*/5* but no "*"), so the implied default weight 1 applies.
|
||||
require.Equal(t, 1, weights.WeightFor(30012))
|
||||
}
|
||||
|
||||
func TestWeights_WeightFor_NoStarRuleImpliesWeight1(t *testing.T) {
|
||||
// With no "*" rule, a code that matches nothing defaults to weight 1 (can be banned), so the
|
||||
// feature can't be silently turned into a no-op by forgetting "*". Explicit codes still win.
|
||||
weights, err := ParseWeights([]string{"42908:0", "42909:10"})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 0, weights.WeightFor(42908)) // explicitly exempt
|
||||
require.Equal(t, 10, weights.WeightFor(42909)) // explicit
|
||||
require.Equal(t, 1, weights.WeightFor(42901)) // unmatched -> implied 1
|
||||
require.Equal(t, 1, weights.WeightFor(40001)) // unmatched -> implied 1
|
||||
}
|
||||
|
||||
func TestWeights_WeightFor_ExplicitStarZeroExemptsAll(t *testing.T) {
|
||||
// An explicit "*:0" is the opt-out: exempt everything not otherwise weighted.
|
||||
weights, err := ParseWeights([]string{"42909:10", "*:0"})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 10, weights.WeightFor(42909)) // explicit
|
||||
require.Equal(t, 0, weights.WeightFor(42901)) // *:0 -> exempt everything else
|
||||
}
|
||||
+33
-1
@@ -10,6 +10,7 @@ import (
|
||||
"net"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"text/template"
|
||||
@@ -17,6 +18,7 @@ import (
|
||||
|
||||
"github.com/urfave/cli/v2"
|
||||
"github.com/urfave/cli/v2/altsrc"
|
||||
"heckel.io/ntfy/v2/ban"
|
||||
"heckel.io/ntfy/v2/log"
|
||||
"heckel.io/ntfy/v2/payments"
|
||||
"heckel.io/ntfy/v2/server"
|
||||
@@ -87,7 +89,7 @@ var flagsServe = append(
|
||||
altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-subscription-limit", Aliases: []string{"visitor_subscription_limit"}, EnvVars: []string{"NTFY_VISITOR_SUBSCRIPTION_LIMIT"}, Value: server.DefaultVisitorSubscriptionLimit, Usage: "number of subscriptions per visitor"}),
|
||||
altsrc.NewBoolFlag(&cli.BoolFlag{Name: "visitor-subscriber-rate-limiting", Aliases: []string{"visitor_subscriber_rate_limiting"}, EnvVars: []string{"NTFY_VISITOR_SUBSCRIBER_RATE_LIMITING"}, Value: false, Usage: "enables subscriber-based rate limiting"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "visitor-attachment-total-size-limit", Aliases: []string{"visitor_attachment_total_size_limit"}, EnvVars: []string{"NTFY_VISITOR_ATTACHMENT_TOTAL_SIZE_LIMIT"}, Value: util.FormatSize(server.DefaultVisitorAttachmentTotalSizeLimit), Usage: "total storage limit used for attachments per visitor"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "visitor-attachment-daily-bandwidth-limit", Aliases: []string{"visitor_attachment_daily_bandwidth_limit"}, EnvVars: []string{"NTFY_VISITOR_ATTACHMENT_DAILY_BANDWIDTH_LIMIT"}, Value: "500M", Usage: "total daily attachment download/upload bandwidth limit per visitor"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "visitor-attachment-daily-bandwidth-limit", Aliases: []string{"visitor_attachment_daily_bandwidth_limit"}, EnvVars: []string{"NTFY_VISITOR_ATTACHMENT_DAILY_BANDWIDTH_LIMIT"}, Value: "500M", Usage: "total daily bandwidth limit per visitor, for attachment downloads/uploads and messages replayed from the cache by poll requests"}),
|
||||
altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-request-limit-burst", Aliases: []string{"visitor_request_limit_burst"}, EnvVars: []string{"NTFY_VISITOR_REQUEST_LIMIT_BURST"}, Value: server.DefaultVisitorRequestLimitBurst, Usage: "initial limit of requests per visitor"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "visitor-request-limit-replenish", Aliases: []string{"visitor_request_limit_replenish"}, EnvVars: []string{"NTFY_VISITOR_REQUEST_LIMIT_REPLENISH"}, Value: util.FormatDuration(server.DefaultVisitorRequestLimitReplenish), Usage: "interval at which burst limit is replenished (one per x)"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "visitor-request-limit-exempt-hosts", Aliases: []string{"visitor_request_limit_exempt_hosts"}, EnvVars: []string{"NTFY_VISITOR_REQUEST_LIMIT_EXEMPT_HOSTS"}, Value: "", Usage: "hostnames and/or IP addresses of hosts that will be exempt from the visitor request limit"}),
|
||||
@@ -98,6 +100,10 @@ var flagsServe = append(
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "visitor-topic-creation-limit-replenish", Aliases: []string{"visitor_topic_creation_limit_replenish"}, EnvVars: []string{"NTFY_VISITOR_TOPIC_CREATION_LIMIT_REPLENISH"}, Value: util.FormatDuration(server.DefaultVisitorTopicCreationLimitReplenish), Usage: "interval at which topic-creation tokens are refilled (one per x)"}),
|
||||
altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-prefix-bits-ipv4", Aliases: []string{"visitor_prefix_bits_ipv4"}, EnvVars: []string{"NTFY_VISITOR_PREFIX_BITS_IPV4"}, Value: server.DefaultVisitorPrefixBitsIPv4, Usage: "number of bits of the IPv4 address to use for rate limiting (default: 32, full address)"}),
|
||||
altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-prefix-bits-ipv6", Aliases: []string{"visitor_prefix_bits_ipv6"}, EnvVars: []string{"NTFY_VISITOR_PREFIX_BITS_IPV6"}, Value: server.DefaultVisitorPrefixBitsIPv6, Usage: "number of bits of the IPv6 address to use for rate limiting (default: 64, /64 subnet)"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "ban-file", Aliases: []string{"ban_file"}, EnvVars: []string{"NTFY_BAN_FILE"}, Value: "", Usage: "if set, append IPs of abusive visitors to this file for fail2ban to tail (empty disables)"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "ban-window", Aliases: []string{"ban_window"}, EnvVars: []string{"NTFY_BAN_WINDOW"}, Value: util.FormatDuration(server.DefaultBanWindow), Usage: "rolling window over which weighted strikes are counted for the ban file"}),
|
||||
altsrc.NewIntFlag(&cli.IntFlag{Name: "ban-threshold", Aliases: []string{"ban_threshold"}, EnvVars: []string{"NTFY_BAN_THRESHOLD"}, Value: server.DefaultBanThreshold, Usage: "weighted strikes per window before an offender is banned"}),
|
||||
altsrc.NewStringSliceFlag(&cli.StringSliceFlag{Name: "ban-weights", Aliases: []string{"ban_weights"}, EnvVars: []string{"NTFY_BAN_WEIGHTS"}, Value: cli.NewStringSlice(server.DefaultBanWeights...), Usage: "per-code strike weights as KEY:WEIGHT, where KEY is an ntfy code, an HTTP status, a PREFIX*, or '*' (weight 0 exempts)"}),
|
||||
altsrc.NewBoolFlag(&cli.BoolFlag{Name: "behind-proxy", Aliases: []string{"behind_proxy", "P"}, EnvVars: []string{"NTFY_BEHIND_PROXY"}, Value: false, Usage: "if set, use forwarded header (e.g. X-Forwarded-For, X-Client-IP) to determine visitor IP address (for rate limiting)"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "proxy-forwarded-header", Aliases: []string{"proxy_forwarded_header"}, EnvVars: []string{"NTFY_PROXY_FORWARDED_HEADER"}, Value: "X-Forwarded-For", Usage: "use specified header to determine visitor IP address (for rate limiting)"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "proxy-trusted-hosts", Aliases: []string{"proxy_trusted_hosts"}, EnvVars: []string{"NTFY_PROXY_TRUSTED_HOSTS"}, Value: "", Usage: "comma-separated list of trusted IP addresses, hosts, or CIDRs to remove from forwarded header"}),
|
||||
@@ -215,6 +221,10 @@ func execServe(c *cli.Context) error {
|
||||
visitorTopicCreationLimitReplenishStr := c.String("visitor-topic-creation-limit-replenish")
|
||||
visitorPrefixBitsIPv4 := c.Int("visitor-prefix-bits-ipv4")
|
||||
visitorPrefixBitsIPv6 := c.Int("visitor-prefix-bits-ipv6")
|
||||
banFile := c.String("ban-file")
|
||||
banWindowStr := c.String("ban-window")
|
||||
banThreshold := c.Int("ban-threshold")
|
||||
banWeightsRaw := c.StringSlice("ban-weights")
|
||||
behindProxy := c.Bool("behind-proxy")
|
||||
proxyForwardedHeader := c.String("proxy-forwarded-header")
|
||||
proxyTrustedHosts := util.SplitNoEmpty(c.String("proxy-trusted-hosts"), ",")
|
||||
@@ -270,6 +280,16 @@ func execServe(c *cli.Context) error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid web push expiry warning duration: %s", webPushExpiryWarningDurationStr)
|
||||
}
|
||||
banWindow, err := util.ParseDuration(banWindowStr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid ban window: %s", banWindowStr)
|
||||
}
|
||||
|
||||
// Parse abuse ban-feed weights ("KEY:WEIGHT" list, "*" fallback)
|
||||
banWeights, err := ban.ParseWeights(banWeightsRaw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Convert sizes to bytes
|
||||
messageSizeLimit, err := util.ParseSize(messageSizeLimitStr)
|
||||
@@ -372,6 +392,14 @@ func execServe(c *cli.Context) error {
|
||||
return errors.New("visitor-prefix-bits-ipv4 must be between 1 and 32")
|
||||
} else if visitorPrefixBitsIPv6 < 1 || visitorPrefixBitsIPv6 > 128 {
|
||||
return errors.New("visitor-prefix-bits-ipv6 must be between 1 and 128")
|
||||
} else if banFile != "" && banWindow <= 0 {
|
||||
return errors.New("if ban-file is set, ban-window must be greater than zero")
|
||||
} else if banFile != "" && banThreshold <= 0 {
|
||||
return errors.New("if ban-file is set, ban-threshold must be greater than zero")
|
||||
} else if banFile != "" && len(banWeights) == 0 {
|
||||
return errors.New("if ban-file is set, ban-weights must not be empty")
|
||||
} else if banFile != "" && !util.FileExists(filepath.Dir(banFile)) {
|
||||
return fmt.Errorf("if ban-file is set, its directory (%s) must exist", filepath.Dir(banFile))
|
||||
} else if runtime.GOOS == "windows" && listenUnix != "" {
|
||||
return errors.New("listen-unix is not supported on Windows")
|
||||
}
|
||||
@@ -512,6 +540,10 @@ func execServe(c *cli.Context) error {
|
||||
conf.VisitorTopicCreationLimitReplenish = visitorTopicCreationLimitReplenish
|
||||
conf.VisitorPrefixBitsIPv4 = visitorPrefixBitsIPv4
|
||||
conf.VisitorPrefixBitsIPv6 = visitorPrefixBitsIPv6
|
||||
conf.BanFile = banFile
|
||||
conf.BanWindow = banWindow
|
||||
conf.BanThreshold = banThreshold
|
||||
conf.BanWeights = banWeights
|
||||
conf.BehindProxy = behindProxy
|
||||
conf.ProxyForwardedHeader = proxyForwardedHeader
|
||||
conf.ProxyTrustedPrefixes = trustedProxyPrefixes
|
||||
|
||||
+88
-3
@@ -8,11 +8,13 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/urfave/cli/v2"
|
||||
"github.com/urfave/cli/v2/altsrc"
|
||||
"heckel.io/ntfy/v2/db"
|
||||
"heckel.io/ntfy/v2/db/pg"
|
||||
"heckel.io/ntfy/v2/mail"
|
||||
"heckel.io/ntfy/v2/server"
|
||||
"heckel.io/ntfy/v2/user"
|
||||
"heckel.io/ntfy/v2/util"
|
||||
@@ -32,12 +34,17 @@ var flagsUser = append(
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "auth-file", Aliases: []string{"auth_file", "H"}, EnvVars: []string{"NTFY_AUTH_FILE"}, Usage: "auth database file used for access control"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "auth-default-access", Aliases: []string{"auth_default_access", "p"}, EnvVars: []string{"NTFY_AUTH_DEFAULT_ACCESS"}, Value: "read-write", Usage: "default permissions if no matching entries in the auth database are found"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "database-url", Aliases: []string{"database_url"}, EnvVars: []string{"NTFY_DATABASE_URL"}, Usage: "PostgreSQL connection string for database-backed stores"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "base-url", Aliases: []string{"base_url", "B"}, EnvVars: []string{"NTFY_BASE_URL"}, Usage: "externally visible base URL for this host (e.g. https://ntfy.sh)"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-sender-addr", Aliases: []string{"smtp_sender_addr"}, EnvVars: []string{"NTFY_SMTP_SENDER_ADDR"}, Usage: "SMTP server address (host:port) for outgoing emails"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-sender-user", Aliases: []string{"smtp_sender_user"}, EnvVars: []string{"NTFY_SMTP_SENDER_USER"}, Usage: "SMTP user (if e-mail sending is enabled)"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-sender-pass", Aliases: []string{"smtp_sender_pass"}, EnvVars: []string{"NTFY_SMTP_SENDER_PASS"}, Usage: "SMTP password (if e-mail sending is enabled)"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-sender-from", Aliases: []string{"smtp_sender_from"}, EnvVars: []string{"NTFY_SMTP_SENDER_FROM"}, Usage: "SMTP sender address (if e-mail sending is enabled)"}),
|
||||
)
|
||||
|
||||
var cmdUser = &cli.Command{
|
||||
Name: "user",
|
||||
Usage: "Manage/show users",
|
||||
UsageText: "ntfy user [list|add|remove|change-pass|change-role] ...",
|
||||
UsageText: "ntfy user [list|add|remove|change-pass|reset-pass|change-role] ...",
|
||||
Flags: flagsUser,
|
||||
Before: initConfigFileInputSourceFunc("config", flagsUser, initLogFunc),
|
||||
Category: categoryServer,
|
||||
@@ -98,6 +105,30 @@ Example:
|
||||
|
||||
You may set the NTFY_PASSWORD environment variable to pass the new password or NTFY_PASSWORD_HASH to pass
|
||||
directly the bcrypt hash. This is useful if you are updating users via scripts.
|
||||
`,
|
||||
},
|
||||
{
|
||||
Name: "reset-pass",
|
||||
Aliases: []string{"rp"},
|
||||
Usage: "Generates a password reset link for a user",
|
||||
UsageText: "ntfy user reset-pass [--send-email] USERNAME",
|
||||
Action: execUserResetPass,
|
||||
Flags: []cli.Flag{
|
||||
&cli.BoolFlag{Name: "send-email", Aliases: []string{"e"}, Usage: "also email the reset link to the user's primary email"},
|
||||
},
|
||||
Description: `Generate a password reset link for the given user and print it to stdout.
|
||||
|
||||
The user completes the reset by opening the link in a browser and choosing a new password;
|
||||
the admin never learns or chooses the new password. The link is single-use and expires after
|
||||
one hour. This is an admin override of the self-service reset flow -- unlike self-service, it
|
||||
does not require the user to have a verified primary email (the token is bound to the user).
|
||||
|
||||
With --send-email, the link is additionally emailed to the user's primary email address (this
|
||||
requires SMTP to be configured and the user to have a verified primary email).
|
||||
|
||||
Example:
|
||||
ntfy user reset-pass phil # Print a reset link for user phil
|
||||
ntfy user reset-pass --send-email phil # Print and email the reset link
|
||||
`,
|
||||
},
|
||||
{
|
||||
@@ -257,7 +288,6 @@ func execUserDel(c *cli.Context) error {
|
||||
func execUserChangePass(c *cli.Context) error {
|
||||
username := c.Args().Get(0)
|
||||
password, hashed := os.LookupEnv("NTFY_PASSWORD_HASH")
|
||||
|
||||
if !hashed {
|
||||
password = os.Getenv("NTFY_PASSWORD")
|
||||
}
|
||||
@@ -286,6 +316,61 @@ func execUserChangePass(c *cli.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func execUserResetPass(c *cli.Context) error {
|
||||
username := c.Args().Get(0)
|
||||
sendEmail := c.Bool("send-email")
|
||||
baseURL := strings.TrimSuffix(c.String("base-url"), "/")
|
||||
if username == "" {
|
||||
return errors.New("username expected, type 'ntfy user reset-pass --help' for help")
|
||||
} else if username == userEveryone || username == user.Everyone {
|
||||
return errors.New("username not allowed")
|
||||
} else if baseURL == "" {
|
||||
return errors.New("base-url must be configured to generate a reset link")
|
||||
}
|
||||
manager, err := createUserManager(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
u, err := manager.User(username)
|
||||
if errors.Is(err, user.ErrUserNotFound) {
|
||||
return fmt.Errorf("user %s does not exist", username)
|
||||
} else if err != nil {
|
||||
return err
|
||||
} else if u.Provisioned {
|
||||
return fmt.Errorf("user %s is provisioned in the config file; its password cannot be reset", username)
|
||||
}
|
||||
// Resolve the primary email up front if we need to send -- fail before creating a token
|
||||
var primaryEmail string
|
||||
if sendEmail {
|
||||
primaryEmail, err = manager.PrimaryEmail(u.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
} else if primaryEmail == "" {
|
||||
return fmt.Errorf("user %s has no primary email; cannot send reset link (omit --send-email to just print it)", username)
|
||||
}
|
||||
}
|
||||
// The reset token is bound to the user, not an email -- so this works even with no SMTP
|
||||
token, err := manager.AddMagicLink(user.MagicLinkKindPasswordReset, u.ID, "", time.Hour)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
link := baseURL + "/account/password/reset/" + token
|
||||
fmt.Fprintln(c.App.Writer, link)
|
||||
if sendEmail {
|
||||
sender := mail.NewSender(&mail.Config{
|
||||
SMTPAddr: c.String("smtp-sender-addr"),
|
||||
SMTPUser: c.String("smtp-sender-user"),
|
||||
SMTPPass: c.String("smtp-sender-pass"),
|
||||
From: c.String("smtp-sender-from"),
|
||||
})
|
||||
if err := sender.SendPasswordReset(primaryEmail, link); err != nil {
|
||||
return fmt.Errorf("failed to send reset email to %s: %w", primaryEmail, err)
|
||||
}
|
||||
fmt.Fprintf(c.App.ErrWriter, "reset link emailed to %s\n", primaryEmail)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func execUserChangeRole(c *cli.Context) error {
|
||||
username := c.Args().Get(0)
|
||||
role := user.Role(c.Args().Get(1))
|
||||
@@ -313,7 +398,7 @@ func execUserHash(c *cli.Context) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
hash, err := user.HashPassword(password)
|
||||
hash, err := user.HashPassword(password, user.DefaultUserPasswordBcryptCost)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to hash password: %w", err)
|
||||
}
|
||||
|
||||
@@ -122,6 +122,69 @@ func TestCLI_User_Delete(t *testing.T) {
|
||||
require.Contains(t, err.Error(), "user phil does not exist")
|
||||
}
|
||||
|
||||
func TestCLI_User_ResetPass(t *testing.T) {
|
||||
s, conf, port := newTestServerWithAuth(t)
|
||||
defer test.StopServer(t, s, port)
|
||||
|
||||
app, stdin, _, _ := newTestApp()
|
||||
stdin.WriteString("mypass\nmypass")
|
||||
require.Nil(t, runUserCommand(app, conf, "add", "phil"))
|
||||
|
||||
// Prints a working-looking reset link when base-url is set
|
||||
app, _, stdout, _ := newTestApp()
|
||||
require.Nil(t, runUserCommand(app, conf, "--base-url=https://ntfy.example.com", "reset-pass", "phil"))
|
||||
require.Contains(t, stdout.String(), "https://ntfy.example.com/account/password/reset/")
|
||||
}
|
||||
|
||||
func TestCLI_User_ResetPass_NoBaseURL(t *testing.T) {
|
||||
s, conf, port := newTestServerWithAuth(t)
|
||||
defer test.StopServer(t, s, port)
|
||||
|
||||
app, stdin, _, _ := newTestApp()
|
||||
stdin.WriteString("mypass\nmypass")
|
||||
require.Nil(t, runUserCommand(app, conf, "add", "phil"))
|
||||
|
||||
app, _, _, _ = newTestApp()
|
||||
err := runUserCommand(app, conf, "reset-pass", "phil")
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "base-url")
|
||||
}
|
||||
|
||||
func TestCLI_User_ResetPass_SendEmailNoPrimary(t *testing.T) {
|
||||
s, conf, port := newTestServerWithAuth(t)
|
||||
defer test.StopServer(t, s, port)
|
||||
|
||||
app, stdin, _, _ := newTestApp()
|
||||
stdin.WriteString("mypass\nmypass")
|
||||
require.Nil(t, runUserCommand(app, conf, "add", "phil"))
|
||||
|
||||
// --send-email requires a primary email; phil has none
|
||||
app, _, _, _ = newTestApp()
|
||||
err := runUserCommand(app, conf, "--base-url=https://ntfy.example.com", "reset-pass", "--send-email", "phil")
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "no primary email")
|
||||
}
|
||||
|
||||
func TestCLI_User_ResetPass_ProvisionedRejected(t *testing.T) {
|
||||
s, conf, port := newTestServerWithAuth(t)
|
||||
defer test.StopServer(t, s, port)
|
||||
|
||||
// Seed a provisioned user into the auth database via config provisioning
|
||||
m, err := user.NewSQLiteManager(conf.AuthFile, "", &user.Config{
|
||||
ProvisionEnabled: true,
|
||||
Users: []*user.User{
|
||||
{Name: "provuser", Hash: "$2a$10$YLiO8U21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C", Role: user.RoleUser},
|
||||
},
|
||||
})
|
||||
require.Nil(t, err)
|
||||
require.Nil(t, m.Close())
|
||||
|
||||
app, _, _, _ := newTestApp()
|
||||
err = runUserCommand(app, conf, "--base-url=https://ntfy.example.com", "reset-pass", "provuser")
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "provisioned")
|
||||
}
|
||||
|
||||
func newTestServerWithAuth(t *testing.T) (s *server.Server, conf *server.Config, port int) {
|
||||
configFile := filepath.Join(t.TempDir(), "server-dummy.yml")
|
||||
require.Nil(t, os.WriteFile(configFile, []byte(""), 0600)) // Dummy config file to avoid lookup of real server.yml
|
||||
|
||||
@@ -90,6 +90,15 @@ func (d *DB) ReadOnly() *sql.DB {
|
||||
return d.primary.DB
|
||||
}
|
||||
|
||||
// MarkReplicasHealthyForTest immediately marks all configured replicas as healthy, bypassing the
|
||||
// async health-check loop's initial delay. It exists so tests can deterministically route
|
||||
// ReadOnly() to a replica without waiting; it is not used in production code.
|
||||
func (d *DB) MarkReplicasHealthyForTest() {
|
||||
for _, r := range d.replicas {
|
||||
r.healthy.Store(true)
|
||||
}
|
||||
}
|
||||
|
||||
// Close closes the primary database and all replicas, and stops the health-check goroutine.
|
||||
func (d *DB) Close() error {
|
||||
d.cancel()
|
||||
|
||||
@@ -13,6 +13,12 @@ import (
|
||||
"heckel.io/ntfy/v2/db"
|
||||
)
|
||||
|
||||
// Advisory lock keys. PostgreSQL advisory locks share one database-wide key space, so every
|
||||
// ntfy key is defined here, following the "ntfy"+2586+letter scheme
|
||||
const (
|
||||
SchemaLockKey = int64(0x6e7466792586a) // Schema setup serialization (transaction-scoped, see db/schema)
|
||||
)
|
||||
|
||||
// Open opens a PostgreSQL connection pool for a primary database. It pings the database
|
||||
// to verify connectivity before returning.
|
||||
func Open(dsn string) (*db.Host, error) {
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
// Package schema tracks and migrates database schemas, and Migrate creates or upgrades a
|
||||
// store's schema inside a single transaction. On PostgreSQL, all stores share one database, so
|
||||
// versions live in a shared schema_version table keyed by store name. On SQLite, every store is
|
||||
// its own database file, so the version lives in the schemaVersion table keyed by id = 1.
|
||||
package schema
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"heckel.io/ntfy/v2/db/pg"
|
||||
"heckel.io/ntfy/v2/log"
|
||||
)
|
||||
|
||||
const (
|
||||
tag = "schema"
|
||||
)
|
||||
|
||||
const (
|
||||
sqliteCreateVersionTableQuery = `CREATE TABLE IF NOT EXISTS schemaVersion (id INT PRIMARY KEY, version INT NOT NULL)`
|
||||
sqliteSelectVersionQuery = `SELECT version FROM schemaVersion WHERE id = 1`
|
||||
sqliteUpsertVersionQuery = `INSERT INTO schemaVersion (id, version) VALUES (1, ?) ON CONFLICT (id) DO UPDATE SET version = excluded.version`
|
||||
|
||||
postgresCreateVersionTableQuery = `CREATE TABLE IF NOT EXISTS schema_version (store TEXT PRIMARY KEY, version INT NOT NULL)`
|
||||
postgresSelectVersionQuery = `SELECT version FROM schema_version WHERE store = $1`
|
||||
postgresUpsertVersionQuery = `INSERT INTO schema_version (store, version) VALUES ($1, $2) ON CONFLICT (store) DO UPDATE SET version = EXCLUDED.version`
|
||||
postgresAdvisoryLockQuery = `SELECT pg_advisory_xact_lock($1)` // Transaction-scoped lock to avoid migration races
|
||||
)
|
||||
|
||||
// Migrate creates or upgrades the named store's schema to targetVersion in one transaction, or
|
||||
// creates a new database using the "create" function.
|
||||
func Migrate(db *sql.DB, dialect Dialect, store string, targetVersion int, create MigrateFunc, migrations map[int]MigrateFunc) error {
|
||||
if dialect != Postgres && dialect != SQLite {
|
||||
return fmt.Errorf("unsupported schema dialect %d", dialect)
|
||||
}
|
||||
tx, err := db.Begin()
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot begin %s schema transaction: %w", store, err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if dialect == Postgres {
|
||||
// Serialize setup across nodes: CREATE TABLE IF NOT EXISTS is not atomic, and
|
||||
// concurrently cold-booting nodes would otherwise race on DDL and crash
|
||||
if _, err := tx.Exec(postgresAdvisoryLockQuery, pg.SchemaLockKey); err != nil {
|
||||
return fmt.Errorf("cannot acquire %s schema advisory lock: %w", store, err)
|
||||
}
|
||||
}
|
||||
if _, err := tx.Exec(createVersionTableQuery(dialect)); err != nil {
|
||||
return fmt.Errorf("cannot create schema version table: %w", err)
|
||||
}
|
||||
version, err := readVersion(tx, dialect, store)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
// Fresh database: create the store's tables at the target version
|
||||
if err := create(tx); err != nil {
|
||||
return fmt.Errorf("cannot create %s schema: %w", store, err)
|
||||
}
|
||||
if err := writeVersion(tx, dialect, store, targetVersion); err != nil {
|
||||
return fmt.Errorf("cannot write %s schema version: %w", store, err)
|
||||
}
|
||||
return tx.Commit()
|
||||
} else if err != nil {
|
||||
return fmt.Errorf("cannot read %s schema version: %w", store, err)
|
||||
}
|
||||
if version == targetVersion {
|
||||
return tx.Commit()
|
||||
}
|
||||
if version > targetVersion {
|
||||
return fmt.Errorf("unexpected %s schema version %d, this version of ntfy supports up to %d", store, version, targetVersion)
|
||||
}
|
||||
for v := version; v < targetVersion; v++ {
|
||||
migrate, ok := migrations[v]
|
||||
if !ok {
|
||||
return fmt.Errorf("cannot find %s migration step from version %d to %d", store, v, v+1)
|
||||
}
|
||||
log.Tag(tag).Info("Migrating %s database schema: from %d to %d", store, v, v+1)
|
||||
if err := migrate(tx); err != nil {
|
||||
return fmt.Errorf("%s migration step from version %d to %d failed: %w", store, v, v+1, err)
|
||||
}
|
||||
}
|
||||
if err := writeVersion(tx, dialect, store, targetVersion); err != nil {
|
||||
return fmt.Errorf("cannot write %s schema version: %w", store, err)
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func createVersionTableQuery(dialect Dialect) string {
|
||||
if dialect == Postgres {
|
||||
return postgresCreateVersionTableQuery
|
||||
}
|
||||
return sqliteCreateVersionTableQuery
|
||||
}
|
||||
|
||||
func readVersion(tx *sql.Tx, dialect Dialect, store string) (version int, err error) {
|
||||
if dialect == Postgres {
|
||||
err = tx.QueryRow(postgresSelectVersionQuery, store).Scan(&version)
|
||||
} else {
|
||||
err = tx.QueryRow(sqliteSelectVersionQuery).Scan(&version)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func writeVersion(tx *sql.Tx, dialect Dialect, store string, version int) error {
|
||||
var err error
|
||||
if dialect == Postgres {
|
||||
_, err = tx.Exec(postgresUpsertVersionQuery, store, version)
|
||||
} else {
|
||||
_, err = tx.Exec(sqliteUpsertVersionQuery, version)
|
||||
}
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
package schema_test
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"heckel.io/ntfy/v2/db/pg"
|
||||
"heckel.io/ntfy/v2/db/schema"
|
||||
dbtest "heckel.io/ntfy/v2/db/test"
|
||||
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
)
|
||||
|
||||
const testCreateQuery = `CREATE TABLE IF NOT EXISTS things (id TEXT PRIMARY KEY, name TEXT NOT NULL)`
|
||||
|
||||
func testCreate(tx *sql.Tx) error {
|
||||
_, err := tx.Exec(testCreateQuery)
|
||||
return err
|
||||
}
|
||||
|
||||
func openTestPostgres(t *testing.T) *sql.DB {
|
||||
t.Helper()
|
||||
host, err := pg.Open(dbtest.CreateTestPostgresSchema(t))
|
||||
require.Nil(t, err)
|
||||
t.Cleanup(func() { host.DB.Close() })
|
||||
return host.DB
|
||||
}
|
||||
|
||||
func openTestSQLite(t *testing.T) *sql.DB {
|
||||
t.Helper()
|
||||
d, err := sql.Open("sqlite3", filepath.Join(t.TempDir(), "test.db"))
|
||||
require.Nil(t, err)
|
||||
t.Cleanup(func() { d.Close() })
|
||||
return d
|
||||
}
|
||||
|
||||
func forEachDialect(t *testing.T, f func(t *testing.T, d *sql.DB, dialect schema.Dialect)) {
|
||||
t.Run("postgres", func(t *testing.T) {
|
||||
f(t, openTestPostgres(t), schema.Postgres)
|
||||
})
|
||||
t.Run("sqlite", func(t *testing.T) {
|
||||
f(t, openTestSQLite(t), schema.SQLite)
|
||||
})
|
||||
}
|
||||
|
||||
func TestMigrate_FreshCreate(t *testing.T) {
|
||||
forEachDialect(t, func(t *testing.T, d *sql.DB, dialect schema.Dialect) {
|
||||
// A fresh database jumps straight to the target version; migration steps are not consulted
|
||||
require.Nil(t, schema.Migrate(d, dialect, "things", 3, testCreate, nil))
|
||||
_, err := d.Exec(`INSERT INTO things (id, name) VALUES ('a', 'thing a')`)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 3, storeVersion(t, d, dialect, "things"))
|
||||
// Idempotent: a second node boots against the migrated schema
|
||||
require.Nil(t, schema.Migrate(d, dialect, "things", 3, testCreate, nil))
|
||||
})
|
||||
}
|
||||
|
||||
func TestMigrate_AppliesMigrationSteps(t *testing.T) {
|
||||
forEachDialect(t, func(t *testing.T, d *sql.DB, dialect schema.Dialect) {
|
||||
require.Nil(t, schema.Migrate(d, dialect, "things", 1, testCreate, nil))
|
||||
// A newer version of the code migrates 1 -> 3 step by step, in order
|
||||
migrations := map[int]schema.MigrateFunc{
|
||||
1: func(tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`ALTER TABLE things ADD COLUMN color TEXT NOT NULL DEFAULT ''`)
|
||||
return err
|
||||
},
|
||||
2: func(tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`ALTER TABLE things ADD COLUMN size INT NOT NULL DEFAULT 0`)
|
||||
return err
|
||||
},
|
||||
}
|
||||
require.Nil(t, schema.Migrate(d, dialect, "things", 3, testCreate, migrations))
|
||||
_, err := d.Exec(`INSERT INTO things (id, name, color, size) VALUES ('b', 'thing b', 'red', 2)`)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 3, storeVersion(t, d, dialect, "things"))
|
||||
})
|
||||
}
|
||||
|
||||
func TestMigrate_ClosureCarriesConfig(t *testing.T) {
|
||||
// Migrations needing config take it via closure at map-construction time; there is no
|
||||
// params plumbing in the framework itself
|
||||
migrationsFor := func(defaultName string) map[int]schema.MigrateFunc {
|
||||
return map[int]schema.MigrateFunc{
|
||||
1: schema.AsMigrateFunc(fmt.Sprintf(`ALTER TABLE things ADD COLUMN nick TEXT NOT NULL DEFAULT '%s'`, defaultName)),
|
||||
}
|
||||
}
|
||||
forEachDialect(t, func(t *testing.T, d *sql.DB, dialect schema.Dialect) {
|
||||
require.Nil(t, schema.Migrate(d, dialect, "things", 1, testCreate, nil))
|
||||
_, err := d.Exec(`INSERT INTO things (id, name) VALUES ('a', 'thing a')`)
|
||||
require.Nil(t, err)
|
||||
require.Nil(t, schema.Migrate(d, dialect, "things", 2, testCreate, migrationsFor("configured-default")))
|
||||
var nick string
|
||||
require.Nil(t, d.QueryRow(`SELECT nick FROM things WHERE id = 'a'`).Scan(&nick))
|
||||
require.Equal(t, "configured-default", nick)
|
||||
})
|
||||
}
|
||||
|
||||
func TestMigrate_InvalidDialect(t *testing.T) {
|
||||
d := openTestSQLite(t)
|
||||
err := schema.Migrate(d, schema.Dialect(99), "things", 1, testCreate, nil)
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestMigrate_RefusesFutureVersion(t *testing.T) {
|
||||
forEachDialect(t, func(t *testing.T, d *sql.DB, dialect schema.Dialect) {
|
||||
require.Nil(t, schema.Migrate(d, dialect, "things", 2, testCreate, map[int]schema.MigrateFunc{}))
|
||||
err := schema.Migrate(d, dialect, "things", 1, testCreate, nil)
|
||||
require.Error(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
func TestMigrate_MissingStepFails(t *testing.T) {
|
||||
forEachDialect(t, func(t *testing.T, d *sql.DB, dialect schema.Dialect) {
|
||||
require.Nil(t, schema.Migrate(d, dialect, "things", 1, testCreate, nil))
|
||||
err := schema.Migrate(d, dialect, "things", 3, testCreate, nil) // No step 1 -> 2 registered
|
||||
require.Error(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
func TestMigrate_StoresAreIndependent(t *testing.T) {
|
||||
// Postgres only: stores share one database, tracked as rows in schema_version. On SQLite
|
||||
// every store has its own database file, so independence is by file.
|
||||
d := openTestPostgres(t)
|
||||
require.Nil(t, schema.Migrate(d, schema.Postgres, "things", 1, testCreate, nil))
|
||||
require.Nil(t, schema.Migrate(d, schema.Postgres, "gadgets", 4, func(tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`CREATE TABLE IF NOT EXISTS gadgets (id TEXT PRIMARY KEY)`)
|
||||
return err
|
||||
}, nil))
|
||||
require.Equal(t, 1, storeVersion(t, d, schema.Postgres, "things"))
|
||||
require.Equal(t, 4, storeVersion(t, d, schema.Postgres, "gadgets"))
|
||||
}
|
||||
|
||||
func TestMigrate_SQLiteReadsExistingSchemaVersionTable(t *testing.T) {
|
||||
// Existing ntfy SQLite databases (message, user, webpush) track their version in a
|
||||
// schemaVersion (id, version) table keyed by id = 1; the framework uses that table as-is
|
||||
// on SQLite, so existing databases migrate without any adoption step
|
||||
d := openTestSQLite(t)
|
||||
_, err := d.Exec(testCreateQuery)
|
||||
require.Nil(t, err)
|
||||
_, err = d.Exec(`CREATE TABLE schemaVersion (id INT PRIMARY KEY, version INT NOT NULL)`)
|
||||
require.Nil(t, err)
|
||||
_, err = d.Exec(`INSERT INTO schemaVersion VALUES (1, 1)`)
|
||||
require.Nil(t, err)
|
||||
migrations := map[int]schema.MigrateFunc{
|
||||
1: func(tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`ALTER TABLE things ADD COLUMN color TEXT NOT NULL DEFAULT ''`)
|
||||
return err
|
||||
},
|
||||
}
|
||||
require.Nil(t, schema.Migrate(d, schema.SQLite, "things", 2, testCreate, migrations))
|
||||
_, err = d.Exec(`INSERT INTO things (id, name, color) VALUES ('a', 'thing a', 'red')`)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 2, storeVersion(t, d, schema.SQLite, "things"))
|
||||
}
|
||||
|
||||
func TestMigrate_ConcurrentFreshCreate(t *testing.T) {
|
||||
// Postgres only: concurrent cold-boots must not race on DDL (CREATE TABLE IF NOT EXISTS is
|
||||
// not atomic); Migrate serializes via an advisory lock. SQLite has a single writer.
|
||||
schemaDSN := dbtest.CreateTestPostgresSchema(t)
|
||||
const n = 8
|
||||
errs := make(chan error, n)
|
||||
for i := 0; i < n; i++ {
|
||||
go func() {
|
||||
host, err := pg.Open(schemaDSN)
|
||||
if err != nil {
|
||||
errs <- err
|
||||
return
|
||||
}
|
||||
defer host.DB.Close()
|
||||
errs <- schema.Migrate(host.DB, schema.Postgres, "things", 1, testCreate, nil)
|
||||
}()
|
||||
}
|
||||
for i := 0; i < n; i++ {
|
||||
require.Nil(t, <-errs)
|
||||
}
|
||||
}
|
||||
|
||||
func storeVersion(t *testing.T, d *sql.DB, dialect schema.Dialect, store string) int {
|
||||
t.Helper()
|
||||
var version int
|
||||
if dialect == schema.Postgres {
|
||||
require.Nil(t, d.QueryRow(`SELECT version FROM schema_version WHERE store = $1`, store).Scan(&version), fmt.Sprintf("store %s", store))
|
||||
} else {
|
||||
require.Nil(t, d.QueryRow(`SELECT version FROM schemaVersion WHERE id = 1`).Scan(&version), fmt.Sprintf("store %s", store))
|
||||
}
|
||||
return version
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package schema
|
||||
|
||||
import "database/sql"
|
||||
|
||||
// Dialect selects the SQL flavor Migrate speaks to the version table.
|
||||
type Dialect int
|
||||
|
||||
// Supported dialects; SQLite is the zero value
|
||||
const (
|
||||
SQLite Dialect = iota
|
||||
Postgres
|
||||
)
|
||||
|
||||
// MigrateFunc applies one schema change inside the setup transaction: the initial creation of
|
||||
// a store's tables, or one step upgrading a store from version N to N+1. Migrations needing
|
||||
// config capture it via closure, e.g. func migrations(cacheDuration time.Duration) map[int]MigrateFunc.
|
||||
type MigrateFunc func(tx *sql.Tx) error
|
||||
|
||||
// AsMigrateFunc converts a simple query to a migration function
|
||||
func AsMigrateFunc(query string) MigrateFunc {
|
||||
return func(tx *sql.Tx) error {
|
||||
_, err := tx.Exec(query)
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// NopMigrateFunc is a migration step that does nothing, for versions where a dialect has no
|
||||
// work to do (e.g. when only the other dialect's schema changed).
|
||||
func NopMigrateFunc(_ *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
package dbtest
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// Querier is the subset of *sql.DB / *db.DB needed to introspect a schema.
|
||||
type Querier interface {
|
||||
Query(query string, args ...any) (*sql.Rows, error)
|
||||
}
|
||||
|
||||
// SQLiteSchema returns a normalized, comparable description of the database schema: tables
|
||||
// with their columns, named indexes, and foreign keys. Column order, declared type spelling
|
||||
// (INT vs INTEGER) and default values are not part of the description, so the schema produced
|
||||
// by a migration chain can be compared to a freshly created one.
|
||||
func SQLiteSchema(t testing.TB, d Querier) string {
|
||||
t.Helper()
|
||||
lines := make([]string, 0)
|
||||
for _, table := range sqliteTables(t, d) {
|
||||
lines = append(lines, "table "+table)
|
||||
lines = append(lines, sqliteColumns(t, d, table)...)
|
||||
lines = append(lines, sqliteForeignKeys(t, d, table)...)
|
||||
lines = append(lines, sqliteIndexes(t, d, table)...)
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
// PostgresSchema is SQLiteSchema's PostgreSQL counterpart, describing the current schema's
|
||||
// tables, columns, constraints and indexes in a normalized, comparable way.
|
||||
func PostgresSchema(t testing.TB, d Querier) string {
|
||||
t.Helper()
|
||||
lines := make([]string, 0)
|
||||
for _, table := range postgresTables(t, d) {
|
||||
lines = append(lines, "table "+table)
|
||||
lines = append(lines, postgresColumns(t, d, table)...)
|
||||
}
|
||||
lines = append(lines, postgresConstraints(t, d)...)
|
||||
lines = append(lines, postgresIndexes(t, d)...)
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
func sqliteTables(t testing.TB, d Querier) []string {
|
||||
t.Helper()
|
||||
return queryStrings(t, d, `SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name`)
|
||||
}
|
||||
|
||||
func sqliteColumns(t testing.TB, d Querier, table string) []string {
|
||||
t.Helper()
|
||||
rows, err := d.Query(fmt.Sprintf(`PRAGMA table_info(%q)`, table))
|
||||
require.Nil(t, err)
|
||||
defer rows.Close()
|
||||
lines := make([]string, 0)
|
||||
for rows.Next() {
|
||||
var cid, notNull, pk int
|
||||
var name, typ string
|
||||
var dflt sql.NullString
|
||||
require.Nil(t, rows.Scan(&cid, &name, &typ, ¬Null, &dflt, &pk))
|
||||
typ = strings.ToUpper(typ)
|
||||
if typ == "INT" { // INT and INTEGER are the same affinity; migrations spell them inconsistently
|
||||
typ = "INTEGER"
|
||||
}
|
||||
lines = append(lines, fmt.Sprintf(" col %s %s notnull=%d pk=%d", name, typ, notNull, pk))
|
||||
}
|
||||
require.Nil(t, rows.Err())
|
||||
sort.Strings(lines)
|
||||
return lines
|
||||
}
|
||||
|
||||
func sqliteForeignKeys(t testing.TB, d Querier, table string) []string {
|
||||
t.Helper()
|
||||
rows, err := d.Query(fmt.Sprintf(`PRAGMA foreign_key_list(%q)`, table))
|
||||
require.Nil(t, err)
|
||||
defer rows.Close()
|
||||
lines := make([]string, 0)
|
||||
for rows.Next() {
|
||||
var id, seq int
|
||||
var refTable, from, onUpdate, onDelete, match string
|
||||
var to sql.NullString // NULL when referencing the parent's primary key implicitly
|
||||
require.Nil(t, rows.Scan(&id, &seq, &refTable, &from, &to, &onUpdate, &onDelete, &match))
|
||||
lines = append(lines, fmt.Sprintf(" fk %s -> %s(%s) on_delete=%s", from, refTable, to.String, onDelete))
|
||||
}
|
||||
require.Nil(t, rows.Err())
|
||||
sort.Strings(lines)
|
||||
return lines
|
||||
}
|
||||
|
||||
func sqliteIndexes(t testing.TB, d Querier, table string) []string {
|
||||
t.Helper()
|
||||
rows, err := d.Query(fmt.Sprintf(`PRAGMA index_list(%q)`, table))
|
||||
require.Nil(t, err)
|
||||
type index struct {
|
||||
name string
|
||||
unique, partial int
|
||||
}
|
||||
indexes := make([]index, 0)
|
||||
for rows.Next() {
|
||||
var seq, unique, partial int
|
||||
var name, origin string
|
||||
require.Nil(t, rows.Scan(&seq, &name, &unique, &origin, &partial))
|
||||
// Skip auto-indexes backing PRIMARY KEY/UNIQUE table constraints; those are described
|
||||
// by the column and constraint listings already
|
||||
if strings.HasPrefix(name, "sqlite_autoindex_") {
|
||||
continue
|
||||
}
|
||||
indexes = append(indexes, index{name, unique, partial})
|
||||
}
|
||||
require.Nil(t, rows.Err())
|
||||
require.Nil(t, rows.Close())
|
||||
lines := make([]string, 0, len(indexes))
|
||||
for _, idx := range indexes {
|
||||
cols := queryStrings(t, d, fmt.Sprintf(`SELECT name FROM pragma_index_info(%q) ORDER BY seqno`, idx.name))
|
||||
lines = append(lines, fmt.Sprintf(" index %s unique=%d partial=%d cols=(%s)", idx.name, idx.unique, idx.partial, strings.Join(cols, ",")))
|
||||
}
|
||||
sort.Strings(lines)
|
||||
return lines
|
||||
}
|
||||
|
||||
func postgresTables(t testing.TB, d Querier) []string {
|
||||
t.Helper()
|
||||
return queryStrings(t, d, `SELECT table_name FROM information_schema.tables WHERE table_schema = current_schema() AND table_type = 'BASE TABLE' ORDER BY table_name`)
|
||||
}
|
||||
|
||||
func postgresColumns(t testing.TB, d Querier, table string) []string {
|
||||
t.Helper()
|
||||
rows, err := d.Query(`SELECT column_name, data_type, is_nullable FROM information_schema.columns WHERE table_schema = current_schema() AND table_name = $1 ORDER BY column_name`, table)
|
||||
require.Nil(t, err)
|
||||
defer rows.Close()
|
||||
lines := make([]string, 0)
|
||||
for rows.Next() {
|
||||
var name, typ, nullable string
|
||||
require.Nil(t, rows.Scan(&name, &typ, &nullable))
|
||||
lines = append(lines, fmt.Sprintf(" col %s %s nullable=%s", name, typ, nullable))
|
||||
}
|
||||
require.Nil(t, rows.Err())
|
||||
return lines
|
||||
}
|
||||
|
||||
func postgresConstraints(t testing.TB, d Querier) []string {
|
||||
t.Helper()
|
||||
return queryStrings(t, d, `
|
||||
SELECT 'constraint ' || conrelid::regclass::text || ': ' || pg_get_constraintdef(oid)
|
||||
FROM pg_constraint
|
||||
WHERE connamespace = current_schema()::regnamespace
|
||||
ORDER BY 1
|
||||
`)
|
||||
}
|
||||
|
||||
func postgresIndexes(t testing.TB, d Querier) []string {
|
||||
t.Helper()
|
||||
rows, err := d.Query(`SELECT indexname, indexdef, schemaname FROM pg_indexes WHERE schemaname = current_schema() ORDER BY indexname`)
|
||||
require.Nil(t, err)
|
||||
defer rows.Close()
|
||||
lines := make([]string, 0)
|
||||
for rows.Next() {
|
||||
var name, def, schema string
|
||||
require.Nil(t, rows.Scan(&name, &def, &schema))
|
||||
// The index definition qualifies the table with the (test-specific) schema name; strip
|
||||
// it so snapshots from different test schemas compare equal
|
||||
def = strings.ReplaceAll(def, schema+".", "")
|
||||
lines = append(lines, "index "+def)
|
||||
}
|
||||
require.Nil(t, rows.Err())
|
||||
return lines
|
||||
}
|
||||
|
||||
func queryStrings(t testing.TB, d Querier, query string) []string {
|
||||
t.Helper()
|
||||
rows, err := d.Query(query)
|
||||
require.Nil(t, err)
|
||||
defer rows.Close()
|
||||
values := make([]string, 0)
|
||||
for rows.Next() {
|
||||
var value string
|
||||
require.Nil(t, rows.Scan(&value))
|
||||
values = append(values, value)
|
||||
}
|
||||
require.Nil(t, rows.Err())
|
||||
return values
|
||||
}
|
||||
+88
-12
@@ -379,7 +379,7 @@ This generator helps you configure your self-hosted ntfy instance. It's not full
|
||||
</div>
|
||||
</div>
|
||||
<div class="cg-panel" id="cg-panel-database">
|
||||
<div class="cg-panel-desc">Configure the PostgreSQL connection. See <a href="/config/#postgresql-experimental" target="_blank">PostgreSQL</a> for details.</div>
|
||||
<div class="cg-panel-desc">Configure the PostgreSQL connection. See <a href="/config/#postgresql" target="_blank">PostgreSQL</a> for details.</div>
|
||||
<div class="cg-field">
|
||||
<label>Database URL</label>
|
||||
<input type="text" data-key="database-url" placeholder="postgres://user:pass@host:5432/ntfy">
|
||||
@@ -417,7 +417,7 @@ no external dependencies:
|
||||
* `auth-file`: Database file for authentication and [access control](#access-control). If set, enables auth.
|
||||
* `web-push-file`: Database file for [web push](#web-push) subscriptions.
|
||||
|
||||
### PostgreSQL (EXPERIMENTAL)
|
||||
### PostgreSQL
|
||||
As an alternative, you can configure ntfy to use PostgreSQL for **all** database-backed stores by setting the
|
||||
`database-url` option to a PostgreSQL connection string.
|
||||
|
||||
@@ -1047,9 +1047,12 @@ configured for `ntfy.sh`):
|
||||
```
|
||||
|
||||
By default, any user (including anonymous users) can send email notifications to any address. To require email
|
||||
address verification, set `smtp-sender-verify` to `true`. When enabled, anonymous users cannot send emails,
|
||||
and authenticated users can only send to email addresses they have verified in their account settings. Users can
|
||||
also use `yes`/`true`/`1` as the `X-Email` value to send to their first verified address.
|
||||
address verification, set `smtp-sender-verify` to `true`. When enabled, anonymous users cannot send emails, and
|
||||
authenticated users can only send to *literal* email addresses they have verified in their account settings.
|
||||
|
||||
Regardless of this setting, a logged-in user can pass `yes`/`true`/`1` as the `X-Email` value to send to their primary
|
||||
verified address (falling back to their first verified address if no primary is designated). `smtp-sender-verify` only
|
||||
governs whether arbitrary literal addresses are allowed.
|
||||
|
||||
Please also refer to the [rate limiting](#rate-limiting) settings below, specifically `visitor-email-limit-burst`
|
||||
and `visitor-email-limit-burst`. Setting these conservatively is necessary to avoid abuse.
|
||||
@@ -1653,7 +1656,7 @@ a database to keep track of the browser's subscriptions, and an admin email addr
|
||||
- `web-push-expiry-duration` defines the duration after which unused subscriptions will expire (default is `60d`)
|
||||
|
||||
Alternatively, you can use PostgreSQL instead of SQLite by setting `database-url`
|
||||
(see [PostgreSQL database](#postgresql-experimental)).
|
||||
(see [PostgreSQL database](#postgresql)).
|
||||
|
||||
Limitations:
|
||||
|
||||
@@ -1908,7 +1911,9 @@ per-visitor limits:
|
||||
* `visitor-attachment-total-size-limit` is the total storage limit used for attachments per visitor. It defaults to 100M.
|
||||
The per-visitor storage is automatically decreased as attachments expire. External attachments (attached via `X-Attach`,
|
||||
see [publishing docs](publish.md#attachments)) do not count here.
|
||||
* `visitor-attachment-daily-bandwidth-limit` is the total daily attachment download/upload bandwidth limit per visitor,
|
||||
* `visitor-attachment-daily-bandwidth-limit` is the total daily bandwidth limit per visitor. It covers attachment
|
||||
downloads/uploads, and messages replayed from the message cache by poll requests (a poll without a `since` cursor
|
||||
returns a topic's entire cache, so a busy topic can be re-read for many times its own size),
|
||||
including PUT and GET requests. This is to protect your precious bandwidth from abuse, since egress costs money in
|
||||
most cloud providers. This defaults to 500M.
|
||||
|
||||
@@ -2126,6 +2131,72 @@ chain.
|
||||
The official ntfy.sh server uses fail2ban to ban IPs. Check out ntfy.sh's [Ansible fail2ban role](https://github.com/binwiederhier/ntfy-ansible/tree/main/roles/fail2ban) for details. Ban actors are banned for 1 hour initially, and up to
|
||||
4 hours at a time for repeated offenses. IPv4 addresses are banned individually, while IPv6 addresses are banned by their `/56` prefix.
|
||||
|
||||
### Ban-feed
|
||||
In addition to the fail2ban setup above, ntfy can detect abusive visitors itself and write their IP
|
||||
addresses to a file for fail2ban to ban from. ntfy keeps a per-prefix weighted "strike" budget, and
|
||||
each rejected request costs strikes based on its response code -- the ntfy error code, or its HTTP
|
||||
status (see `ban-weights`) -- so different kinds of rejection can be weighted differently or exempted
|
||||
entirely. When a prefix exceeds the budget, ntfy appends the offending IP address to `ban-file`. Since
|
||||
every line is already a confirmed offender, the fail2ban jail can ban on first sight (`maxretry = 1`).
|
||||
|
||||
- `ban-file` is the file offenders are appended to. If it is not set, the ban-feed is disabled. Its
|
||||
parent directory must exist and be writable by ntfy. Be sure to rotate it (e.g. with logrotate and
|
||||
`copytruncate`) so it does not grow unbounded.
|
||||
- `ban-window` is the rolling window over which weighted strikes are counted, per IP prefix.
|
||||
- `ban-threshold` is the number of weighted strikes per `ban-window` before a prefix is written to
|
||||
`ban-file`. Each prefix has one shared budget, so it cannot be gamed by mixing error codes.
|
||||
- `ban-weights` assigns a strike weight per matcher key, formatted as `KEY:WEIGHT`. A key is an exact
|
||||
ntfy error code (`42909`), a code family (`429*`, `403*`, `4*`), a bare HTTP status (`403`, short for
|
||||
`403*`), or `*`. The longest matching key wins. A weight of `0` exempts a code entirely (it never
|
||||
counts toward a ban), useful to spare a specific code from a `*` catch-all. Heavier weights ban faster. If you do not
|
||||
include a `*` rule, any code that matches nothing defaults to weight `1` (i.e. it can be banned);
|
||||
set `*:0` to exempt everything that is not explicitly weighted.
|
||||
|
||||
Only rejections (4xx/5xx) count towards a ban; successful requests never do. Because the budget
|
||||
refills over `ban-window`, the trigger is a sustained rate: a prefix is only written out once it
|
||||
exceeds `ban-threshold / ban-window` rejections per second (with the defaults, `100 / 10m` = ~0.17/s).
|
||||
|
||||
Each line in `ban-file` has the format `<RFC3339-timestamp> <ip> <prefix> <http-code> <ntfy-code>`, for example:
|
||||
|
||||
```
|
||||
2026-01-15T20:56:32Z 1.2.3.4 1.2.3.4/32 429 42901
|
||||
2026-01-15T20:56:32Z 2001:db8::abcd 2001:db8::/64 429 42909
|
||||
```
|
||||
|
||||
`<prefix>` is `<ip>` masked to the rate-limiting prefix (`visitor-prefix-bits-ipv4`/`-ipv6`) -- the
|
||||
same unit ntfy rate-limits by. Have the fail2ban filter capture the bare `<ip>` (the action then
|
||||
applies the prefix):
|
||||
|
||||
=== "server.yml"
|
||||
```yaml
|
||||
ban-file: "/var/log/ntfy/ban.log"
|
||||
ban-window: "10m"
|
||||
ban-threshold: 100
|
||||
ban-weights:
|
||||
- "42909:10" # too many auth failures -> brute force, ban fast
|
||||
# everything else 4xx/5xx defaults to weight 1
|
||||
```
|
||||
|
||||
=== "/etc/fail2ban/filter.d/ntfy-ban.conf"
|
||||
```
|
||||
[Definition]
|
||||
failregex = ^\S+ <HOST> \S+ \d+ \d+$
|
||||
datepattern = ^%%Y-%%m-%%dT%%H:%%M:%%S
|
||||
ignoreregex =
|
||||
```
|
||||
|
||||
=== "/etc/fail2ban/jail.d/ntfy-ban.local"
|
||||
```
|
||||
[ntfy-ban]
|
||||
enabled = true
|
||||
filter = ntfy-ban
|
||||
action = iptables-multiport[name=ntfy-ban, port="http,https", protocol=tcp]
|
||||
logpath = /var/log/ntfy/ban.log
|
||||
maxretry = 1
|
||||
findtime = 1m
|
||||
bantime = 1h
|
||||
```
|
||||
|
||||
## IPv6 support
|
||||
ntfy fully supports IPv6, though there are a few things to keep in mind.
|
||||
|
||||
@@ -2156,13 +2227,14 @@ See [Installation for Docker](install.md#docker) for an example of how this coul
|
||||
If configured, ntfy can expose a `/metrics` endpoint for [Prometheus](https://prometheus.io/), which can then be used to
|
||||
create dashboards and alerts (e.g. via [Grafana](https://grafana.com/)).
|
||||
|
||||
To configure the metrics endpoint, either set `enable-metrics` and/or set the `metrics-listen-http` option to a dedicated
|
||||
To configure the metrics endpoint, either set `enable-metrics`, or set the `metrics-listen-http` option to a dedicated
|
||||
listen address. Metrics may be considered sensitive information, so before you enable them, be sure you know what you are
|
||||
doing, and/or secure access to the endpoint in your reverse proxy.
|
||||
|
||||
- `enable-metrics` enables the /metrics endpoint for the default ntfy server (i.e. HTTP, HTTPS and/or Unix socket)
|
||||
- `metrics-listen-http` exposes the metrics endpoint via a dedicated `[IP]:port`. If set, this option implicitly
|
||||
enables metrics as well, e.g. "10.0.1.1:9090" or ":9090"
|
||||
- `metrics-listen-http` moves the metrics endpoint to a dedicated `[IP]:port`, e.g. "10.0.1.1:9090" or ":9090". It
|
||||
implicitly enables metrics. If set, the metrics are served only on that dedicated port, and the default ntfy server
|
||||
does not serve /metrics, even if `enable-metrics` is also set.
|
||||
|
||||
=== "server.yml (Using default port)"
|
||||
```yaml
|
||||
@@ -2312,7 +2384,7 @@ variable before running the `ntfy` command (e.g. `export NTFY_LISTEN_HTTP=:80`).
|
||||
| `upstream-base-url` | `NTFY_UPSTREAM_BASE_URL` | *URL* | `https://ntfy.sh` | Forward poll request to an upstream server, this is needed for iOS push notifications for self-hosted servers |
|
||||
| `upstream-access-token` | `NTFY_UPSTREAM_ACCESS_TOKEN` | *string* | `tk_zyYLYj...` | Access token to use for the upstream server; needed only if upstream rate limits are exceeded or upstream server requires auth |
|
||||
| `visitor-attachment-total-size-limit` | `NTFY_VISITOR_ATTACHMENT_TOTAL_SIZE_LIMIT` | *size* | 100M | Rate limiting: Total storage limit used for attachments per visitor, for all attachments combined. Storage is freed after attachments expire. See `attachment-expiry-duration`. |
|
||||
| `visitor-attachment-daily-bandwidth-limit` | `NTFY_VISITOR_ATTACHMENT_DAILY_BANDWIDTH_LIMIT` | *size* | 500M | Rate limiting: Total daily attachment download/upload traffic limit per visitor. This is to protect your bandwidth costs from exploding. |
|
||||
| `visitor-attachment-daily-bandwidth-limit` | `NTFY_VISITOR_ATTACHMENT_DAILY_BANDWIDTH_LIMIT` | *size* | 500M | Rate limiting: Total daily traffic limit per visitor, covering attachment downloads/uploads and messages replayed from the cache by poll requests. This is to protect your bandwidth costs from exploding. |
|
||||
| `visitor-email-limit-burst` | `NTFY_VISITOR_EMAIL_LIMIT_BURST` | *number* | 16 | Rate limiting:Initial limit of e-mails per visitor |
|
||||
| `visitor-email-limit-replenish` | `NTFY_VISITOR_EMAIL_LIMIT_REPLENISH` | *duration* | 1h | Rate limiting: Strongly related to `visitor-email-limit-burst`: The rate at which the bucket is refilled |
|
||||
| `visitor-message-daily-limit` | `NTFY_VISITOR_MESSAGE_DAILY_LIMIT` | *number* | - | Rate limiting: Allowed number of messages per day per visitor, reset every day at midnight (UTC). By default, this value is unset. |
|
||||
@@ -2325,6 +2397,10 @@ variable before running the `ntfy` command (e.g. `export NTFY_LISTEN_HTTP=:80`).
|
||||
| `visitor-topic-creation-limit-replenish` | `NTFY_VISITOR_TOPIC_CREATION_LIMIT_REPLENISH` | *duration* | 1m | Rate limiting: Rate at which the per-visitor topic-creation bucket is refilled (one new topic per x). |
|
||||
| `visitor-prefix-bits-ipv4` | `NTFY_VISITOR_PREFIX_BITS_IPV4` | *number* | 32 | Rate limiting: Number of bits to use for IPv4 visitor prefix, e.g. 24 for /24 |
|
||||
| `visitor-prefix-bits-ipv6` | `NTFY_VISITOR_PREFIX_BITS_IPV6` | *number* | 64 | Rate limiting: Number of bits to use for IPv6 visitor prefix, e.g. 48 for /48 |
|
||||
| `ban-file` | `NTFY_BAN_FILE` | *filename* | - | Abuse ban-feed: file confirmed abusive visitor IPs are appended to, for fail2ban to tail. Empty disables the feature. See [Banning bad actors](#banning-bad-actors-fail2ban) |
|
||||
| `ban-window` | `NTFY_BAN_WINDOW` | *duration* | 10m | Abuse ban-feed: rolling window over which weighted strikes are counted, per IP prefix |
|
||||
| `ban-threshold` | `NTFY_BAN_THRESHOLD` | *number* | 100 | Abuse ban-feed: weighted strikes per `ban-window` before a prefix is written to `ban-file` |
|
||||
| `ban-weights` | `NTFY_BAN_WEIGHTS` | *list of KEY:WEIGHT* | `42909:10`| Abuse ban-feed: per-code strike weights (exact code, family `429*`, or `*`; longest match wins; `0` exempts). See [Banning bad actors](#banning-bad-actors-fail2ban) |
|
||||
| `web-root` | `NTFY_WEB_ROOT` | *path*, e.g. `/` or `/app`, or `disable` | `/` | Sets root of the web app (e.g. /, or /app), or disables it entirely (disable) |
|
||||
| `enable-signup` | `NTFY_ENABLE_SIGNUP` | *boolean* (`true` or `false`) | `false` | Allows users to sign up via the web app, or API |
|
||||
| `enable-login` | `NTFY_ENABLE_LOGIN` | *boolean* (`true` or `false`) | `false` | Allows users to log in via the web app, or API |
|
||||
@@ -2424,7 +2500,7 @@ OPTIONS:
|
||||
--visitor-subscription-limit value, --visitor_subscription_limit value number of subscriptions per visitor (default: 30) [$NTFY_VISITOR_SUBSCRIPTION_LIMIT]
|
||||
--visitor-subscriber-rate-limiting, --visitor_subscriber_rate_limiting enables subscriber-based rate limiting (default: false) [$NTFY_VISITOR_SUBSCRIBER_RATE_LIMITING]
|
||||
--visitor-attachment-total-size-limit value, --visitor_attachment_total_size_limit value total storage limit used for attachments per visitor (default: "100M") [$NTFY_VISITOR_ATTACHMENT_TOTAL_SIZE_LIMIT]
|
||||
--visitor-attachment-daily-bandwidth-limit value, --visitor_attachment_daily_bandwidth_limit value total daily attachment download/upload bandwidth limit per visitor (default: "500M") [$NTFY_VISITOR_ATTACHMENT_DAILY_BANDWIDTH_LIMIT]
|
||||
--visitor-attachment-daily-bandwidth-limit value, --visitor_attachment_daily_bandwidth_limit value total daily bandwidth limit per visitor, for attachment downloads/uploads and messages replayed from the cache by poll requests (default: "500M") [$NTFY_VISITOR_ATTACHMENT_DAILY_BANDWIDTH_LIMIT]
|
||||
--visitor-request-limit-burst value, --visitor_request_limit_burst value initial limit of requests per visitor (default: 60) [$NTFY_VISITOR_REQUEST_LIMIT_BURST]
|
||||
--visitor-request-limit-replenish value, --visitor_request_limit_replenish value interval at which burst limit is replenished (one per x) (default: "5s") [$NTFY_VISITOR_REQUEST_LIMIT_REPLENISH]
|
||||
--visitor-request-limit-exempt-hosts value, --visitor_request_limit_exempt_hosts value hostnames and/or IP addresses of hosts that will be exempt from the visitor request limit [$NTFY_VISITOR_REQUEST_LIMIT_EXEMPT_HOSTS]
|
||||
|
||||
+6
-1
@@ -28,7 +28,7 @@ via the following channels:
|
||||
| Channel | Contact | Description |
|
||||
|-----------------------|-----------------------------------------------------|------------------------------------------|
|
||||
| **General Support** | [support@mail.ntfy.sh](mailto:support@mail.ntfy.sh) | Direct email support for Pro subscribers |
|
||||
| **Billing Inquiries** | [billing@mail.ntfy.sh](mailto:support@mail.ntfy.sh) | Inquire about billing issues |
|
||||
| **Billing Inquiries** | [billing@mail.ntfy.sh](mailto:billing@mail.ntfy.sh) | Inquire about billing issues |
|
||||
| **Discord/Matrix** | Mention your Pro status | Priority responses in community channels |
|
||||
|
||||
Please include your ntfy.sh username when contacting support so we can verify your subscription status.
|
||||
@@ -37,6 +37,11 @@ Please include your ntfy.sh username when contacting support so we can verify yo
|
||||
|
||||
If you discover a security vulnerability, please report it responsibly via [security@mail.ntfy.sh](mailto:security@mail.ntfy.sh). See also: [SECURITY.md](https://github.com/binwiederhier/ntfy/blob/main/SECURITY.md).
|
||||
|
||||
## Abuse reports
|
||||
|
||||
To report spam, phishing, or other abuse of ntfy.sh, please email [abuse@mail.ntfy.sh](mailto:abuse@mail.ntfy.sh).
|
||||
Please include the topic name and any relevant message details so we can investigate.
|
||||
|
||||
## Other inquiries
|
||||
|
||||
For questions about our [privacy policy](privacy.md), data handling, or to exercise your data rights
|
||||
|
||||
+6
-4
@@ -65,8 +65,8 @@ These steps **assume Ubuntu**. Steps may vary on different Linux distributions.
|
||||
|
||||
First, install [Go](https://go.dev/) (see [official instructions](https://go.dev/doc/install)):
|
||||
``` shell
|
||||
wget https://go.dev/dl/go1.19.1.linux-amd64.tar.gz
|
||||
sudo rm -rf /usr/local/go && sudo tar -C /usr/local -xzf go1.19.1.linux-amd64.tar.gz
|
||||
wget https://go.dev/dl/go1.25.8.linux-amd64.tar.gz
|
||||
sudo rm -rf /usr/local/go && sudo tar -C /usr/local -xzf go1.25.8.linux-amd64.tar.gz
|
||||
export PATH=$PATH:/usr/local/go/bin:$HOME/go/bin
|
||||
go version # verifies that it worked
|
||||
```
|
||||
@@ -77,9 +77,11 @@ go install github.com/goreleaser/goreleaser@latest
|
||||
goreleaser -v # verifies that it worked
|
||||
```
|
||||
|
||||
Install [nodejs](https://nodejs.org/en/) (see [official instructions](https://nodejs.org/en/download/package-manager/)):
|
||||
Install [nodejs](https://nodejs.org/en/) (see [official instructions](https://nodejs.org/en/download/package-manager/)).
|
||||
Use a current LTS release (Node 24 is what CI builds with; anything older than Node 20 will not work
|
||||
with the current Vite-based web build):
|
||||
``` shell
|
||||
curl -fsSL https://deb.nodesource.com/setup_18.x | sudo -E bash -
|
||||
curl -fsSL https://deb.nodesource.com/setup_24.x | sudo -E bash -
|
||||
sudo apt-get install -y nodejs
|
||||
npm -v # verifies that it worked
|
||||
```
|
||||
|
||||
@@ -641,6 +641,32 @@ or by simply providing traccar with a valid username/password combination.
|
||||
<entry key='sms.http.password'>mypass</entry>
|
||||
```
|
||||
|
||||
## Flowtriq DDoS detection
|
||||
[Flowtriq](https://flowtriq.com) is a real-time DDoS detection and mitigation platform. Its Linux agent, ftagent,
|
||||
supports webhook alerts that can POST directly to an ntfy topic, so you get push notifications on your phone
|
||||
whenever an attack is detected.
|
||||
|
||||
Configure the webhook URL in your ftagent configuration to point to your ntfy topic:
|
||||
|
||||
```yaml
|
||||
# /etc/ftagent/ftagent.yml
|
||||
alerts:
|
||||
webhooks:
|
||||
- url: https://ntfy.sh/flowtriq-attacks
|
||||
method: POST
|
||||
```
|
||||
|
||||
You can also use curl to test the integration manually with a sample attack alert:
|
||||
|
||||
```bash
|
||||
curl \
|
||||
-H "Title: DDoS Attack Detected" \
|
||||
-H "Priority: urgent" \
|
||||
-H "Tags: rotating_light" \
|
||||
-d "Attack detected on 203.0.113.5: 14.2 Gbps UDP flood from 3,482 sources" \
|
||||
ntfy.sh/flowtriq-attacks
|
||||
```
|
||||
|
||||
## Terminal Notifications for Long-Running Commands
|
||||
|
||||
This example provides a simple way to send notifications using [ntfy.sh](https://ntfy.sh) when a terminal command completes. It includes success or failure indicators based on the command's exit status.
|
||||
|
||||
+66
-38
@@ -34,37 +34,37 @@ as a service starting at boot time.
|
||||
|
||||
=== "x86_64/amd64"
|
||||
```bash
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.24.0/ntfy_2.24.0_linux_amd64.tar.gz
|
||||
tar zxvf ntfy_2.24.0_linux_amd64.tar.gz
|
||||
sudo cp -a ntfy_2.24.0_linux_amd64/ntfy /usr/local/bin/ntfy
|
||||
sudo mkdir /etc/ntfy && sudo cp ntfy_2.24.0_linux_amd64/{client,server}/*.yml /etc/ntfy
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.28.0/ntfy_2.28.0_linux_amd64.tar.gz
|
||||
tar zxvf ntfy_2.28.0_linux_amd64.tar.gz
|
||||
sudo cp -a ntfy_2.28.0_linux_amd64/ntfy /usr/local/bin/ntfy
|
||||
sudo mkdir /etc/ntfy && sudo cp ntfy_2.28.0_linux_amd64/{client,server}/*.yml /etc/ntfy
|
||||
sudo ntfy serve
|
||||
```
|
||||
|
||||
=== "armv6"
|
||||
```bash
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.24.0/ntfy_2.24.0_linux_armv6.tar.gz
|
||||
tar zxvf ntfy_2.24.0_linux_armv6.tar.gz
|
||||
sudo cp -a ntfy_2.24.0_linux_armv6/ntfy /usr/bin/ntfy
|
||||
sudo mkdir /etc/ntfy && sudo cp ntfy_2.24.0_linux_armv6/{client,server}/*.yml /etc/ntfy
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.28.0/ntfy_2.28.0_linux_armv6.tar.gz
|
||||
tar zxvf ntfy_2.28.0_linux_armv6.tar.gz
|
||||
sudo cp -a ntfy_2.28.0_linux_armv6/ntfy /usr/bin/ntfy
|
||||
sudo mkdir /etc/ntfy && sudo cp ntfy_2.28.0_linux_armv6/{client,server}/*.yml /etc/ntfy
|
||||
sudo ntfy serve
|
||||
```
|
||||
|
||||
=== "armv7/armhf"
|
||||
```bash
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.24.0/ntfy_2.24.0_linux_armv7.tar.gz
|
||||
tar zxvf ntfy_2.24.0_linux_armv7.tar.gz
|
||||
sudo cp -a ntfy_2.24.0_linux_armv7/ntfy /usr/bin/ntfy
|
||||
sudo mkdir /etc/ntfy && sudo cp ntfy_2.24.0_linux_armv7/{client,server}/*.yml /etc/ntfy
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.28.0/ntfy_2.28.0_linux_armv7.tar.gz
|
||||
tar zxvf ntfy_2.28.0_linux_armv7.tar.gz
|
||||
sudo cp -a ntfy_2.28.0_linux_armv7/ntfy /usr/bin/ntfy
|
||||
sudo mkdir /etc/ntfy && sudo cp ntfy_2.28.0_linux_armv7/{client,server}/*.yml /etc/ntfy
|
||||
sudo ntfy serve
|
||||
```
|
||||
|
||||
=== "arm64"
|
||||
```bash
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.24.0/ntfy_2.24.0_linux_arm64.tar.gz
|
||||
tar zxvf ntfy_2.24.0_linux_arm64.tar.gz
|
||||
sudo cp -a ntfy_2.24.0_linux_arm64/ntfy /usr/bin/ntfy
|
||||
sudo mkdir /etc/ntfy && sudo cp ntfy_2.24.0_linux_arm64/{client,server}/*.yml /etc/ntfy
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.28.0/ntfy_2.28.0_linux_arm64.tar.gz
|
||||
tar zxvf ntfy_2.28.0_linux_arm64.tar.gz
|
||||
sudo cp -a ntfy_2.28.0_linux_arm64/ntfy /usr/bin/ntfy
|
||||
sudo mkdir /etc/ntfy && sudo cp ntfy_2.28.0_linux_arm64/{client,server}/*.yml /etc/ntfy
|
||||
sudo ntfy serve
|
||||
```
|
||||
|
||||
@@ -84,25 +84,25 @@ Install the ntfy server unit file (which contains parameters to start the servic
|
||||
|
||||
=== "x86_64/amd64"
|
||||
```bash
|
||||
sudo mv ntfy_2.24.0_linux_amd64/server/ntfy.service /etc/systemd/system/
|
||||
sudo mv ntfy_2.28.0_linux_amd64/server/ntfy.service /etc/systemd/system/
|
||||
sudo chmod 644 /etc/systemd/system/ntfy.service
|
||||
```
|
||||
|
||||
=== "armv6"
|
||||
```bash
|
||||
sudo mv ntfy_2.24.0_linux_armv6/server/ntfy.service /etc/systemd/system/
|
||||
sudo mv ntfy_2.28.0_linux_armv6/server/ntfy.service /etc/systemd/system/
|
||||
sudo chmod 644 /etc/systemd/system/ntfy.service
|
||||
```
|
||||
|
||||
=== "armv7/armhf"
|
||||
```bash
|
||||
sudo mv ntfy_2.24.0_linux_armv7/server/ntfy.service /etc/systemd/system/
|
||||
sudo mv ntfy_2.28.0_linux_armv7/server/ntfy.service /etc/systemd/system/
|
||||
sudo chmod 644 /etc/systemd/system/ntfy.service
|
||||
```
|
||||
|
||||
=== "arm64"
|
||||
```bash
|
||||
sudo mv ntfy_2.24.0_linux_arm64/server/ntfy.service /etc/systemd/system/
|
||||
sudo mv ntfy_2.28.0_linux_arm64/server/ntfy.service /etc/systemd/system/
|
||||
sudo chmod 644 /etc/systemd/system/ntfy.service
|
||||
```
|
||||
|
||||
@@ -118,25 +118,25 @@ Install the ntfy server service script:
|
||||
|
||||
=== "x86_64/amd64"
|
||||
```bash
|
||||
sudo mv ntfy_2.24.0_linux_amd64/server/ntfy.openrc /etc/init.d/ntfy
|
||||
sudo mv ntfy_2.28.0_linux_amd64/server/ntfy.openrc /etc/init.d/ntfy
|
||||
sudo chmod 755 /etc/init.d/ntfy
|
||||
```
|
||||
|
||||
=== "armv6"
|
||||
```bash
|
||||
sudo mv ntfy_2.24.0_linux_armv6/server/ntfy.openrc /etc/init.d/ntfy
|
||||
sudo mv ntfy_2.28.0_linux_armv6/server/ntfy.openrc /etc/init.d/ntfy
|
||||
sudo chmod 755 /etc/init.d/ntfy
|
||||
```
|
||||
|
||||
=== "armv7/armhf"
|
||||
```bash
|
||||
sudo mv ntfy_2.24.0_linux_armv7/server/ntfy.openrc /etc/init.d/ntfy
|
||||
sudo mv ntfy_2.28.0_linux_armv7/server/ntfy.openrc /etc/init.d/ntfy
|
||||
sudo chmod 755 /etc/init.d/ntfy
|
||||
```
|
||||
|
||||
=== "arm64"
|
||||
```bash
|
||||
sudo mv ntfy_2.24.0_linux_arm64/server/ntfy.openrc /etc/init.d/ntfy
|
||||
sudo mv ntfy_2.28.0_linux_arm64/server/ntfy.openrc /etc/init.d/ntfy
|
||||
sudo chmod 755 /etc/init.d/ntfy
|
||||
```
|
||||
|
||||
@@ -204,7 +204,7 @@ Manually installing the .deb file:
|
||||
|
||||
=== "x86_64/amd64"
|
||||
```bash
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.24.0/ntfy_2.24.0_linux_amd64.deb
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.28.0/ntfy_2.28.0_linux_amd64.deb
|
||||
sudo dpkg -i ntfy_*.deb
|
||||
sudo systemctl enable ntfy
|
||||
sudo systemctl start ntfy
|
||||
@@ -212,7 +212,7 @@ Manually installing the .deb file:
|
||||
|
||||
=== "armv6"
|
||||
```bash
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.24.0/ntfy_2.24.0_linux_armv6.deb
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.28.0/ntfy_2.28.0_linux_armv6.deb
|
||||
sudo dpkg -i ntfy_*.deb
|
||||
sudo systemctl enable ntfy
|
||||
sudo systemctl start ntfy
|
||||
@@ -220,7 +220,7 @@ Manually installing the .deb file:
|
||||
|
||||
=== "armv7/armhf"
|
||||
```bash
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.24.0/ntfy_2.24.0_linux_armv7.deb
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.28.0/ntfy_2.28.0_linux_armv7.deb
|
||||
sudo dpkg -i ntfy_*.deb
|
||||
sudo systemctl enable ntfy
|
||||
sudo systemctl start ntfy
|
||||
@@ -228,7 +228,7 @@ Manually installing the .deb file:
|
||||
|
||||
=== "arm64"
|
||||
```bash
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.24.0/ntfy_2.24.0_linux_arm64.deb
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.28.0/ntfy_2.28.0_linux_arm64.deb
|
||||
sudo dpkg -i ntfy_*.deb
|
||||
sudo systemctl enable ntfy
|
||||
sudo systemctl start ntfy
|
||||
@@ -238,28 +238,28 @@ Manually installing the .deb file:
|
||||
|
||||
=== "x86_64/amd64"
|
||||
```bash
|
||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.24.0/ntfy_2.24.0_linux_amd64.rpm
|
||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.28.0/ntfy_2.28.0_linux_amd64.rpm
|
||||
sudo systemctl enable ntfy
|
||||
sudo systemctl start ntfy
|
||||
```
|
||||
|
||||
=== "armv6"
|
||||
```bash
|
||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.24.0/ntfy_2.24.0_linux_armv6.rpm
|
||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.28.0/ntfy_2.28.0_linux_armv6.rpm
|
||||
sudo systemctl enable ntfy
|
||||
sudo systemctl start ntfy
|
||||
```
|
||||
|
||||
=== "armv7/armhf"
|
||||
```bash
|
||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.24.0/ntfy_2.24.0_linux_armv7.rpm
|
||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.28.0/ntfy_2.28.0_linux_armv7.rpm
|
||||
sudo systemctl enable ntfy
|
||||
sudo systemctl start ntfy
|
||||
```
|
||||
|
||||
=== "arm64"
|
||||
```bash
|
||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.24.0/ntfy_2.24.0_linux_arm64.rpm
|
||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.28.0/ntfy_2.28.0_linux_arm64.rpm
|
||||
sudo systemctl enable ntfy
|
||||
sudo systemctl start ntfy
|
||||
```
|
||||
@@ -301,18 +301,18 @@ pkg install go-ntfy
|
||||
|
||||
## macOS
|
||||
The [ntfy CLI](subscribe/cli.md) (`ntfy publish` and `ntfy subscribe` only) is supported on macOS as well.
|
||||
To install, please [download the tarball](https://github.com/binwiederhier/ntfy/releases/download/v2.24.0/ntfy_2.24.0_darwin_all.tar.gz),
|
||||
To install, please [download the tarball](https://github.com/binwiederhier/ntfy/releases/download/v2.28.0/ntfy_2.28.0_darwin_all.tar.gz),
|
||||
extract it and place it somewhere in your `PATH` (e.g. `/usr/local/bin/ntfy`).
|
||||
|
||||
If run as `root`, ntfy will look for its config at `/etc/ntfy/client.yml`. For all other users, it'll look for it at
|
||||
`~/Library/Application Support/ntfy/client.yml` (sample included in the tarball).
|
||||
|
||||
```bash
|
||||
curl -L https://github.com/binwiederhier/ntfy/releases/download/v2.24.0/ntfy_2.24.0_darwin_all.tar.gz > ntfy_2.24.0_darwin_all.tar.gz
|
||||
tar zxvf ntfy_2.24.0_darwin_all.tar.gz
|
||||
sudo cp -a ntfy_2.24.0_darwin_all/ntfy /usr/local/bin/ntfy
|
||||
curl -L https://github.com/binwiederhier/ntfy/releases/download/v2.28.0/ntfy_2.28.0_darwin_all.tar.gz > ntfy_2.28.0_darwin_all.tar.gz
|
||||
tar zxvf ntfy_2.28.0_darwin_all.tar.gz
|
||||
sudo cp -a ntfy_2.28.0_darwin_all/ntfy /usr/local/bin/ntfy
|
||||
mkdir ~/Library/Application\ Support/ntfy
|
||||
cp ntfy_2.24.0_darwin_all/client/client.yml ~/Library/Application\ Support/ntfy/client.yml
|
||||
cp ntfy_2.28.0_darwin_all/client/client.yml ~/Library/Application\ Support/ntfy/client.yml
|
||||
ntfy --help
|
||||
```
|
||||
|
||||
@@ -333,7 +333,7 @@ brew install ntfy
|
||||
The ntfy server and CLI are fully supported on Windows. You can run the ntfy server directly or as a Windows service.
|
||||
To install, you can either
|
||||
|
||||
* [Download the latest ZIP](https://github.com/binwiederhier/ntfy/releases/download/v2.24.0/ntfy_2.24.0_windows_amd64.zip),
|
||||
* [Download the latest ZIP](https://github.com/binwiederhier/ntfy/releases/download/v2.28.0/ntfy_2.28.0_windows_amd64.zip),
|
||||
extract it and place the `ntfy.exe` binary somewhere in your `%Path%`.
|
||||
* Or install ntfy from the [Scoop](https://scoop.sh) main repository via `scoop install ntfy`
|
||||
|
||||
@@ -726,3 +726,31 @@ kubectl apply -k /ntfy
|
||||
cache-file: "/var/cache/ntfy/cache.db"
|
||||
attachment-cache-dir: "/var/cache/ntfy/attachments"
|
||||
```
|
||||
|
||||
## Helm
|
||||
<span class="community-badge" title="This package is maintained by the community, not the ntfy developers"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M11 7h2v2h-2zm0 4h2v6h-2zm1-9C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8z"/></svg> Community maintained</span>
|
||||
|
||||
If you prefer [Helm](https://helm.sh/), ntfy can be deployed using the third-party
|
||||
[HelmForge chart](https://helmforge.dev/docs/charts/ntfy), which packages the official
|
||||
`binwiederhier/ntfy` image with persistent storage, Service, Ingress, optional Prometheus metrics, and more.
|
||||
This chart is **not** maintained by the ntfy developers.
|
||||
|
||||
!!! warning
|
||||
The HelmForge project is young and maintained by a small community. Review the chart before deploying it,
|
||||
and use it at your own risk.
|
||||
|
||||
```bash
|
||||
helm repo add helmforge https://repo.helmforge.dev
|
||||
helm repo update
|
||||
helm install ntfy helmforge/ntfy
|
||||
```
|
||||
|
||||
Alternatively, install it directly from the OCI registry:
|
||||
|
||||
```bash
|
||||
helm install ntfy oci://ghcr.io/helmforgedev/helm/ntfy
|
||||
```
|
||||
|
||||
Because ntfy's default SQLite storage is single-writer, run the chart as a single instance rather than
|
||||
treating it as a horizontally scalable deployment. See the [chart documentation](https://helmforge.dev/docs/charts/ntfy)
|
||||
for the full list of configurable values.
|
||||
|
||||
@@ -43,6 +43,7 @@ I've added a ⭐ to projects or posts that have a significant following, or had
|
||||
- [Miniflux](https://miniflux.app/docs/ntfy.html) - Minimalist and opinionated feed reader
|
||||
- [Beszel](https://beszel.dev/guide/notifications/ntfy) - Server monitoring platform
|
||||
- [Simple Observability](https://simpleobservability.com/docs/alerts/ntfy) - Server monitoring and observability platform
|
||||
- [Sifio](https://sifio.net) - Aggregate updates from RSS, social media, and other sources, then deliver them to ntfy, Slack, Notion and more
|
||||
|
||||
## Integration via HTTP/SMTP/etc.
|
||||
|
||||
@@ -54,6 +55,7 @@ I've added a ⭐ to projects or posts that have a significant following, or had
|
||||
- [Proxmox-Ntfy](https://github.com/qtsone/proxmox-ntfy) - Python script that monitors Proxmox tasks and sends notifications using the Ntfy service.
|
||||
- [Scrutiny](https://github.com/AnalogJ/scrutiny) - WebUI for smartd S.M.A.R.T monitoring. Scrutiny includes shoutrrr/ntfy integration ([see integration README](https://github.com/AnalogJ/scrutiny?tab=readme-ov-file#notifications))
|
||||
- [UptimeObserver](https://uptimeobserver.com) - Uptime Monitoring tool for Websites, APIs, SSL Certificates, DNS, Domain Names and Ports. [Integration Guide](https://support.uptimeobserver.com/integrations/ntfy/)
|
||||
- [Flowtriq](https://flowtriq.com) - Real-time DDoS detection and mitigation platform (integration via [webhook alerts](https://flowtriq.com))
|
||||
|
||||
## [UnifiedPush](https://unifiedpush.org/users/apps/) integrations
|
||||
|
||||
@@ -82,13 +84,14 @@ I've added a ⭐ to projects or posts that have a significant following, or had
|
||||
- [symfony/ntfy-notifier](https://symfony.com/components/NtfyNotifier) ⭐ - Symfony Notifier integration for ntfy (PHP)
|
||||
- [ntfy-java](https://github.com/MaheshBabu11/ntfy-java/) - A Java package to interact with a ntfy server (Java)
|
||||
- [aiontfy](https://github.com/tr4nt0r/aiontfy) - Asynchronous client library for publishing and subscribing to ntfy (Python)
|
||||
- [ex_ntfy](https://github.com/houllette/ex_ntfy) - Elixir SDK covering publishing, polling, and streaming subscriptions for ntfy servers (Elixir)
|
||||
- [ntfy-logging](https://github.com/Pimak/ntfy-logging) - Turns JVM error logs into ntfy notifications, with zero-code adapters for java.util.logging, Logback, Log4j2, Spring Boot, Micronaut and Quarkus (Java)
|
||||
|
||||
## CLIs + GUIs
|
||||
|
||||
- [ntfy.sh.sh](https://github.com/mininmobile/ntfy.sh.sh) - Run scripts on ntfy.sh events
|
||||
- [ntfy-desktop](https://codeberg.org/zvava/ntfy-desktop) - Cross-platform desktop application for ntfy
|
||||
- [ntfy-desktop](https://github.com/Aetherinox/ntfy-desktop) - Desktop client for Windows, Linux, and MacOS with push notifications
|
||||
- [ntfy svelte front-end](https://github.com/novatorem/Ntfy) - Front-end built with svelte
|
||||
- [ntfy Desktop (Windows)](https://github.com/simoneferrari/ntfy-desktop) - Native Windows desktop client with multi-server support, toast notifications and message history, built with WPF and .NET (C#)
|
||||
- [wio-ntfy-ticker](https://github.com/nachotp/wio-ntfy-ticker) - Ticker display for a ntfy.sh topic
|
||||
- [ntfysh-windows](https://github.com/mshafer1/ntfysh-windows) - A ntfy client for Windows Desktop
|
||||
@@ -189,6 +192,7 @@ I've added a ⭐ to projects or posts that have a significant following, or had
|
||||
- [send_to_ntfy_extension](https://github.com/TheDuffman85/send_to_ntfy_extension/) ⭐ - A browser extension to send the notifications to ntfy (JS)
|
||||
- [SIA-Server](https://github.com/ZebMcKayhan/SIA-Server) - A light weight, self-hosted notification Server for Honywell Galaxy Flex alarm systems (Python)
|
||||
- [zabbix-ntfy](https://github.com/torgrimt/zabbix-ntfy) - Zabbix server Mediatype to add support for ntfy.sh services
|
||||
- [Rubix Notify](https://wordpress.org/plugins/rubix-notify) - WordPress Integration with ntfy (PHP + React).
|
||||
|
||||
## Blog + forum posts
|
||||
|
||||
|
||||
+8
-6
@@ -1,6 +1,6 @@
|
||||
# Privacy policy
|
||||
|
||||
**Last updated:** March 31, 2026
|
||||
**Last updated:** June 15, 2026
|
||||
|
||||
This privacy policy describes how ntfy ("we", "us", or "our") collects, uses, and handles your information
|
||||
when you use the ntfy.sh service, web app, and mobile applications (Android and iOS).
|
||||
@@ -19,8 +19,9 @@ If you create an account on ntfy.sh, we collect:
|
||||
|
||||
- **Username** - A unique identifier you choose
|
||||
- **Password** - Stored as a secure bcrypt hash (we never store your plaintext password)
|
||||
- **Email address** - If you subscribe to a paid plan (for billing purposes via Stripe), or if you add a verified
|
||||
email address for use with the email notification feature
|
||||
- **Email address** - If you add an email address to your account for account recovery and password resets, for use
|
||||
with the email notification feature, or if you subscribe to a paid plan (for billing purposes via Stripe). Email
|
||||
addresses you add to your account are verified by sending a confirmation link.
|
||||
- **Phone number** - Only if you enable the phone call notification feature (verified via SMS/call)
|
||||
|
||||
You can use ntfy without creating an account. Anonymous usage is fully supported.
|
||||
@@ -77,9 +78,10 @@ Your phone number is shared with Twilio to deliver these services. Twilio's
|
||||
|
||||
### Amazon SES (email delivery)
|
||||
|
||||
If you use the email notification feature (`X-Email` header), we use Amazon Simple Email Service (SES) to
|
||||
deliver emails. The recipient email address and message content are transmitted through Amazon's infrastructure.
|
||||
Amazon's [privacy policy](https://aws.amazon.com/privacy/) applies.
|
||||
If you use the email notification feature (`X-Email` header), or when ntfy sends account-related emails (email
|
||||
address verification and password reset links), we use Amazon Simple Email Service (SES) to deliver emails. The
|
||||
recipient email address and message content are transmitted through Amazon's infrastructure. Amazon's
|
||||
[privacy policy](https://aws.amazon.com/privacy/) applies.
|
||||
|
||||
### Stripe (payments)
|
||||
|
||||
|
||||
+97
-11
@@ -1,7 +1,7 @@
|
||||
# Publishing
|
||||
Publishing messages can be done via HTTP PUT/POST or via the [ntfy CLI](subscribe/cli.md#publish-messages) ([install instructions](install.md)).
|
||||
Topics are created on the fly by subscribing or publishing to them. Because there is no sign-up, **the topic is essentially a password**, so pick
|
||||
something that's not easily guessable.
|
||||
something that's not easily guessable (see [picking a topic](#picking-a-topic) for a handy topic name generator).
|
||||
|
||||
Here's an example showing how to publish a simple message using a POST request:
|
||||
|
||||
@@ -308,6 +308,44 @@ an [external image attachment](#attach-file-from-a-url) and [email publishing](#
|
||||
<figcaption>Notification using a click action, a user action, with an external image attachment and forwarded via email</figcaption>
|
||||
</figure>
|
||||
|
||||
## Picking a topic
|
||||
Since there is no sign-up, **the topic is essentially a password**, so pick something that's not easily guessable. Topic names may
|
||||
only contain letters, numbers, underscores and dashes (`[-_A-Za-z0-9]`), and may be up to 64 characters long.
|
||||
|
||||
Not sure what to pick? Type a name below and the generator will add a random, hard-to-guess suffix for you. Everything happens locally in your browser:
|
||||
|
||||
<div id="tg-widget" class="tg-generator">
|
||||
<div class="tg-header">
|
||||
<span class="tg-title">Topic name generator</span>
|
||||
<button type="button" id="tg-reroll" class="tg-reset" title="Generate a new random suffix">Regenerate suffix</button>
|
||||
</div>
|
||||
<div class="tg-body">
|
||||
<div class="tg-left">
|
||||
<div class="tg-field">
|
||||
<label for="tg-input">Type a topic name</label>
|
||||
<input type="text" id="tg-input" placeholder="e.g. backups, alerts, phil-home" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false">
|
||||
</div>
|
||||
<div class="tg-note">Spaces and characters other than letters, numbers, <code>-</code> and <code>_</code> are removed automatically as you type. Names are capped at 64 characters.</div>
|
||||
</div>
|
||||
<div class="tg-right">
|
||||
<div class="tg-output-row">
|
||||
<span class="tg-output-label">Your topic:</span>
|
||||
<div class="tg-output-line">
|
||||
<pre class="tg-output" id="tg-output-name"></pre>
|
||||
<button type="button" class="tg-btn-copy" data-copy="tg-output-name" title="Copy to clipboard"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path></svg></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tg-output-row">
|
||||
<span class="tg-output-label">Your topic URL:</span>
|
||||
<div class="tg-output-line">
|
||||
<pre class="tg-output" id="tg-output-url">https://ntfy.sh/</pre>
|
||||
<button type="button" class="tg-btn-copy" data-copy="tg-output-url" title="Copy to clipboard"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path></svg></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
## Message title
|
||||
_Supported on:_ :material-android: :material-apple: :material-firefox:
|
||||
|
||||
@@ -628,7 +666,7 @@ them with a comma, e.g. `tag1,tag2,tag3`.
|
||||
_Supported on:_ :material-android: :material-firefox:
|
||||
|
||||
You can format messages using [Markdown](https://www.markdownguide.org/basic-syntax/) 🤩. That means you can use
|
||||
**bold text**, *italicized text*, links, images, and more. Supported Markdown features (web app only for now):
|
||||
**bold text**, *italicized text*, links, images, and more. Supported Markdown features:
|
||||
|
||||
- [Emphasis](https://www.markdownguide.org/basic-syntax/#emphasis) such as **bold** (`**bold**`), *italics* (`*italics*`)
|
||||
- [Links](https://www.markdownguide.org/basic-syntax/#links) (`[some tool](https://ntfy.sh)`)
|
||||
@@ -2777,16 +2815,20 @@ Here's an example of a dead man's switch that sends an alert if the script stops
|
||||
### Canceling scheduled notifications
|
||||
|
||||
You can cancel a scheduled message before it is delivered by sending a DELETE request to the
|
||||
`/<topic>/<sequence_id>` endpoint, just like [deleting notifications](#deleting-notifications). This will remove the
|
||||
scheduled message from the server so it will never be delivered, and emit a `message_delete` event to any subscribers.
|
||||
`/<topic>/<sequence_id>` endpoint, just like [deleting notifications](#deleting-notifications). Alternatively, you can send a `GET`
|
||||
request to `/<topic>/<sequence_id>/delete`. This will remove the scheduled message from the server so it will never be delivered,
|
||||
and emit a `message_delete` event to any subscribers.
|
||||
|
||||
=== "Command line (curl)"
|
||||
```bash
|
||||
# Schedule a reminder for 2 hours from now
|
||||
curl -H "In: 2h" -d "Take a break!" ntfy.sh/mytopic/break-reminder
|
||||
|
||||
# Changed your mind? Cancel the scheduled message
|
||||
# Changed your mind? Cancel the scheduled message via DELETE
|
||||
curl -X DELETE ntfy.sh/mytopic/break-reminder
|
||||
|
||||
# Or cancel it via GET
|
||||
curl ntfy.sh/mytopic/break-reminder/delete
|
||||
```
|
||||
|
||||
=== "ntfy CLI"
|
||||
@@ -3182,6 +3224,14 @@ You can use the following features in your templates:
|
||||
A good way to experiment with Go templates is the **[Go Template Playground](https://repeatit.io)**. It is _highly recommended_ to test
|
||||
your templates there first ([example for Grafana alert](https://repeatit.io/#/share/eyJ0ZW1wbGF0ZSI6InRpdGxlPUdyYWZhbmErYWxlcnQ6K3t7LnRpdGxlfX0mbWVzc2FnZT17ey5tZXNzYWdlfX0iLCJpbnB1dCI6IntcbiAgXCJyZWNlaXZlclwiOiBcIm50ZnlcXFxcLmV4YW1wbGVcXFxcLmNvbS9hbGVydHNcIixcbiAgXCJzdGF0dXNcIjogXCJyZXNvbHZlZFwiLFxuICBcImFsZXJ0c1wiOiBbXG4gICAge1xuICAgICAgXCJzdGF0dXNcIjogXCJyZXNvbHZlZFwiLFxuICAgICAgXCJsYWJlbHNcIjoge1xuICAgICAgICBcImFsZXJ0bmFtZVwiOiBcIkxvYWQgYXZnIDE1bSB0b28gaGlnaFwiLFxuICAgICAgICBcImdyYWZhbmFfZm9sZGVyXCI6IFwiTm9kZSBhbGVydHNcIixcbiAgICAgICAgXCJpbnN0YW5jZVwiOiBcIjEwLjEwOC4wLjI6OTEwMFwiLFxuICAgICAgICBcImpvYlwiOiBcIm5vZGUtZXhwb3J0ZXJcIlxuICAgICAgfSxcbiAgICAgIFwiYW5ub3RhdGlvbnNcIjoge1xuICAgICAgICBcInN1bW1hcnlcIjogXCIxNW0gbG9hZCBhdmVyYWdlIHRvbyBoaWdoXCJcbiAgICAgIH0sXG4gICAgICBcInN0YXJ0c0F0XCI6IFwiMjAyNC0wMy0xNVQwMjoyODowMFpcIixcbiAgICAgIFwiZW5kc0F0XCI6IFwiMjAyNC0wMy0xNVQwMjo0MjowMFpcIixcbiAgICAgIFwiZ2VuZXJhdG9yVVJMXCI6IFwibG9jYWxob3N0OjMwMDAvYWxlcnRpbmcvZ3JhZmFuYS9OVzlvRHctNHovdmlld1wiLFxuICAgICAgXCJmaW5nZXJwcmludFwiOiBcImJlY2JmYjk0YmQ4MWVmNDhcIixcbiAgICAgIFwic2lsZW5jZVVSTFwiOiBcImxvY2FsaG9zdDozMDAwL2FsZXJ0aW5nL3NpbGVuY2UvbmV3P2FsZXJ0bWFuYWdlcj1ncmFmYW5hJm1hdGNoZXI9YWxlcnRuYW1lJTNETG9hZCthdmcrMTVtK3RvbytoaWdoJm1hdGNoZXI9Z3JhZmFuYV9mb2xkZXIlM0ROb2RlK2FsZXJ0cyZtYXRjaGVyPWluc3RhbmNlJTNEMTAuMTA4LjAuMiUzQTkxMDAmbWF0Y2hlcj1qb2IlM0Rub2RlLWV4cG9ydGVyXCIsXG4gICAgICBcImRhc2hib2FyZFVSTFwiOiBcIlwiLFxuICAgICAgXCJwYW5lbFVSTFwiOiBcIlwiLFxuICAgICAgXCJ2YWx1ZXNcIjoge1xuICAgICAgICBcIkJcIjogMTguOTgyMTEzMTQ0NzU4NzYsXG4gICAgICAgIFwiQ1wiOiAwXG4gICAgICB9LFxuICAgICAgXCJ2YWx1ZVN0cmluZ1wiOiBcIlsgdmFyPSdCJyBsYWJlbHM9e19fbmFtZV9fPW5vZGVfbG9hZDE1LCBpbnN0YW5jZT0xMC4xMDguMC4yOjkxMDAsIGpvYj1ub2RlLWV4cG9ydGVyfSB2YWx1ZT0xOC45ODIxMTMxNDQ3NTg3NiBdLCBbIHZhcj0nQycgbGFiZWxzPXtfX25hbWVfXz1ub2RlX2xvYWQxNSwgaW5zdGFuY2U9MTAuMTA4LjAuMjo5MTAwLCBqb2I9bm9kZS1leHBvcnRlcn0gdmFsdWU9MCBdXCJcbiAgICB9XG4gIF0sXG4gIFwiZ3JvdXBMYWJlbHNcIjoge1xuICAgIFwiYWxlcnRuYW1lXCI6IFwiTG9hZCBhdmcgMTVtIHRvbyBoaWdoXCIsXG4gICAgXCJncmFmYW5hX2ZvbGRlclwiOiBcIk5vZGUgYWxlcnRzXCJcbiAgfSxcbiAgXCJjb21tb25MYWJlbHNcIjoge1xuICAgIFwiYWxlcnRuYW1lXCI6IFwiTG9hZCBhdmcgMTVtIHRvbyBoaWdoXCIsXG4gICAgXCJncmFmYW5hX2ZvbGRlclwiOiBcIk5vZGUgYWxlcnRzXCIsXG4gICAgXCJpbnN0YW5jZVwiOiBcIjEwLjEwOC4wLjI6OTEwMFwiLFxuICAgIFwiam9iXCI6IFwibm9kZS1leHBvcnRlclwiXG4gIH0sXG4gIFwiY29tbW9uQW5ub3RhdGlvbnNcIjoge1xuICAgIFwic3VtbWFyeVwiOiBcIjE1bSBsb2FkIGF2ZXJhZ2UgdG9vIGhpZ2hcIlxuICB9LFxuICBcImV4dGVybmFsVVJMXCI6IFwibG9jYWxob3N0OjMwMDAvXCIsXG4gIFwidmVyc2lvblwiOiBcIjFcIixcbiAgXCJncm91cEtleVwiOiBcInt9OnthbGVydG5hbWU9XFxcIkxvYWQgYXZnIDE1bSB0b28gaGlnaFxcXCIsIGdyYWZhbmFfZm9sZGVyPVxcXCJOb2RlIGFsZXJ0c1xcXCJ9XCIsXG4gIFwidHJ1bmNhdGVkQWxlcnRzXCI6IDAsXG4gIFwib3JnSWRcIjogMSxcbiAgXCJ0aXRsZVwiOiBcIltSRVNPTFZFRF0gTG9hZCBhdmcgMTVtIHRvbyBoaWdoIE5vZGUgYWxlcnRzICgxMC4xMDguMC4yOjkxMDAgbm9kZS1leHBvcnRlcilcIixcbiAgXCJzdGF0ZVwiOiBcIm9rXCIsXG4gIFwibWVzc2FnZVwiOiBcIioqUmVzb2x2ZWQqKlxcblxcblZhbHVlOiBCPTE4Ljk4MjExMzE0NDc1ODc2LCBDPTBcXG5MYWJlbHM6XFxuIC0gYWxlcnRuYW1lID0gTG9hZCBhdmcgMTVtIHRvbyBoaWdoXFxuIC0gZ3JhZmFuYV9mb2xkZXIgPSBOb2RlIGFsZXJ0c1xcbiAtIGluc3RhbmNlID0gMTAuMTA4LjAuMjo5MTAwXFxuIC0gam9iID0gbm9kZS1leHBvcnRlclxcbkFubm90YXRpb25zOlxcbiAtIHN1bW1hcnkgPSAxNW0gbG9hZCBhdmVyYWdlIHRvbyBoaWdoXFxuU291cmNlOiBsb2NhbGhvc3Q6MzAwMC9hbGVydGluZy9ncmFmYW5hL05XOW9Edy00ei92aWV3XFxuU2lsZW5jZTogbG9jYWxob3N0OjMwMDAvYWxlcnRpbmcvc2lsZW5jZS9uZXc/YWxlcnRtYW5hZ2VyPWdyYWZhbmEmbWF0Y2hlcj1hbGVydG5hbWUlM0RMb2FkK2F2ZysxNW0rdG9vK2hpZ2gmbWF0Y2hlcj1ncmFmYW5hX2ZvbGRlciUzRE5vZGUrYWxlcnRzJm1hdGNoZXI9aW5zdGFuY2UlM0QxMC4xMDguMC4yJTNBOTEwMCZtYXRjaGVyPWpvYiUzRG5vZGUtZXhwb3J0ZXJcXG5cIlxufVxuIiwiY29uZmlnIjp7InRlbXBsYXRlIjoidGV4dCIsImZ1bGxTY3JlZW5IVE1MIjpmYWxzZSwiZnVuY3Rpb25zIjpbInNwcmlnIl0sIm9wdGlvbnMiOlsibGl2ZSJdLCJpbnB1dFR5cGUiOiJ5YW1sIn19)).
|
||||
|
||||
!!! info
|
||||
A few Go template features are disabled for user-supplied templates: `{{define}}`, `{{template}}`,
|
||||
`{{block}}`, and `{{call}}` are not allowed. Templates also run with a short execution time limit --
|
||||
a template that loops too long is stopped and rejected with an HTTP 400 error. Templates are
|
||||
limited to 32 KB in size, `printf` widths and precisions must be below 1000 (`%999d` is
|
||||
allowed, `%1000d` is not), including the `%*d` form that takes the width from an argument, and
|
||||
`indent`/`nindent` are limited to 100 spaces.
|
||||
|
||||
### Template functions
|
||||
ntfy supports a subset of the **[Sprig template functions](publish/template-functions.md)** (originally copied from [Sprig](https://github.com/Masterminds/sprig),
|
||||
thank you to the Sprig developers 🙏). This is useful for advanced message templating and for transforming the data provided through the JSON payload.
|
||||
@@ -3213,8 +3263,13 @@ You can forward messages to e-mail by specifying an address in the header. This
|
||||
you'd like to persist longer, or to blast-notify yourself on all possible channels.
|
||||
|
||||
Usage is easy: Simply pass the `X-Email` header (or any of its aliases: `X-E-mail`, `Email`, `E-mail`, `Mail`, or `e`).
|
||||
Only one e-mail address is supported. If the server has [`smtp-sender-verify`](config.md#e-mail-notifications) enabled (ntfy.sh has this enabled),
|
||||
you can also pass `yes`, `true`, or `1` to send to your first verified email address.
|
||||
Only one e-mail address is supported.
|
||||
|
||||
If you are logged in and have a verified email address on your account, you can pass `yes`, `true`, or `1` instead of an
|
||||
address to send to your **primary email address** (the one marked primary in the web app's
|
||||
[Account section](https://ntfy.sh/account)); if you haven't designated a primary, it falls back to your first verified
|
||||
address. This works regardless of the [`smtp-sender-verify`](config.md#e-mail-notifications) setting -- that setting only
|
||||
controls whether *literal* addresses must already be verified on your account.
|
||||
|
||||
ntfy allows anonymous email sending (if enabled), so the rate limiting is pretty strict (see [limitations](#limitations)). In the
|
||||
default configuration, you get **16 e-mails per visitor** (IP address) and then after that one per hour. On top of
|
||||
@@ -3664,7 +3719,7 @@ all the supported fields:
|
||||
| `icon` | - | *string* | `https://example.com/icon.png` | URL to use as notification [icon](#icons) |
|
||||
| `filename` | - | *string* | `file.jpg` | File name of the attachment |
|
||||
| `delay` | - | *string* | `30min`, `9am` | Timestamp or duration for delayed delivery |
|
||||
| `email` | - | *e-mail address or 'yes'* | `phil@example.com` or `yes` | E-mail address for e-mail notifications, or `yes` to use first verified address |
|
||||
| `email` | - | *e-mail address or 'yes'* | `phil@example.com` or `yes` | E-mail address for e-mail notifications, or `yes` to use your primary verified address |
|
||||
| `call` | - | *phone number or 'yes'* | `+1222334444` or `yes` | Phone number to use for [voice call](#phone-calls) |
|
||||
| `sequence_id` | - | *string* | `my-sequence-123` | Sequence ID for [updating/deleting notifications](#updating-deleting-notifications) |
|
||||
|
||||
@@ -4100,26 +4155,41 @@ field the response. A sequence of updates may look like this (first example from
|
||||
### Clearing notifications
|
||||
Clearing a notification means **marking it as read and dismissing it from the notification drawer**.
|
||||
|
||||
To do this, send a PUT request to the `/<topic>/<sequence_id>/clear` endpoint (or `/<topic>/<sequence_id>/read` as an alias).
|
||||
To do this, send a `PUT` request to the `/<topic>/<sequence_id>/clear` endpoint (or `/<topic>/<sequence_id>/read` as an alias).
|
||||
This will then emit a `message_clear` event that is used by the clients (web app and Android app) to update the read status
|
||||
and dismiss the notification.
|
||||
|
||||
Alternatively, if your client has limited HTTP support, you can send a `GET` request to the same endpoints:
|
||||
`GET /<topic>/<sequence_id>/clear` or `GET /<topic>/<sequence_id>/read`.
|
||||
|
||||
=== "Command line (curl)"
|
||||
```bash
|
||||
# Via PUT method
|
||||
curl -X PUT ntfy.sh/mytopic/my-download-123/clear
|
||||
|
||||
# Via GET method
|
||||
curl ntfy.sh/mytopic/my-download-123/clear
|
||||
```
|
||||
|
||||
=== "HTTP"
|
||||
``` http
|
||||
PUT /mytopic/my-download-123/clear HTTP/1.1
|
||||
Host: ntfy.sh
|
||||
|
||||
# Or using GET
|
||||
GET /mytopic/my-download-123/clear HTTP/1.1
|
||||
Host: ntfy.sh
|
||||
```
|
||||
|
||||
=== "JavaScript"
|
||||
``` javascript
|
||||
// Via PUT method
|
||||
await fetch('https://ntfy.sh/mytopic/my-download-123/clear', {
|
||||
method: 'PUT'
|
||||
});
|
||||
|
||||
// Via GET method
|
||||
await fetch('https://ntfy.sh/mytopic/my-download-123/clear');
|
||||
```
|
||||
|
||||
=== "Go"
|
||||
@@ -4154,25 +4224,40 @@ An example response from the server with the `message_clear` event may look like
|
||||
### Deleting notifications
|
||||
Deleting a notification means **removing it from the notification drawer and from the client's database**.
|
||||
|
||||
To do this, send a DELETE request to the `/<topic>/<sequence_id>` endpoint. This will emit a `message_delete` event
|
||||
To do this, send a `DELETE` request to the `/<topic>/<sequence_id>` endpoint. This will emit a `message_delete` event
|
||||
that is used by the clients (web app and Android app) to remove the notification entirely.
|
||||
|
||||
Alternatively, if your client has limited HTTP support (e.g. webhooks or IoT devices), you can also delete a message by sending
|
||||
a `GET` request to `/<topic>/<sequence_id>/delete`.
|
||||
|
||||
=== "Command line (curl)"
|
||||
```bash
|
||||
# Via DELETE method
|
||||
curl -X DELETE ntfy.sh/mytopic/my-download-123
|
||||
|
||||
# Via GET method
|
||||
curl ntfy.sh/mytopic/my-download-123/delete
|
||||
```
|
||||
|
||||
=== "HTTP"
|
||||
``` http
|
||||
DELETE /mytopic/my-download-123 HTTP/1.1
|
||||
Host: ntfy.sh
|
||||
|
||||
# Or using GET
|
||||
GET /mytopic/my-download-123/delete HTTP/1.1
|
||||
Host: ntfy.sh
|
||||
```
|
||||
|
||||
=== "JavaScript"
|
||||
``` javascript
|
||||
// Via DELETE method
|
||||
await fetch('https://ntfy.sh/mytopic/my-download-123', {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
// Via GET method
|
||||
await fetch('https://ntfy.sh/mytopic/my-download-123/delete');
|
||||
```
|
||||
|
||||
=== "Go"
|
||||
@@ -4846,7 +4931,8 @@ but just in case, let's list them all:
|
||||
| **Subscription limit** | By default, the server allows each visitor to keep 30 connections to the server open. |
|
||||
| **Attachment size limit** | By default, the server allows attachments up to 15 MB in size, up to 100 MB in total per visitor and up to 5 GB across all visitors. On ntfy.sh, the attachment size limit is 2 MB, and the per-visitor total is 20 MB. |
|
||||
| **Attachment expiry** | By default, the server deletes attachments after 3 hours and thereby frees up space from the total visitor attachment limit. |
|
||||
| **Attachment bandwidth** | By default, the server allows 500 MB of GET/PUT/POST traffic for attachments per visitor in a 24 hour period. Traffic exceeding that is rejected. On ntfy.sh, the daily bandwidth limit is 200 MB. |
|
||||
| **Title and tag size** | The message title is limited to 1 KB, and all tags combined to 512 bytes. Requests exceeding either are rejected with HTTP 400. |
|
||||
| **Daily bandwidth** | By default, the server allows 500 MB of traffic per visitor in a 24 hour period, covering attachment GET/PUT/POST traffic and messages replayed from the cache by [poll requests](subscribe/api.md#replay-limits). Traffic exceeding that is rejected. On ntfy.sh, the daily bandwidth limit is 200 MB. |
|
||||
| **Total number of topics** | By default, the server is configured to allow 15,000 topics. The ntfy.sh server has higher limits though. |
|
||||
|
||||
These limits can be changed on a per-user basis using [tiers](config.md#tiers). If [payments](config.md#payments) are enabled, a user tier can be changed by purchasing
|
||||
|
||||
+154
-36
@@ -4,15 +4,160 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release
|
||||
|
||||
## Current stable releases
|
||||
|
||||
| Component | Version | Release date |
|
||||
|------------------|---------|--------------|
|
||||
| ntfy server | v2.24.0 | June 4, 2026 |
|
||||
| ntfy Android app | v1.24.0 | Mar 5, 2026 |
|
||||
| ntfy iOS app | v1.7.0 | May 30, 2026 |
|
||||
| Component | Version | Release date |
|
||||
|------------------|---------|---------------|
|
||||
| ntfy server | v2.28.0 | Aug 27, 2026 |
|
||||
| ntfy Android app | v1.25.2 | July 23, 2026 |
|
||||
| ntfy iOS app | v1.7.0 | May 30, 2026 |
|
||||
|
||||
Please check out the release notes for [upcoming releases](#not-released-yet) below.
|
||||
|
||||
### ntfy server v2.24.0
|
||||
### ntfy server v2.28.0
|
||||
Released August 27, 2026
|
||||
|
||||
This is a hardening release. A single topic on ntfy.sh was polled continuously with `poll=1` and no
|
||||
`since` cursor, which replays a topic's entire cache on every request. The changes below bound what one
|
||||
replay can cost, close two fields that had no size limit at all, and fix an ordering bug found while
|
||||
digging into it.
|
||||
|
||||
**Bug fixes + maintenance:**
|
||||
|
||||
* Fix messages being returned out of publish order when polling or replaying **several topics at once** (`/topic1,topic2/json?poll=1`). `Message.Time` has second granularity, so a multi-topic replay sorts many equal keys; the sort was unstable, which could shuffle a single topic's own messages. Single-topic replays were not affected ([#1297](https://github.com/binwiederhier/ntfy/issues/1297))
|
||||
* Limit the message title to 1 KB and all tags combined to 512 bytes, rejecting larger requests with HTTP 400 (error codes `40057` and `40058`). Neither field had a size limit before, unlike the message body; on ntfy.sh the 99.9th percentile is 212 bytes for titles and 244 for tags
|
||||
* Cap a single cache replay at 10 MB of messages per topic. A poll without a `since` cursor returns a topic's entire cache, which was previously unbounded and could reach tens of megabytes on a busy topic, so one request could allocate that much on the server. The newest messages that fit are kept and a truncated response carries an `X-Messages-Truncated: 1` header
|
||||
* `visitor-attachment-daily-bandwidth-limit` now also covers messages replayed from the message cache by poll requests, not just attachment traffic. A poll without a `since` cursor returns a topic's entire cache, so a topic that is cheap to fill can be re-read for many times its own size; polls beyond the budget are rejected with HTTP 429 (error code 42905) before anything is written. **Note that heavy pollers now consume the same budget as attachment downloads**, so operators serving both may want to raise the limit
|
||||
|
||||
### ntfy server v2.27.0
|
||||
Released August 4, 2026
|
||||
|
||||
This release lets you sign in with your verified email address instead of your username, which should help if you ever
|
||||
signed up with an email and then forgot which username you picked. It also hardens the message templating engine against
|
||||
a few ways a small template could eat a lot of memory, and it drops the "experimental" label from
|
||||
[PostgreSQL support](config.md#postgresql), which has been running ntfy.sh for a while now.
|
||||
|
||||
I also did a bunch of refactoring in, mostly in preparation for being able to cluster ntfy nodes and scale the service
|
||||
horizontally. It'll be a while until then, ... baby steps.
|
||||
|
||||
**Security:**
|
||||
|
||||
* Limit message templates (`Template: yes`) to 32 KB, limit `printf` widths and precisions to below 1000, and limit `indent`/`nindent` to 100 spaces, preventing excessive memory use from a single small template
|
||||
* Exclude secrets from the config hash served to the web app, preventing a rather theoretical information leak
|
||||
|
||||
**Features:**
|
||||
|
||||
* Allow logging in with your verified primary email address (in addition to your username), so a password reset no longer leaves you unable to sign in when you only remember the email you signed up with
|
||||
|
||||
**Bug fixes + maintenance:**
|
||||
|
||||
* Fix Twilio phone calls and phone number verifications failing silently when Twilio rejected the request, and move the Twilio integration into its own `twilio` package
|
||||
* Move the Prometheus metrics into a dedicated `metrics` package
|
||||
* Message cache databases from ntfy older than v1.10.0 (November 2021) can no longer be migrated; upgrade via an older ntfy version first, or delete the cache database
|
||||
* Fix `user_phone` table in the SQLite user database referencing a dropped table after the v2.14 schema migration; repaired automatically by a new migration
|
||||
|
||||
## ntfy Android v1.25.2
|
||||
Released July 23, 2026
|
||||
|
||||
This release makes the "connection lost" alert configurable and turns it off by default. Folks did not like it and many reached out
|
||||
or even gave ntfy bad reviews. I heard you! You can re-enable the alert in the advanced settings.
|
||||
|
||||
The release also tries to be smarter about not retrying the connection at all if the app is in flight mode, or has no network. If there
|
||||
is no network, ntfy now keeps the foreground service alive and shows a "Waiting for network" notification, then resumes automatically
|
||||
once connectivity returns.
|
||||
|
||||
Another change related to the networking is that we now force-reconnect when the connection is changed, e.g. during transitions
|
||||
from Wi-Fi to cellular network, or vice versa. That should allow for faster transitions during hand-overs.
|
||||
|
||||
We also increase the client-side WebSocket ping interval from 1 minute to 3 minutes, which should slightly improve battery life,
|
||||
especially when paired with increaseing the server-side `keepalive-interval` in your self-hosted server.
|
||||
|
||||
**Features:**
|
||||
|
||||
* Add configurable "Alert when connection is lost" setting, turned off by default ([#1665](https://github.com/binwiederhier/ntfy/issues/1665), [#1662](https://github.com/binwiederhier/ntfy/issues/1662), [#1652](https://github.com/binwiederhier/ntfy/issues/1652), [#1655](https://github.com/binwiederhier/ntfy/issues/1655), thanks to [@tintamarre](https://github.com/tintamarre), [@sjozs](https://github.com/sjozs), [@TheRealOne78](https://github.com/TheRealOne78), and [@DAE51D](https://github.com/DAE51D) for reporting)
|
||||
* Handle "no network" gracefully: when the device is offline or in airplane mode, ntfy now stops retrying, suppresses the connection-lost alert, and keeps the foreground service alive with a "Waiting for network" notification, resuming instant delivery automatically when connectivity returns ([ntfy-android#165](https://github.com/binwiederhier/ntfy-android/pull/165), thanks to [@tintamarre](https://github.com/tintamarre) for the contribution, and [#1709](https://github.com/binwiederhier/ntfy/issues/1709), thanks to [@isaitgirl](https://github.com/isaitgirl) for reporting)
|
||||
* Improve battery life by increasing WebSocket client ping interval from 1 min to 3 min, and reconnect instantly on Wi-Fi/cellular/VPN transitions ([ntfy-android#113](https://github.com/binwiederhier/ntfy-android/pull/113), thanks to [@ftilde](https://github.com/ftilde) for the investigation)
|
||||
* Disable UnifiedPush components when UnifiedPush is disabled in settings ([ntfy-android#168](https://github.com/binwiederhier/ntfy-android/pull/168), thanks to [@p1gp1g](https://github.com/p1gp1g) for the contribution)
|
||||
|
||||
**Bug fixes + maintenance:**
|
||||
|
||||
* Fix the "connection lost" alert briefly disappearing and re-firing when roaming between networks (e.g. Wi-Fi to cellular), by no longer cancelling it during the transient no-network gap of a handover
|
||||
* Fix the "connection lost" alert repeatedly waking the screen while a server stayed unreachable, by no longer re-posting the alert once it is already showing
|
||||
* Fix the "connection lost" alert firing late, erratically, or not at all when a connection kept dropping (e.g. a flaky server) rather than being refused outright, by tracking how long the connection has been down independently of whether the drop warrants a UI error
|
||||
* Undo automatic phone number linking for numbers in message body ([ntfy-android#170](https://github.com/binwiederhier/ntfy-android/pull/170), thanks to [@acortelyou](https://github.com/acortelyou) for the contribution)
|
||||
* Fix subscription icons disappearing after a few days due to Android clearing cache ([#1322](https://github.com/binwiederhier/ntfy/issues/1322), thanks to [@mcanning](https://github.com/mcanning) for reporting)
|
||||
* Fix UnifiedPush `failed_reason` being sent as an enum instead of a string, which caused an exception in receiving apps that read it as a string extra ([ntfy-android#182](https://github.com/binwiederhier/ntfy-android/pull/182), thanks to [@p1gp1g](https://github.com/p1gp1g) for the contribution)
|
||||
|
||||
### ntfy server v2.26.3
|
||||
Released July 20, 2026
|
||||
|
||||
This is a hotfix release, useful pretty much only for ntfy.sh. It was adds the ability to track abusive IPs more
|
||||
efficiently, reducing the load on the IP banning services and preventing them from falling behind and leaving abusers
|
||||
unbanned for too long. It works by tracking HTTP errors, and writing out a ban file that fail2ban can read and ban
|
||||
offenders instantly. See [ban-feed](config.md#ban-feed) for details.
|
||||
|
||||
**Features:**
|
||||
|
||||
* Add an abuse ban-feed: when enabled via `ban-file`, ntfy tracks a weighted strike budget per visitor and appends abusive IPs to a file that fail2ban can tail and ban on sight (`ban-file`, `ban-window`, `ban-threshold`, `ban-weights`; see [ban-feed docs](config.md#ban-feed))
|
||||
|
||||
### ntfy server v2.26.0
|
||||
Released July 9, 2026
|
||||
|
||||
This release hardens **message templates**, which are now executed with a hard-capped execution timeout. This closes
|
||||
a denial-of-service hole.
|
||||
|
||||
On the web app side, it adds configurable **date and time formats**, a smoother loading and page-transition experience,
|
||||
and a fix that strips unsafe URL protocols from rendered Markdown.
|
||||
|
||||
**Security:**
|
||||
|
||||
* Prevent a CPU denial of service via message templates (`Template: yes`) ([#1826](https://github.com/binwiederhier/ntfy/pull/1826), thanks to [@alanturing881](https://github.com/alanturing881), [@5ud0er](https://github.com/5ud0er) and [@jvoisin](https://github.com/jvoisin) for reporting)
|
||||
|
||||
**Features:**
|
||||
|
||||
* Web app: Add "Date format" and "Time format" settings (Settings -> Appearance), with ISO 8601, day/month/year (slash or dot) and month/day/year date options and a 12-/24-hour clock option, and base the default format on your browser/system locale rather than the selected display language. When logged in, both settings sync across devices via your account ([#1647](https://github.com/binwiederhier/ntfy/issues/1647), thanks to [@wsw70](https://github.com/wsw70) for reporting)
|
||||
|
||||
**Bug fixes + maintenance:**
|
||||
|
||||
* Web app: Smooth transitions and loading animation, remove flickering
|
||||
* Web app: `GET /account` now reads from the primary database instead of a read replica, so the account view no longer shows stale data right after a change when replicas lag behind
|
||||
* Docs: Document the third-party HelmForge Helm chart as a Kubernetes installation option ([#1727](https://github.com/binwiederhier/ntfy/issues/1727), thanks to [@mberlofa](https://github.com/mberlofa))
|
||||
* Web app: Strip unsafe URL protocols (`javascript:`, `data:`, ...) from links and images in Markdown-rendered messages, so they no longer trigger an uncaught "React has blocked a javascript: URL" error (thanks to [@jvoisin](https://github.com/jvoisin) for reporting)
|
||||
|
||||
## ntfy server v2.25.0
|
||||
Released June 24, 2026
|
||||
|
||||
This release adds **password reset** via email, and reworks email verification to use durable,
|
||||
link-based magic links (replacing the old in-memory 6-digit codes). Email stays optional at
|
||||
signup; a user can reset their password only once they have a verified "primary" (recovery)
|
||||
email.
|
||||
|
||||
All of this work is probably not useful for self-hosters, but it hopefully will be useful for me,
|
||||
since I do have to reset accounts on a regular basis.
|
||||
|
||||
**Security issues:**
|
||||
|
||||
* Generate access tokens, IDs, and magic-link tokens with a cryptographically secure RNG (`crypto/rand`) instead of a clock-seeded PRNG
|
||||
|
||||
**Features:**
|
||||
|
||||
* Add password reset via emailed magic link, with a "Forgot password" link on the login page and a `ntfy user reset-pass` CLI command for admins
|
||||
* Rework email verification to use durable, single-use, expiring magic links instead of in-memory 6-digit codes, and add a "primary" email (used for account recovery and as the `X-Email: yes` target) with verified/unverified state in the account UI
|
||||
* You can now clear/read messages and delete messages with a GET request ([#1771](https://github.com/binwiederhier/ntfy/issues/1771), thanks to [@lemmi](https://github.com/lemmi) for reporting and to [@wunter8](https://github.com/wunter8) for implementing)
|
||||
* Add a reload button to the web app's action bar when running as an installed PWA, which clears the service worker caches and hard-refreshes the app ([#1281](https://github.com/binwiederhier/ntfy/issues/1281), thanks to [@leanza](https://github.com/leanza) for reporting)
|
||||
* Add a "Back to app" link to the web app's login, signup, and password-reset pages (alongside the existing links), which previously had no way back to the app
|
||||
|
||||
**Bug fixes + maintenance:**
|
||||
|
||||
* `X-Email: yes` (also `true`/`1`) now sends to your primary verified email regardless of the `smtp-sender-verify` setting (previously it was rejected unless verification was enabled); it requires being logged in with a verified address
|
||||
* Grant users full access to their own sync topic (`st_...`) so cross-device subscription sync works under `auth-default-access: deny-all` ([#733](https://github.com/binwiederhier/ntfy/issues/733), [#1795](https://github.com/binwiederhier/ntfy/pull/1795), thanks to [@lmorchard](https://github.com/lmorchard) for the contribution)
|
||||
* Support HTTP (non-TLS) S3-compatible endpoints by preserving the endpoint scheme, e.g. for a local MinIO instance ([#1794](https://github.com/binwiederhier/ntfy/pull/1794), [#1734](https://github.com/binwiederhier/ntfy/issues/1734), thanks to [@sskender](https://github.com/sskender) for the contribution, and [@Kernald](https://github.com/Kernald) for reporting)
|
||||
* Stop silently stripping spaces from passwords while typing in the web app's login, signup, and password-reset forms ([#1246](https://github.com/binwiederhier/ntfy/issues/1246), thanks to [@aldem](https://github.com/aldem) for reporting)
|
||||
* Update web app dependencies, including major-version upgrades to Vite (6 -> 8, now Rolldown-based), Material UI (5 -> 9), and Dexie (3 -> 4) ([#1800](https://github.com/binwiederhier/ntfy/pull/1800), [#1764](https://github.com/binwiederhier/ntfy/pull/1764), [#1767](https://github.com/binwiederhier/ntfy/pull/1767), [#1762](https://github.com/binwiederhier/ntfy/pull/1762), [#1766](https://github.com/binwiederhier/ntfy/pull/1766), [#1765](https://github.com/binwiederhier/ntfy/pull/1765), thanks Dependabot)
|
||||
* Play notification sounds in the web app even when the Notification API is unavailable, e.g. over plain HTTP or in browsers without notification support ([#1772](https://github.com/binwiederhier/ntfy/pull/1772), thanks to [@mitya12342](https://github.com/mitya12342) for the contribution)
|
||||
* Stop escaping `<`, `>`, and `&` as `\u003c`/`\u003e`/`\u0026` in JSON responses ([#1511](https://github.com/binwiederhier/ntfy/issues/1511), [#1512](https://github.com/binwiederhier/ntfy/pull/1512), thanks to [@wunter8](https://github.com/wunter8) for the contribution)
|
||||
* Fix the web app navbar not reflecting a topic reservation (lock icon, and "Reserve topic" -> "Change reservation"/"Remove reservation" menu) until a page reload, by persisting reservation and display-name changes onto already-subscribed topics during account sync
|
||||
* Reduce the web app's initial bundle size by ~300 KB (~50 KB gzipped) by lazy-loading the emoji picker dataset and the Markdown renderer, and by importing Material UI icons individually
|
||||
|
||||
## ntfy server v2.24.0
|
||||
Released June 4, 2026
|
||||
|
||||
The main feature for this release is an in-memory ACL cache (`auth-access-cache`) that can help bring down the read load
|
||||
@@ -197,7 +342,7 @@ to the primary until the replica recovers.
|
||||
|
||||
**Features:**
|
||||
|
||||
* Support [PostgreSQL read replicas](config.md#postgresql-experimental) for offloading non-critical read queries via `database-replica-urls` config option ([#1648](https://github.com/binwiederhier/ntfy/pull/1648))
|
||||
* Support [PostgreSQL read replicas](config.md#postgresql) for offloading non-critical read queries via `database-replica-urls` config option ([#1648](https://github.com/binwiederhier/ntfy/pull/1648))
|
||||
* Add interactive [config generator](config.md#config-generator) to the documentation to help create server configuration files ([#1654](https://github.com/binwiederhier/ntfy/pull/1654))
|
||||
|
||||
**Bug fixes + maintenance:**
|
||||
@@ -209,7 +354,7 @@ to the primary until the replica recovers.
|
||||
Released March 7, 2026
|
||||
|
||||
This is the biggest release I've ever done on the server. It's 14,997 added lines of code, and 10,202 lines removed, all from
|
||||
one [pull request](https://github.com/binwiederhier/ntfy/pull/1619) that adds [PostgreSQL support](config.md#postgresql-experimental).
|
||||
one [pull request](https://github.com/binwiederhier/ntfy/pull/1619) that adds [PostgreSQL support](config.md#postgresql).
|
||||
|
||||
The code was written by Cursor and Claude, but reviewed and heavily tested over 2-3 weeks by me. I created comparison documents,
|
||||
went through all queries multiple times and reviewed the logic over and over again. I also did load tests and manual regression tests,
|
||||
@@ -220,7 +365,7 @@ if things are working (or not working). There is a [one-off migration tool](http
|
||||
|
||||
**Features:**
|
||||
|
||||
* Add experimental [PostgreSQL support](config.md#postgresql-experimental) as an alternative database backend (message cache, user manager, web push subscriptions) via `database-url` config option ([#1114](https://github.com/binwiederhier/ntfy/issues/1114)/[#1619](https://github.com/binwiederhier/ntfy/pull/1619), thanks to [@brettinternet](https://github.com/brettinternet) for reporting)
|
||||
* Add experimental [PostgreSQL support](config.md#postgresql) as an alternative database backend (message cache, user manager, web push subscriptions) via `database-url` config option ([#1114](https://github.com/binwiederhier/ntfy/issues/1114)/[#1619](https://github.com/binwiederhier/ntfy/pull/1619), thanks to [@brettinternet](https://github.com/brettinternet) for reporting)
|
||||
|
||||
**Bug fixes + maintenance:**
|
||||
|
||||
@@ -1948,33 +2093,6 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release
|
||||
|
||||
## Not released yet
|
||||
|
||||
### ntfy Android v1.25.x (UNRELEASED)
|
||||
|
||||
This release makes the "connection lost" alert configurable and turns it off by default. Folks did not like it and many reached out
|
||||
or even gave ntfy bad reviews. I heard you! You can re-enable the alert in the advanced settings.
|
||||
|
||||
The release also tries to be smarter about not retrying the connection at all if the app is in flight mode, or has no network. If there
|
||||
is no network, ntfy will now stop the foreground service entirely.
|
||||
|
||||
Another change related to the networking is that we now force-reconnect when the connection is changed, e.g. during transitions
|
||||
from Wi-Fi to cellular network, or vice versa. That should allow for faster transitions during hand-overs.
|
||||
|
||||
We also increase the client-side WebSocket ping interval from 1 minute to 3 minutes, which should slightly improve battery life,
|
||||
especially when paired with increaseing the server-side `keepalive-interval` in your self-hosted server.
|
||||
|
||||
**Features:**
|
||||
|
||||
* Add configurable "Alert when connection is lost" setting ([#1665](https://github.com/binwiederhier/ntfy/issues/1665), [#1662](https://github.com/binwiederhier/ntfy/issues/1662), [#1652](https://github.com/binwiederhier/ntfy/issues/1652), [#1655](https://github.com/binwiederhier/ntfy/issues/1655), thanks to [@tintamarre](https://github.com/tintamarre), [@sjozs](https://github.com/sjozs), [@TheRealOne78](https://github.com/TheRealOne78), and [@DAE51D](https://github.com/DAE51D) for reporting)
|
||||
* Suppress connection alerts and stop foreground service when there is no network ([ntfy-android#165](https://github.com/binwiederhier/ntfy-android/pull/165), thanks to [@tintamarre](https://github.com/tintamarre) for the contribution)
|
||||
* Restart the foreground service immediately when network returns, even if the app process was killed while offline
|
||||
* Improve battery life by increasing WebSocket client ping interval from 1 min to 3 min, and reconnect instantly on Wi-Fi/cellular/VPN transitions ([ntfy-android#113](https://github.com/binwiederhier/ntfy-android/pull/113), thanks to [@ftilde](https://github.com/ftilde) for the investigation)
|
||||
* Disable UnifiedPush components when UnifiedPush is disabled in settings ([ntfy-android#168](https://github.com/binwiederhier/ntfy-android/pull/168), thanks to [@p1gp1g](https://github.com/p1gp1g) for the contribution)
|
||||
|
||||
**Bug fixes + maintenance:**
|
||||
|
||||
* Undo automatic phone number linking for numbers in message body ([ntfy-android#170](https://github.com/binwiederhier/ntfy-android/pull/170), thanks to [@acortelyou](https://github.com/acortelyou) for the contribution)
|
||||
* Fix subscription icons disappearing after a few days due to Android clearing cache ([#1322](https://github.com/binwiederhier/ntfy/issues/1322), thanks to [@mcanning](https://github.com/mcanning) for reporting)
|
||||
|
||||
### ntfy iOS app v1.8.0 (UNRELEASED)
|
||||
|
||||
**Features:**
|
||||
|
||||
Vendored
+235
@@ -0,0 +1,235 @@
|
||||
/* Topic name generator (Publishing page) */
|
||||
/* Styled to mirror the config generator (header + left form / right output panels). */
|
||||
|
||||
.tg-generator {
|
||||
margin: 16px 0 24px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
overflow: hidden;
|
||||
font-size: 0.78rem;
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
/* Header (matches .cg-modal-header) */
|
||||
.tg-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 16px;
|
||||
border-bottom: 1px solid #ddd;
|
||||
}
|
||||
|
||||
.tg-title {
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.tg-reset {
|
||||
background: none;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
font-size: 0.72rem;
|
||||
color: #777;
|
||||
cursor: pointer;
|
||||
padding: 4px 12px;
|
||||
font-family: inherit;
|
||||
transition: color 0.15s, border-color 0.15s;
|
||||
}
|
||||
|
||||
.tg-reset:hover {
|
||||
color: #333;
|
||||
border-color: #999;
|
||||
}
|
||||
|
||||
/* Body: left (form) + right (output), matches .cg-modal-body */
|
||||
.tg-body {
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.tg-left {
|
||||
flex: 1;
|
||||
border-right: 1px solid #ddd;
|
||||
padding: 16px 18px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.tg-right {
|
||||
flex: 1;
|
||||
padding: 16px 18px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* One output per block: label on its own line, then value field + copy button */
|
||||
.tg-output-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.tg-output-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Form field (matches .cg-field) */
|
||||
.tg-field > label {
|
||||
display: block;
|
||||
font-weight: 500;
|
||||
margin-bottom: 4px;
|
||||
font-size: 0.78rem;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.tg-field input[type="text"] {
|
||||
width: 100%;
|
||||
padding: 6px 8px;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
font-size: 0.78rem;
|
||||
font-family: inherit;
|
||||
box-sizing: border-box;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.tg-field input[type="text"]:focus {
|
||||
border-color: var(--md-primary-fg-color);
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px rgba(51, 133, 116, 0.15);
|
||||
}
|
||||
|
||||
.tg-note {
|
||||
margin-top: 10px;
|
||||
font-size: 0.72rem;
|
||||
color: #999;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.tg-note code {
|
||||
font-size: 0.72rem;
|
||||
padding: 1px 4px;
|
||||
}
|
||||
|
||||
.tg-output-label {
|
||||
margin-bottom: 4px;
|
||||
white-space: nowrap;
|
||||
font-weight: 500;
|
||||
font-size: 0.78rem;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
/* Copy button (matches .cg-btn-copy) */
|
||||
.tg-btn-copy {
|
||||
background: none;
|
||||
color: #777;
|
||||
border: none;
|
||||
padding: 2px 4px;
|
||||
cursor: pointer;
|
||||
line-height: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
|
||||
.tg-btn-copy:hover {
|
||||
color: #333;
|
||||
}
|
||||
|
||||
/* Output block (matches .cg-output-wrap pre). Scoped under .tg-generator so the margin
|
||||
reset beats the theme's .md-typeset pre rule, which otherwise adds a stray top margin. */
|
||||
.tg-generator .tg-output {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
padding: 6px 9px;
|
||||
background: #f5f5f5;
|
||||
color: var(--md-default-fg-color);
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 6px;
|
||||
overflow-x: auto;
|
||||
font-family: var(--md-code-font-family, monospace);
|
||||
font-size: 0.72rem;
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
/* Dark mode */
|
||||
body[data-md-color-scheme="slate"] .tg-generator {
|
||||
background: #1e1e2e;
|
||||
border-color: #444;
|
||||
}
|
||||
|
||||
body[data-md-color-scheme="slate"] .tg-header {
|
||||
border-bottom-color: #444;
|
||||
}
|
||||
|
||||
body[data-md-color-scheme="slate"] .tg-title {
|
||||
color: #ddd;
|
||||
}
|
||||
|
||||
body[data-md-color-scheme="slate"] .tg-reset {
|
||||
border-color: #555;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
body[data-md-color-scheme="slate"] .tg-reset:hover {
|
||||
border-color: #888;
|
||||
color: #ddd;
|
||||
}
|
||||
|
||||
body[data-md-color-scheme="slate"] .tg-left {
|
||||
border-right-color: #444;
|
||||
}
|
||||
|
||||
body[data-md-color-scheme="slate"] .tg-field > label,
|
||||
body[data-md-color-scheme="slate"] .tg-output-label {
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
body[data-md-color-scheme="slate"] .tg-btn-copy {
|
||||
color: #888;
|
||||
}
|
||||
|
||||
body[data-md-color-scheme="slate"] .tg-btn-copy:hover {
|
||||
color: #bbb;
|
||||
}
|
||||
|
||||
body[data-md-color-scheme="slate"] .tg-field input[type="text"] {
|
||||
background: #2a2a3a;
|
||||
border-color: #555;
|
||||
color: #ddd;
|
||||
}
|
||||
|
||||
body[data-md-color-scheme="slate"] .tg-note {
|
||||
color: #777;
|
||||
}
|
||||
|
||||
body[data-md-color-scheme="slate"] .tg-output {
|
||||
background: #161620;
|
||||
border-color: #444;
|
||||
}
|
||||
|
||||
/* Responsive: stack panels like the config generator does on mobile */
|
||||
@media (max-width: 700px) {
|
||||
.tg-body {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.tg-left {
|
||||
border-right: none;
|
||||
border-bottom: 1px solid #ddd;
|
||||
}
|
||||
|
||||
body[data-md-color-scheme="slate"] .tg-left {
|
||||
border-bottom-color: #444;
|
||||
}
|
||||
}
|
||||
Vendored
+121
@@ -0,0 +1,121 @@
|
||||
// Topic name generator for the ntfy docs
|
||||
//
|
||||
// A tiny helper that lives on the "Publishing" page. The user types a memorable
|
||||
// prefix (e.g. "backups"), and the widget appends a random, hard-to-guess suffix
|
||||
// (e.g. "backups-x7Kp2mQ9"). The result is a valid, unguessable topic name.
|
||||
//
|
||||
// Topic names on the server must match ^[-_A-Za-z0-9]{1,64}$ (see server.go), so as
|
||||
// the user types we strip anything that isn't allowed (spaces, slashes, punctuation,
|
||||
// emoji, ...) live and cap the whole thing at 64 characters. The random suffix is
|
||||
// generated once on load and can be re-rolled with the "Regenerate suffix" button.
|
||||
(function () {
|
||||
// Allowed topic characters per the server regex ^[-_A-Za-z0-9]{1,64}$
|
||||
const ALLOWED = /[^-_A-Za-z0-9]/g;
|
||||
const MAX_LEN = 64;
|
||||
|
||||
// Suffix alphabet: full base62 (letters + digits). We deliberately keep look-alikes
|
||||
// (0/O, l/1) for maximum entropy -- this is a generated suffix, not something typed by
|
||||
// hand. Hyphen/underscore are excluded so the "-" separator stays visually clear.
|
||||
const SUFFIX_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
||||
const SUFFIX_LEN = 10;
|
||||
|
||||
// randomSuffix returns a cryptographically random string from SUFFIX_ALPHABET.
|
||||
// It uses rejection sampling to avoid the modulo bias that a plain `byte % 62` would
|
||||
// introduce (256 is not a multiple of 62), keeping every character equally likely.
|
||||
function randomSuffix() {
|
||||
const n = SUFFIX_ALPHABET.length;
|
||||
const limit = Math.floor(256 / n) * n; // largest multiple of n that fits in a byte
|
||||
const buf = new Uint8Array(1);
|
||||
let out = "";
|
||||
while (out.length < SUFFIX_LEN) {
|
||||
crypto.getRandomValues(buf);
|
||||
if (buf[0] < limit) {
|
||||
out += SUFFIX_ALPHABET[buf[0] % n];
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// sanitize strips everything that isn't a valid topic character.
|
||||
function sanitize(value) {
|
||||
return value.replace(ALLOWED, "");
|
||||
}
|
||||
|
||||
function initTopicGenerator() {
|
||||
const root = document.getElementById("tg-widget");
|
||||
if (!root) return;
|
||||
|
||||
const input = root.querySelector("#tg-input");
|
||||
const outputName = root.querySelector("#tg-output-name");
|
||||
const outputUrl = root.querySelector("#tg-output-url");
|
||||
const reroll = root.querySelector("#tg-reroll");
|
||||
|
||||
let suffix = randomSuffix();
|
||||
|
||||
// update recomputes the live preview from the (sanitized) input + current suffix.
|
||||
function update() {
|
||||
// Sanitize in place so the user sees disallowed characters disappear as they type.
|
||||
const cleaned = sanitize(input.value);
|
||||
if (cleaned !== input.value) {
|
||||
const pos = input.selectionStart - (input.value.length - cleaned.length);
|
||||
// Reassigning .value and setSelectionRange make the browser scroll the field into
|
||||
// view (there is no preventScroll option for setSelectionRange), which jumps the
|
||||
// whole page. Capture the scroll position and restore it afterwards.
|
||||
const scrollX = window.scrollX;
|
||||
const scrollY = window.scrollY;
|
||||
input.value = cleaned;
|
||||
// Best-effort caret restore so removing a bad char doesn't jump the cursor to the end.
|
||||
try { input.setSelectionRange(pos, pos); } catch { /* ignore */ }
|
||||
window.scrollTo(scrollX, scrollY);
|
||||
}
|
||||
|
||||
// Compose "<prefix>-<suffix>", capped at the 64-char topic limit. With no prefix,
|
||||
// fall back to just the random suffix so the output is always a valid topic.
|
||||
let topic;
|
||||
if (cleaned === "") {
|
||||
topic = suffix;
|
||||
} else {
|
||||
const maxPrefix = MAX_LEN - suffix.length - 1; // room for "-" + suffix
|
||||
const prefix = cleaned.slice(0, Math.max(0, maxPrefix));
|
||||
topic = prefix === "" ? suffix : prefix + "-" + suffix;
|
||||
}
|
||||
|
||||
outputName.textContent = topic;
|
||||
outputUrl.textContent = "https://ntfy.sh/" + topic;
|
||||
}
|
||||
|
||||
input.addEventListener("input", update);
|
||||
reroll.addEventListener("click", function () {
|
||||
suffix = randomSuffix();
|
||||
update();
|
||||
input.focus();
|
||||
});
|
||||
|
||||
// Copy buttons: copy the target output and briefly swap the clipboard icon for a checkmark,
|
||||
// mirroring the config generator's copy button behavior.
|
||||
const copyIcon = "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"14\" height=\"14\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><rect x=\"9\" y=\"9\" width=\"13\" height=\"13\" rx=\"2\" ry=\"2\"></rect><path d=\"M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1\"></path></svg>";
|
||||
const checkIcon = "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"14\" height=\"14\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><polyline points=\"20 6 9 17 4 12\"></polyline></svg>";
|
||||
root.querySelectorAll(".tg-btn-copy").forEach(function (btn) {
|
||||
btn.addEventListener("click", function () {
|
||||
const target = root.querySelector("#" + btn.dataset.copy);
|
||||
if (!target || !target.textContent) return;
|
||||
navigator.clipboard.writeText(target.textContent).then(function () {
|
||||
btn.innerHTML = checkIcon;
|
||||
btn.style.color = "var(--md-primary-fg-color)";
|
||||
setTimeout(function () {
|
||||
btn.innerHTML = copyIcon;
|
||||
btn.style.color = "";
|
||||
}, 2000);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
update();
|
||||
}
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", initTopicGenerator);
|
||||
} else {
|
||||
initTopicGenerator();
|
||||
}
|
||||
})();
|
||||
+31
-1
@@ -245,6 +245,9 @@ combined with `since=` (defaults to `since=all`).
|
||||
curl -s "ntfy.sh/mytopic/json?poll=1"
|
||||
```
|
||||
|
||||
Note that a poll without `since=` returns a topic's **entire cache**, which on a busy topic can be
|
||||
large. See [replay limits](#replay-limits) below.
|
||||
|
||||
### Fetch cached messages
|
||||
Messages may be cached for a couple of hours (see [message caching](../config.md#message-cache)) to account for network
|
||||
interruptions of subscribers. If the server has configured message caching, you can read back what you missed by using
|
||||
@@ -275,6 +278,28 @@ parameter (makes most sense with the `poll=1` parameter):
|
||||
curl -s "ntfy.sh/mytopic/json?poll=1&sched=1"
|
||||
```
|
||||
|
||||
### Replay limits
|
||||
Reading cached messages (a `poll=1` request, or any request with `since=`) replays messages the server
|
||||
already stored, so unlike a live subscription its cost grows with the size of the topic's cache. Two
|
||||
server-side limits apply, both of which a well-behaved client should handle:
|
||||
|
||||
* **The response is size-capped.** A replay returns only the newest messages that fit in 10 MB
|
||||
**per topic** (counting body, title, tags and every other publisher-set field), and a capped response carries an `X-Messages-Truncated: 1` header. If you see that
|
||||
header, older messages were dropped and you did not receive the full cache. In practice this only
|
||||
affects very large topics; a client polling with `since=` never comes close.
|
||||
* **Replayed bytes count against your daily bandwidth budget**, the same one attachment downloads use
|
||||
(see [limitations](../publish.md#limitations)). Exceeding it returns `HTTP 429` with ntfy error code
|
||||
`42905`, and no messages are written.
|
||||
|
||||
Both limits exist because a poll without `since=` re-reads the whole cache every time. If you are
|
||||
polling repeatedly, **pass `since=<last message ID>`** rather than re-fetching everything. The limits
|
||||
still apply to a `since=` replay, but it returns only what is new, so in practice you will not come
|
||||
near either one:
|
||||
|
||||
```
|
||||
curl -s "ntfy.sh/mytopic/json?poll=1&since=nFS3knfcQ1xe"
|
||||
```
|
||||
|
||||
### Filter messages
|
||||
You can filter which messages are returned based on the well-known message fields `id`, `message`, `title`, `priority` and
|
||||
`tags`. Here's an example that only returns messages of high or urgent priority that contains the both tags
|
||||
@@ -308,6 +333,11 @@ $ curl -s ntfy.sh/mytopic1,mytopic2/json
|
||||
{"id":"Cm02DsxUHb","time":1637182643,"event":"message","topic":"mytopic2","message":"for topic 2"}
|
||||
```
|
||||
|
||||
When replaying cached messages for several topics at once, they are ordered by their `time` field.
|
||||
Because `time` has **second granularity**, messages published within the same second share a sort key:
|
||||
each topic's own messages stay in publish order, but the interleaving *between* topics is not defined.
|
||||
If you need a total order across topics, sort by `time` and fall back to the order received.
|
||||
|
||||
### Authentication
|
||||
Depending on whether the server is configured to support [access control](../config.md#access-control), some topics
|
||||
may be read/write protected so that only users with the correct credentials can subscribe or publish to them.
|
||||
@@ -427,7 +457,7 @@ and can be passed as **HTTP headers** or **query parameters in the URL**. They a
|
||||
|
||||
| Parameter | Aliases (case-insensitive) | Description |
|
||||
|-------------|----------------------------|---------------------------------------------------------------------------------|
|
||||
| `poll` | `X-Poll`, `po` | Return cached messages and close connection |
|
||||
| `poll` | `X-Poll`, `po` | Return cached messages and close connection (see [replay limits](#replay-limits)) |
|
||||
| `since` | `X-Since`, `si` | Return cached messages since timestamp, duration or message ID |
|
||||
| `scheduled` | `X-Scheduled`, `sched` | Include scheduled/delayed messages in message list |
|
||||
| `id` | `X-ID` | Filter: Only return messages that match this exact message ID |
|
||||
|
||||
@@ -3,23 +3,23 @@ module heckel.io/ntfy/v2
|
||||
go 1.25.8
|
||||
|
||||
require (
|
||||
cloud.google.com/go/firestore v1.22.0 // indirect
|
||||
cloud.google.com/go/storage v1.62.2 // indirect
|
||||
cloud.google.com/go/firestore v1.25.0 // indirect
|
||||
cloud.google.com/go/storage v1.65.1 // indirect
|
||||
github.com/BurntSushi/toml v1.6.0 // indirect
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect
|
||||
github.com/emersion/go-smtp v0.24.0
|
||||
github.com/gabriel-vasile/mimetype v1.4.13
|
||||
github.com/emersion/go-smtp v0.25.0
|
||||
github.com/gabriel-vasile/mimetype v1.4.15
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/mattn/go-sqlite3 v1.14.44
|
||||
github.com/mattn/go-sqlite3 v1.14.50
|
||||
github.com/olebedev/when v1.1.0
|
||||
github.com/stretchr/testify v1.11.1
|
||||
github.com/stretchr/testify v1.12.1
|
||||
github.com/urfave/cli/v2 v2.27.7
|
||||
golang.org/x/crypto v0.52.0
|
||||
golang.org/x/crypto v0.55.0
|
||||
golang.org/x/oauth2 v0.36.0 // indirect
|
||||
golang.org/x/sync v0.20.0
|
||||
golang.org/x/term v0.43.0
|
||||
golang.org/x/sync v0.22.0
|
||||
golang.org/x/term v0.45.0
|
||||
golang.org/x/time v0.15.0
|
||||
google.golang.org/api v0.283.0
|
||||
google.golang.org/api v0.294.0
|
||||
gopkg.in/yaml.v2 v2.4.0
|
||||
)
|
||||
|
||||
@@ -28,78 +28,78 @@ replace github.com/emersion/go-smtp => github.com/emersion/go-smtp v0.17.0 // Pi
|
||||
require github.com/pkg/errors v0.9.1 // indirect
|
||||
|
||||
require (
|
||||
firebase.google.com/go/v4 v4.20.0
|
||||
firebase.google.com/go/v4 v4.21.0
|
||||
github.com/SherClockHolmes/webpush-go v1.4.0
|
||||
github.com/jackc/pgx/v5 v5.10.0
|
||||
github.com/microcosm-cc/bluemonday v1.0.27
|
||||
github.com/prometheus/client_golang v1.23.2
|
||||
github.com/prometheus/client_golang v1.24.1
|
||||
github.com/stripe/stripe-go/v74 v74.30.0
|
||||
golang.org/x/sys v0.45.0
|
||||
golang.org/x/text v0.37.0
|
||||
golang.org/x/sys v0.47.0
|
||||
golang.org/x/text v0.41.0
|
||||
)
|
||||
|
||||
require (
|
||||
cel.dev/expr v0.25.2 // indirect
|
||||
cel.dev/expr v0.25.3 // indirect
|
||||
cloud.google.com/go v0.123.0 // indirect
|
||||
cloud.google.com/go/auth v0.20.0 // indirect
|
||||
cloud.google.com/go/auth v0.23.2 // indirect
|
||||
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
|
||||
cloud.google.com/go/compute/metadata v0.9.0 // indirect
|
||||
cloud.google.com/go/iam v1.11.0 // indirect
|
||||
cloud.google.com/go/longrunning v1.0.0 // indirect
|
||||
cloud.google.com/go/monitoring v1.29.0 // indirect
|
||||
cloud.google.com/go/iam v1.13.0 // indirect
|
||||
cloud.google.com/go/longrunning v1.2.0 // indirect
|
||||
cloud.google.com/go/monitoring v1.30.0 // indirect
|
||||
github.com/AlekSi/pointer v1.2.0 // indirect
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.32.0 // indirect
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.56.0 // indirect
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.56.0 // indirect
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.36.0 // indirect
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.60.0 // indirect
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.60.0 // indirect
|
||||
github.com/MicahParks/keyfunc v1.9.0 // indirect
|
||||
github.com/aymerick/douceur v0.2.0 // indirect
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 // indirect
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 // indirect
|
||||
github.com/envoyproxy/go-control-plane/envoy v1.37.0 // indirect
|
||||
github.com/envoyproxy/go-control-plane/envoy v1.39.0 // indirect
|
||||
github.com/envoyproxy/protoc-gen-validate v1.3.3 // indirect
|
||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||
github.com/felixge/httpsnoop v1.1.0 // indirect
|
||||
github.com/go-jose/go-jose/v4 v4.1.4 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-logr/logr v1.4.4 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/golang-jwt/jwt/v4 v4.5.2 // indirect
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
|
||||
github.com/golang/protobuf v1.5.4 // indirect
|
||||
github.com/google/s2a-go v0.1.9 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.16 // indirect
|
||||
github.com/googleapis/gax-go/v2 v2.22.0 // indirect
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.21 // indirect
|
||||
github.com/googleapis/gax-go/v2 v2.24.0 // indirect
|
||||
github.com/gorilla/css v1.0.1 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
github.com/kr/text v0.2.0 // indirect
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||
github.com/prometheus/client_model v0.6.2 // indirect
|
||||
github.com/prometheus/common v0.68.1 // indirect
|
||||
github.com/prometheus/procfs v0.20.1 // indirect
|
||||
github.com/prometheus/common v0.70.1 // indirect
|
||||
github.com/prometheus/procfs v0.21.1 // indirect
|
||||
github.com/russross/blackfriday/v2 v2.1.0 // indirect
|
||||
github.com/spiffe/go-spiffe/v2 v2.7.0 // indirect
|
||||
github.com/stretchr/objx v0.5.2 // indirect
|
||||
github.com/spiffe/go-spiffe/v2 v2.8.1 // indirect
|
||||
github.com/stretchr/objx v0.5.3 // indirect
|
||||
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||
go.opentelemetry.io/contrib/detectors/gcp v1.44.0 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.69.0 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 // indirect
|
||||
go.opentelemetry.io/otel v1.44.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.44.0 // indirect
|
||||
go.opentelemetry.io/otel/sdk v1.44.0 // indirect
|
||||
go.opentelemetry.io/otel/sdk/metric v1.44.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.44.0 // indirect
|
||||
golang.org/x/net v0.55.0 // indirect
|
||||
go.opentelemetry.io/contrib/detectors/gcp v1.46.0 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.71.0 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.71.0 // indirect
|
||||
go.opentelemetry.io/otel v1.46.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.46.0 // indirect
|
||||
go.opentelemetry.io/otel/sdk v1.46.0 // indirect
|
||||
go.opentelemetry.io/otel/sdk/metric v1.46.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.46.0 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.5 // indirect
|
||||
golang.org/x/net v0.58.0 // indirect
|
||||
google.golang.org/appengine/v2 v2.0.6 // indirect
|
||||
google.golang.org/genproto v0.0.0-20260526163538-3dc84a4a5aaa // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect
|
||||
google.golang.org/grpc v1.81.1 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
google.golang.org/genproto v0.0.0-20260825221802-da73d73af1c5 // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260825221802-da73d73af1c5 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260825221802-da73d73af1c5 // indirect
|
||||
google.golang.org/grpc v1.83.2 // indirect
|
||||
google.golang.org/protobuf v1.36.12 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
@@ -1,41 +1,41 @@
|
||||
cel.dev/expr v0.25.2 h1:K6j46C81hXtZQfuX60cVWQFBJahKSE2gfRbNuvr5bFs=
|
||||
cel.dev/expr v0.25.2/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4=
|
||||
cel.dev/expr v0.25.3 h1:A2jO8jwOugrrovveCWfj0KEZOfqiLgAcwjpHPhzIGw0=
|
||||
cel.dev/expr v0.25.3/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4=
|
||||
cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE=
|
||||
cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU=
|
||||
cloud.google.com/go/auth v0.20.0 h1:kXTssoVb4azsVDoUiF8KvxAqrsQcQtB53DcSgta74CA=
|
||||
cloud.google.com/go/auth v0.20.0/go.mod h1:942/yi/itH1SsmpyrbnTMDgGfdy2BUqIKyd0cyYLc5Q=
|
||||
cloud.google.com/go/auth v0.23.2 h1:pxSCpfiji41hpzpPdMCftEUCezpgpqmmDdYiAjCKXxo=
|
||||
cloud.google.com/go/auth v0.23.2/go.mod h1:4DhBRcqvtljQN3dJ57qtqbib5ZGCYE5f2crfiiC2EM0=
|
||||
cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc=
|
||||
cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c=
|
||||
cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs=
|
||||
cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10=
|
||||
cloud.google.com/go/firestore v1.22.0 h1:avooeboIq37vKXobrbPUFhFBxS/c3FqmWoX0xs8dO6E=
|
||||
cloud.google.com/go/firestore v1.22.0/go.mod h1:PaM4i7i7ruALSKmlpHXXZaPObcZw0W7ie5UOPr72iTU=
|
||||
cloud.google.com/go/iam v1.11.0 h1:KieQ9Pb+LLPak1O3Rv3GgCxhnmkYf7Xyh0P5HfF1jFM=
|
||||
cloud.google.com/go/iam v1.11.0/go.mod h1:KP+nKGugNJW4LcLx1uEZcq1ok5sQHFaQehQNl4QDgV4=
|
||||
cloud.google.com/go/logging v1.18.0 h1:KhzZq+1cSkPH9YUaKLLhLtQxIHitVayBmk0sGfoM9+k=
|
||||
cloud.google.com/go/logging v1.18.0/go.mod h1:ZGKnpBaURITh+g/uom2VhbiFoFWvejcrHPDhxFtU/gI=
|
||||
cloud.google.com/go/longrunning v1.0.0 h1:lwzWEYD8+NkYV7dhexOz6kmlvajZA70+bW/xMhRVVdY=
|
||||
cloud.google.com/go/longrunning v1.0.0/go.mod h1:8nqFBPOO1U/XkhWl0I19AMZEphrHi73VNABIpKYaTwM=
|
||||
cloud.google.com/go/monitoring v1.29.0 h1:AHhDsFaSax1/4k+qlIDX/SDGe6hggnfXJ9dkgD9qBPY=
|
||||
cloud.google.com/go/monitoring v1.29.0/go.mod h1:72NOVjJXHY/HBfoLT0+qlCZBT059+9VXLeAnL2PeeVM=
|
||||
cloud.google.com/go/storage v1.62.2 h1:WgR4U9n7bIzXkkVnwPKKE8bkaKUNsHG+0MAAlh9DGU4=
|
||||
cloud.google.com/go/storage v1.62.2/go.mod h1:cpYz/kRVZ+UQAF1uHeea10/9ewcRbxGoGNKsS9daSXA=
|
||||
cloud.google.com/go/firestore v1.25.0 h1:yY3rQKyQXNhnhETdseNayF6W1p4x0bdg9ZYS4hKJfOw=
|
||||
cloud.google.com/go/firestore v1.25.0/go.mod h1:0PU6hj+r/QlhB6BLsRX+Kt/SYefTXrpYrBeHbYaSis8=
|
||||
cloud.google.com/go/iam v1.13.0 h1:ufT3FPT5rFFXu6UtLkNoxaOaV5EuA1dsSkmemCSTo6U=
|
||||
cloud.google.com/go/iam v1.13.0/go.mod h1:gHXdDEiPDvqd1q1KwBDGQlgZY/BwY760zU2LhOZS5w0=
|
||||
cloud.google.com/go/logging v1.19.1 h1:7SsLhyTDBDrJw+Ll6Ns3I2mByqHXvJUc3rGjSlwiWgU=
|
||||
cloud.google.com/go/logging v1.19.1/go.mod h1:2IkQ/d8jVJqV2qW8ZUGUiMjdZG1gkLD2JReGbZ8isqg=
|
||||
cloud.google.com/go/longrunning v1.2.0 h1:WjYH3YHBGCxGJP9M4dWGHBfXr/cFIjMkNgWcJj7/iMM=
|
||||
cloud.google.com/go/longrunning v1.2.0/go.mod h1:5KMQALFGOCtFoi2xSOA1u3H7WKlhmckgiyFw7+LGQp0=
|
||||
cloud.google.com/go/monitoring v1.30.0 h1:r/d+JUbyKmJ8b07iznuKfzVzrIXTWxHQ3lBRm3x2LlY=
|
||||
cloud.google.com/go/monitoring v1.30.0/go.mod h1:htlUR0QWVMrjFzZmN4LGnMAve9xB/eduwjmINxVZ8RM=
|
||||
cloud.google.com/go/storage v1.65.1 h1:LRRpBJUTf+OXDPX9jZUKZ3mSLIsz3htG+qUpeNZovyA=
|
||||
cloud.google.com/go/storage v1.65.1/go.mod h1:UsS9OgFg/XHOSYakQ8ZtLWWeyGkk1WnmD/GsGfN0BHM=
|
||||
cloud.google.com/go/trace v1.16.0 h1:GmQovzFc5F0CNfl0VLgL64aoTtu7xsM0YajW2GlG9+E=
|
||||
cloud.google.com/go/trace v1.16.0/go.mod h1:r+bdAn16dKLSV1G2D5v3e58IlQlizfxWrUfjx7kM7X0=
|
||||
firebase.google.com/go/v4 v4.20.0 h1:ighpjeAC45rY/95cUQ+ojIKlKcTnz2YC0ldam56z2YU=
|
||||
firebase.google.com/go/v4 v4.20.0/go.mod h1:hqhkQtZkThGH42TnaYi7A8EFR1E0FEuB5oHvJ1Q57t8=
|
||||
firebase.google.com/go/v4 v4.21.0 h1:HBZV4jrLtFYj8EwWyqEZOuRLfkfkV2bpnfyyXHOhPxY=
|
||||
firebase.google.com/go/v4 v4.21.0/go.mod h1:CDumIdA5oTiyDpLNVcQoW8ZrB5CTgyE2D45DuENIABg=
|
||||
github.com/AlekSi/pointer v1.2.0 h1:glcy/gc4h8HnG2Z3ZECSzZ1IX1x2JxRVuDzaJwQE0+w=
|
||||
github.com/AlekSi/pointer v1.2.0/go.mod h1:gZGfd3dpW4vEc/UlyfKKi1roIqcCgwOIvb0tSNSBle0=
|
||||
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
|
||||
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.32.0 h1:rIkQfkCOVKc1OiRCNcSDD8ml5RJlZbH/Xsq7lbpynwc=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.32.0/go.mod h1:RD2SsorTmYhF6HkTmDw7KmPYQk8OBYwTkuasChwv7R4=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.56.0 h1:O2sXMyJh8b7devAGdE+163xtRurt0RVpB6DIzX5vGfg=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.56.0/go.mod h1:hEpiGU18xf70qb3jbTcIggWAiEfX/cOIVc2OTe4OegA=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.56.0 h1:ZIT85vKP7LBS84XJ0WdJ3dPOX3iz4j3c0+lpajGQMyo=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.56.0/go.mod h1:rqP9UEhOXv9WhQ7Gjz+G5y/pf8+BJZW5/Ts0AhE0PwE=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.56.0 h1:0YP0+/ixwu+Uqeu/FGiBZNQ19huiUxxiPXIc9WsLKuQ=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.56.0/go.mod h1:6ZZMQhZKDvUvkJw2rc+oDP90tMMzuU/J+5HG1ZmPOmE=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.36.0 h1:3SdxXLkgAfiHRWcGTq6fneq9jgoJzneiY0yPQnjoT2E=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.36.0/go.mod h1:1iIdl0k+ppn9wT0wzR9H7HkSvIui/4qgtnKW10cQtds=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.60.0 h1:HldzheTs05E3ybqSitI/wHaof6+XERRudgZLjYbs3eE=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.60.0/go.mod h1:evkqaSczW9g2BQm1veCtgNhJ4wCCsRrOsSgNIn9LHQk=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.60.0 h1:Fx8NtDCmKH4ML2hUkPz4Dq250903vRDojMjVCDKwQuc=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.60.0/go.mod h1:V9g30lTKzfUsEW+gpWssck6u9IhARajmipodImLLcwI=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.60.0 h1:Oblia1QXBJlM/wOY9ARRUtsXdDYiMCzk3eCMikqoLbI=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.60.0/go.mod h1:SRAbhyZ4R4FagHMM9VtRgSY/lheRoht2fKelZXQUenk=
|
||||
github.com/MicahParks/keyfunc v1.9.0 h1:lhKd5xrFHLNOWrDc4Tyb/Q1AJ4LCzQ48GVJyVIID3+o=
|
||||
github.com/MicahParks/keyfunc v1.9.0/go.mod h1:IdnCilugA0O/99dW+/MkvlyrsX8+L8+x95xuVNtM5jw=
|
||||
github.com/SherClockHolmes/webpush-go v1.4.0 h1:ocnzNKWN23T9nvHi6IfyrQjkIc0oJWv1B1pULsf9i3s=
|
||||
@@ -50,9 +50,8 @@ github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 h1:aBangftG7EVZoUb69Os
|
||||
github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2/go.mod h1:qwXFYgsP6T7XnJtbKlf1HP8AjxZZyzxMmc+Lq5GjlU4=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3sHPnBo=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
|
||||
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 h1:oP4q0fw+fOSWn3DfFi4EXdT+B+gTtzx8GC9xsc26Znk=
|
||||
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
|
||||
@@ -60,21 +59,21 @@ github.com/emersion/go-smtp v0.17.0 h1:tq90evlrcyqRfE6DSXaWVH54oX6OuZOQECEmhWBME
|
||||
github.com/emersion/go-smtp v0.17.0/go.mod h1:qm27SGYgoIPRot6ubfQ/GpiPy/g3PaZAVRxiO/sDUgQ=
|
||||
github.com/envoyproxy/go-control-plane v0.14.0 h1:hbG2kr4RuFj222B6+7T83thSPqLjwBIfQawTkC++2HA=
|
||||
github.com/envoyproxy/go-control-plane v0.14.0/go.mod h1:NcS5X47pLl/hfqxU70yPwL9ZMkUlwlKxtAohpi2wBEU=
|
||||
github.com/envoyproxy/go-control-plane/envoy v1.37.0 h1:u3riX6BoYRfF4Dr7dwSOroNfdSbEPe9Yyl09/B6wBrQ=
|
||||
github.com/envoyproxy/go-control-plane/envoy v1.37.0/go.mod h1:DReE9MMrmecPy+YvQOAOHNYMALuowAnbjjEMkkWOi6A=
|
||||
github.com/envoyproxy/go-control-plane/envoy v1.39.0 h1:1uwRDYPYG8BIBU9Mj1sUAebNmlM6beu/ZKKweSLDxk8=
|
||||
github.com/envoyproxy/go-control-plane/envoy v1.39.0/go.mod h1:5e4ylfTZO723MEEFsCpSW4ZEBWR8mwkEyXfwJBTCZ9c=
|
||||
github.com/envoyproxy/go-control-plane/ratelimit v0.1.0 h1:/G9QYbddjL25KvtKTv3an9lx6VBE2cnb8wp1vEGNYGI=
|
||||
github.com/envoyproxy/go-control-plane/ratelimit v0.1.0/go.mod h1:Wk+tMFAFbCXaJPzVVHnPgRKdUdwW/KdbRt94AzgRee4=
|
||||
github.com/envoyproxy/protoc-gen-validate v1.3.3 h1:MVQghNeW+LZcmXe7SY1V36Z+WFMDjpqGAGacLe2T0ds=
|
||||
github.com/envoyproxy/protoc-gen-validate v1.3.3/go.mod h1:TsndJ/ngyIdQRhMcVVGDDHINPLWB7C82oDArY51KfB0=
|
||||
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||
github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM=
|
||||
github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||
github.com/felixge/httpsnoop v1.1.0 h1:3YtUj32ZZkqZtt3sZZsClsymw/QDuVfpNhoA31zeORc=
|
||||
github.com/felixge/httpsnoop v1.1.0/go.mod h1:Zqxgdd+1Rkcz8euOqdr7lqgCRJztwr5hp9vDSi5UZCE=
|
||||
github.com/gabriel-vasile/mimetype v1.4.15 h1:05iP/CYtZ/w455R/KZM6rZ5ieAdh99UPtd+d3YzLmaI=
|
||||
github.com/gabriel-vasile/mimetype v1.4.15/go.mod h1:azpTcoLcDZRNgFou5j+APrqQx9HqVPWa6ijYQIIVswQ=
|
||||
github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
|
||||
github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
|
||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8=
|
||||
github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/golang-jwt/jwt/v4 v4.4.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
|
||||
@@ -96,10 +95,10 @@ github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0=
|
||||
github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.16 h1:F/VPrx0YPBdksZJQdCAp0WUsqnNmZpUZszzfYt0M5Dw=
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.16/go.mod h1:9Yb0eAkH/Xqhvv3zbeKf/+wMJqCeocWc6KIhDvEAuYE=
|
||||
github.com/googleapis/gax-go/v2 v2.22.0 h1:PjIWBpgGIVKGoCXuiCoP64altEJCj3/Ei+kSU5vlZD4=
|
||||
github.com/googleapis/gax-go/v2 v2.22.0/go.mod h1:irWBbALSr0Sk3qlqb9SyJ1h68WjgeFuiOzI4Rqw5+aY=
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.21 h1:OFdQ3tnCX/zaQ0Cedur3D3z7kI6HiLX9g3TiAN4/DFU=
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.21/go.mod h1:L3D/IQExI6LqEjBdXcZQ1WluSgigQmSwBboFstVPM4w=
|
||||
github.com/googleapis/gax-go/v2 v2.24.0 h1:myMaPYyF9MecEmvQqMqomIwn9t/4KCZN9qnwsS76wlg=
|
||||
github.com/googleapis/gax-go/v2 v2.24.0/go.mod h1:IaTHBDd7NHxSCiu0vEs8pQZu4dGZrWwuSoxCnk16OFM=
|
||||
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
|
||||
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
@@ -112,16 +111,16 @@ github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0=
|
||||
github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
|
||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
|
||||
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
|
||||
github.com/klauspost/compress v1.19.1 h1:VsB4HPswih7mmZ8WleSFQ75c/Ui1M4trX5oAsJnhSlk=
|
||||
github.com/klauspost/compress v1.19.1/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||
github.com/mattn/go-sqlite3 v1.14.44 h1:3VSe+xafpbzsLbdr2AWlAZk9yRHiBhTBakioXaCKTF8=
|
||||
github.com/mattn/go-sqlite3 v1.14.44/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ=
|
||||
github.com/mattn/go-sqlite3 v1.14.50 h1:dmdFvo1XG4MPzA4IkAmE9upVz/Nj31uRoM5+jC8hYbY=
|
||||
github.com/mattn/go-sqlite3 v1.14.50/go.mod h1:6JTjA44L93a0QCyJef5YvlPoKXntQPjzWv5gtm9sB6w=
|
||||
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
|
||||
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||
@@ -133,29 +132,27 @@ github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINE
|
||||
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo=
|
||||
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
|
||||
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
|
||||
github.com/prometheus/client_golang v1.24.1 h1:JnJkREXzWxUdCuPFpIWZiPispT9xVV59uiuyR2bPlnU=
|
||||
github.com/prometheus/client_golang v1.24.1/go.mod h1:F+oSRECHg4sse5ucfYpYDeIv/hu68Zo0uoHKetWnzcE=
|
||||
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
|
||||
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
|
||||
github.com/prometheus/common v0.68.1 h1:omjRRl4QP4komogpXuhfeOiisQg7xdy8VM1UY+pStaY=
|
||||
github.com/prometheus/common v0.68.1/go.mod h1:ZzL3f6u94qUxh9p+tJTrF+FvBS1XXbbRAZCQkytAL0Y=
|
||||
github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc=
|
||||
github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo=
|
||||
github.com/prometheus/common v0.70.1 h1:1HvjP4D5oL3t8RsPlwxA9onvvStjtIHYE5XuuwOi/PY=
|
||||
github.com/prometheus/common v0.70.1/go.mod h1:VdFUQDMZK3VLkurFUVhia6uys/0suUp86TJz5qbJRhc=
|
||||
github.com/prometheus/procfs v0.21.1 h1:GljZCt+zSTS+NZq88cyQ1LjZ+RCHp3uVuabBWA5+OJI=
|
||||
github.com/prometheus/procfs v0.21.1/go.mod h1:aB55Cww9pdSJVHk0hUf0inxWyyjPogFIjmHKYgMKmtY=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/spiffe/go-spiffe/v2 v2.7.0 h1:uXe1MflJoHw58wAUvxVlcM7WpKtijWG7I1UidcGh6g4=
|
||||
github.com/spiffe/go-spiffe/v2 v2.7.0/go.mod h1:47Q0Q9/AqGha8QLHp+kxpH4Wca7X7EnOtlIJy3mxZ3U=
|
||||
github.com/spiffe/go-spiffe/v2 v2.8.1 h1:eXZMLsu+3MLEPJyGJkolqtVrteZfQdUpOWj6LTiDl/E=
|
||||
github.com/spiffe/go-spiffe/v2 v2.8.1/go.mod h1:47Q0Q9/AqGha8QLHp+kxpH4Wca7X7EnOtlIJy3mxZ3U=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4=
|
||||
github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
|
||||
github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
|
||||
github.com/stripe/stripe-go/v74 v74.30.0 h1:0Kf0KkeFnY7iRhOwvTerX0Ia1BRw+eV1CVJ51mGYAUY=
|
||||
github.com/stripe/stripe-go/v74 v74.30.0/go.mod h1:f9L6LvaXa35ja7eyvP6GQswoaIPaBRvGAimAO+udbBw=
|
||||
github.com/urfave/cli/v2 v2.27.7 h1:bH59vdhbjLv3LAvIu6gd0usJHgoTTPhCFib8qqOwXYU=
|
||||
@@ -165,38 +162,40 @@ github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342/go.mod h1:Ohn+xnUBi
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||
go.opentelemetry.io/contrib/detectors/gcp v1.44.0 h1:NmLfL734pJhM0JKaYd2Y28+nY9dPRWYAAbxhRCrKXPw=
|
||||
go.opentelemetry.io/contrib/detectors/gcp v1.44.0/go.mod h1:tNAsgd8avTGke1+MndXlU5Cru4PQ9Ai/cCNWQv/ZJ/s=
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.69.0 h1:2yEATaop1/a1I4psnSLgWVPLWwCzkqWakgJy7xTDVy0=
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.69.0/go.mod h1:D7J12YRapIekYyPWgGPlA/23pRmpSEZC5xJC/TTLI9U=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 h1:8tvICD4vSTOOsNrsI4Ljf6C+6UKvpTEH5XY3JMoyPoo=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0/go.mod h1:z9+yiacE0IHRqM4qFfkbt/JYlmYXgss8GY/jXoNuPJI=
|
||||
go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU=
|
||||
go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc=
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.43.0 h1:TC+BewnDpeiAmcscXbGMfxkO+mwYUwE/VySwvw88PfA=
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.43.0/go.mod h1:J/ZyF4vfPwsSr9xJSPyQ4LqtcTPULFR64KwTikGLe+A=
|
||||
go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc=
|
||||
go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo=
|
||||
go.opentelemetry.io/otel/metric/x v0.66.0 h1:YkCrx1zLOChi9ZcZ6euupOcsgzbVlec7D/xoEU1+cTA=
|
||||
go.opentelemetry.io/otel/metric/x v0.66.0/go.mod h1:d1+BDj9t96do0/1LoU1ayfCv79ZgNE41qbhBvnMOBZk=
|
||||
go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58=
|
||||
go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA=
|
||||
go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk=
|
||||
go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE=
|
||||
go.opentelemetry.io/contrib/detectors/gcp v1.46.0 h1:PI8dGkqDaQkwJ8kOopqMhDTbrnK3UIeG/RCHH4HErbo=
|
||||
go.opentelemetry.io/contrib/detectors/gcp v1.46.0/go.mod h1:nsrN5c/sOLoY2vsPxN/rQ0V0nvGrWJCqcW4UXLtqNG8=
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.71.0 h1:B2h3uqicet1CT2N5TOFhS+Gq++9i0/CLmaxvhmhtP5s=
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.71.0/go.mod h1:dylvB+ZiiwMvsDij9O84Uy7SijLgHMX4mbkncds+4Sw=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.71.0 h1:3g7B90UzBltIDKq1/5mrTGxTnOFDV0ICOhLoxiZ8jlg=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.71.0/go.mod h1:Ef8SuTh59BT7+ofpDxN9z+yOlc4t2GjLmKDgYNJL/NU=
|
||||
go.opentelemetry.io/otel v1.46.0 h1:FHt5/CDyVxi/8IM1CH7VE/rRgq3kLHa2mSTVMO8AWyc=
|
||||
go.opentelemetry.io/otel v1.46.0/go.mod h1:Gj3SEScelsNC45tp4nSxRYlS+f5iez7W8XPMCt905kE=
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.44.0 h1:hqxVTu/GtBF+vJ8d1fzW7fRxZFvgoDjWcxwwCaFDYpU=
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.44.0/go.mod h1:z5fVEF4X5v0ESvlJqBrrFlBVoj5EQuefZpzsu7R+x5Q=
|
||||
go.opentelemetry.io/otel/metric v1.46.0 h1:yBnkXvgV7AXFILZc5K6IZe/CBFF3OS7BJ8ov6/lj0K8=
|
||||
go.opentelemetry.io/otel/metric v1.46.0/go.mod h1:iPmdWqifKUdzziPkvvzIJXITl56fQx2mGM/DHLB3/2o=
|
||||
go.opentelemetry.io/otel/metric/x v0.68.0 h1:TA/cBT23D3MnxYPwHL7YFOdYGdx0A0v+s7Mzotpd1dU=
|
||||
go.opentelemetry.io/otel/metric/x v0.68.0/go.mod h1:agudOmvWhwUTjgibWDzxD2PoWYnpw5Ht5jISYOD2Hd4=
|
||||
go.opentelemetry.io/otel/sdk v1.46.0 h1:h5CNQQjEbuQXY/JfZtgt3i7HVFV3aHPO2OAwO2eTYPI=
|
||||
go.opentelemetry.io/otel/sdk v1.46.0/go.mod h1:GAERFXFt5SYCEB+YiKUbMBeza6UaDH7GmGOZEfh2gSM=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.46.0 h1:0piZ26EG4RBfebb2jhDH6ERCYHoVWduc3kLgPCwSnSE=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.46.0/go.mod h1:I1PbKrdVc8Qu8HYVDNtqVIwLwjNrhsV/uFuxfwg8mO4=
|
||||
go.opentelemetry.io/otel/trace v1.46.0 h1:OULy7ccdJnZtJ0UDYFOIGaCmiWzJ8Vi2G/Rsu60qs1c=
|
||||
go.opentelemetry.io/otel/trace v1.46.0/go.mod h1:J7GAXweO77XSFkB/rmAqk9D6ihszhFjLU+d9WuUxDLI=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
|
||||
go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
|
||||
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
|
||||
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
|
||||
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||
golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
|
||||
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
|
||||
golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
|
||||
golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
@@ -211,8 +210,8 @@ golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
|
||||
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
|
||||
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
|
||||
golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
|
||||
golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
|
||||
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
||||
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
@@ -222,8 +221,8 @@ golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
|
||||
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
@@ -236,8 +235,8 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
|
||||
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
@@ -247,8 +246,8 @@ golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
|
||||
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
|
||||
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
|
||||
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
|
||||
golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
|
||||
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
|
||||
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
|
||||
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
@@ -260,8 +259,8 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
|
||||
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
|
||||
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
|
||||
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
|
||||
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
||||
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
@@ -274,22 +273,22 @@ golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8T
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
|
||||
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
|
||||
google.golang.org/api v0.283.0 h1:0lkp8u0MPwJVHqRL+nJlMAoZVVzbmiXmFHXMOTmSPik=
|
||||
google.golang.org/api v0.283.0/go.mod h1:6Wssta4c5n9qHq5CBhmlai5h/PUa1djdDAIhYEHyvcM=
|
||||
google.golang.org/api v0.294.0 h1:8gASjJxdtcIieB3OqbkLcF0FfbXVNqKtU5iozD1ssvA=
|
||||
google.golang.org/api v0.294.0/go.mod h1:02qB8+Ox1ZFzcaKFMguy1nQLJmSIyvV6Ff4txJEXtl4=
|
||||
google.golang.org/appengine/v2 v2.0.6 h1:LvPZLGuchSBslPBp+LAhihBeGSiRh1myRoYK4NtuBIw=
|
||||
google.golang.org/appengine/v2 v2.0.6/go.mod h1:WoEXGoXNfa0mLvaH5sV3ZSGXwVmy8yf7Z1JKf3J3wLI=
|
||||
google.golang.org/genproto v0.0.0-20260526163538-3dc84a4a5aaa h1:mfj8IS4EA4VAR9a6QDVxTQkLY64iBybb5QI1B4pXrpE=
|
||||
google.golang.org/genproto v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:fuT7yonGw1Iq2oa+YC0fyqPPQJkgo/54gPNC6VitOkI=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa h1:Kjn0N0tCrDgiAFW+lGO4JZ3ck44CehvJQMAwj9QF0G8=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:q4lMZS6kskjT5HvCPrnnypcDPVJqT/f4nfxmkE7gryY=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||
google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ=
|
||||
google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I=
|
||||
google.golang.org/genproto v0.0.0-20260825221802-da73d73af1c5 h1:jPP56YzdY899KJ5W7efXHt/CkjlVfAaoFOwdi/IEAFA=
|
||||
google.golang.org/genproto v0.0.0-20260825221802-da73d73af1c5/go.mod h1:gutZdP0DwAHp4vu5WaXgEK7tjsJ77ZEqzlOFWGZGziE=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260825221802-da73d73af1c5 h1:izFU9hz7aeLI/Mi1J0991ae+xcwRLr7hTqWnB/9aIIU=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260825221802-da73d73af1c5/go.mod h1:3LhxRw4YYkf+ylAfgaY9JlVLFKhokkCV8duhLLe7+t0=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260825221802-da73d73af1c5 h1:1VUiZAXyC+zmiFYi+WLtBzr68Cj8wOofHjjrA/kkizc=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260825221802-da73d73af1c5/go.mod h1:DjtHYE8FKJLivXcBEjGwndXfIC23G0VpXiXKqG179uA=
|
||||
google.golang.org/grpc v1.83.2 h1:EManeRomTObA0BU7I8vXgg/78uE5MJ9M8B39EX2WscU=
|
||||
google.golang.org/grpc v1.83.2/go.mod h1:YPI1hK3kDked6iHvgX3tR0y+nX/qpMFKhPgFsokw1S8=
|
||||
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
||||
google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc=
|
||||
google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package server
|
||||
package mail
|
||||
|
||||
import (
|
||||
_ "embed" // required by go:embed
|
||||
@@ -6,66 +6,24 @@ import (
|
||||
"fmt"
|
||||
"mime"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"heckel.io/ntfy/v2/log"
|
||||
"heckel.io/ntfy/v2/mail"
|
||||
"heckel.io/ntfy/v2/model"
|
||||
"heckel.io/ntfy/v2/util"
|
||||
)
|
||||
|
||||
type mailer interface {
|
||||
Send(v *visitor, m *model.Message, to string) error
|
||||
Counts() (total int64, success int64, failure int64)
|
||||
}
|
||||
var (
|
||||
//go:embed "mailer_emoji_map.json"
|
||||
emojisJSON string
|
||||
|
||||
type smtpSender struct {
|
||||
config *Config
|
||||
sender *mail.Sender
|
||||
success int64
|
||||
failure int64
|
||||
mu sync.Mutex
|
||||
}
|
||||
// emojiMap maps ntfy tag names to emoji, parsed once from the embedded JSON in init
|
||||
emojiMap map[string]string
|
||||
)
|
||||
|
||||
func (s *smtpSender) Send(v *visitor, m *model.Message, to string) error {
|
||||
return s.withCount(v, m, func() error {
|
||||
message, err := formatMail(s.config.BaseURL, v.ip.String(), s.sender.From(), to, m)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ev := logvm(v, m).
|
||||
Tag(tagEmail).
|
||||
Fields(log.Context{
|
||||
"email_via": s.sender.Addr(),
|
||||
"email_user": s.sender.User(),
|
||||
"email_to": to,
|
||||
})
|
||||
if ev.IsTrace() {
|
||||
ev.Field("email_body", message).Trace("Sending email")
|
||||
}
|
||||
ev.Info("Sending email")
|
||||
return s.sender.SendRaw(to, []byte(message))
|
||||
})
|
||||
}
|
||||
|
||||
func (s *smtpSender) Counts() (total int64, success int64, failure int64) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.success + s.failure, s.success, s.failure
|
||||
}
|
||||
|
||||
func (s *smtpSender) withCount(v *visitor, m *model.Message, fn func() error) error {
|
||||
err := fn()
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if err != nil {
|
||||
logvm(v, m).Err(err).Debug("Sending mail failed")
|
||||
s.failure++
|
||||
} else {
|
||||
s.success++
|
||||
func init() {
|
||||
if err := json.Unmarshal([]byte(emojisJSON), &emojiMap); err != nil {
|
||||
panic("mail: invalid embedded emoji map: " + err.Error())
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func formatMail(baseURL, senderIP, from, to string, m *model.Message) (string, error) {
|
||||
@@ -78,10 +36,7 @@ func formatMail(baseURL, senderIP, from, to string, m *model.Message) (string, e
|
||||
message := m.Message
|
||||
trailer := ""
|
||||
if len(m.Tags) > 0 {
|
||||
emojis, tags, err := toEmojis(m.Tags)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
emojis, tags := toEmojis(m.Tags)
|
||||
if len(emojis) > 0 {
|
||||
subject = strings.Join(emojis, " ") + " " + subject
|
||||
}
|
||||
@@ -126,16 +81,7 @@ This message was sent by {ip} at {time} via {topicURL}`
|
||||
return body, nil
|
||||
}
|
||||
|
||||
var (
|
||||
//go:embed "mailer_emoji_map.json"
|
||||
emojisJSON string
|
||||
)
|
||||
|
||||
func toEmojis(tags []string) (emojisOut []string, tagsOut []string, err error) {
|
||||
var emojiMap map[string]string
|
||||
if err = json.Unmarshal([]byte(emojisJSON), &emojiMap); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
func toEmojis(tags []string) (emojisOut []string, tagsOut []string) {
|
||||
tagsOut = make([]string, 0)
|
||||
emojisOut = make([]string, 0)
|
||||
for _, t := range tags {
|
||||
@@ -1,4 +1,4 @@
|
||||
package server
|
||||
package mail
|
||||
|
||||
import (
|
||||
"testing"
|
||||
+80
-94
@@ -10,82 +10,94 @@ import (
|
||||
"time"
|
||||
|
||||
"heckel.io/ntfy/v2/log"
|
||||
"heckel.io/ntfy/v2/util"
|
||||
"heckel.io/ntfy/v2/model"
|
||||
)
|
||||
|
||||
const (
|
||||
verifyCodeExpiry = 10 * time.Minute
|
||||
verifyCodeLength = 6
|
||||
verifyCodeSubject = "ntfy email verification"
|
||||
tagMail = "mail"
|
||||
|
||||
emailVerificationSubject = "Verify your email for ntfy"
|
||||
passwordResetSubject = "Reset your ntfy password"
|
||||
)
|
||||
|
||||
// Config holds the SMTP configuration for the mail sender
|
||||
type Config struct {
|
||||
BaseURL string // ntfy base URL, used to build topic URLs in notification emails
|
||||
SMTPAddr string // SMTP server address (host:port)
|
||||
SMTPUser string // SMTP auth username
|
||||
SMTPPass string // SMTP auth password
|
||||
From string // Sender email address
|
||||
}
|
||||
|
||||
// Sender sends emails and manages email verification codes
|
||||
type Sender struct {
|
||||
config *Config
|
||||
codes map[string]verifyCode // Verification codes, keyed by email
|
||||
mu sync.Mutex
|
||||
closeChan chan struct{}
|
||||
// Sender sends all of ntfy's outgoing email: notification emails (the email-on-publish feature)
|
||||
// as well as the magic-link emails for email verification and password reset. realSender is the
|
||||
// SMTP-backed implementation; tests inject a fake.
|
||||
type Sender interface {
|
||||
SendNotification(to string, m *model.Message, senderIP string) error
|
||||
NotificationCounts() (total int64, success int64, failure int64)
|
||||
SendEmailVerification(to, link string) error
|
||||
SendPasswordReset(to, link string) error
|
||||
}
|
||||
|
||||
type verifyCode struct {
|
||||
code string
|
||||
expires time.Time
|
||||
// realSender is the SMTP-backed implementation of Sender. Pending verification/reset state lives
|
||||
// in the database (see user.Manager), not in this struct.
|
||||
type realSender struct {
|
||||
config *Config
|
||||
success int64
|
||||
failure int64
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// NewSender creates a new mail Sender with the given SMTP config
|
||||
func NewSender(config *Config) *Sender {
|
||||
s := &Sender{
|
||||
config: config,
|
||||
codes: make(map[string]verifyCode),
|
||||
closeChan: make(chan struct{}),
|
||||
}
|
||||
go s.expireLoop()
|
||||
return s
|
||||
func NewSender(config *Config) Sender {
|
||||
return &realSender{config: config}
|
||||
}
|
||||
|
||||
// Close stops the background expiry loop
|
||||
func (s *Sender) Close() {
|
||||
close(s.closeChan)
|
||||
}
|
||||
|
||||
// Addr returns the SMTP server address
|
||||
func (s *Sender) Addr() string {
|
||||
return s.config.SMTPAddr
|
||||
}
|
||||
|
||||
// User returns the SMTP username
|
||||
func (s *Sender) User() string {
|
||||
return s.config.SMTPUser
|
||||
}
|
||||
|
||||
// From returns the sender email address
|
||||
func (s *Sender) From() string {
|
||||
return s.config.From
|
||||
}
|
||||
|
||||
// SendRaw sends a raw email message via SMTP
|
||||
func (s *Sender) SendRaw(to string, message []byte) error {
|
||||
host, _, err := net.SplitHostPort(s.config.SMTPAddr)
|
||||
// SendNotification formats a ntfy message into a notification email and sends it via SMTP. It
|
||||
// tracks success/failure counts, exposed via Counts (used for the server stats).
|
||||
func (s *realSender) SendNotification(to string, m *model.Message, senderIP string) error {
|
||||
message, err := formatMail(s.config.BaseURL, senderIP, s.config.From, to, m)
|
||||
if err != nil {
|
||||
s.count(false)
|
||||
return err
|
||||
}
|
||||
var auth smtp.Auth
|
||||
if s.config.SMTPUser != "" {
|
||||
auth = smtp.PlainAuth("", s.config.SMTPUser, s.config.SMTPPass, host)
|
||||
}
|
||||
return smtp.SendMail(s.config.SMTPAddr, auth, s.config.From, []string{to}, message)
|
||||
log.Tag(tagMail).Field("email_to", to).Debug("Sending notification email")
|
||||
err = s.sendRaw(to, []byte(message))
|
||||
s.count(err == nil)
|
||||
return err
|
||||
}
|
||||
|
||||
// Send sends a plain text email via SMTP
|
||||
func (s *Sender) Send(to, subject, body string) error {
|
||||
// NotificationCounts returns the number of notification emails sent, broken down into total, success and failure
|
||||
func (s *realSender) NotificationCounts() (total int64, success int64, failure int64) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.success + s.failure, s.success, s.failure
|
||||
}
|
||||
|
||||
// SendEmailVerification sends an email containing a magic link to verify ownership of the
|
||||
// recipient address. The link carries a one-time token validated against the database.
|
||||
func (s *realSender) SendEmailVerification(to, link string) error {
|
||||
body := fmt.Sprintf(`Click the link below to verify this email address for your ntfy account:
|
||||
|
||||
%s
|
||||
|
||||
This link expires in 24 hours. If you did not request this, you can safely ignore this email.`, link)
|
||||
return s.send(to, emailVerificationSubject, body)
|
||||
}
|
||||
|
||||
// SendPasswordReset sends an email containing a magic link to set a new password. The link
|
||||
// carries a one-time token validated against the database.
|
||||
func (s *realSender) SendPasswordReset(to, link string) error {
|
||||
body := fmt.Sprintf(`Click the link below to set a new password for your ntfy account:
|
||||
|
||||
%s
|
||||
|
||||
This link expires in 1 hour. If you did not request this, you can safely ignore this email -- your password will not change.`, link)
|
||||
return s.send(to, passwordResetSubject, body)
|
||||
}
|
||||
|
||||
// send sends a plain text email via SMTP
|
||||
func (s *realSender) send(to, subject, body string) error {
|
||||
date := time.Now().UTC().Format(time.RFC1123Z)
|
||||
encodedSubject := mime.BEncoding.Encode("utf-8", subject)
|
||||
message := `From: ntfy <{from}>
|
||||
@@ -100,55 +112,29 @@ Content-Type: text/plain; charset="utf-8"
|
||||
message = strings.ReplaceAll(message, "{date}", date)
|
||||
message = strings.ReplaceAll(message, "{subject}", encodedSubject)
|
||||
message = strings.ReplaceAll(message, "{body}", body)
|
||||
log.Tag("mail").Field("email_to", to).Debug("Sending email")
|
||||
return s.SendRaw(to, []byte(message))
|
||||
log.Tag(tagMail).Field("email_to", to).Debug("Sending email")
|
||||
return s.sendRaw(to, []byte(message))
|
||||
}
|
||||
|
||||
// SendVerification generates a random code, stores it in-memory, and sends a verification email
|
||||
func (s *Sender) SendVerification(to string) error {
|
||||
code := util.RandomString(verifyCodeLength)
|
||||
s.mu.Lock()
|
||||
s.codes[to] = verifyCode{
|
||||
code: code,
|
||||
expires: time.Now().Add(verifyCodeExpiry),
|
||||
// sendRaw sends a raw email message via SMTP
|
||||
func (s *realSender) sendRaw(to string, message []byte) error {
|
||||
host, _, err := net.SplitHostPort(s.config.SMTPAddr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.mu.Unlock()
|
||||
body := fmt.Sprintf("Your ntfy email verification code is: %s\n\nThis code expires in 10 minutes.", code)
|
||||
return s.Send(to, verifyCodeSubject, body)
|
||||
var auth smtp.Auth
|
||||
if s.config.SMTPUser != "" {
|
||||
auth = smtp.PlainAuth("", s.config.SMTPUser, s.config.SMTPPass, host)
|
||||
}
|
||||
return smtp.SendMail(s.config.SMTPAddr, auth, s.config.From, []string{to}, message)
|
||||
}
|
||||
|
||||
// CheckVerification checks if the code matches and hasn't expired. Removes the entry on success.
|
||||
func (s *Sender) CheckVerification(email, code string) bool {
|
||||
func (s *realSender) count(ok bool) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
vc, ok := s.codes[email]
|
||||
if !ok || time.Now().After(vc.expires) || vc.code != code {
|
||||
return false
|
||||
}
|
||||
delete(s.codes, email)
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *Sender) expireLoop() {
|
||||
ticker := time.NewTicker(time.Minute)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
s.expireVerificationCodes()
|
||||
case <-s.closeChan:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Sender) expireVerificationCodes() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
now := time.Now()
|
||||
for email, vc := range s.codes {
|
||||
if now.After(vc.expires) {
|
||||
delete(s.codes, email)
|
||||
}
|
||||
if ok {
|
||||
s.success++
|
||||
} else {
|
||||
s.failure++
|
||||
}
|
||||
}
|
||||
|
||||
+68
-16
@@ -5,6 +5,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/netip"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -17,6 +18,10 @@ import (
|
||||
|
||||
const (
|
||||
tagMessageCache = "message_cache"
|
||||
schemaStore = "message" // Store name in the schema_version table (see db/schema)
|
||||
|
||||
// NoLimit reads a topic's cached messages without a size budget.
|
||||
NoLimit = 0
|
||||
)
|
||||
|
||||
var errNoRows = errors.New("no rows found")
|
||||
@@ -185,19 +190,29 @@ func (c *Cache) addMessages(ms []*model.Message) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Messages returns messages for a topic since the given marker, optionally including scheduled messages
|
||||
// Messages returns all cached messages for a topic, oldest first. Prefer MessagesCapped on
|
||||
// request paths: an uncapped replay of a busy topic is as large as the topic's entire cache.
|
||||
func (c *Cache) Messages(topic string, since model.SinceMarker, scheduled bool) ([]*model.Message, error) {
|
||||
if since.IsNone() {
|
||||
return make([]*model.Message, 0), nil
|
||||
} else if since.IsLatest() {
|
||||
return c.messagesLatest(topic)
|
||||
} else if since.IsID() {
|
||||
return c.messagesSinceID(topic, since, scheduled)
|
||||
}
|
||||
return c.messagesSinceTime(topic, since, scheduled)
|
||||
messages, _, err := c.MessagesCapped(topic, since, scheduled, NoLimit)
|
||||
return messages, err
|
||||
}
|
||||
|
||||
func (c *Cache) messagesSinceTime(topic string, since model.SinceMarker, scheduled bool) ([]*model.Message, error) {
|
||||
// MessagesCapped returns cached messages for a topic, oldest first, keeping the newest messages
|
||||
// that fit in maxBytes worth of Message.Size (0 = no budget). The bool reports whether older messages
|
||||
// were dropped, so the caller can tell the client that what it got is incomplete.
|
||||
func (c *Cache) MessagesCapped(topic string, since model.SinceMarker, scheduled bool, maxBytes int64) ([]*model.Message, bool, error) {
|
||||
if since.IsNone() {
|
||||
return make([]*model.Message, 0), false, nil
|
||||
} else if since.IsLatest() {
|
||||
messages, err := c.messagesLatest(topic)
|
||||
return messages, false, err
|
||||
} else if since.IsID() {
|
||||
return c.messagesSinceID(topic, since, scheduled, maxBytes)
|
||||
}
|
||||
return c.messagesSinceTime(topic, since, scheduled, maxBytes)
|
||||
}
|
||||
|
||||
func (c *Cache) messagesSinceTime(topic string, since model.SinceMarker, scheduled bool, maxBytes int64) ([]*model.Message, bool, error) {
|
||||
var rows *sql.Rows
|
||||
var err error
|
||||
rdb := c.db.ReadOnly()
|
||||
@@ -207,12 +222,12 @@ func (c *Cache) messagesSinceTime(topic string, since model.SinceMarker, schedul
|
||||
rows, err = rdb.Query(c.queries.selectMessagesSinceTime, topic, since.Time().Unix())
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, false, err
|
||||
}
|
||||
return readMessages(rows)
|
||||
return readMessagesCapped(rows, maxBytes)
|
||||
}
|
||||
|
||||
func (c *Cache) messagesSinceID(topic string, since model.SinceMarker, scheduled bool) ([]*model.Message, error) {
|
||||
func (c *Cache) messagesSinceID(topic string, since model.SinceMarker, scheduled bool, maxBytes int64) ([]*model.Message, bool, error) {
|
||||
var rows *sql.Rows
|
||||
var err error
|
||||
rdb := c.db.ReadOnly()
|
||||
@@ -222,9 +237,9 @@ func (c *Cache) messagesSinceID(topic string, since model.SinceMarker, scheduled
|
||||
rows, err = rdb.Query(c.queries.selectMessagesSinceID, topic, since.ID())
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, false, err
|
||||
}
|
||||
return readMessages(rows)
|
||||
return readMessagesCapped(rows, maxBytes)
|
||||
}
|
||||
|
||||
func (c *Cache) messagesLatest(topic string) ([]*model.Message, error) {
|
||||
@@ -285,7 +300,8 @@ func (c *Cache) MarkPublished(m *model.Message) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// MessagesCount returns the total number of messages in the cache
|
||||
// MessagesCount returns the total number of messages in the cache. On Postgres, this is the
|
||||
// planner's estimate once the table has been analyzed, not an exact count.
|
||||
func (c *Cache) MessagesCount() (int, error) {
|
||||
rows, err := c.db.ReadOnly().Query(c.queries.selectMessagesCount)
|
||||
if err != nil {
|
||||
@@ -457,6 +473,42 @@ func (c *Cache) processMessageBatches() {
|
||||
}
|
||||
}
|
||||
|
||||
// readMessagesCapped reads a newest-first result set, keeping the newest messages that fit in
|
||||
// maxBytes worth of Message.Size (0 = no budget), and reverses them into the oldest-first
|
||||
// order callers expect. It stops scanning once the budget is spent rather than reading everything
|
||||
// and trimming, so a replay of a huge topic never materializes the whole cache. The bool reports
|
||||
// whether older messages were left behind.
|
||||
func readMessagesCapped(rows *sql.Rows, maxBytes int64) ([]*model.Message, bool, error) {
|
||||
defer rows.Close()
|
||||
messages := make([]*model.Message, 0)
|
||||
truncated := false
|
||||
var total int64
|
||||
for rows.Next() {
|
||||
m, err := readMessage(rows)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if maxBytes > 0 {
|
||||
size := int64(m.Size())
|
||||
// Always return at least one message, even if it alone exceeds the budget: an empty
|
||||
// reply is less useful than an oversized one, and the per-field limits bound how big it gets.
|
||||
if len(messages) > 0 && total+size > maxBytes {
|
||||
truncated = true
|
||||
break
|
||||
}
|
||||
total += size
|
||||
}
|
||||
messages = append(messages, m)
|
||||
}
|
||||
if !truncated {
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
}
|
||||
slices.Reverse(messages)
|
||||
return messages, truncated, nil
|
||||
}
|
||||
|
||||
func readMessages(rows *sql.Rows) ([]*model.Message, error) {
|
||||
defer rows.Close()
|
||||
messages := make([]*model.Message, 0)
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"time"
|
||||
|
||||
"heckel.io/ntfy/v2/db"
|
||||
"heckel.io/ntfy/v2/db/schema"
|
||||
)
|
||||
|
||||
// PostgreSQL runtime query constants
|
||||
@@ -24,13 +25,13 @@ const (
|
||||
SELECT mid, sequence_id, time, event, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user_id, content_type, encoding
|
||||
FROM message
|
||||
WHERE topic = $1 AND time >= $2 AND published = TRUE
|
||||
ORDER BY time, id
|
||||
ORDER BY time DESC, id DESC
|
||||
`
|
||||
postgresSelectMessagesSinceTimeIncludeScheduledQuery = `
|
||||
SELECT mid, sequence_id, time, event, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user_id, content_type, encoding
|
||||
FROM message
|
||||
WHERE topic = $1 AND time >= $2
|
||||
ORDER BY time, id
|
||||
ORDER BY time DESC, id DESC
|
||||
`
|
||||
postgresSelectMessagesSinceIDQuery = `
|
||||
SELECT mid, sequence_id, time, event, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user_id, content_type, encoding
|
||||
@@ -38,14 +39,14 @@ const (
|
||||
WHERE topic = $1
|
||||
AND id > COALESCE((SELECT id FROM message WHERE mid = $2), 0)
|
||||
AND published = TRUE
|
||||
ORDER BY time, id
|
||||
ORDER BY time DESC, id DESC
|
||||
`
|
||||
postgresSelectMessagesSinceIDIncludeScheduledQuery = `
|
||||
SELECT mid, sequence_id, time, event, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user_id, content_type, encoding
|
||||
FROM message
|
||||
WHERE topic = $1
|
||||
AND (id > COALESCE((SELECT id FROM message WHERE mid = $2), 0) OR published = FALSE)
|
||||
ORDER BY time, id
|
||||
ORDER BY time DESC, id DESC
|
||||
`
|
||||
postgresSelectMessagesLatestQuery = `
|
||||
SELECT mid, sequence_id, time, event, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user_id, content_type, encoding
|
||||
@@ -61,8 +62,13 @@ const (
|
||||
ORDER BY time, id
|
||||
`
|
||||
postgresUpdateMessagePublishedQuery = `UPDATE message SET published = TRUE WHERE mid = $1`
|
||||
postgresSelectMessagesCountQuery = `SELECT COUNT(*) FROM message`
|
||||
postgresSelectTopicsQuery = `SELECT topic FROM message GROUP BY topic`
|
||||
// Planner estimate, since a COUNT(*) scans the whole table; reltuples is -1 if never analyzed
|
||||
postgresSelectMessagesCountQuery = `
|
||||
SELECT CASE WHEN reltuples < 0 THEN (SELECT COUNT(*) FROM message) ELSE reltuples::BIGINT END
|
||||
FROM pg_class
|
||||
WHERE oid = 'message'::regclass
|
||||
`
|
||||
postgresSelectTopicsQuery = `SELECT topic FROM message GROUP BY topic`
|
||||
|
||||
postgresDeleteExpiredMessagesQuery = `DELETE FROM message WHERE mid IN (SELECT mid FROM message WHERE expires <= $1 AND published = TRUE LIMIT $2)`
|
||||
postgresMarkExpiredAttachmentsDeletedQuery = `UPDATE message SET attachment_deleted = TRUE WHERE mid IN (SELECT mid FROM message WHERE attachment_expires > 0 AND attachment_expires <= $1 AND attachment_deleted = FALSE LIMIT $2)`
|
||||
@@ -102,7 +108,7 @@ var postgresQueries = queries{
|
||||
|
||||
// NewPostgresStore creates a new PostgreSQL-backed message cache store using an existing database connection pool.
|
||||
func NewPostgresStore(d *db.DB, batchSize int, batchTimeout time.Duration) (*Cache, error) {
|
||||
if err := setupPostgres(d.Primary()); err != nil {
|
||||
if err := schema.Migrate(d.Primary(), schema.Postgres, schemaStore, postgresCurrentSchemaVersion, postgresCreateTables, postgresMigrations); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return newCache(d, postgresQueries, nil, batchSize, batchTimeout, false), nil
|
||||
|
||||
@@ -1,16 +1,13 @@
|
||||
package message
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
|
||||
"heckel.io/ntfy/v2/db"
|
||||
"heckel.io/ntfy/v2/log"
|
||||
"heckel.io/ntfy/v2/db/schema"
|
||||
)
|
||||
|
||||
// Initial PostgreSQL schema
|
||||
const (
|
||||
postgresCreateTablesQuery = `
|
||||
postgresCurrentSchemaVersion = 15
|
||||
postgresCreateTablesQuery = `
|
||||
CREATE TABLE IF NOT EXISTS message (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
mid TEXT NOT NULL,
|
||||
@@ -50,21 +47,9 @@ const (
|
||||
value BIGINT
|
||||
);
|
||||
INSERT INTO message_stats (key, value) VALUES ('messages', 0);
|
||||
CREATE TABLE IF NOT EXISTS schema_version (
|
||||
store TEXT PRIMARY KEY,
|
||||
version INT NOT NULL
|
||||
);
|
||||
`
|
||||
)
|
||||
|
||||
// PostgreSQL schema management queries
|
||||
const (
|
||||
postgresCurrentSchemaVersion = 15
|
||||
postgresInsertSchemaVersionQuery = `INSERT INTO schema_version (store, version) VALUES ('message', $1)`
|
||||
postgresUpdateSchemaVersionQuery = `UPDATE schema_version SET version = $1 WHERE store = 'message'`
|
||||
postgresSelectSchemaVersionQuery = `SELECT version FROM schema_version WHERE store = 'message'`
|
||||
)
|
||||
|
||||
// PostgreSQL schema migrations
|
||||
const (
|
||||
// 14 -> 15
|
||||
@@ -73,51 +58,12 @@ const (
|
||||
`
|
||||
)
|
||||
|
||||
var postgresMigrations = map[int]func(d *sql.DB) error{
|
||||
14: postgresMigrateFrom14,
|
||||
}
|
||||
var (
|
||||
postgresCreateTables = schema.AsMigrateFunc(postgresCreateTablesQuery)
|
||||
|
||||
func setupPostgres(d *sql.DB) error {
|
||||
var schemaVersion int
|
||||
if err := d.QueryRow(postgresSelectSchemaVersionQuery).Scan(&schemaVersion); err != nil {
|
||||
return setupNewPostgresDB(d)
|
||||
} else if schemaVersion == postgresCurrentSchemaVersion {
|
||||
return nil
|
||||
} else if schemaVersion > postgresCurrentSchemaVersion {
|
||||
return fmt.Errorf("unexpected schema version: version %d is higher than current version %d", schemaVersion, postgresCurrentSchemaVersion)
|
||||
// postgresMigrations maps a schema version to the migration upgrading it to the next
|
||||
// version. Always append migrations at the end, never insert in the middle.
|
||||
postgresMigrations = map[int]schema.MigrateFunc{
|
||||
14: schema.AsMigrateFunc(postgresMigrate14To15CreateIndexQuery),
|
||||
}
|
||||
for i := schemaVersion; i < postgresCurrentSchemaVersion; i++ {
|
||||
fn, ok := postgresMigrations[i]
|
||||
if !ok {
|
||||
return fmt.Errorf("cannot find migration step from schema version %d to %d", i, i+1)
|
||||
} else if err := fn(d); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func postgresMigrateFrom14(d *sql.DB) error {
|
||||
log.Tag(tagMessageCache).Info("Migrating message cache database schema: from 14 to 15")
|
||||
return db.ExecTx(d, func(tx *sql.Tx) error {
|
||||
if _, err := tx.Exec(postgresMigrate14To15CreateIndexQuery); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(postgresUpdateSchemaVersionQuery, 15); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func setupNewPostgresDB(sqlDB *sql.DB) error {
|
||||
return db.ExecTx(sqlDB, func(tx *sql.Tx) error {
|
||||
if _, err := tx.Exec(postgresCreateTablesQuery); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(postgresInsertSchemaVersionQuery, postgresCurrentSchemaVersion); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
package message_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
dbtest "heckel.io/ntfy/v2/db/test"
|
||||
"heckel.io/ntfy/v2/message"
|
||||
"heckel.io/ntfy/v2/model"
|
||||
)
|
||||
|
||||
func TestPostgresStore_Migration_From14(t *testing.T) {
|
||||
// A pre-framework database at version 14: full v14 schema, version tracked in the
|
||||
// hand-rolled schema_version table, and no idx_message_attachment_expires yet
|
||||
testDB := dbtest.CreateTestPostgres(t)
|
||||
_, err := testDB.Exec(`
|
||||
CREATE TABLE message (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
mid TEXT NOT NULL,
|
||||
sequence_id TEXT NOT NULL,
|
||||
time BIGINT NOT NULL,
|
||||
event TEXT NOT NULL,
|
||||
expires BIGINT NOT NULL,
|
||||
topic TEXT NOT NULL,
|
||||
message TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
priority INT NOT NULL,
|
||||
tags TEXT NOT NULL,
|
||||
click TEXT NOT NULL,
|
||||
icon TEXT NOT NULL,
|
||||
actions TEXT NOT NULL,
|
||||
attachment_name TEXT NOT NULL,
|
||||
attachment_type TEXT NOT NULL,
|
||||
attachment_size BIGINT NOT NULL,
|
||||
attachment_expires BIGINT NOT NULL,
|
||||
attachment_url TEXT NOT NULL,
|
||||
attachment_deleted BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
sender TEXT NOT NULL,
|
||||
user_id TEXT NOT NULL,
|
||||
content_type TEXT NOT NULL,
|
||||
encoding TEXT NOT NULL,
|
||||
published BOOLEAN NOT NULL DEFAULT FALSE
|
||||
);
|
||||
CREATE INDEX idx_message_mid ON message (mid);
|
||||
CREATE INDEX idx_message_sequence_id ON message (sequence_id);
|
||||
CREATE INDEX idx_message_topic_published_time ON message (topic, published, time, id);
|
||||
CREATE INDEX idx_message_published_expires ON message (published, expires);
|
||||
CREATE INDEX idx_message_sender_attachment_expires ON message (sender, attachment_expires) WHERE user_id = '';
|
||||
CREATE INDEX idx_message_user_id_attachment_expires ON message (user_id, attachment_expires);
|
||||
CREATE TABLE message_stats (key TEXT PRIMARY KEY, value BIGINT);
|
||||
INSERT INTO message_stats (key, value) VALUES ('messages', 0);
|
||||
CREATE TABLE schema_version (store TEXT PRIMARY KEY, version INT NOT NULL);
|
||||
INSERT INTO schema_version (store, version) VALUES ('message', 14);
|
||||
`)
|
||||
require.Nil(t, err)
|
||||
store, err := message.NewPostgresStore(testDB, 0, 0)
|
||||
require.Nil(t, err)
|
||||
// The 14 -> 15 step ran: version bumped, partial index created
|
||||
var version int
|
||||
require.Nil(t, testDB.QueryRow(`SELECT version FROM schema_version WHERE store = 'message'`).Scan(&version))
|
||||
require.Equal(t, 15, version)
|
||||
var indexCount int
|
||||
require.Nil(t, testDB.QueryRow(`SELECT COUNT(*) FROM pg_indexes WHERE indexname = 'idx_message_attachment_expires' AND schemaname = current_schema()`).Scan(&indexCount))
|
||||
require.Equal(t, 1, indexCount)
|
||||
// And the store works
|
||||
require.Nil(t, store.AddMessage(model.NewDefaultMessage("mytopic", "hi there")))
|
||||
messages, err := store.Messages("mytopic", model.SinceAllMessages, false)
|
||||
require.Nil(t, err)
|
||||
require.Len(t, messages, 1)
|
||||
|
||||
// The migrated database must be structurally identical to a freshly created one
|
||||
freshDB := dbtest.CreateTestPostgres(t)
|
||||
_, err = message.NewPostgresStore(freshDB, 0, 0)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, dbtest.PostgresSchema(t, freshDB), dbtest.PostgresSchema(t, testDB))
|
||||
}
|
||||
|
||||
func TestPostgresStore_MessagesCount_UsesPlannerEstimate(t *testing.T) {
|
||||
// The manager calls MessagesCount every minute for a metric; a COUNT(*) scans the whole
|
||||
// table on every call, so once the table has been analyzed, the planner's estimate is used
|
||||
testDB := dbtest.CreateTestPostgres(t)
|
||||
store, err := message.NewPostgresStore(testDB, 0, 0)
|
||||
require.Nil(t, err)
|
||||
for i := 0; i < 10; i++ {
|
||||
require.Nil(t, store.AddMessage(model.NewDefaultMessage("mytopic", "some message")))
|
||||
}
|
||||
|
||||
// Never analyzed: falls back to an exact count
|
||||
count, err := store.MessagesCount()
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 10, count)
|
||||
|
||||
// Analyzed, then rows deleted: the estimate lags until the next (auto)analyze
|
||||
_, err = testDB.Exec(`ANALYZE message`)
|
||||
require.Nil(t, err)
|
||||
_, err = testDB.Exec(`DELETE FROM message WHERE id IN (SELECT id FROM message LIMIT 4)`)
|
||||
require.Nil(t, err)
|
||||
count, err = store.MessagesCount()
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 10, count)
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
|
||||
_ "github.com/mattn/go-sqlite3" // SQLite driver
|
||||
"heckel.io/ntfy/v2/db"
|
||||
"heckel.io/ntfy/v2/db/schema"
|
||||
"heckel.io/ntfy/v2/util"
|
||||
)
|
||||
|
||||
@@ -30,25 +31,25 @@ const (
|
||||
SELECT mid, sequence_id, time, event, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding
|
||||
FROM messages
|
||||
WHERE topic = ? AND time >= ? AND published = 1
|
||||
ORDER BY time, id
|
||||
ORDER BY time DESC, id DESC
|
||||
`
|
||||
sqliteSelectMessagesSinceTimeIncludeScheduledQuery = `
|
||||
SELECT mid, sequence_id, time, event, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding
|
||||
FROM messages
|
||||
WHERE topic = ? AND time >= ?
|
||||
ORDER BY time, id
|
||||
ORDER BY time DESC, id DESC
|
||||
`
|
||||
sqliteSelectMessagesSinceIDQuery = `
|
||||
SELECT mid, sequence_id, time, event, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding
|
||||
FROM messages
|
||||
WHERE topic = ? AND id > COALESCE((SELECT id FROM messages WHERE mid = ?), 0) AND published = 1
|
||||
ORDER BY time, id
|
||||
ORDER BY time DESC, id DESC
|
||||
`
|
||||
sqliteSelectMessagesSinceIDIncludeScheduledQuery = `
|
||||
SELECT mid, sequence_id, time, event, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding
|
||||
FROM messages
|
||||
WHERE topic = ? AND (id > COALESCE((SELECT id FROM messages WHERE mid = ?), 0) OR published = 0)
|
||||
ORDER BY time, id
|
||||
ORDER BY time DESC, id DESC
|
||||
`
|
||||
sqliteSelectMessagesLatestQuery = `
|
||||
SELECT mid, sequence_id, time, event, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding
|
||||
@@ -113,7 +114,10 @@ func NewSQLiteStore(filename, startupQueries string, cacheDuration time.Duration
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := setupSQLite(d, startupQueries, cacheDuration); err != nil {
|
||||
if err := runSQLiteStartupQueries(d, startupQueries); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := schema.Migrate(d, schema.SQLite, schemaStore, sqliteCurrentSchemaVersion, sqliteCreateTables, sqliteMigrations(cacheDuration)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return newCache(db.New(&db.Host{DB: d}, nil), sqliteQueries, &sync.Mutex{}, batchSize, batchTimeout, nop), nil
|
||||
|
||||
+30
-283
@@ -2,16 +2,15 @@ package message
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"heckel.io/ntfy/v2/db"
|
||||
"heckel.io/ntfy/v2/log"
|
||||
"heckel.io/ntfy/v2/db/schema"
|
||||
)
|
||||
|
||||
// Initial SQLite schema
|
||||
const (
|
||||
sqliteCreateTablesQuery = `
|
||||
sqliteCurrentSchemaVersion = 15
|
||||
sqliteCreateTablesQuery = `
|
||||
CREATE TABLE IF NOT EXISTS messages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
mid TEXT NOT NULL,
|
||||
@@ -55,29 +54,9 @@ const (
|
||||
`
|
||||
)
|
||||
|
||||
// Schema version management for SQLite
|
||||
// Schema migrations for SQLite. Databases older than schema version 1 (ntfy < v1.10.0,
|
||||
// November 2021) can no longer be migrated.
|
||||
const (
|
||||
sqliteCurrentSchemaVersion = 15
|
||||
sqliteCreateSchemaVersionTableQuery = `
|
||||
CREATE TABLE IF NOT EXISTS schemaVersion (
|
||||
id INT PRIMARY KEY,
|
||||
version INT NOT NULL
|
||||
);
|
||||
`
|
||||
sqliteInsertSchemaVersionQuery = `INSERT INTO schemaVersion VALUES (1, ?)`
|
||||
sqliteUpdateSchemaVersionQuery = `UPDATE schemaVersion SET version = ? WHERE id = 1`
|
||||
sqliteSelectSchemaVersionQuery = `SELECT version FROM schemaVersion WHERE id = 1`
|
||||
)
|
||||
|
||||
// Schema migrations for SQLite
|
||||
const (
|
||||
// 0 -> 1
|
||||
sqliteMigrate0To1AlterMessagesTableQuery = `
|
||||
ALTER TABLE messages ADD COLUMN title TEXT NOT NULL DEFAULT('');
|
||||
ALTER TABLE messages ADD COLUMN priority INT NOT NULL DEFAULT(0);
|
||||
ALTER TABLE messages ADD COLUMN tags TEXT NOT NULL DEFAULT('');
|
||||
`
|
||||
|
||||
// 1 -> 2
|
||||
sqliteMigrate1To2AlterMessagesTableQuery = `
|
||||
ALTER TABLE messages ADD COLUMN published INT NOT NULL DEFAULT(1);
|
||||
@@ -193,67 +172,35 @@ const (
|
||||
)
|
||||
|
||||
var (
|
||||
sqliteMigrations = map[int]func(db *sql.DB, cacheDuration time.Duration) error{
|
||||
0: sqliteMigrateFrom0,
|
||||
1: sqliteMigrateFrom1,
|
||||
2: sqliteMigrateFrom2,
|
||||
3: sqliteMigrateFrom3,
|
||||
4: sqliteMigrateFrom4,
|
||||
5: sqliteMigrateFrom5,
|
||||
6: sqliteMigrateFrom6,
|
||||
7: sqliteMigrateFrom7,
|
||||
8: sqliteMigrateFrom8,
|
||||
9: sqliteMigrateFrom9,
|
||||
10: sqliteMigrateFrom10,
|
||||
11: sqliteMigrateFrom11,
|
||||
12: sqliteMigrateFrom12,
|
||||
13: sqliteMigrateFrom13,
|
||||
14: sqliteMigrateFrom14,
|
||||
}
|
||||
sqliteCreateTables = schema.AsMigrateFunc(sqliteCreateTablesQuery)
|
||||
)
|
||||
|
||||
func setupSQLite(db *sql.DB, startupQueries string, cacheDuration time.Duration) error {
|
||||
if err := runSQLiteStartupQueries(db, startupQueries); err != nil {
|
||||
return err
|
||||
}
|
||||
// If 'messages' table does not exist, this must be a new database
|
||||
var messagesCount int
|
||||
if err := db.QueryRow(sqliteSelectMessagesCountQuery).Scan(&messagesCount); err != nil {
|
||||
return setupNewSQLite(db)
|
||||
}
|
||||
// If 'messages' table exists (schema >= 0), check 'schemaVersion' table
|
||||
var schemaVersion int
|
||||
db.QueryRow(sqliteSelectSchemaVersionQuery).Scan(&schemaVersion) // Error means schema version is zero!
|
||||
// Do migrations
|
||||
if schemaVersion == sqliteCurrentSchemaVersion {
|
||||
return nil
|
||||
} else if schemaVersion > sqliteCurrentSchemaVersion {
|
||||
return fmt.Errorf("unexpected schema version: version %d is higher than current version %d", schemaVersion, sqliteCurrentSchemaVersion)
|
||||
}
|
||||
for i := schemaVersion; i < sqliteCurrentSchemaVersion; i++ {
|
||||
fn, ok := sqliteMigrations[i]
|
||||
if !ok {
|
||||
return fmt.Errorf("cannot find migration step from schema version %d to %d", i, i+1)
|
||||
} else if err := fn(db, cacheDuration); err != nil {
|
||||
// sqliteMigrations returns the migration steps, keyed by the version they upgrade FROM. The
|
||||
// cache duration is carried into the 9 -> 10 step via closure (it backfills "expires" from it).
|
||||
// Always append migrations at the end, never insert in the middle.
|
||||
func sqliteMigrations(cacheDuration time.Duration) map[int]schema.MigrateFunc {
|
||||
return map[int]schema.MigrateFunc{
|
||||
1: schema.AsMigrateFunc(sqliteMigrate1To2AlterMessagesTableQuery),
|
||||
2: schema.AsMigrateFunc(sqliteMigrate2To3AlterMessagesTableQuery),
|
||||
3: schema.AsMigrateFunc(sqliteMigrate3To4AlterMessagesTableQuery),
|
||||
4: schema.AsMigrateFunc(sqliteMigrate4To5AlterMessagesTableQuery),
|
||||
5: schema.AsMigrateFunc(sqliteMigrate5To6AlterMessagesTableQuery),
|
||||
6: schema.AsMigrateFunc(sqliteMigrate6To7AlterMessagesTableQuery),
|
||||
7: schema.AsMigrateFunc(sqliteMigrate7To8AlterMessagesTableQuery),
|
||||
8: schema.AsMigrateFunc(sqliteMigrate8To9AlterMessagesTableQuery),
|
||||
9: func(tx *sql.Tx) error {
|
||||
if _, err := tx.Exec(sqliteMigrate9To10AlterMessagesTableQuery); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := tx.Exec(sqliteMigrate9To10UpdateMessageExpiryQuery, int64(cacheDuration.Seconds()))
|
||||
return err
|
||||
}
|
||||
},
|
||||
10: schema.AsMigrateFunc(sqliteMigrate10To11AlterMessagesTableQuery),
|
||||
11: schema.AsMigrateFunc(sqliteMigrate11To12AlterMessagesTableQuery),
|
||||
12: schema.AsMigrateFunc(sqliteMigrate12To13AlterMessagesTableQuery),
|
||||
13: schema.AsMigrateFunc(sqliteMigrate13To14AlterMessagesTableQuery),
|
||||
14: schema.NopMigrateFunc, // Corresponds to Postgres migration
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func setupNewSQLite(sqlDB *sql.DB) error {
|
||||
return db.ExecTx(sqlDB, func(tx *sql.Tx) error {
|
||||
if _, err := tx.Exec(sqliteCreateTablesQuery); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(sqliteCreateSchemaVersionTableQuery); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(sqliteInsertSchemaVersionQuery, sqliteCurrentSchemaVersion); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func runSQLiteStartupQueries(db *sql.DB, startupQueries string) error {
|
||||
@@ -264,203 +211,3 @@ func runSQLiteStartupQueries(db *sql.DB, startupQueries string) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func sqliteMigrateFrom0(sqlDB *sql.DB, _ time.Duration) error {
|
||||
log.Tag(tagMessageCache).Info("Migrating cache database schema: from 0 to 1")
|
||||
return db.ExecTx(sqlDB, func(tx *sql.Tx) error {
|
||||
if _, err := tx.Exec(sqliteMigrate0To1AlterMessagesTableQuery); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(sqliteCreateSchemaVersionTableQuery); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(sqliteInsertSchemaVersionQuery, 1); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func sqliteMigrateFrom1(sqlDB *sql.DB, _ time.Duration) error {
|
||||
log.Tag(tagMessageCache).Info("Migrating cache database schema: from 1 to 2")
|
||||
return db.ExecTx(sqlDB, func(tx *sql.Tx) error {
|
||||
if _, err := tx.Exec(sqliteMigrate1To2AlterMessagesTableQuery); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(sqliteUpdateSchemaVersionQuery, 2); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func sqliteMigrateFrom2(sqlDB *sql.DB, _ time.Duration) error {
|
||||
log.Tag(tagMessageCache).Info("Migrating cache database schema: from 2 to 3")
|
||||
return db.ExecTx(sqlDB, func(tx *sql.Tx) error {
|
||||
if _, err := tx.Exec(sqliteMigrate2To3AlterMessagesTableQuery); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(sqliteUpdateSchemaVersionQuery, 3); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func sqliteMigrateFrom3(sqlDB *sql.DB, _ time.Duration) error {
|
||||
log.Tag(tagMessageCache).Info("Migrating cache database schema: from 3 to 4")
|
||||
return db.ExecTx(sqlDB, func(tx *sql.Tx) error {
|
||||
if _, err := tx.Exec(sqliteMigrate3To4AlterMessagesTableQuery); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(sqliteUpdateSchemaVersionQuery, 4); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func sqliteMigrateFrom4(sqlDB *sql.DB, _ time.Duration) error {
|
||||
log.Tag(tagMessageCache).Info("Migrating cache database schema: from 4 to 5")
|
||||
return db.ExecTx(sqlDB, func(tx *sql.Tx) error {
|
||||
if _, err := tx.Exec(sqliteMigrate4To5AlterMessagesTableQuery); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(sqliteUpdateSchemaVersionQuery, 5); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func sqliteMigrateFrom5(sqlDB *sql.DB, _ time.Duration) error {
|
||||
log.Tag(tagMessageCache).Info("Migrating cache database schema: from 5 to 6")
|
||||
return db.ExecTx(sqlDB, func(tx *sql.Tx) error {
|
||||
if _, err := tx.Exec(sqliteMigrate5To6AlterMessagesTableQuery); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(sqliteUpdateSchemaVersionQuery, 6); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func sqliteMigrateFrom6(sqlDB *sql.DB, _ time.Duration) error {
|
||||
log.Tag(tagMessageCache).Info("Migrating cache database schema: from 6 to 7")
|
||||
return db.ExecTx(sqlDB, func(tx *sql.Tx) error {
|
||||
if _, err := tx.Exec(sqliteMigrate6To7AlterMessagesTableQuery); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(sqliteUpdateSchemaVersionQuery, 7); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func sqliteMigrateFrom7(sqlDB *sql.DB, _ time.Duration) error {
|
||||
log.Tag(tagMessageCache).Info("Migrating cache database schema: from 7 to 8")
|
||||
return db.ExecTx(sqlDB, func(tx *sql.Tx) error {
|
||||
if _, err := tx.Exec(sqliteMigrate7To8AlterMessagesTableQuery); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(sqliteUpdateSchemaVersionQuery, 8); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func sqliteMigrateFrom8(sqlDB *sql.DB, _ time.Duration) error {
|
||||
log.Tag(tagMessageCache).Info("Migrating cache database schema: from 8 to 9")
|
||||
return db.ExecTx(sqlDB, func(tx *sql.Tx) error {
|
||||
if _, err := tx.Exec(sqliteMigrate8To9AlterMessagesTableQuery); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(sqliteUpdateSchemaVersionQuery, 9); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func sqliteMigrateFrom9(sqlDB *sql.DB, cacheDuration time.Duration) error {
|
||||
log.Tag(tagMessageCache).Info("Migrating cache database schema: from 9 to 10")
|
||||
return db.ExecTx(sqlDB, func(tx *sql.Tx) error {
|
||||
if _, err := tx.Exec(sqliteMigrate9To10AlterMessagesTableQuery); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(sqliteMigrate9To10UpdateMessageExpiryQuery, int64(cacheDuration.Seconds())); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(sqliteUpdateSchemaVersionQuery, 10); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func sqliteMigrateFrom10(sqlDB *sql.DB, _ time.Duration) error {
|
||||
log.Tag(tagMessageCache).Info("Migrating cache database schema: from 10 to 11")
|
||||
return db.ExecTx(sqlDB, func(tx *sql.Tx) error {
|
||||
if _, err := tx.Exec(sqliteMigrate10To11AlterMessagesTableQuery); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(sqliteUpdateSchemaVersionQuery, 11); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func sqliteMigrateFrom11(sqlDB *sql.DB, _ time.Duration) error {
|
||||
log.Tag(tagMessageCache).Info("Migrating cache database schema: from 11 to 12")
|
||||
return db.ExecTx(sqlDB, func(tx *sql.Tx) error {
|
||||
if _, err := tx.Exec(sqliteMigrate11To12AlterMessagesTableQuery); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(sqliteUpdateSchemaVersionQuery, 12); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func sqliteMigrateFrom12(sqlDB *sql.DB, _ time.Duration) error {
|
||||
log.Tag(tagMessageCache).Info("Migrating cache database schema: from 12 to 13")
|
||||
return db.ExecTx(sqlDB, func(tx *sql.Tx) error {
|
||||
if _, err := tx.Exec(sqliteMigrate12To13AlterMessagesTableQuery); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(sqliteUpdateSchemaVersionQuery, 13); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func sqliteMigrateFrom13(sqlDB *sql.DB, _ time.Duration) error {
|
||||
log.Tag(tagMessageCache).Info("Migrating cache database schema: from 13 to 14")
|
||||
return db.ExecTx(sqlDB, func(tx *sql.Tx) error {
|
||||
if _, err := tx.Exec(sqliteMigrate13To14AlterMessagesTableQuery); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(sqliteUpdateSchemaVersionQuery, 14); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// sqliteMigrateFrom14 is a no-op; the corresponding Postgres migration adds
|
||||
// idx_message_attachment_expires, which SQLite already has from the initial schema.
|
||||
func sqliteMigrateFrom14(sqlDB *sql.DB, _ time.Duration) error {
|
||||
log.Tag(tagMessageCache).Info("Migrating cache database schema: from 14 to 15")
|
||||
return db.ExecTx(sqlDB, func(tx *sql.Tx) error {
|
||||
if _, err := tx.Exec(sqliteUpdateSchemaVersionQuery, 15); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
@@ -9,50 +9,11 @@ import (
|
||||
|
||||
_ "github.com/mattn/go-sqlite3" // SQLite driver
|
||||
"github.com/stretchr/testify/require"
|
||||
dbtest "heckel.io/ntfy/v2/db/test"
|
||||
"heckel.io/ntfy/v2/message"
|
||||
"heckel.io/ntfy/v2/model"
|
||||
)
|
||||
|
||||
func TestSqliteStore_Migration_From0(t *testing.T) {
|
||||
filename := newSqliteTestStoreFile(t)
|
||||
db, err := sql.Open("sqlite3", filename)
|
||||
require.Nil(t, err)
|
||||
|
||||
// Create "version 0" schema
|
||||
_, err = db.Exec(`
|
||||
BEGIN;
|
||||
CREATE TABLE IF NOT EXISTS messages (
|
||||
id VARCHAR(20) PRIMARY KEY,
|
||||
time INT NOT NULL,
|
||||
topic VARCHAR(64) NOT NULL,
|
||||
message VARCHAR(1024) NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_topic ON messages (topic);
|
||||
COMMIT;
|
||||
`)
|
||||
require.Nil(t, err)
|
||||
|
||||
// Insert a bunch of messages
|
||||
for i := 0; i < 10; i++ {
|
||||
_, err = db.Exec(`INSERT INTO messages (id, time, topic, message) VALUES (?, ?, ?, ?)`,
|
||||
fmt.Sprintf("abcd%d", i), time.Now().Unix(), "mytopic", fmt.Sprintf("some message %d", i))
|
||||
require.Nil(t, err)
|
||||
}
|
||||
require.Nil(t, db.Close())
|
||||
|
||||
// Create store to trigger migration
|
||||
s := newSqliteTestStoreFromFile(t, filename, "")
|
||||
checkSqliteSchemaVersion(t, filename)
|
||||
|
||||
messages, err := s.Messages("mytopic", model.SinceAllMessages, false)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 10, len(messages))
|
||||
require.Equal(t, "some message 5", messages[5].Message)
|
||||
require.Equal(t, "", messages[5].Title)
|
||||
require.Nil(t, messages[5].Tags)
|
||||
require.Equal(t, 0, messages[5].Priority)
|
||||
}
|
||||
|
||||
func TestSqliteStore_Migration_From1(t *testing.T) {
|
||||
filename := newSqliteTestStoreFile(t)
|
||||
db, err := sql.Open("sqlite3", filename)
|
||||
@@ -90,6 +51,19 @@ func TestSqliteStore_Migration_From1(t *testing.T) {
|
||||
s := newSqliteTestStoreFromFile(t, filename, "")
|
||||
checkSqliteSchemaVersion(t, filename)
|
||||
|
||||
// The migrated database must be structurally identical to a freshly created one
|
||||
freshFile := newSqliteTestStoreFile(t)
|
||||
fresh, err := message.NewSQLiteStore(freshFile, "", time.Hour, 0, 0, false)
|
||||
require.Nil(t, err)
|
||||
t.Cleanup(func() { fresh.Close() })
|
||||
freshDB, err := sql.Open("sqlite3", freshFile)
|
||||
require.Nil(t, err)
|
||||
defer freshDB.Close()
|
||||
migratedDB, err := sql.Open("sqlite3", filename)
|
||||
require.Nil(t, err)
|
||||
defer migratedDB.Close()
|
||||
require.Equal(t, dbtest.SQLiteSchema(t, freshDB), dbtest.SQLiteSchema(t, migratedDB))
|
||||
|
||||
// Add delayed message
|
||||
delayedMessage := model.NewDefaultMessage("mytopic", "some delayed message")
|
||||
delayedMessage.Time = time.Now().Add(time.Minute).Unix()
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
// Package metrics defines the Prometheus metrics exposed by the ntfy server, and registers them
|
||||
// with the default Prometheus registry on import. It is decoupled from the ntfy server, so that
|
||||
// call sites can update metrics without depending on the server package.
|
||||
package metrics
|
||||
|
||||
import (
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
)
|
||||
|
||||
// Collectors for all metrics exposed by the server.
|
||||
//
|
||||
// These are never nil, so that call sites can update them unconditionally. If metrics are
|
||||
// disabled, the server never mounts the /metrics handler, and the values are simply never read.
|
||||
var (
|
||||
MessagesPublishedSuccess = prometheus.NewCounter(prometheus.CounterOpts{
|
||||
Name: "ntfy_messages_published_success",
|
||||
})
|
||||
MessagesPublishedFailure = prometheus.NewCounter(prometheus.CounterOpts{
|
||||
Name: "ntfy_messages_published_failure",
|
||||
})
|
||||
MessagesCached = prometheus.NewGauge(prometheus.GaugeOpts{
|
||||
Name: "ntfy_messages_cached_total",
|
||||
})
|
||||
MessagePublishDurationMillis = prometheus.NewGauge(prometheus.GaugeOpts{
|
||||
Name: "ntfy_message_publish_duration_ms",
|
||||
})
|
||||
FirebasePublishedSuccess = prometheus.NewCounter(prometheus.CounterOpts{
|
||||
Name: "ntfy_firebase_published_success",
|
||||
})
|
||||
FirebasePublishedFailure = prometheus.NewCounter(prometheus.CounterOpts{
|
||||
Name: "ntfy_firebase_published_failure",
|
||||
})
|
||||
EmailsPublishedSuccess = prometheus.NewCounter(prometheus.CounterOpts{
|
||||
Name: "ntfy_emails_sent_success",
|
||||
})
|
||||
EmailsPublishedFailure = prometheus.NewCounter(prometheus.CounterOpts{
|
||||
Name: "ntfy_emails_sent_failure",
|
||||
})
|
||||
EmailsReceivedSuccess = prometheus.NewCounter(prometheus.CounterOpts{
|
||||
Name: "ntfy_emails_received_success",
|
||||
})
|
||||
EmailsReceivedFailure = prometheus.NewCounter(prometheus.CounterOpts{
|
||||
Name: "ntfy_emails_received_failure",
|
||||
})
|
||||
CallsMadeSuccess = prometheus.NewCounter(prometheus.CounterOpts{
|
||||
Name: "ntfy_calls_made_success",
|
||||
})
|
||||
CallsMadeFailure = prometheus.NewCounter(prometheus.CounterOpts{
|
||||
Name: "ntfy_calls_made_failure",
|
||||
})
|
||||
UnifiedPushPublishedSuccess = prometheus.NewCounter(prometheus.CounterOpts{
|
||||
Name: "ntfy_unifiedpush_published_success",
|
||||
})
|
||||
MatrixPublishedSuccess = prometheus.NewCounter(prometheus.CounterOpts{
|
||||
Name: "ntfy_matrix_published_success",
|
||||
})
|
||||
MatrixPublishedFailure = prometheus.NewCounter(prometheus.CounterOpts{
|
||||
Name: "ntfy_matrix_published_failure",
|
||||
})
|
||||
AttachmentsTotalSize = prometheus.NewGauge(prometheus.GaugeOpts{
|
||||
Name: "ntfy_attachments_total_size",
|
||||
})
|
||||
Visitors = prometheus.NewGauge(prometheus.GaugeOpts{
|
||||
Name: "ntfy_visitors_total",
|
||||
})
|
||||
Users = prometheus.NewGauge(prometheus.GaugeOpts{
|
||||
Name: "ntfy_users_total",
|
||||
})
|
||||
Subscribers = prometheus.NewGauge(prometheus.GaugeOpts{
|
||||
Name: "ntfy_subscribers_total",
|
||||
})
|
||||
Topics = prometheus.NewGauge(prometheus.GaugeOpts{
|
||||
Name: "ntfy_topics_total",
|
||||
})
|
||||
HTTPRequests = prometheus.NewCounterVec(prometheus.CounterOpts{
|
||||
Name: "ntfy_http_requests_total",
|
||||
}, []string{"http_code", "ntfy_code", "http_method"})
|
||||
)
|
||||
|
||||
// init registers all collectors with the default Prometheus registry. Registration is
|
||||
// unconditional: the collectors are only ever exposed if the server mounts the /metrics handler,
|
||||
// so there is nothing to be gained by tying registration to the config.
|
||||
func init() {
|
||||
prometheus.MustRegister(
|
||||
MessagesPublishedSuccess,
|
||||
MessagesPublishedFailure,
|
||||
MessagesCached,
|
||||
MessagePublishDurationMillis,
|
||||
FirebasePublishedSuccess,
|
||||
FirebasePublishedFailure,
|
||||
EmailsPublishedSuccess,
|
||||
EmailsPublishedFailure,
|
||||
EmailsReceivedSuccess,
|
||||
EmailsReceivedFailure,
|
||||
CallsMadeSuccess,
|
||||
CallsMadeFailure,
|
||||
UnifiedPushPublishedSuccess,
|
||||
MatrixPublishedSuccess,
|
||||
MatrixPublishedFailure,
|
||||
AttachmentsTotalSize,
|
||||
Visitors,
|
||||
Users,
|
||||
Subscribers,
|
||||
Topics,
|
||||
HTTPRequests,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package metrics
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// expectedMetricNames is the exact set of metrics the server exposes. These names are a public
|
||||
// contract: renaming or dropping one silently breaks existing dashboards and alerts.
|
||||
var expectedMetricNames = []string{
|
||||
"ntfy_attachments_total_size",
|
||||
"ntfy_calls_made_failure",
|
||||
"ntfy_calls_made_success",
|
||||
"ntfy_emails_received_failure",
|
||||
"ntfy_emails_received_success",
|
||||
"ntfy_emails_sent_failure",
|
||||
"ntfy_emails_sent_success",
|
||||
"ntfy_firebase_published_failure",
|
||||
"ntfy_firebase_published_success",
|
||||
"ntfy_http_requests_total",
|
||||
"ntfy_matrix_published_failure",
|
||||
"ntfy_matrix_published_success",
|
||||
"ntfy_message_publish_duration_ms",
|
||||
"ntfy_messages_cached_total",
|
||||
"ntfy_messages_published_failure",
|
||||
"ntfy_messages_published_success",
|
||||
"ntfy_subscribers_total",
|
||||
"ntfy_topics_total",
|
||||
"ntfy_unifiedpush_published_success",
|
||||
"ntfy_users_total",
|
||||
"ntfy_visitors_total",
|
||||
}
|
||||
|
||||
func TestRegisteredMetricNames(t *testing.T) {
|
||||
HTTPRequests.WithLabelValues("200", "20000", "GET").Inc()
|
||||
families, err := prometheus.DefaultGatherer.Gather()
|
||||
require.Nil(t, err)
|
||||
names := make([]string, 0)
|
||||
for _, family := range families {
|
||||
if strings.HasPrefix(family.GetName(), "ntfy_") {
|
||||
names = append(names, family.GetName())
|
||||
}
|
||||
}
|
||||
sort.Strings(names)
|
||||
require.Equal(t, expectedMetricNames, names)
|
||||
}
|
||||
|
||||
func TestCollectors_NeverNil(t *testing.T) {
|
||||
// Call sites update metrics unconditionally, even when metrics are disabled, so no collector
|
||||
// may ever be nil
|
||||
MessagesPublishedSuccess.Inc()
|
||||
MessagesCached.Set(1)
|
||||
HTTPRequests.WithLabelValues("200", "20000", "PUT").Inc()
|
||||
}
|
||||
@@ -44,9 +44,11 @@ extra_javascript:
|
||||
- static/js/extra.js
|
||||
- static/js/bcrypt.js
|
||||
- static/js/config-generator.js
|
||||
- static/js/topic-generator.js
|
||||
extra_css:
|
||||
- static/css/extra.css
|
||||
- static/css/config-generator.css
|
||||
- static/css/topic-generator.css
|
||||
|
||||
markdown_extensions:
|
||||
- admonition
|
||||
|
||||
@@ -101,6 +101,24 @@ func (m *Message) ForJSON() *Message {
|
||||
return m
|
||||
}
|
||||
|
||||
// Size returns an approximate byte size of the variable-length, publisher-controlled parts of a
|
||||
// message. It is used to budget cache replays, so it deliberately counts every field a publisher
|
||||
// can grow rather than trying to match the exact wire size.
|
||||
func (m *Message) Size() int {
|
||||
size := len(m.ID) + len(m.SequenceID) + len(m.Event) + len(m.Topic) + len(m.Title) +
|
||||
len(m.Message) + len(m.Click) + len(m.Icon) + len(m.ContentType) + len(m.Encoding) + len(m.PollID)
|
||||
for _, tag := range m.Tags {
|
||||
size += len(tag)
|
||||
}
|
||||
for _, action := range m.Actions {
|
||||
size += action.Size()
|
||||
}
|
||||
if m.Attachment != nil {
|
||||
size += len(m.Attachment.Name) + len(m.Attachment.Type) + len(m.Attachment.URL)
|
||||
}
|
||||
return size
|
||||
}
|
||||
|
||||
// Attachment represents a file attachment on a message
|
||||
type Attachment struct {
|
||||
Name string `json:"name"`
|
||||
@@ -125,6 +143,18 @@ type Action struct {
|
||||
Value string `json:"value,omitempty"` // used in "copy" action
|
||||
}
|
||||
|
||||
// Size returns an approximate byte size of an action's variable-length fields.
|
||||
func (a *Action) Size() int {
|
||||
size := len(a.ID) + len(a.Action) + len(a.Label) + len(a.URL) + len(a.Method) + len(a.Body) + len(a.Intent) + len(a.Value)
|
||||
for key, value := range a.Headers {
|
||||
size += len(key) + len(value)
|
||||
}
|
||||
for key, value := range a.Extras {
|
||||
size += len(key) + len(value)
|
||||
}
|
||||
return size
|
||||
}
|
||||
|
||||
// NewAction creates a new action with initialized maps
|
||||
func NewAction() *Action {
|
||||
return &Action{
|
||||
|
||||
@@ -22,6 +22,7 @@ func TestParseURL_Success(t *testing.T) {
|
||||
require.Equal(t, "us-east-1", cfg.Region)
|
||||
require.Equal(t, "AKID", cfg.AccessKey)
|
||||
require.Equal(t, "SECRET", cfg.SecretKey)
|
||||
require.Equal(t, "https", cfg.Scheme)
|
||||
require.Equal(t, "s3.us-east-1.amazonaws.com", cfg.Endpoint)
|
||||
require.False(t, cfg.PathStyle)
|
||||
}
|
||||
@@ -38,6 +39,7 @@ func TestParseURL_WithEndpoint(t *testing.T) {
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, "my-bucket", cfg.Bucket)
|
||||
require.Equal(t, "prefix", cfg.Prefix)
|
||||
require.Equal(t, "https", cfg.Scheme)
|
||||
require.Equal(t, "s3.example.com", cfg.Endpoint)
|
||||
require.True(t, cfg.PathStyle)
|
||||
}
|
||||
@@ -45,10 +47,32 @@ func TestParseURL_WithEndpoint(t *testing.T) {
|
||||
func TestParseURL_EndpointHTTP(t *testing.T) {
|
||||
cfg, err := ParseURL("s3://AKID:SECRET@my-bucket?region=us-east-1&endpoint=http://localhost:9000")
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, "http", cfg.Scheme)
|
||||
require.Equal(t, "localhost:9000", cfg.Endpoint)
|
||||
require.True(t, cfg.PathStyle)
|
||||
}
|
||||
|
||||
func TestParseURL_EndpointNoScheme(t *testing.T) {
|
||||
// A bare host:port endpoint (no scheme) must default to https for backward compatibility.
|
||||
// Without this, url.Parse treats the host as the scheme ("localhost:9000" -> scheme "localhost").
|
||||
cfg, err := ParseURL("s3://AKID:SECRET@my-bucket?region=us-east-1&endpoint=localhost:9000")
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, "https", cfg.Scheme)
|
||||
require.Equal(t, "localhost:9000", cfg.Endpoint)
|
||||
require.True(t, cfg.PathStyle)
|
||||
require.Equal(t, "https://localhost:9000/my-bucket", cfg.BucketURL())
|
||||
}
|
||||
|
||||
func TestParseURL_EndpointNoSchemeHostname(t *testing.T) {
|
||||
// A dotted hostname with a port and no scheme must also default to https
|
||||
// ("minio.example.com:9000" must not become scheme "minio.example.com").
|
||||
cfg, err := ParseURL("s3://AKID:SECRET@my-bucket?region=us-east-1&endpoint=minio.example.com:9000")
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, "https", cfg.Scheme)
|
||||
require.Equal(t, "minio.example.com:9000", cfg.Endpoint)
|
||||
require.Equal(t, "https://minio.example.com:9000/my-bucket", cfg.BucketURL())
|
||||
}
|
||||
|
||||
func TestParseURL_EndpointTrailingSlash(t *testing.T) {
|
||||
cfg, err := ParseURL("s3://AKID:SECRET@my-bucket?region=us-east-1&endpoint=https://s3.example.com/")
|
||||
require.Nil(t, err)
|
||||
@@ -111,6 +135,11 @@ func TestConfig_BucketURL_PathStyle(t *testing.T) {
|
||||
require.Equal(t, "https://s3.example.com/my-bucket", c.BucketURL())
|
||||
}
|
||||
|
||||
func TestConfig_BucketURL_PathStyle_EndpointHTTP(t *testing.T) {
|
||||
c := &Config{Scheme: "http", Endpoint: "localhost:9000", Bucket: "b", PathStyle: true}
|
||||
require.Equal(t, "http://localhost:9000/b", c.BucketURL())
|
||||
}
|
||||
|
||||
func TestConfig_BucketURL_VirtualHosted(t *testing.T) {
|
||||
c := &Config{Endpoint: "s3.us-east-1.amazonaws.com", Bucket: "my-bucket", PathStyle: false}
|
||||
require.Equal(t, "https://my-bucket.s3.us-east-1.amazonaws.com", c.BucketURL())
|
||||
|
||||
+8
-3
@@ -11,6 +11,7 @@ import (
|
||||
|
||||
// Config holds the parsed fields from an S3 URL. Use ParseURL to create one from a URL string.
|
||||
type Config struct {
|
||||
Scheme string // URL scheme, e.g. "https" or "http"
|
||||
Endpoint string // host[:port] only, e.g. "s3.us-east-1.amazonaws.com"
|
||||
PathStyle bool
|
||||
Bucket string
|
||||
@@ -24,10 +25,14 @@ type Config struct {
|
||||
|
||||
// BucketURL returns the base URL for bucket-level operations.
|
||||
func (c *Config) BucketURL() string {
|
||||
if c.PathStyle {
|
||||
return fmt.Sprintf("https://%s/%s", c.Endpoint, c.Bucket)
|
||||
scheme := "https"
|
||||
if c.Scheme != "" {
|
||||
scheme = c.Scheme
|
||||
}
|
||||
return fmt.Sprintf("https://%s.%s", c.Bucket, c.Endpoint)
|
||||
if c.PathStyle {
|
||||
return fmt.Sprintf("%s://%s/%s", scheme, c.Endpoint, c.Bucket)
|
||||
}
|
||||
return fmt.Sprintf("%s://%s.%s", scheme, c.Bucket, c.Endpoint)
|
||||
}
|
||||
|
||||
// HostHeader returns the value for the Host header.
|
||||
|
||||
+10
-1
@@ -70,21 +70,30 @@ func ParseURL(s3URL string) (*Config, error) {
|
||||
return nil, fmt.Errorf("s3: region query parameter is required")
|
||||
}
|
||||
endpointParam := u.Query().Get("endpoint")
|
||||
var scheme string
|
||||
var endpoint string
|
||||
var pathStyle bool
|
||||
if endpointParam != "" {
|
||||
// Custom endpoint: strip scheme prefix to extract host[:port]
|
||||
// Custom endpoint: derive the scheme from the prefix and strip it to extract host[:port].
|
||||
// Default to https for backward compatibility, including bare "host:port" endpoints (no
|
||||
// scheme) -- url.Parse would otherwise misread the host before the port colon as the scheme.
|
||||
scheme = "https"
|
||||
if strings.HasPrefix(endpointParam, "http://") {
|
||||
scheme = "http"
|
||||
}
|
||||
ep := strings.TrimRight(endpointParam, "/")
|
||||
ep = strings.TrimPrefix(ep, "https://")
|
||||
ep = strings.TrimPrefix(ep, "http://")
|
||||
endpoint = ep
|
||||
pathStyle = true
|
||||
} else {
|
||||
scheme = "https"
|
||||
endpoint = fmt.Sprintf("s3.%s.amazonaws.com", region)
|
||||
pathStyle = false
|
||||
}
|
||||
disableHTTP2, _ := strconv.ParseBool(u.Query().Get("disable_http2"))
|
||||
return &Config{
|
||||
Scheme: scheme,
|
||||
Endpoint: endpoint,
|
||||
PathStyle: pathStyle,
|
||||
Bucket: bucket,
|
||||
|
||||
@@ -11,11 +11,20 @@ if [ -z "$1" ]; then
|
||||
echo "Example:"
|
||||
echo " $0 emoji-converted.json"
|
||||
echo " $0 $ROOTDIR/web/src/app/emojis.js"
|
||||
echo " $0 $ROOTDIR/web/src/app/emojisMapped.js"
|
||||
echo " $0 $ROOTDIR/docs/emojis.md"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "$1" == *.js ]]; then
|
||||
if [[ "$1" == *emojisMapped.js ]]; then
|
||||
# Small alias -> emoji lookup used to render tags as emojis. Precomputed so the full
|
||||
# emoji dataset (emojis.js) stays out of the main web bundle.
|
||||
echo -n "// This file is generated by scripts/emoji-convert.sh -- alias to emoji lookup
|
||||
// Original data source: https://github.com/github/gemoji/blob/master/db/emoji.json
|
||||
export default " > "$1"
|
||||
cat "$SCRIPTDIR/emoji.json" | jq -jc '[.[] | .aliases[] as $a | {key: $a, value: .emoji}] | from_entries' >> "$1"
|
||||
echo ";" >> "$1"
|
||||
elif [[ "$1" == *.js ]]; then
|
||||
echo -n "// This file is generated by scripts/emoji-convert.sh to reduce the size
|
||||
// Original data source: https://github.com/github/gemoji/blob/master/db/emoji.json
|
||||
export const rawEmojis = " > "$1"
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"heckel.io/ntfy/v2/ban"
|
||||
)
|
||||
|
||||
// TestServer_BanFeed_WritesOffenderToFile is the end-to-end wiring test: a rejected request flows
|
||||
// through s.handle -> the error responder -> s.ban.Record, and once the offender's prefix
|
||||
// breaches, its ban line lands in the ban file (flushed on Close).
|
||||
func TestServer_BanFeed_WritesOffenderToFile(t *testing.T) {
|
||||
banFile := filepath.Join(t.TempDir(), "ban.log")
|
||||
conf := newTestConfig(t, "")
|
||||
conf.BanFile = banFile
|
||||
conf.BanWindow = time.Minute
|
||||
conf.BanThreshold = 1 // capacity 1: the 2nd rejection breaches
|
||||
conf.BanWeights = ban.Weights{"*": 1} // any 4xx counts one strike
|
||||
s := newTestServer(t, conf)
|
||||
require.NotNil(t, s.ban)
|
||||
|
||||
// A delayed message with caching disabled is a deterministic 400 (errHTTPBadRequestDelayNoCache).
|
||||
// request() sends from RemoteAddr 9.9.9.9.
|
||||
reject := map[string]string{"Cache": "no", "In": "30 min"}
|
||||
for i := 0; i < 3; i++ {
|
||||
response := request(t, s, "PUT", "/mytopic", "", reject)
|
||||
require.Equal(t, 400, response.Code)
|
||||
}
|
||||
|
||||
// Writes are async; Close flushes the buffer. The offender's prefix must be in the feed exactly
|
||||
// once (throttled to one line per window).
|
||||
s.ban.Close()
|
||||
data, err := os.ReadFile(banFile)
|
||||
require.NoError(t, err)
|
||||
lines := strings.Split(strings.TrimRight(string(data), "\n"), "\n")
|
||||
require.Len(t, lines, 1)
|
||||
require.Contains(t, lines[0], " 9.9.9.9 9.9.9.9/32 400 ") // <ip> <prefix> <http-code> <ntfy-code>
|
||||
}
|
||||
|
||||
// TestServer_BanFeed_DisabledByDefault verifies the feature is off with no ban file: s.ban is
|
||||
// nil and the error path skips it (guarded), so a rejected request must not panic.
|
||||
func TestServer_BanFeed_DisabledByDefault(t *testing.T) {
|
||||
conf := newTestConfig(t, "") // no BanFile
|
||||
s := newTestServer(t, conf)
|
||||
require.Nil(t, s.ban)
|
||||
|
||||
reject := map[string]string{"Cache": "no", "In": "30 min"}
|
||||
response := request(t, s, "PUT", "/mytopic", "", reject) // guarded callsite, no Record
|
||||
require.Equal(t, 400, response.Code)
|
||||
}
|
||||
+55
-13
@@ -10,6 +10,7 @@ import (
|
||||
"text/template"
|
||||
"time"
|
||||
|
||||
"heckel.io/ntfy/v2/ban"
|
||||
"heckel.io/ntfy/v2/user"
|
||||
)
|
||||
|
||||
@@ -42,6 +43,24 @@ const (
|
||||
DefaultWebPushExpiryDuration = 60 * 24 * time.Hour
|
||||
)
|
||||
|
||||
// Defines default abuse ban-feed settings (see BanFile, BanWindow, BanThreshold, BanWeights)
|
||||
const (
|
||||
DefaultBanWindow = 10 * time.Minute
|
||||
DefaultBanThreshold = 100 // Weighted strikes per BanWindow before a prefix is banned
|
||||
)
|
||||
|
||||
// DefaultBanWeights is the ban-feed's default per-code strike weights: the auth-failure flood bans fast,
|
||||
// and everything else defaults to weight 1 (no "*" rule needed; see BanWeights.WeightFor).
|
||||
var DefaultBanWeights = []string{
|
||||
banWeight(errHTTPTooManyRequestsLimitAuthFailure, 10), // brute-force auth flood -> ban fast
|
||||
}
|
||||
|
||||
// banWeight formats a "CODE:WEIGHT" ban-feed default from an ntfy error, so the codes stay in sync with
|
||||
// the errHTTP definitions instead of being duplicated as string literals.
|
||||
func banWeight(err *errHTTP, weight int) string {
|
||||
return fmt.Sprintf("%d:%d", err.Code, weight)
|
||||
}
|
||||
|
||||
// Defines all global and per-visitor limits
|
||||
// - message size limit: the max number of bytes for a message
|
||||
// - total topic limit: max number of topics overall
|
||||
@@ -54,6 +73,16 @@ const (
|
||||
DefaultAttachmentExpiryDuration = 3 * time.Hour
|
||||
DefaultAttachmentOrphanGracePeriod = time.Hour // Don't delete orphaned objects younger than this to avoid races with in-flight uploads
|
||||
|
||||
// DefaultMessagePollSizeLimit caps what one cache replay returns per topic. It is a backstop
|
||||
// against a single request materializing an entire topic cache, not a tunable: on ntfy.sh it
|
||||
// would fire on 2 of ~98k cached topics. See docs/subscribe/api.md#replay-limits.
|
||||
DefaultMessagePollSizeLimit = 10 * 1024 * 1024
|
||||
|
||||
// messageTitleSizeLimit and messageTagsSizeLimit cap two publisher-controlled fields that
|
||||
// otherwise have no limit of their own. Sized off ntfy.sh's own cache: title p999 is 212 bytes
|
||||
// (16 of ~3M messages exceed 1 KB), tags p999 is 244 (197 exceed 512).
|
||||
messageTitleSizeLimit = 1024
|
||||
messageTagsSizeLimit = 512
|
||||
)
|
||||
|
||||
// Defines all per-visitor limits
|
||||
@@ -71,7 +100,7 @@ const (
|
||||
DefaultVisitorEmailLimitReplenish = time.Hour
|
||||
DefaultVisitorTopicCreationLimitBurst = 100
|
||||
DefaultVisitorTopicCreationLimitReplenish = time.Minute
|
||||
DefaultVisitorAccountCreationLimitBurst = 3
|
||||
DefaultVisitorAccountCreationLimitBurst = 6 // Shared by signup and password-reset requests (same per-visitor bucket)
|
||||
DefaultVisitorAccountCreationLimitReplenish = 24 * time.Hour
|
||||
DefaultVisitorAuthFailureLimitBurst = 30
|
||||
DefaultVisitorAuthFailureLimitReplenish = time.Minute
|
||||
@@ -111,9 +140,9 @@ type Config struct {
|
||||
AuthFile string
|
||||
AuthStartupQueries string
|
||||
AuthDefault user.Permission
|
||||
AuthUsers []*user.User
|
||||
AuthUsers []*user.User `hash:"-"`
|
||||
AuthAccess map[string][]*user.Grant
|
||||
AuthTokens map[string][]*user.Token
|
||||
AuthTokens map[string][]*user.Token `hash:"-"`
|
||||
AuthBcryptCost int
|
||||
AuthStatsQueueWriterInterval time.Duration
|
||||
AuthAccessCacheEnabled bool // Enables the in-memory ACL cache (high volume servers only)
|
||||
@@ -134,28 +163,28 @@ type Config struct {
|
||||
FirebasePollInterval time.Duration
|
||||
FirebaseQuotaExceededPenaltyDuration time.Duration
|
||||
UpstreamBaseURL string
|
||||
UpstreamAccessToken string
|
||||
UpstreamAccessToken string `hash:"-"`
|
||||
SMTPSenderAddr string
|
||||
SMTPSenderUser string
|
||||
SMTPSenderPass string
|
||||
SMTPSenderPass string `hash:"-"`
|
||||
SMTPSenderFrom string
|
||||
SMTPSenderVerify bool
|
||||
SMTPServerListen string
|
||||
SMTPServerDomain string
|
||||
SMTPServerAddrPrefix string
|
||||
TwilioAccount string
|
||||
TwilioAuthToken string
|
||||
TwilioAuthToken string `hash:"-"`
|
||||
TwilioPhoneNumber string
|
||||
TwilioCallsBaseURL string
|
||||
TwilioVerifyBaseURL string
|
||||
TwilioVerifyService string
|
||||
TwilioCallFormat *template.Template
|
||||
MetricsEnable bool
|
||||
MetricsListenHTTP string
|
||||
ProfileListenHTTP string
|
||||
MessageDelayMin time.Duration
|
||||
MessageDelayMax time.Duration
|
||||
MessageSizeLimit int
|
||||
MessagePollSizeLimit int64
|
||||
TotalTopicLimit int
|
||||
TotalAttachmentSizeLimit int64
|
||||
VisitorSubscriptionLimit int
|
||||
@@ -180,8 +209,8 @@ type Config struct {
|
||||
BehindProxy bool // If true, the server will trust the proxy client IP header to determine the client IP address (IPv4 and IPv6 supported)
|
||||
ProxyForwardedHeader string // The header field to read the real/client IP address from, if BehindProxy is true, defaults to "X-Forwarded-For" (IPv4 and IPv6 supported)
|
||||
ProxyTrustedPrefixes []netip.Prefix // List of trusted proxy networks (IPv4 or IPv6) that will be stripped from the Forwarded header if BehindProxy is true
|
||||
StripeSecretKey string
|
||||
StripeWebhookKey string
|
||||
StripeSecretKey string `hash:"-"`
|
||||
StripeWebhookKey string `hash:"-"`
|
||||
StripePriceCacheDuration time.Duration
|
||||
BillingContact string
|
||||
EnableSignup bool // Enable creation of accounts via API and UI
|
||||
@@ -190,16 +219,20 @@ type Config struct {
|
||||
EnableReservations bool // Allow users with role "user" to own/reserve topics
|
||||
EnableMetrics bool
|
||||
AccessControlAllowOrigin string // CORS header field to restrict access from web clients
|
||||
WebPushPrivateKey string
|
||||
WebPushPrivateKey string `hash:"-"`
|
||||
WebPushPublicKey string
|
||||
WebPushFile string
|
||||
WebPushEmailAddress string
|
||||
WebPushStartupQueries string
|
||||
WebPushExpiryDuration time.Duration
|
||||
WebPushExpiryWarningDuration time.Duration
|
||||
BuildVersion string // Injected by App
|
||||
BuildDate string // Injected by App
|
||||
BuildCommit string // Injected by App
|
||||
BanFile string // Abuse ban-feed: file that fail2ban tails; empty string disables the feature
|
||||
BanWindow time.Duration // Abuse ban-feed: rolling window over which weighted strikes are counted
|
||||
BanThreshold int // Abuse ban-feed: weighted strikes per window before a prefix is banned
|
||||
BanWeights ban.Weights // Abuse ban-feed: code matcher -> strike weight (see ban.ParseWeights, ban.Weights.WeightFor)
|
||||
BuildVersion string // Injected by App
|
||||
BuildDate string // Injected by App
|
||||
BuildCommit string // Injected by App
|
||||
}
|
||||
|
||||
// NewConfig instantiates a default new server config
|
||||
@@ -260,6 +293,7 @@ func NewConfig() *Config {
|
||||
TwilioVerifyService: "",
|
||||
TwilioCallFormat: nil,
|
||||
MessageSizeLimit: DefaultMessageSizeLimit,
|
||||
MessagePollSizeLimit: DefaultMessagePollSizeLimit,
|
||||
MessageDelayMin: DefaultMessageDelayMin,
|
||||
MessageDelayMax: DefaultMessageDelayMax,
|
||||
TotalTopicLimit: DefaultTotalTopicLimit,
|
||||
@@ -300,6 +334,10 @@ func NewConfig() *Config {
|
||||
WebPushEmailAddress: "",
|
||||
WebPushExpiryDuration: DefaultWebPushExpiryDuration,
|
||||
WebPushExpiryWarningDuration: DefaultWebPushExpiryWarningDuration,
|
||||
BanFile: "",
|
||||
BanWindow: DefaultBanWindow,
|
||||
BanThreshold: DefaultBanThreshold,
|
||||
BanWeights: nil,
|
||||
BuildVersion: "",
|
||||
BuildDate: "",
|
||||
BuildCommit: "",
|
||||
@@ -316,6 +354,10 @@ func (c *Config) Hash() string {
|
||||
for i := 0; i < v.NumField(); i++ {
|
||||
field := v.Field(i)
|
||||
fieldName := t.Field(i).Name
|
||||
// Secrets must not feed the hash
|
||||
if t.Field(i).Tag.Get("hash") == "-" {
|
||||
continue
|
||||
}
|
||||
// Try to marshal the field and skip if it fails (e.g. *template.Template, netip.Prefix)
|
||||
if b, err := json.Marshal(field.Interface()); err == nil {
|
||||
result += fmt.Sprintf("%s:%s|", fieldName, string(b))
|
||||
|
||||
@@ -3,6 +3,7 @@ package server_test
|
||||
import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
"heckel.io/ntfy/v2/server"
|
||||
"heckel.io/ntfy/v2/user"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -11,3 +12,24 @@ func TestConfig_New(t *testing.T) {
|
||||
assert.Equal(t, ":80", c.ListenHTTP)
|
||||
assert.Equal(t, server.DefaultKeepaliveInterval, c.KeepaliveInterval)
|
||||
}
|
||||
|
||||
func TestConfig_HashExcludesSecrets(t *testing.T) {
|
||||
// The config hash is served to browsers (ConfigHash, for webapp change detection), so
|
||||
// secret material must not feed it: a weak secret would otherwise be offline-brute-forceable
|
||||
// against a publicly visible hash.
|
||||
conf1 := server.NewConfig()
|
||||
conf2 := server.NewConfig()
|
||||
conf2.StripeSecretKey = "sk_live_topsecret"
|
||||
conf2.StripeWebhookKey = "whsec_topsecret"
|
||||
conf2.TwilioAuthToken = "twilio-auth-token"
|
||||
conf2.UpstreamAccessToken = "tk_upstream"
|
||||
conf2.WebPushPrivateKey = "web-push-private-key"
|
||||
conf2.SMTPSenderPass = "hunter2"
|
||||
conf2.AuthUsers = []*user.User{{Name: "phil", Hash: "$2a$10$somebcrypthash"}}
|
||||
conf2.AuthTokens = map[string][]*user.Token{"phil": {{Value: "tk_secrettoken"}}}
|
||||
assert.Equal(t, conf1.Hash(), conf2.Hash())
|
||||
// Non-secret fields must still change the hash
|
||||
conf3 := server.NewConfig()
|
||||
conf3.BaseURL = "https://ntfy.example.com"
|
||||
assert.NotEqual(t, conf1.Hash(), conf3.Hash())
|
||||
}
|
||||
|
||||
+9
-3
@@ -136,16 +136,21 @@ var (
|
||||
errHTTPBadRequestTemplateMessageTooLarge = &errHTTP{40041, http.StatusBadRequest, "invalid request: message or title is too large after replacing template", "https://ntfy.sh/docs/publish/#message-templating", nil}
|
||||
errHTTPBadRequestTemplateMessageNotJSON = &errHTTP{40042, http.StatusBadRequest, "invalid request: message body must be JSON if templating is enabled", "https://ntfy.sh/docs/publish/#message-templating", nil}
|
||||
errHTTPBadRequestTemplateInvalid = &errHTTP{40043, http.StatusBadRequest, "invalid request: could not parse template", "https://ntfy.sh/docs/publish/#message-templating", nil}
|
||||
errHTTPBadRequestTemplateDisallowedFunctionCalls = &errHTTP{40044, http.StatusBadRequest, "invalid request: template contains disallowed function calls, e.g. template, call, or define", "https://ntfy.sh/docs/publish/#message-templating", nil}
|
||||
errHTTPBadRequestTemplateDisallowedFunctionCalls = &errHTTP{40044, http.StatusBadRequest, "invalid request: template contains disallowed function calls, e.g. template, call, define, or block", "https://ntfy.sh/docs/publish/#message-templating", nil}
|
||||
errHTTPBadRequestTemplateExecuteFailed = &errHTTP{40045, http.StatusBadRequest, "invalid request: template execution failed", "https://ntfy.sh/docs/publish/#message-templating", nil}
|
||||
errHTTPBadRequestTemplateExecutionTimeout = &errHTTP{40055, http.StatusBadRequest, "invalid request: template execution timed out", "https://ntfy.sh/docs/publish/#message-templating", nil}
|
||||
errHTTPBadRequestInvalidUsername = &errHTTP{40046, http.StatusBadRequest, "invalid request: invalid username", "", nil}
|
||||
errHTTPBadRequestTemplateFileNotFound = &errHTTP{40047, http.StatusBadRequest, "invalid request: template file not found", "https://ntfy.sh/docs/publish/#message-templating", nil}
|
||||
errHTTPBadRequestTemplateFileInvalid = &errHTTP{40048, http.StatusBadRequest, "invalid request: template file invalid", "https://ntfy.sh/docs/publish/#message-templating", nil}
|
||||
errHTTPBadRequestSequenceIDInvalid = &errHTTP{40049, http.StatusBadRequest, "invalid request: sequence ID invalid", "https://ntfy.sh/docs/publish/#updating-deleting-notifications", nil}
|
||||
errHTTPBadRequestEmailAddressInvalid = &errHTTP{40050, http.StatusBadRequest, "invalid request: invalid e-mail address", "https://ntfy.sh/docs/publish/#e-mail-notifications", nil}
|
||||
errHTTPBadRequestEmailVerificationCodeInvalid = &errHTTP{40051, http.StatusBadRequest, "invalid request: email verification code invalid or expired", "", nil}
|
||||
errHTTPBadRequestEmailVerificationLinkInvalid = &errHTTP{40051, http.StatusBadRequest, "invalid request: email verification link invalid or expired", "", nil}
|
||||
errHTTPBadRequestEmailAddressNotVerified = &errHTTP{40052, http.StatusBadRequest, "invalid request: email address not verified", "https://ntfy.sh/docs/publish/#e-mail-notifications", nil}
|
||||
errHTTPBadRequestAnonymousEmailNotAllowed = &errHTTP{40053, http.StatusBadRequest, "invalid request: anonymous email sending is not allowed", "https://ntfy.sh/docs/publish/#e-mail-notifications", nil}
|
||||
errHTTPBadRequestResetLinkInvalid = &errHTTP{40054, http.StatusBadRequest, "invalid request: password reset link invalid or expired", "", nil}
|
||||
errHTTPBadRequestTemplateTooLarge = &errHTTP{40056, http.StatusBadRequest, "invalid request: template too large", "https://ntfy.sh/docs/publish/#message-templating", nil}
|
||||
errHTTPBadRequestTitleTooLarge = &errHTTP{40057, http.StatusBadRequest, "invalid request: title is too large", "https://ntfy.sh/docs/publish/#limitations", nil}
|
||||
errHTTPBadRequestTagsTooLarge = &errHTTP{40058, http.StatusBadRequest, "invalid request: tags are too large", "https://ntfy.sh/docs/publish/#limitations", nil}
|
||||
errHTTPNotFound = &errHTTP{40401, http.StatusNotFound, "page not found", "", nil}
|
||||
errHTTPUnauthorized = &errHTTP{40101, http.StatusUnauthorized, "unauthorized", "https://ntfy.sh/docs/publish/#authentication", nil}
|
||||
errHTTPForbidden = &errHTTP{40301, http.StatusForbidden, "forbidden", "https://ntfy.sh/docs/publish/#authentication", nil}
|
||||
@@ -156,6 +161,7 @@ var (
|
||||
errHTTPConflictProvisionedUserChange = &errHTTP{40905, http.StatusConflict, "conflict: cannot change or delete provisioned user", "", nil}
|
||||
errHTTPConflictProvisionedTokenChange = &errHTTP{40906, http.StatusConflict, "conflict: cannot change or delete provisioned token", "", nil}
|
||||
errHTTPConflictEmailExists = &errHTTP{40907, http.StatusConflict, "conflict: email address already exists", "", nil}
|
||||
errHTTPConflictEmailPrimaryElsewhere = &errHTTP{40908, http.StatusConflict, "conflict: email address is the primary email on another account", "", nil}
|
||||
errHTTPGonePhoneVerificationExpired = &errHTTP{41001, http.StatusGone, "phone number verification expired or does not exist", "", nil}
|
||||
errHTTPEntityTooLargeAttachment = &errHTTP{41301, http.StatusRequestEntityTooLarge, "attachment too large, or bandwidth limit reached", "https://ntfy.sh/docs/publish/#limitations", nil}
|
||||
errHTTPEntityTooLargeMatrixRequest = &errHTTP{41302, http.StatusRequestEntityTooLarge, "Matrix request is larger than the max allowed length", "", nil}
|
||||
@@ -165,7 +171,7 @@ var (
|
||||
errHTTPTooManyRequestsLimitSubscriptions = &errHTTP{42903, http.StatusTooManyRequests, "limit reached: too many active subscriptions", "https://ntfy.sh/docs/publish/#limitations", nil}
|
||||
errHTTPTooManyRequestsLimitTotalTopics = &errHTTP{42904, http.StatusTooManyRequests, "limit reached: the total number of topics on the server has been reached, please contact the admin", "https://ntfy.sh/docs/publish/#limitations", nil}
|
||||
errHTTPTooManyRequestsLimitAttachmentBandwidth = &errHTTP{42905, http.StatusTooManyRequests, "limit reached: daily bandwidth reached", "https://ntfy.sh/docs/publish/#limitations", nil}
|
||||
errHTTPTooManyRequestsLimitAccountCreation = &errHTTP{42906, http.StatusTooManyRequests, "limit reached: too many accounts created", "https://ntfy.sh/docs/publish/#limitations", nil} // FIXME document limit
|
||||
errHTTPTooManyRequestsLimitAccountActions = &errHTTP{42906, http.StatusTooManyRequests, "limit reached: too many account requests", "https://ntfy.sh/docs/publish/#limitations", nil} // FIXME document limit
|
||||
errHTTPTooManyRequestsLimitReservations = &errHTTP{42907, http.StatusTooManyRequests, "limit reached: too many topic reservations for this user", "", nil}
|
||||
errHTTPTooManyRequestsLimitMessages = &errHTTP{42908, http.StatusTooManyRequests, "limit reached: daily message quota reached", "https://ntfy.sh/docs/publish/#limitations", nil}
|
||||
errHTTPTooManyRequestsLimitAuthFailure = &errHTTP{42909, http.StatusTooManyRequests, "limit reached: too many auth failures", "https://ntfy.sh/docs/publish/#limitations", nil} // FIXME document limit
|
||||
|
||||
+15
-16
@@ -16,22 +16,21 @@ import (
|
||||
|
||||
// Log tags
|
||||
const (
|
||||
tagStartup = "startup"
|
||||
tagHTTP = "http"
|
||||
tagPublish = "publish"
|
||||
tagSubscribe = "subscribe"
|
||||
tagFirebase = "firebase"
|
||||
tagSMTP = "smtp" // Receive email
|
||||
tagEmail = "email" // Send email
|
||||
tagTwilio = "twilio"
|
||||
tagMessageCache = "message_cache"
|
||||
tagStripe = "stripe"
|
||||
tagAccount = "account"
|
||||
tagManager = "manager"
|
||||
tagResetter = "resetter"
|
||||
tagWebsocket = "websocket"
|
||||
tagMatrix = "matrix"
|
||||
tagWebPush = "webpush"
|
||||
tagStartup = "startup"
|
||||
tagHTTP = "http"
|
||||
tagPublish = "publish"
|
||||
tagSubscribe = "subscribe"
|
||||
tagFirebase = "firebase"
|
||||
tagSMTP = "smtp" // Receive email
|
||||
tagEmail = "email" // Send email
|
||||
tagTwilio = "twilio"
|
||||
tagStripe = "stripe"
|
||||
tagAccount = "account"
|
||||
tagManager = "manager"
|
||||
tagResetter = "resetter"
|
||||
tagWebsocket = "websocket"
|
||||
tagMatrix = "matrix"
|
||||
tagWebPush = "webpush"
|
||||
)
|
||||
|
||||
var (
|
||||
|
||||
+193
-426
File diff suppressed because it is too large
Load Diff
+40
-3
@@ -370,6 +370,39 @@
|
||||
# visitor-topic-creation-limit-burst: 100
|
||||
# visitor-topic-creation-limit-replenish: "1m"
|
||||
|
||||
# Abuse ban-feed: Count HTTP response statuses per visitor and append abusive IPs to a file that
|
||||
# fail2ban (or similar) can tail and ban on sight. This captures ntfy-layer rejections (e.g. ACL
|
||||
# 403s and ntfy's own 429s), so fail2ban does not have to regex-parse the full access log.
|
||||
# - ban-file is the file abusive IPs are appended to; leave empty to disable the feature. Its
|
||||
# directory must exist and be writable by ntfy. Rotate it (e.g. logrotate, copytruncate) so it
|
||||
# cannot grow unbounded.
|
||||
# - ban-window is the rolling window over which weighted strikes are counted, per visitor.
|
||||
# - ban-threshold is the number of weighted strikes per window before a visitor is banned. Each
|
||||
# visitor has ONE strike budget; rejections draw it down, so there is no way to game it by mixing
|
||||
# codes.
|
||||
# - ban-weights assigns a strike weight to a matcher KEY (KEY:WEIGHT). A KEY is an exact ntfy code
|
||||
# ("42909"), a prefix family ("429*"), a bare HTTP status ("403", shorthand for "403*"), or "*".
|
||||
# Longest match wins. A weight of 0 exempts a code (never contributes to a ban), so the legit quota
|
||||
# 429s can be carved out from a "*" catch-all. Heavier weights ban faster (auth-failure floods).
|
||||
#
|
||||
# Each appended line has the exact format
|
||||
# "<RFC3339-UTC-timestamp> <ip> <prefix> <http-code> <ntfy-code>", for example:
|
||||
# 2026-07-17T20:56:32Z 1.2.3.4 1.2.3.4/32 429 42901
|
||||
# 2026-07-17T20:56:32Z 2001:db8::abcd 2001:db8::/64 429 42909
|
||||
# <prefix> is <ip> masked to the rate-limiting prefix (visitor-prefix-bits-ipv4/ipv6); that is the
|
||||
# unit a fail2ban jail should ban, so a whole IPv6 subnet is banned as one.
|
||||
#
|
||||
# ban-file: "/var/log/ntfy-ban.log"
|
||||
# ban-window: "1m"
|
||||
# ban-threshold: 100
|
||||
# ban-weights:
|
||||
# - "42909:10" # auth-failure flood: bans in ~10
|
||||
# - "42908:0" # daily message quota reached -> legit, never counts
|
||||
# - "42903:0" # subscription limit -> legit
|
||||
# - "42905:0" # daily bandwidth reached -> legit
|
||||
# - "42910:0" # daily phone call quota reached -> legit
|
||||
# - "*:1" # everything else 4xx/5xx
|
||||
|
||||
# Rate limiting: IPv4/IPv6 address prefix bits used for rate limiting
|
||||
# - visitor-prefix-bits-ipv4: number of bits of the IPv4 address to use for rate limiting (default: 32, full address)
|
||||
# - visitor-prefix-bits-ipv6: number of bits of the IPv6 address to use for rate limiting (default: 64, /64 subnet)
|
||||
@@ -384,7 +417,10 @@
|
||||
|
||||
# Rate limiting: Attachment size and bandwidth limits per visitor:
|
||||
# - visitor-attachment-total-size-limit is the total storage limit used for attachments per visitor
|
||||
# - visitor-attachment-daily-bandwidth-limit is the total daily attachment download/upload traffic limit per visitor
|
||||
# - visitor-attachment-daily-bandwidth-limit is the total daily traffic limit per visitor. It covers
|
||||
# attachment downloads/uploads AND messages replayed from the message cache by poll requests. A
|
||||
# poll without a "since" cursor returns the topic's entire cache, so a busy topic can be re-read
|
||||
# for many times its own size; charging it here caps what one visitor can pull per day.
|
||||
#
|
||||
# visitor-attachment-total-size-limit: "100M"
|
||||
# visitor-attachment-daily-bandwidth-limit: "500M"
|
||||
@@ -423,8 +459,9 @@
|
||||
# doing, and/or secure access to the endpoint in your reverse proxy.
|
||||
#
|
||||
# - enable-metrics enables the /metrics endpoint for the default ntfy server (i.e. HTTP, HTTPS and/or Unix socket)
|
||||
# - metrics-listen-http exposes the metrics endpoint via a dedicated [IP]:port. If set, this option implicitly
|
||||
# enables metrics as well, e.g. "10.0.1.1:9090" or ":9090"
|
||||
# - metrics-listen-http moves the metrics endpoint to a dedicated [IP]:port, e.g. "10.0.1.1:9090" or ":9090".
|
||||
# It implicitly enables metrics. If set, the metrics are served only on that dedicated port, and the default
|
||||
# ntfy server does not serve /metrics, even if enable-metrics is also set.
|
||||
#
|
||||
# enable-metrics: false
|
||||
# metrics-listen-http:
|
||||
|
||||
+308
-60
@@ -10,13 +10,16 @@ import (
|
||||
|
||||
"heckel.io/ntfy/v2/log"
|
||||
"heckel.io/ntfy/v2/model"
|
||||
"heckel.io/ntfy/v2/twilio"
|
||||
"heckel.io/ntfy/v2/user"
|
||||
"heckel.io/ntfy/v2/util"
|
||||
)
|
||||
|
||||
const (
|
||||
syncTopicAccountSyncEvent = "sync"
|
||||
tokenExpiryDuration = 72 * time.Hour // Extend tokens by this much
|
||||
syncTopicAccountSyncEvent = "sync"
|
||||
tokenExpiryDuration = 72 * time.Hour // Extend tokens by this much
|
||||
emailVerificationTokenExpiry = 24 * time.Hour // Magic-link lifetime for email verification
|
||||
passwordResetTokenExpiry = time.Hour // Magic-link lifetime for password reset (higher-privilege -> shorter)
|
||||
)
|
||||
|
||||
func (s *Server) handleAccountCreate(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||
@@ -27,14 +30,17 @@ func (s *Server) handleAccountCreate(w http.ResponseWriter, r *http.Request, v *
|
||||
} else if u != nil {
|
||||
return errHTTPUnauthorized // Cannot create account from user context
|
||||
}
|
||||
if !v.AccountCreationAllowed() {
|
||||
return errHTTPTooManyRequestsLimitAccountCreation
|
||||
if !v.AccountActionAllowed() {
|
||||
return errHTTPTooManyRequestsLimitAccountActions
|
||||
}
|
||||
}
|
||||
newAccount, err := readJSONWithLimit[apiAccountCreateRequest](r.Body, jsonBodyBytesLimit, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if newAccount.Email != "" && !emailAddressRegex.MatchString(newAccount.Email) {
|
||||
return errHTTPBadRequestEmailAddressInvalid
|
||||
}
|
||||
if existingUser, _ := s.userManager.User(newAccount.Username); existingUser != nil {
|
||||
return errHTTPConflictUserExists
|
||||
}
|
||||
@@ -45,7 +51,17 @@ func (s *Server) handleAccountCreate(w http.ResponseWriter, r *http.Request, v *
|
||||
}
|
||||
return err
|
||||
}
|
||||
v.AccountCreated()
|
||||
v.AccountActionPerformed()
|
||||
// If an email was provided and email sending is configured, start verification (best-effort).
|
||||
// The address becomes the primary email on verify (the new account has no primary yet); a
|
||||
// failure to send must not fail signup, so we only log it.
|
||||
if newAccount.Email != "" && s.mailer != nil {
|
||||
if u, err := s.userManager.User(newAccount.Username); err != nil {
|
||||
logvr(v, r).Tag(tagAccount).Err(err).Warn("Failed to load new user for email verification")
|
||||
} else if err := s.enqueueEmailVerification(u.ID, newAccount.Email); err != nil {
|
||||
logvr(v, r).Tag(tagAccount).Err(err).Warn("Failed to send signup email verification")
|
||||
}
|
||||
}
|
||||
return s.writeJSON(w, newSuccessResponse())
|
||||
}
|
||||
|
||||
@@ -92,6 +108,12 @@ func (s *Server) handleAccountGet(w http.ResponseWriter, r *http.Request, v *vis
|
||||
if u.Prefs.Language != nil {
|
||||
response.Language = *u.Prefs.Language
|
||||
}
|
||||
if u.Prefs.DateFormat != nil {
|
||||
response.DateFormat = *u.Prefs.DateFormat
|
||||
}
|
||||
if u.Prefs.TimeFormat != nil {
|
||||
response.TimeFormat = *u.Prefs.TimeFormat
|
||||
}
|
||||
if u.Prefs.Notification != nil {
|
||||
response.Notification = u.Prefs.Notification
|
||||
}
|
||||
@@ -160,13 +182,25 @@ func (s *Server) handleAccountGet(w http.ResponseWriter, r *http.Request, v *vis
|
||||
response.PhoneNumbers = phoneNumbers
|
||||
}
|
||||
}
|
||||
if s.mailSender != nil {
|
||||
if s.mailer != nil {
|
||||
emails, err := s.userManager.Emails(u.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(emails) > 0 {
|
||||
response.Emails = emails
|
||||
pendingEmails, err := s.userManager.PendingEmails(u.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Combine verified (with primary flag) and pending (unverified) into one list
|
||||
emailInfos := make([]*apiAccountEmailInfo, 0, len(emails)+len(pendingEmails))
|
||||
for _, email := range emails {
|
||||
emailInfos = append(emailInfos, &apiAccountEmailInfo{Address: email.Address, Primary: email.Primary})
|
||||
}
|
||||
for _, email := range pendingEmails {
|
||||
emailInfos = append(emailInfos, &apiAccountEmailInfo{Address: email, Pending: true})
|
||||
}
|
||||
if len(emailInfos) > 0 {
|
||||
response.Emails = emailInfos
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -235,6 +269,24 @@ func (s *Server) handleAccountPasswordChange(w http.ResponseWriter, r *http.Requ
|
||||
return s.writeJSON(w, newSuccessResponse())
|
||||
}
|
||||
|
||||
// handleAccountLogin authenticates a username-or-email + password (via the ensureUser wrapper's
|
||||
// Basic Auth), mints a session token, and returns it together with the canonical username. Unlike
|
||||
// the token endpoint (which exists to mint arbitrary API tokens), this endpoint's job is to log a
|
||||
// user in, so it also reports who they are (the identifier they typed may be a primary email).
|
||||
func (s *Server) handleAccountLogin(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||
u := v.User()
|
||||
logvr(v, r).Tag(tagAccount).Info("Logging in user %s", u.Name)
|
||||
token, err := s.userManager.CreateToken(u.ID, "", time.Now().Add(tokenExpiryDuration), v.IP(), false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
response := &apiAccountLoginResponse{
|
||||
Token: token.Value,
|
||||
Username: u.Name,
|
||||
}
|
||||
return s.writeJSON(w, response)
|
||||
}
|
||||
|
||||
func (s *Server) handleAccountTokenCreate(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||
req, err := readJSONWithLimit[apiAccountTokenIssueRequest](r.Body, jsonBodyBytesLimit, true) // Allow empty body!
|
||||
if err != nil {
|
||||
@@ -346,6 +398,12 @@ func (s *Server) handleAccountSettingsChange(w http.ResponseWriter, r *http.Requ
|
||||
if newPrefs.Language != nil {
|
||||
prefs.Language = newPrefs.Language
|
||||
}
|
||||
if newPrefs.DateFormat != nil {
|
||||
prefs.DateFormat = newPrefs.DateFormat
|
||||
}
|
||||
if newPrefs.TimeFormat != nil {
|
||||
prefs.TimeFormat = newPrefs.TimeFormat
|
||||
}
|
||||
if newPrefs.Notification != nil {
|
||||
if prefs.Notification == nil {
|
||||
prefs.Notification = &user.NotificationPrefs{}
|
||||
@@ -574,7 +632,7 @@ func (s *Server) handleAccountPhoneNumberVerify(w http.ResponseWriter, r *http.R
|
||||
}
|
||||
// Actually add the unverified number, and send verification
|
||||
logvr(v, r).Tag(tagAccount).Field("phone_number", req.Number).Debug("Sending phone number verification")
|
||||
if err := s.verifyPhoneNumber(v, r, req.Number, req.Channel); err != nil {
|
||||
if err := s.twilio.Verify(req.Number, req.Channel); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.writeJSON(w, newSuccessResponse())
|
||||
@@ -589,7 +647,10 @@ func (s *Server) handleAccountPhoneNumberAdd(w http.ResponseWriter, r *http.Requ
|
||||
if !phoneNumberRegex.MatchString(req.Number) {
|
||||
return errHTTPBadRequestPhoneNumberInvalid
|
||||
}
|
||||
if err := s.verifyPhoneNumberCheck(v, r, req.Number, req.Code); err != nil {
|
||||
if err := s.twilio.CheckVerify(req.Number, req.Code); err != nil {
|
||||
if errors.Is(err, twilio.ErrVerificationExpired) {
|
||||
return errHTTPGonePhoneVerificationExpired
|
||||
}
|
||||
return err
|
||||
}
|
||||
logvr(v, r).Tag(tagAccount).Field("phone_number", req.Number).Debug("Adding phone number as verified")
|
||||
@@ -615,83 +676,254 @@ func (s *Server) handleAccountPhoneNumberDelete(w http.ResponseWriter, r *http.R
|
||||
return s.writeJSON(w, newSuccessResponse())
|
||||
}
|
||||
|
||||
func (s *Server) handleAccountEmailVerify(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||
// handleAccountEmailAdd starts email verification (PUT /v1/account/email): it generates a
|
||||
// magic-link token, stores a pending verification, and emails the link. The address is NOT
|
||||
// added to the verified list until the user clicks the link (handleAccountEmailVerify).
|
||||
func (s *Server) handleAccountEmailAdd(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||
u := v.User()
|
||||
req, err := readJSONWithLimit[apiAccountEmailVerifyRequest](r.Body, jsonBodyBytesLimit, false)
|
||||
req, err := readJSONWithLimit[apiAccountEmailRequest](r.Body, jsonBodyBytesLimit, false)
|
||||
if err != nil {
|
||||
return err
|
||||
} else if !emailAddressRegex.MatchString(req.Email) {
|
||||
return errHTTPBadRequestEmailAddressInvalid
|
||||
}
|
||||
// Check user is allowed to add emails
|
||||
if u == nil {
|
||||
return errHTTPUnauthorized
|
||||
} else if u.IsUser() && u.Tier != nil && u.Tier.EmailLimit == 0 {
|
||||
// Check user is allowed to add emails (the tier email limit gates the feature)
|
||||
if u.IsUser() && u.Tier != nil && u.Tier.EmailLimit == 0 {
|
||||
return errHTTPUnauthorized
|
||||
} else if u.IsUser() && u.Tier == nil && s.config.VisitorEmailLimitBurst == 0 {
|
||||
return errHTTPUnauthorized
|
||||
}
|
||||
// Check if email already exists
|
||||
// Reject if already verified on this account (pending re-requests are fine -- they replace)
|
||||
emails, err := s.userManager.Emails(u.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
} else if util.Contains(emails, req.Email) {
|
||||
} else if emails.Contains(req.Email) {
|
||||
return errHTTPConflictEmailExists
|
||||
}
|
||||
// Check email rate limit (counts against the user's email quota)
|
||||
// Rate limit (counts against the user's email quota)
|
||||
if !v.EmailAllowed() {
|
||||
return errHTTPTooManyRequestsLimitEmails
|
||||
}
|
||||
// Send verification email
|
||||
logvr(v, r).Tag(tagAccount).Field("email", req.Email).Info("Sending email verification")
|
||||
if err := s.mailSender.SendVerification(req.Email); err != nil {
|
||||
logvr(v, r).Tag(tagAccount).Field("email", req.Email).Info("Starting email verification")
|
||||
if err := s.enqueueEmailVerification(u.ID, req.Email); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.writeJSON(w, newSuccessResponse())
|
||||
}
|
||||
|
||||
func (s *Server) handleAccountEmailAdd(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||
// handleAccountEmailVerify performs verification from the (unauthenticated) landing page
|
||||
// (POST /v1/account/email/verify): it validates the raw token, adds the address to the user's
|
||||
// verified emails, and -- if the user has no primary yet -- promotes it. No auth is required;
|
||||
// the token binds the action to a user, so the click works from a logged-out mail client.
|
||||
func (s *Server) handleAccountEmailVerify(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||
req, err := readJSONWithLimit[apiAccountEmailVerifyRequest](r.Body, jsonBodyBytesLimit, false)
|
||||
if err != nil {
|
||||
return err
|
||||
} else if req.Token == "" {
|
||||
return errHTTPBadRequestEmailVerificationLinkInvalid
|
||||
}
|
||||
m, err := s.userManager.VerifyEmail(req.Token)
|
||||
if errors.Is(err, user.ErrMagicLinkNotFound) {
|
||||
return errHTTPBadRequestEmailVerificationLinkInvalid
|
||||
} else if err != nil {
|
||||
return err
|
||||
}
|
||||
logvr(v, r).Tag(tagAccount).Field("email", m.Email).Info("Email verified")
|
||||
// Refresh the verified user's other sessions. The request is unauthenticated (v.User() is
|
||||
// usually nil), so resolve the user from the token row and publish to their sync topic.
|
||||
s.publishSyncEventForUserIDAsync(v, m.UserID)
|
||||
return s.writeJSON(w, newSuccessResponse())
|
||||
}
|
||||
|
||||
// handleAccountEmailDelete removes an email address, whether verified or still pending
|
||||
// (DELETE /v1/account/email). Removing the primary leaves the account with no primary.
|
||||
func (s *Server) handleAccountEmailDelete(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||
u := v.User()
|
||||
req, err := readJSONWithLimit[apiAccountEmailAddRequest](r.Body, jsonBodyBytesLimit, false)
|
||||
req, err := readJSONWithLimit[apiAccountEmailRequest](r.Body, jsonBodyBytesLimit, false)
|
||||
if err != nil {
|
||||
return err
|
||||
} else if !emailAddressRegex.MatchString(req.Email) {
|
||||
return errHTTPBadRequestEmailAddressInvalid
|
||||
} else if !s.mailSender.CheckVerification(req.Email, req.Code) {
|
||||
return errHTTPBadRequestEmailVerificationCodeInvalid
|
||||
}
|
||||
logvr(v, r).Tag(tagAccount).Field("email", req.Email).Info("Adding email as verified")
|
||||
if err := s.userManager.AddEmail(u.ID, req.Email); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.writeJSON(w, newSuccessResponse())
|
||||
}
|
||||
|
||||
func (s *Server) handleAccountEmailDelete(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||
u := v.User()
|
||||
req, err := readJSONWithLimit[apiAccountEmailVerifyRequest](r.Body, jsonBodyBytesLimit, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !emailAddressRegex.MatchString(req.Email) {
|
||||
return errHTTPBadRequestEmailAddressInvalid
|
||||
}
|
||||
logvr(v, r).Tag(tagAccount).Field("email", req.Email).Debug("Deleting verified email")
|
||||
logvr(v, r).Tag(tagAccount).Field("email", req.Email).Debug("Deleting email (verified or pending)")
|
||||
if err := s.userManager.RemoveEmail(u.ID, req.Email); err != nil {
|
||||
return err
|
||||
}
|
||||
// Also drop any pending verification for the address (no-op if there is none)
|
||||
if err := s.userManager.DeleteEmailVerification(u.ID, req.Email); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.writeJSON(w, newSuccessResponse())
|
||||
}
|
||||
|
||||
// convertEmailAddress checks the email address against the user's verified email list.
|
||||
// If smtp-sender-verify is false (default), the email is passed through as-is for
|
||||
// backwards compatibility. If true, the user must be authenticated and the email must be
|
||||
// in their verified list. "yes"/"true"/"1" resolves to the first verified email.
|
||||
func (s *Server) convertEmailAddress(u *user.User, email string) (string, *errHTTP) {
|
||||
if !s.config.SMTPSenderVerify {
|
||||
if toBool(email) {
|
||||
return "", errHTTPBadRequestEmailAddressInvalid
|
||||
// handleAccountEmailSetPrimary marks an already-verified email as the user's primary (recovery)
|
||||
// email (POST /v1/account/email/primary).
|
||||
func (s *Server) handleAccountEmailSetPrimary(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||
u := v.User()
|
||||
req, err := readJSONWithLimit[apiAccountEmailRequest](r.Body, jsonBodyBytesLimit, false)
|
||||
if err != nil {
|
||||
return err
|
||||
} else if !emailAddressRegex.MatchString(req.Email) {
|
||||
return errHTTPBadRequestEmailAddressInvalid
|
||||
}
|
||||
logvr(v, r).Tag(tagAccount).Field("email", req.Email).Info("Setting primary email")
|
||||
err = s.userManager.SetPrimaryEmail(u.ID, req.Email)
|
||||
if errors.Is(err, user.ErrEmailPrimaryElsewhere) {
|
||||
return errHTTPConflictEmailPrimaryElsewhere
|
||||
} else if errors.Is(err, user.ErrEmailNotFound) {
|
||||
return errHTTPBadRequestEmailAddressNotVerified
|
||||
} else if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.writeJSON(w, newSuccessResponse())
|
||||
}
|
||||
|
||||
// handleAccountEmailResend re-sends a pending email verification (POST /v1/account/email/resend).
|
||||
func (s *Server) handleAccountEmailResend(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||
u := v.User()
|
||||
req, err := readJSONWithLimit[apiAccountEmailRequest](r.Body, jsonBodyBytesLimit, false)
|
||||
if err != nil {
|
||||
return err
|
||||
} else if !emailAddressRegex.MatchString(req.Email) {
|
||||
return errHTTPBadRequestEmailAddressInvalid
|
||||
}
|
||||
// Only resend for an address that is actually pending on this account
|
||||
pending, err := s.userManager.PendingEmails(u.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
} else if !util.Contains(pending, req.Email) {
|
||||
return errHTTPBadRequestEmailAddressInvalid
|
||||
}
|
||||
if !v.EmailAllowed() {
|
||||
return errHTTPTooManyRequestsLimitEmails
|
||||
}
|
||||
logvr(v, r).Tag(tagAccount).Field("email", req.Email).Info("Resending email verification")
|
||||
if err := s.enqueueEmailVerification(u.ID, req.Email); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.writeJSON(w, newSuccessResponse())
|
||||
}
|
||||
|
||||
// enqueueEmailVerification generates a magic-link token for the given address, stores the
|
||||
// pending verification (replacing any existing one), and emails the link. Shared by the add,
|
||||
// resend, signup, and Stripe paths. Requires base-url to build an absolute link.
|
||||
func (s *Server) enqueueEmailVerification(userID, email string) error {
|
||||
if s.config.BaseURL == "" {
|
||||
return errHTTPInternalErrorMissingBaseURL
|
||||
}
|
||||
token, err := s.userManager.AddMagicLink(user.MagicLinkKindEmailVerify, userID, email, emailVerificationTokenExpiry)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
link := s.config.BaseURL + webAppEmailVerifyPathPrefix + token
|
||||
return s.mailer.SendEmailVerification(email, link)
|
||||
}
|
||||
|
||||
// handleAccountPasswordResetRequest starts a password reset (POST /v1/account/password/reset/request,
|
||||
// unauthenticated). It resolves the identifier (username or primary email) to at most one account
|
||||
// and emails a reset link to that account's primary email. The response is always a uniform 200,
|
||||
// regardless of whether anything matched, so it cannot be used to probe for accounts.
|
||||
func (s *Server) handleAccountPasswordResetRequest(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||
req, err := readJSONWithLimit[apiAccountPasswordResetRequest](r.Body, jsonBodyBytesLimit, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Rate limit via the shared per-visitor account-creation bucket (no new limiter/config)
|
||||
if !v.AccountActionAllowed() {
|
||||
return errHTTPTooManyRequestsLimitAccountActions
|
||||
}
|
||||
v.AccountActionPerformed() // Consume a token on every request (including no-match), to throttle probing
|
||||
identifier := strings.TrimSpace(req.Identifier)
|
||||
if identifier != "" && s.config.BaseURL != "" {
|
||||
if userID, email, ok := s.resolveResetPasswordTarget(identifier); ok {
|
||||
token, err := s.userManager.AddMagicLink(user.MagicLinkKindPasswordReset, userID, "", passwordResetTokenExpiry)
|
||||
if err != nil {
|
||||
logvr(v, r).Tag(tagAccount).Err(err).Warn("Failed to create password reset token")
|
||||
} else {
|
||||
link := s.config.BaseURL + webAppPasswordResetPathPrefix + token
|
||||
logvr(v, r).Tag(tagAccount).Field("user_id", userID).Info("Sending password reset link")
|
||||
if err := s.mailer.SendPasswordReset(email, link); err != nil {
|
||||
logvr(v, r).Tag(tagAccount).Err(err).Warn("Failed to send password reset email")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
logvr(v, r).Tag(tagAccount).Debug("Password reset requested for unknown identifier (uniform response)")
|
||||
}
|
||||
}
|
||||
return s.writeJSON(w, newSuccessResponse())
|
||||
}
|
||||
|
||||
// resolveResetPasswordTarget resolves a reset identifier (username or primary email) to a single account
|
||||
// and its primary email. It applies the reset policy on top of the lookup: provisioned users are
|
||||
// excluded, and ok=false is returned unless the account has a verified primary email (reset
|
||||
// requires one, and that is where the link is sent).
|
||||
func (s *Server) resolveResetPasswordTarget(identifier string) (userID string, email string, ok bool) {
|
||||
u, err := s.userManager.UserByEmailOrUsername(identifier)
|
||||
if err != nil || u == nil || u.Provisioned {
|
||||
return "", "", false
|
||||
}
|
||||
primary, err := s.userManager.PrimaryEmail(u.ID)
|
||||
if err != nil || primary == "" {
|
||||
return "", "", false
|
||||
}
|
||||
return u.ID, primary, true
|
||||
}
|
||||
|
||||
// handleAccountPasswordReset performs the reset (POST /v1/account/password/reset, unauthenticated):
|
||||
// it validates the token and sets the new password. Existing access tokens stay valid.
|
||||
func (s *Server) handleAccountPasswordReset(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||
req, err := readJSONWithLimit[apiAccountPasswordResetConfirmRequest](r.Body, jsonBodyBytesLimit, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if req.Token == "" {
|
||||
return errHTTPBadRequestResetLinkInvalid
|
||||
} else if req.Password == "" {
|
||||
return errHTTPBadRequest
|
||||
}
|
||||
err = s.userManager.ResetPassword(req.Token, req.Password)
|
||||
if errors.Is(err, user.ErrMagicLinkNotFound) || errors.Is(err, user.ErrProvisionedUserChange) {
|
||||
return errHTTPBadRequestResetLinkInvalid // Generic 400 (provisioned users can't be reset; don't leak that)
|
||||
} else if err != nil {
|
||||
return err
|
||||
}
|
||||
logvr(v, r).Tag(tagAccount).Info("Password reset performed")
|
||||
return s.writeJSON(w, newSuccessResponse())
|
||||
}
|
||||
|
||||
// convertEmailAddress resolves the X-Email value to the address ntfy should send to.
|
||||
//
|
||||
// "yes"/"true"/"1" resolves to the user's primary verified address -- or, if no primary is
|
||||
// designated (e.g. a provisioned user), the first verified address (alphabetically). This is
|
||||
// independent of smtp-sender-verify: it only requires an authenticated user with a verified
|
||||
// address, since it means "send to my own email".
|
||||
//
|
||||
// A literal address is sent as-is when smtp-sender-verify is false (the default, backwards
|
||||
// compatible); when true, the address must be one the user has verified.
|
||||
func (s *Server) convertEmailAddress(u *user.User, email string) (string, *errHTTP) {
|
||||
if toBool(email) {
|
||||
if u == nil {
|
||||
return "", errHTTPBadRequestAnonymousEmailNotAllowed
|
||||
} else if s.userManager == nil {
|
||||
return "", errHTTPBadRequestEmailAddressNotVerified
|
||||
}
|
||||
primary, err := s.userManager.PrimaryEmail(u.ID)
|
||||
if err != nil {
|
||||
return "", errHTTPInternalError
|
||||
} else if primary != "" {
|
||||
return primary, nil
|
||||
}
|
||||
// No primary designated -> fall back to the first verified address, if any
|
||||
emails, err := s.userManager.Emails(u.ID)
|
||||
if err != nil {
|
||||
return "", errHTTPInternalError
|
||||
} else if len(emails) > 0 {
|
||||
return emails[0].Address, nil
|
||||
}
|
||||
return "", errHTTPBadRequestEmailAddressNotVerified
|
||||
}
|
||||
// A literal address
|
||||
if !s.config.SMTPSenderVerify {
|
||||
return email, nil
|
||||
} else if u == nil {
|
||||
return "", errHTTPBadRequestAnonymousEmailNotAllowed
|
||||
@@ -701,12 +933,7 @@ func (s *Server) convertEmailAddress(u *user.User, email string) (string, *errHT
|
||||
emails, err := s.userManager.Emails(u.ID)
|
||||
if err != nil {
|
||||
return "", errHTTPInternalError
|
||||
} else if len(emails) == 0 {
|
||||
return "", errHTTPBadRequestEmailAddressNotVerified
|
||||
}
|
||||
if toBool(email) {
|
||||
return emails[0], nil
|
||||
} else if util.Contains(emails, email) {
|
||||
} else if emails.Contains(email) {
|
||||
return email, nil
|
||||
}
|
||||
return "", errHTTPBadRequestEmailAddressNotVerified
|
||||
@@ -721,9 +948,30 @@ func (s *Server) publishSyncEventAsync(v *visitor) {
|
||||
}()
|
||||
}
|
||||
|
||||
// publishSyncEvent publishes a sync message to the user's sync topic
|
||||
// publishSyncEvent publishes a sync message to the authenticated user's sync topic
|
||||
func (s *Server) publishSyncEvent(v *visitor) error {
|
||||
u := v.User()
|
||||
return s.publishSyncEventForUser(v, v.User())
|
||||
}
|
||||
|
||||
// publishSyncEventForUserIDAsync publishes a sync event to the sync topic of the user with the
|
||||
// given ID, resolving the user first. Used by the unauthenticated email-verify handler, where
|
||||
// the request visitor has no associated user but the token identifies the account to refresh.
|
||||
func (s *Server) publishSyncEventForUserIDAsync(v *visitor, userID string) {
|
||||
go func() {
|
||||
u, err := s.userManager.UserByID(userID)
|
||||
if err != nil {
|
||||
logv(v).Err(err).Trace("Error loading user for sync event")
|
||||
return
|
||||
}
|
||||
if err := s.publishSyncEventForUser(v, u); err != nil {
|
||||
logv(v).Err(err).Trace("Error publishing to user's sync topic")
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// publishSyncEventForUser publishes a sync message to the given user's sync topic, using v as
|
||||
// the publishing visitor (for rate-limit accounting). No-op if the user has no sync topic.
|
||||
func (s *Server) publishSyncEventForUser(v *visitor, u *user.User) error {
|
||||
if u == nil || u.SyncTopic == "" {
|
||||
return nil
|
||||
}
|
||||
@@ -737,7 +985,7 @@ func (s *Server) publishSyncEvent(v *visitor) error {
|
||||
return err
|
||||
}
|
||||
m := model.NewDefaultMessage(syncTopic.ID, string(messageBytes))
|
||||
if err := syncTopic.Publish(v, m); err != nil {
|
||||
if err := s.dispatch(v, syncTopic, m, dispatchOpts{}); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
|
||||
@@ -0,0 +1,468 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"heckel.io/ntfy/v2/model"
|
||||
"heckel.io/ntfy/v2/user"
|
||||
"heckel.io/ntfy/v2/util"
|
||||
)
|
||||
|
||||
// captureMailer is a fake mailer that records the magic links it is asked to send, so tests can
|
||||
// "click" them without a real SMTP server. The notification side is a no-op.
|
||||
type captureMailer struct {
|
||||
verifyLinks map[string]string // email -> verification link
|
||||
resetLinks map[string]string // email -> reset link
|
||||
}
|
||||
|
||||
func newCaptureMailer() *captureMailer {
|
||||
return &captureMailer{verifyLinks: map[string]string{}, resetLinks: map[string]string{}}
|
||||
}
|
||||
|
||||
func (c *captureMailer) SendEmailVerification(to, link string) error {
|
||||
c.verifyLinks[to] = link
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *captureMailer) SendPasswordReset(to, link string) error {
|
||||
c.resetLinks[to] = link
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *captureMailer) SendNotification(to string, m *model.Message, senderIP string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *captureMailer) NotificationCounts() (total int64, success int64, failure int64) {
|
||||
return 0, 0, 0
|
||||
}
|
||||
|
||||
// newEmailTestServer creates a server with email sending "enabled" (SMTP + base-url configured)
|
||||
// and a capturing mailer injected, plus a tier-less user "ben" logged in via basic auth.
|
||||
func newEmailTestServer(t *testing.T, databaseURL string) (*Server, *captureMailer, map[string]string) {
|
||||
conf := newTestConfigWithAuthFile(t, databaseURL)
|
||||
conf.SMTPSenderAddr = "localhost:25"
|
||||
conf.SMTPSenderFrom = "noreply@example.com"
|
||||
conf.BaseURL = "https://ntfy.example.com"
|
||||
s := newTestServer(t, conf)
|
||||
mailer := newCaptureMailer()
|
||||
s.mailer = mailer
|
||||
require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser, false))
|
||||
auth := map[string]string{"Authorization": util.BasicAuth("ben", "ben")}
|
||||
return s, mailer, auth
|
||||
}
|
||||
|
||||
func getAccount(t *testing.T, s *Server, auth map[string]string) *apiAccountResponse {
|
||||
rr := request(t, s, "GET", "/v1/account", "", auth)
|
||||
require.Equal(t, 200, rr.Code)
|
||||
account, err := util.UnmarshalJSON[apiAccountResponse](io.NopCloser(rr.Body))
|
||||
require.Nil(t, err)
|
||||
return account
|
||||
}
|
||||
|
||||
// verifiedAddrs / pendingAddrs / primaryAddr extract the addresses from the structured email
|
||||
// list returned by GET /v1/account, so assertions stay readable.
|
||||
func verifiedAddrs(account *apiAccountResponse) []string {
|
||||
addrs := make([]string, 0)
|
||||
for _, e := range account.Emails {
|
||||
if !e.Pending {
|
||||
addrs = append(addrs, e.Address)
|
||||
}
|
||||
}
|
||||
return addrs
|
||||
}
|
||||
|
||||
func pendingAddrs(account *apiAccountResponse) []string {
|
||||
addrs := make([]string, 0)
|
||||
for _, e := range account.Emails {
|
||||
if e.Pending {
|
||||
addrs = append(addrs, e.Address)
|
||||
}
|
||||
}
|
||||
return addrs
|
||||
}
|
||||
|
||||
func primaryAddr(account *apiAccountResponse) string {
|
||||
for _, e := range account.Emails {
|
||||
if e.Primary {
|
||||
return e.Address
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func tokenFromLink(t *testing.T, link, prefix string) string {
|
||||
require.True(t, strings.HasPrefix(link, prefix), "link %q missing prefix %q", link, prefix)
|
||||
return strings.TrimPrefix(link, prefix)
|
||||
}
|
||||
|
||||
func TestAccount_Email_AddVerifySetsPrimary(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
s, mailer, auth := newEmailTestServer(t, databaseURL)
|
||||
defer s.closeDatabases()
|
||||
|
||||
// Start verification
|
||||
rr := request(t, s, "PUT", "/v1/account/email", `{"email":"ben@example.com"}`, auth)
|
||||
require.Equal(t, 200, rr.Code)
|
||||
|
||||
// Pending, not yet verified, no primary
|
||||
account := getAccount(t, s, auth)
|
||||
require.Equal(t, []string{"ben@example.com"}, pendingAddrs(account))
|
||||
require.Empty(t, verifiedAddrs(account))
|
||||
require.Equal(t, "", primaryAddr(account))
|
||||
|
||||
// "Click" the captured link (unauthenticated POST)
|
||||
token := tokenFromLink(t, mailer.verifyLinks["ben@example.com"], "https://ntfy.example.com/account/email/verify/")
|
||||
rr = request(t, s, "POST", "/v1/account/email/verify", fmt.Sprintf(`{"token":"%s"}`, token), nil)
|
||||
require.Equal(t, 200, rr.Code)
|
||||
|
||||
// Now verified + primary, no longer pending
|
||||
account = getAccount(t, s, auth)
|
||||
require.Equal(t, []string{"ben@example.com"}, verifiedAddrs(account))
|
||||
require.Equal(t, "ben@example.com", primaryAddr(account))
|
||||
require.Empty(t, pendingAddrs(account))
|
||||
})
|
||||
}
|
||||
|
||||
func TestAccount_Email_VerifyInvalidToken(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
s, _, _ := newEmailTestServer(t, databaseURL)
|
||||
defer s.closeDatabases()
|
||||
|
||||
rr := request(t, s, "POST", "/v1/account/email/verify", `{"token":"doesnotexist"}`, nil)
|
||||
require.Equal(t, 400, rr.Code)
|
||||
require.Equal(t, 40051, toHTTPError(t, rr.Body.String()).Code)
|
||||
|
||||
// Empty token also rejected
|
||||
rr = request(t, s, "POST", "/v1/account/email/verify", `{"token":""}`, nil)
|
||||
require.Equal(t, 400, rr.Code)
|
||||
})
|
||||
}
|
||||
|
||||
func TestAccount_Email_DeletePending(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
s, _, auth := newEmailTestServer(t, databaseURL)
|
||||
defer s.closeDatabases()
|
||||
|
||||
require.Equal(t, 200, request(t, s, "PUT", "/v1/account/email", `{"email":"ben@example.com"}`, auth).Code)
|
||||
require.Equal(t, []string{"ben@example.com"}, pendingAddrs(getAccount(t, s, auth)))
|
||||
|
||||
// Deleting the pending address clears it (no verification ever happened)
|
||||
require.Equal(t, 200, request(t, s, "DELETE", "/v1/account/email", `{"email":"ben@example.com"}`, auth).Code)
|
||||
account := getAccount(t, s, auth)
|
||||
require.Empty(t, pendingAddrs(account))
|
||||
require.Empty(t, verifiedAddrs(account))
|
||||
})
|
||||
}
|
||||
|
||||
func TestAccount_Email_Resend(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
s, mailer, auth := newEmailTestServer(t, databaseURL)
|
||||
defer s.closeDatabases()
|
||||
|
||||
require.Equal(t, 200, request(t, s, "PUT", "/v1/account/email", `{"email":"ben@example.com"}`, auth).Code)
|
||||
firstLink := mailer.verifyLinks["ben@example.com"]
|
||||
require.NotEmpty(t, firstLink)
|
||||
|
||||
// Resend issues a fresh link (the old one is replaced)
|
||||
require.Equal(t, 200, request(t, s, "POST", "/v1/account/email/resend", `{"email":"ben@example.com"}`, auth).Code)
|
||||
require.NotEqual(t, firstLink, mailer.verifyLinks["ben@example.com"])
|
||||
|
||||
// The old token no longer verifies; the new one does
|
||||
oldToken := tokenFromLink(t, firstLink, "https://ntfy.example.com/account/email/verify/")
|
||||
require.Equal(t, 400, request(t, s, "POST", "/v1/account/email/verify", fmt.Sprintf(`{"token":"%s"}`, oldToken), nil).Code)
|
||||
newToken := tokenFromLink(t, mailer.verifyLinks["ben@example.com"], "https://ntfy.example.com/account/email/verify/")
|
||||
require.Equal(t, 200, request(t, s, "POST", "/v1/account/email/verify", fmt.Sprintf(`{"token":"%s"}`, newToken), nil).Code)
|
||||
|
||||
// Resending for a non-pending address is rejected
|
||||
require.Equal(t, 400, request(t, s, "POST", "/v1/account/email/resend", `{"email":"never@example.com"}`, auth).Code)
|
||||
})
|
||||
}
|
||||
|
||||
func TestAccount_Email_SetPrimaryCollision(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
s, mailer, auth := newEmailTestServer(t, databaseURL)
|
||||
defer s.closeDatabases()
|
||||
|
||||
// ben verifies shared@ -> becomes his primary
|
||||
require.Equal(t, 200, request(t, s, "PUT", "/v1/account/email", `{"email":"shared@example.com"}`, auth).Code)
|
||||
benToken := tokenFromLink(t, mailer.verifyLinks["shared@example.com"], "https://ntfy.example.com/account/email/verify/")
|
||||
require.Equal(t, 200, request(t, s, "POST", "/v1/account/email/verify", fmt.Sprintf(`{"token":"%s"}`, benToken), nil).Code)
|
||||
require.Equal(t, "shared@example.com", primaryAddr(getAccount(t, s, auth)))
|
||||
|
||||
// alice verifies the same address -> allowed as secondary, but it is not her primary
|
||||
require.Nil(t, s.userManager.AddUser("alice", "alice", user.RoleUser, false))
|
||||
aliceAuth := map[string]string{"Authorization": util.BasicAuth("alice", "alice")}
|
||||
require.Equal(t, 200, request(t, s, "PUT", "/v1/account/email", `{"email":"shared@example.com"}`, aliceAuth).Code)
|
||||
aliceToken := tokenFromLink(t, mailer.verifyLinks["shared@example.com"], "https://ntfy.example.com/account/email/verify/")
|
||||
require.Equal(t, 200, request(t, s, "POST", "/v1/account/email/verify", fmt.Sprintf(`{"token":"%s"}`, aliceToken), nil).Code)
|
||||
aliceAccount := getAccount(t, s, aliceAuth)
|
||||
require.Equal(t, []string{"shared@example.com"}, verifiedAddrs(aliceAccount))
|
||||
require.Equal(t, "", primaryAddr(aliceAccount))
|
||||
|
||||
// alice trying to promote it to primary collides with ben's
|
||||
rr := request(t, s, "POST", "/v1/account/email/primary", `{"email":"shared@example.com"}`, aliceAuth)
|
||||
require.Equal(t, 409, rr.Code)
|
||||
require.Equal(t, 40908, toHTTPError(t, rr.Body.String()).Code)
|
||||
})
|
||||
}
|
||||
|
||||
// verifyEmailFor runs the full add->click flow so the user ends up with a verified primary email.
|
||||
func verifyEmailFor(t *testing.T, s *Server, mailer *captureMailer, auth map[string]string, email string) {
|
||||
require.Equal(t, 200, request(t, s, "PUT", "/v1/account/email", fmt.Sprintf(`{"email":"%s"}`, email), auth).Code)
|
||||
token := tokenFromLink(t, mailer.verifyLinks[email], "https://ntfy.example.com/account/email/verify/")
|
||||
require.Equal(t, 200, request(t, s, "POST", "/v1/account/email/verify", fmt.Sprintf(`{"token":"%s"}`, token), nil).Code)
|
||||
}
|
||||
|
||||
// canLogin returns true if username/password authenticates (via the token-create endpoint).
|
||||
func canLogin(t *testing.T, s *Server, username, password string) bool {
|
||||
rr := request(t, s, "POST", "/v1/account/token", "", map[string]string{"Authorization": util.BasicAuth(username, password)})
|
||||
return rr.Code == 200
|
||||
}
|
||||
|
||||
func TestAccount_LoginByPrimaryEmail(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
s, mailer, auth := newEmailTestServer(t, databaseURL)
|
||||
defer s.closeDatabases()
|
||||
verifyEmailFor(t, s, mailer, auth, "ben@example.com")
|
||||
|
||||
// Basic Auth works with either the username or the verified primary email
|
||||
require.True(t, canLogin(t, s, "ben", "ben"))
|
||||
require.True(t, canLogin(t, s, "ben@example.com", "ben"))
|
||||
|
||||
// ...but not with the wrong password or an unknown email
|
||||
require.False(t, canLogin(t, s, "ben@example.com", "wrong"))
|
||||
require.False(t, canLogin(t, s, "nobody@example.com", "ben"))
|
||||
})
|
||||
}
|
||||
|
||||
func TestAccount_PasswordReset_ByUsername(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
s, mailer, auth := newEmailTestServer(t, databaseURL)
|
||||
defer s.closeDatabases()
|
||||
verifyEmailFor(t, s, mailer, auth, "ben@example.com")
|
||||
|
||||
// Request reset by username
|
||||
rr := request(t, s, "POST", "/v1/account/password/reset/request", `{"identifier":"ben"}`, nil)
|
||||
require.Equal(t, 200, rr.Code)
|
||||
token := tokenFromLink(t, mailer.resetLinks["ben@example.com"], "https://ntfy.example.com/account/password/reset/")
|
||||
|
||||
// Confirm with a new password
|
||||
rr = request(t, s, "POST", "/v1/account/password/reset", fmt.Sprintf(`{"token":"%s","password":"brandnew"}`, token), nil)
|
||||
require.Equal(t, 200, rr.Code)
|
||||
|
||||
require.True(t, canLogin(t, s, "ben", "brandnew"))
|
||||
require.False(t, canLogin(t, s, "ben", "ben"))
|
||||
})
|
||||
}
|
||||
|
||||
func TestAccount_PasswordReset_ByEmail(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
s, mailer, auth := newEmailTestServer(t, databaseURL)
|
||||
defer s.closeDatabases()
|
||||
verifyEmailFor(t, s, mailer, auth, "ben@example.com")
|
||||
|
||||
rr := request(t, s, "POST", "/v1/account/password/reset/request", `{"identifier":"ben@example.com"}`, nil)
|
||||
require.Equal(t, 200, rr.Code)
|
||||
token := tokenFromLink(t, mailer.resetLinks["ben@example.com"], "https://ntfy.example.com/account/password/reset/")
|
||||
rr = request(t, s, "POST", "/v1/account/password/reset", fmt.Sprintf(`{"token":"%s","password":"brandnew"}`, token), nil)
|
||||
require.Equal(t, 200, rr.Code)
|
||||
require.True(t, canLogin(t, s, "ben", "brandnew"))
|
||||
})
|
||||
}
|
||||
|
||||
func TestAccount_PasswordReset_EmailLookalikeUsernameDoesNotShadow(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
s, mailer, auth := newEmailTestServer(t, databaseURL)
|
||||
defer s.closeDatabases()
|
||||
|
||||
// Account A (the email owner): user "ben" with verified primary email "phil@example.com"
|
||||
verifyEmailFor(t, s, mailer, auth, "phil@example.com")
|
||||
|
||||
// Account B (the squatter): a different account whose USERNAME looks like A's email, with
|
||||
// its own, different verified primary email
|
||||
require.Nil(t, s.userManager.AddUser("phil@example.com", "squatterpass", user.RoleUser, false))
|
||||
squatter, err := s.userManager.User("phil@example.com")
|
||||
require.Nil(t, err)
|
||||
require.Nil(t, s.userManager.AddEmail(squatter.ID, "squatter@example.com"))
|
||||
require.Nil(t, s.userManager.SetPrimaryEmail(squatter.ID, "squatter@example.com"))
|
||||
|
||||
// Reset by the ambiguous identifier: the verified email must win over the look-alike username
|
||||
rr := request(t, s, "POST", "/v1/account/password/reset/request", `{"identifier":"phil@example.com"}`, nil)
|
||||
require.Equal(t, 200, rr.Code)
|
||||
require.NotEmpty(t, mailer.resetLinks["phil@example.com"]) // sent to the email owner (account A)
|
||||
require.Empty(t, mailer.resetLinks["squatter@example.com"]) // NOT the username squatter (account B)
|
||||
|
||||
// The token resets account A (ben); the squatter's password is untouched
|
||||
token := tokenFromLink(t, mailer.resetLinks["phil@example.com"], "https://ntfy.example.com/account/password/reset/")
|
||||
rr = request(t, s, "POST", "/v1/account/password/reset", fmt.Sprintf(`{"token":"%s","password":"brandnew"}`, token), nil)
|
||||
require.Equal(t, 200, rr.Code)
|
||||
require.True(t, canLogin(t, s, "ben", "brandnew")) // account A was reset
|
||||
require.True(t, canLogin(t, s, "phil@example.com", "squatterpass")) // account B unaffected
|
||||
})
|
||||
}
|
||||
|
||||
func TestAccount_PasswordReset_UnknownIdentifierUniform(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
s, mailer, _ := newEmailTestServer(t, databaseURL)
|
||||
defer s.closeDatabases()
|
||||
|
||||
// Unknown identifier still returns a uniform 200, and no email is sent
|
||||
rr := request(t, s, "POST", "/v1/account/password/reset/request", `{"identifier":"ghost"}`, nil)
|
||||
require.Equal(t, 200, rr.Code)
|
||||
require.Empty(t, mailer.resetLinks)
|
||||
})
|
||||
}
|
||||
|
||||
func TestAccount_PasswordReset_NoPrimaryEmailNoSend(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
s, mailer, _ := newEmailTestServer(t, databaseURL)
|
||||
defer s.closeDatabases()
|
||||
|
||||
// ben exists but has no verified primary email -> uniform 200, nothing sent
|
||||
rr := request(t, s, "POST", "/v1/account/password/reset/request", `{"identifier":"ben"}`, nil)
|
||||
require.Equal(t, 200, rr.Code)
|
||||
require.Empty(t, mailer.resetLinks)
|
||||
})
|
||||
}
|
||||
|
||||
func TestAccount_Signup_WithEmail_SendsVerification(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
conf := newTestConfigWithAuthFile(t, databaseURL)
|
||||
conf.EnableSignup = true
|
||||
conf.SMTPSenderAddr = "localhost:25"
|
||||
conf.SMTPSenderFrom = "noreply@example.com"
|
||||
conf.BaseURL = "https://ntfy.example.com"
|
||||
s := newTestServer(t, conf)
|
||||
mailer := newCaptureMailer()
|
||||
s.mailer = mailer
|
||||
defer s.closeDatabases()
|
||||
|
||||
// Sign up with an optional email -> account created and a verification link sent
|
||||
rr := request(t, s, "POST", "/v1/account", `{"username":"emma","password":"emmapass","email":"emma@example.com"}`, nil)
|
||||
require.Equal(t, 200, rr.Code)
|
||||
link := mailer.verifyLinks["emma@example.com"]
|
||||
require.NotEmpty(t, link)
|
||||
|
||||
// Verifying the link makes it the (first) primary email
|
||||
token := tokenFromLink(t, link, "https://ntfy.example.com/account/email/verify/")
|
||||
require.Equal(t, 200, request(t, s, "POST", "/v1/account/email/verify", fmt.Sprintf(`{"token":"%s"}`, token), nil).Code)
|
||||
account := getAccount(t, s, map[string]string{"Authorization": util.BasicAuth("emma", "emmapass")})
|
||||
require.Equal(t, []string{"emma@example.com"}, verifiedAddrs(account))
|
||||
require.Equal(t, "emma@example.com", primaryAddr(account))
|
||||
})
|
||||
}
|
||||
|
||||
func TestAccount_Signup_WithoutEmail_NoSend(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
conf := newTestConfigWithAuthFile(t, databaseURL)
|
||||
conf.EnableSignup = true
|
||||
conf.SMTPSenderAddr = "localhost:25"
|
||||
conf.SMTPSenderFrom = "noreply@example.com"
|
||||
conf.BaseURL = "https://ntfy.example.com"
|
||||
s := newTestServer(t, conf)
|
||||
mailer := newCaptureMailer()
|
||||
s.mailer = mailer
|
||||
defer s.closeDatabases()
|
||||
|
||||
// No email -> account created, nothing sent
|
||||
require.Equal(t, 200, request(t, s, "POST", "/v1/account", `{"username":"emma","password":"emmapass"}`, nil).Code)
|
||||
require.Empty(t, mailer.verifyLinks)
|
||||
|
||||
// Invalid email -> rejected
|
||||
rr := request(t, s, "POST", "/v1/account", `{"username":"otto","password":"ottopass","email":"not-an-email"}`, nil)
|
||||
require.Equal(t, 400, rr.Code)
|
||||
require.Equal(t, 40050, toHTTPError(t, rr.Body.String()).Code)
|
||||
})
|
||||
}
|
||||
|
||||
func TestAccount_Email_ProvisionedPrimary(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
hash, err := user.HashPassword("provpass", user.DefaultUserPasswordBcryptCost)
|
||||
require.Nil(t, err)
|
||||
conf := newTestConfigWithAuthFile(t, databaseURL)
|
||||
conf.SMTPSenderAddr = "localhost:25"
|
||||
conf.SMTPSenderFrom = "noreply@example.com"
|
||||
conf.BaseURL = "https://ntfy.example.com"
|
||||
conf.AuthUsers = []*user.User{{Name: "prov", Hash: hash, Role: user.RoleUser}}
|
||||
s := newTestServer(t, conf)
|
||||
mailer := newCaptureMailer()
|
||||
s.mailer = mailer
|
||||
defer s.closeDatabases()
|
||||
auth := map[string]string{"Authorization": util.BasicAuth("prov", "provpass")}
|
||||
|
||||
// A provisioned user's first verified email becomes their primary (used by X-Email: yes;
|
||||
// password reset stays blocked separately for provisioned users)
|
||||
verifyEmailFor(t, s, mailer, auth, "prov@example.com")
|
||||
account := getAccount(t, s, auth)
|
||||
require.Equal(t, []string{"prov@example.com"}, verifiedAddrs(account))
|
||||
require.Equal(t, "prov@example.com", primaryAddr(account))
|
||||
|
||||
// Verify a second address and explicitly set it primary -> allowed, star moves
|
||||
verifyEmailFor(t, s, mailer, auth, "prov2@example.com")
|
||||
rr := request(t, s, "POST", "/v1/account/email/primary", `{"email":"prov2@example.com"}`, auth)
|
||||
require.Equal(t, 200, rr.Code)
|
||||
account = getAccount(t, s, auth)
|
||||
require.Equal(t, "prov2@example.com", primaryAddr(account))
|
||||
})
|
||||
}
|
||||
|
||||
func TestAccount_PasswordReset_ProvisionedUserNoSend(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
// Provision a user via config (AuthUsers), with email sending enabled
|
||||
conf := newTestConfigWithAuthFile(t, databaseURL)
|
||||
conf.SMTPSenderAddr = "localhost:25"
|
||||
conf.SMTPSenderFrom = "noreply@example.com"
|
||||
conf.BaseURL = "https://ntfy.example.com"
|
||||
conf.AuthUsers = []*user.User{
|
||||
{Name: "prov", Hash: "$2a$10$YLiO8U21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C", Role: user.RoleUser},
|
||||
}
|
||||
s := newTestServer(t, conf)
|
||||
mailer := newCaptureMailer()
|
||||
s.mailer = mailer
|
||||
defer s.closeDatabases()
|
||||
|
||||
// Give the provisioned user a verified primary email anyway
|
||||
prov, err := s.userManager.User("prov")
|
||||
require.Nil(t, err)
|
||||
require.True(t, prov.Provisioned)
|
||||
require.Nil(t, s.userManager.AddEmail(prov.ID, "prov@example.com"))
|
||||
require.Nil(t, s.userManager.SetPrimaryEmail(prov.ID, "prov@example.com"))
|
||||
|
||||
// Reset request by username and by email -> uniform 200, but no email sent (can't reset)
|
||||
require.Equal(t, 200, request(t, s, "POST", "/v1/account/password/reset/request", `{"identifier":"prov"}`, nil).Code)
|
||||
require.Equal(t, 200, request(t, s, "POST", "/v1/account/password/reset/request", `{"identifier":"prov@example.com"}`, nil).Code)
|
||||
require.Empty(t, mailer.resetLinks)
|
||||
})
|
||||
}
|
||||
|
||||
func TestAccount_PasswordReset_InvalidToken(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
s, _, _ := newEmailTestServer(t, databaseURL)
|
||||
defer s.closeDatabases()
|
||||
|
||||
rr := request(t, s, "POST", "/v1/account/password/reset", `{"token":"nope","password":"brandnew"}`, nil)
|
||||
require.Equal(t, 400, rr.Code)
|
||||
require.Equal(t, 40054, toHTTPError(t, rr.Body.String()).Code)
|
||||
})
|
||||
}
|
||||
|
||||
func TestAccount_Email_AddDuplicateVerified(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
s, mailer, auth := newEmailTestServer(t, databaseURL)
|
||||
defer s.closeDatabases()
|
||||
|
||||
require.Equal(t, 200, request(t, s, "PUT", "/v1/account/email", `{"email":"ben@example.com"}`, auth).Code)
|
||||
token := tokenFromLink(t, mailer.verifyLinks["ben@example.com"], "https://ntfy.example.com/account/email/verify/")
|
||||
require.Equal(t, 200, request(t, s, "POST", "/v1/account/email/verify", fmt.Sprintf(`{"token":"%s"}`, token), nil).Code)
|
||||
|
||||
// Adding the same already-verified address is a conflict
|
||||
rr := request(t, s, "PUT", "/v1/account/email", `{"email":"ben@example.com"}`, auth)
|
||||
require.Equal(t, 409, rr.Code)
|
||||
require.Equal(t, 40907, toHTTPError(t, rr.Body.String()).Code)
|
||||
})
|
||||
}
|
||||
@@ -55,6 +55,58 @@ func TestAccount_Signup_Success(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestAccount_Login_Success(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
conf := newTestConfigWithAuthFile(t, databaseURL)
|
||||
s := newTestServer(t, conf)
|
||||
defer s.closeDatabases()
|
||||
|
||||
require.Nil(t, s.userManager.AddUser("phil", "mypass", user.RoleUser, false))
|
||||
u, err := s.userManager.User("phil")
|
||||
require.Nil(t, err)
|
||||
require.Nil(t, s.userManager.AddEmail(u.ID, "phil@example.com"))
|
||||
require.Nil(t, s.userManager.SetPrimaryEmail(u.ID, "phil@example.com"))
|
||||
|
||||
// Login by username returns a token and the canonical username
|
||||
rr := request(t, s, "POST", "/v1/account/login", "", map[string]string{
|
||||
"Authorization": util.BasicAuth("phil", "mypass"),
|
||||
})
|
||||
require.Equal(t, 200, rr.Code)
|
||||
resp, _ := util.UnmarshalJSON[apiAccountLoginResponse](io.NopCloser(rr.Body))
|
||||
require.True(t, strings.HasPrefix(resp.Token, "tk_"))
|
||||
require.Equal(t, "phil", resp.Username)
|
||||
|
||||
// The returned token actually authenticates
|
||||
rr = request(t, s, "GET", "/v1/account", "", map[string]string{
|
||||
"Authorization": util.BearerAuth(resp.Token),
|
||||
})
|
||||
require.Equal(t, 200, rr.Code)
|
||||
|
||||
// Login by primary email returns the canonical username, not the email that was typed
|
||||
rr = request(t, s, "POST", "/v1/account/login", "", map[string]string{
|
||||
"Authorization": util.BasicAuth("phil@example.com", "mypass"),
|
||||
})
|
||||
require.Equal(t, 200, rr.Code)
|
||||
resp, _ = util.UnmarshalJSON[apiAccountLoginResponse](io.NopCloser(rr.Body))
|
||||
require.True(t, strings.HasPrefix(resp.Token, "tk_"))
|
||||
require.Equal(t, "phil", resp.Username)
|
||||
})
|
||||
}
|
||||
|
||||
func TestAccount_Login_InvalidCredentials(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
conf := newTestConfigWithAuthFile(t, databaseURL)
|
||||
s := newTestServer(t, conf)
|
||||
defer s.closeDatabases()
|
||||
require.Nil(t, s.userManager.AddUser("phil", "mypass", user.RoleUser, false))
|
||||
|
||||
rr := request(t, s, "POST", "/v1/account/login", "", map[string]string{
|
||||
"Authorization": util.BasicAuth("phil", "wrongpass"),
|
||||
})
|
||||
require.Equal(t, 401, rr.Code)
|
||||
})
|
||||
}
|
||||
|
||||
func TestAccount_Signup_UserExists(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
conf := newTestConfigWithAuthFile(t, databaseURL)
|
||||
@@ -78,7 +130,8 @@ func TestAccount_Signup_LimitReached(t *testing.T) {
|
||||
s := newTestServer(t, conf)
|
||||
defer s.closeDatabases()
|
||||
|
||||
for i := 0; i < 3; i++ {
|
||||
// Burst is DefaultVisitorAccountCreationLimitBurst (shared with password-reset requests)
|
||||
for i := 0; i < 6; i++ {
|
||||
rr := request(t, s, "POST", "/v1/account", fmt.Sprintf(`{"username":"phil%d", "password":"mypass"}`, i), nil)
|
||||
require.Equal(t, 200, rr.Code)
|
||||
}
|
||||
@@ -131,7 +184,8 @@ func TestAccount_Signup_Rate_Limit(t *testing.T) {
|
||||
conf.EnableSignup = true
|
||||
s := newTestServer(t, conf)
|
||||
|
||||
for i := 0; i < 3; i++ {
|
||||
// Burst is DefaultVisitorAccountCreationLimitBurst (shared with password-reset requests)
|
||||
for i := 0; i < 6; i++ {
|
||||
rr := request(t, s, "POST", "/v1/account", fmt.Sprintf(`{"username":"phil%d", "password":"mypass"}`, i), nil)
|
||||
require.Equal(t, 200, rr.Code, "failed on iteration %d", i)
|
||||
}
|
||||
@@ -149,7 +203,7 @@ func TestAccount_Get_Anonymous(t *testing.T) {
|
||||
conf.VisitorAttachmentTotalSizeLimit = 5123
|
||||
conf.AttachmentFileSizeLimit = 512
|
||||
s := newTestServer(t, conf)
|
||||
s.smtpSender = &testMailer{}
|
||||
s.mailer = &testMailer{}
|
||||
defer s.closeDatabases()
|
||||
|
||||
rr := request(t, s, "GET", "/v1/account", "", nil)
|
||||
@@ -205,12 +259,19 @@ func TestAccount_ChangeSettings(t *testing.T) {
|
||||
})
|
||||
require.Equal(t, 200, rr.Code)
|
||||
|
||||
rr = request(t, s, "PATCH", "/v1/account/settings", `{"date_format": "iso8601", "time_format": "24h"}`, map[string]string{
|
||||
"Authorization": util.BearerAuth(token.Value),
|
||||
})
|
||||
require.Equal(t, 200, rr.Code)
|
||||
|
||||
rr = request(t, s, "GET", "/v1/account", `{"username":"marian", "password":"marian"}`, map[string]string{
|
||||
"Authorization": util.BearerAuth(token.Value),
|
||||
})
|
||||
require.Equal(t, 200, rr.Code)
|
||||
account, _ := util.UnmarshalJSON[apiAccountResponse](io.NopCloser(rr.Body))
|
||||
require.Equal(t, "de", account.Language)
|
||||
require.Equal(t, "iso8601", account.DateFormat) // Merged, not overwritten by previous PATCH
|
||||
require.Equal(t, "24h", account.TimeFormat)
|
||||
require.Equal(t, util.Int(86400), account.Notification.DeleteAfter)
|
||||
require.Equal(t, util.String("juntos"), account.Notification.Sound)
|
||||
require.Nil(t, account.Notification.MinPriority) // Not set
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"heckel.io/ntfy/v2/user"
|
||||
)
|
||||
|
||||
// maybeAuthenticate reads the "Authorization" header and will try to authenticate the user
|
||||
// if it is set.
|
||||
//
|
||||
// - If auth-file is not configured, immediately return an IP-based visitor
|
||||
// - If the header is not set or not supported (anything non-Basic and non-Bearer),
|
||||
// an IP-based visitor is returned
|
||||
// - If the header is set, authenticate will be called to check the username/password (Basic auth),
|
||||
// or the token (Bearer auth), and read the user from the database
|
||||
//
|
||||
// This function will ALWAYS return a visitor, even if an error occurs (e.g. unauthorized), so
|
||||
// that subsequent logging calls still have a visitor context.
|
||||
func (s *Server) maybeAuthenticate(r *http.Request) (*http.Request, *visitor, error) {
|
||||
// Read the "Authorization" header value and exit out early if it's not set
|
||||
ip := extractIPAddress(r, s.config.BehindProxy, s.config.ProxyForwardedHeader, s.config.ProxyTrustedPrefixes)
|
||||
// Stash the extracted client IP in the request context so downstream code (the abuse ban-feed in
|
||||
// handleError) can reuse it without re-parsing headers, and so an account-keyed (tier'd) visitor --
|
||||
// whose shared visitor object has a stale v.ip -- is still attributed to the actual request IP.
|
||||
r = withContext(r, map[contextKey]any{contextVisitorIP: ip})
|
||||
vip := s.visitor(ip, nil)
|
||||
if s.userManager == nil {
|
||||
return r, vip, nil
|
||||
}
|
||||
header, err := readAuthHeader(r)
|
||||
if err != nil {
|
||||
return r, vip, err
|
||||
} else if !supportedAuthHeader(header) {
|
||||
return r, vip, nil
|
||||
}
|
||||
// If we're trying to auth, check the rate limiter first
|
||||
if !vip.AuthAllowed() {
|
||||
return r, vip, errHTTPTooManyRequestsLimitAuthFailure // Always return visitor, even when error occurs!
|
||||
}
|
||||
u, err := s.authenticate(r, header)
|
||||
if err != nil {
|
||||
vip.AuthFailed()
|
||||
logr(r).Err(err).Debug("Authentication failed")
|
||||
return r, vip, errHTTPUnauthorized // Always return visitor, even when error occurs!
|
||||
}
|
||||
// Authentication with user was successful
|
||||
return r, s.visitor(ip, u), nil
|
||||
}
|
||||
|
||||
// authenticate a user based on basic auth username/password (Authorization: Basic ...), or token auth (Authorization: Bearer ...).
|
||||
// The Authorization header can be passed as a header or the ?auth=... query param. The latter is required only to
|
||||
// support the WebSocket JavaScript class, which does not support passing headers during the initial request. The auth
|
||||
// query param is effectively doubly base64 encoded. Its format is base64(Basic base64(user:pass)).
|
||||
func (s *Server) authenticate(r *http.Request, header string) (user *user.User, err error) {
|
||||
if strings.HasPrefix(header, "Bearer") {
|
||||
return s.authenticateBearerAuth(r, strings.TrimSpace(strings.TrimPrefix(header, "Bearer")))
|
||||
}
|
||||
return s.authenticateBasicAuth(r, header)
|
||||
}
|
||||
|
||||
// readAuthHeader reads the raw value of the Authorization header, either from the actual HTTP header,
|
||||
// or from the ?auth... query parameter
|
||||
func readAuthHeader(r *http.Request) (string, error) {
|
||||
value := strings.TrimSpace(r.Header.Get("Authorization"))
|
||||
queryParam := readQueryParam(r, "authorization", "auth")
|
||||
if queryParam != "" {
|
||||
a, err := base64.RawURLEncoding.DecodeString(queryParam)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
value = strings.TrimSpace(string(a))
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
// supportedAuthHeader returns true only if the Authorization header value starts
|
||||
// with "Basic" or "Bearer". In particular, an empty value is not supported, and neither
|
||||
// are things like "WebPush", or "vapid" (see #629).
|
||||
func supportedAuthHeader(value string) bool {
|
||||
value = strings.ToLower(value)
|
||||
return strings.HasPrefix(value, "basic ") || strings.HasPrefix(value, "bearer ")
|
||||
}
|
||||
|
||||
func (s *Server) authenticateBasicAuth(r *http.Request, value string) (user *user.User, err error) {
|
||||
r.Header.Set("Authorization", value)
|
||||
username, password, ok := r.BasicAuth()
|
||||
if !ok {
|
||||
return nil, errors.New("invalid basic auth")
|
||||
} else if username == "" {
|
||||
return s.authenticateBearerAuth(r, password) // Treat password as token
|
||||
}
|
||||
return s.userManager.Authenticate(username, password)
|
||||
}
|
||||
|
||||
func (s *Server) authenticateBearerAuth(r *http.Request, token string) (*user.User, error) {
|
||||
u, err := s.userManager.AuthenticateToken(token)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ip := extractIPAddress(r, s.config.BehindProxy, s.config.ProxyForwardedHeader, s.config.ProxyTrustedPrefixes)
|
||||
go s.userManager.EnqueueTokenUpdate(token, &user.TokenUpdate{
|
||||
LastAccess: time.Now(),
|
||||
LastOrigin: ip,
|
||||
})
|
||||
return u, nil
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package server
|
||||
|
||||
import (
|
||||
"heckel.io/ntfy/v2/log"
|
||||
"heckel.io/ntfy/v2/metrics"
|
||||
"heckel.io/ntfy/v2/util"
|
||||
)
|
||||
|
||||
@@ -54,8 +55,8 @@ func (s *Server) execManager() {
|
||||
receivedMailTotal, receivedMailSuccess, receivedMailFailure = s.smtpServerBackend.Counts()
|
||||
}
|
||||
var sentMailTotal, sentMailSuccess, sentMailFailure int64
|
||||
if s.smtpSender != nil {
|
||||
sentMailTotal, sentMailSuccess, sentMailFailure = s.smtpSender.Counts()
|
||||
if s.mailer != nil {
|
||||
sentMailTotal, sentMailSuccess, sentMailFailure = s.mailer.NotificationCounts()
|
||||
}
|
||||
|
||||
// Users
|
||||
@@ -93,13 +94,13 @@ func (s *Server) execManager() {
|
||||
"emails_sent_failure": sentMailFailure,
|
||||
}).
|
||||
Info("Server stats")
|
||||
mset(metricMessagesCached, messagesCached)
|
||||
mset(metricVisitors, visitorsCount)
|
||||
mset(metricUsers, usersCount)
|
||||
mset(metricSubscribers, subscribers)
|
||||
mset(metricTopics, topicsCount)
|
||||
metrics.MessagesCached.Set(float64(messagesCached))
|
||||
metrics.Visitors.Set(float64(visitorsCount))
|
||||
metrics.Users.Set(float64(usersCount))
|
||||
metrics.Subscribers.Set(float64(subscribers))
|
||||
metrics.Topics.Set(float64(topicsCount))
|
||||
if s.attachment != nil {
|
||||
mset(metricAttachmentsTotalSize, s.attachment.Size())
|
||||
metrics.AttachmentsTotalSize.Set(float64(s.attachment.Size()))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -165,8 +165,5 @@ func writeMatrixResponse(w http.ResponseWriter, rejectedPushKey string) error {
|
||||
Rejected: rejected,
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(response); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
return util.EncodeJSON(w, response)
|
||||
}
|
||||
|
||||
@@ -1,132 +0,0 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
)
|
||||
|
||||
var (
|
||||
metricMessagesPublishedSuccess prometheus.Counter
|
||||
metricMessagesPublishedFailure prometheus.Counter
|
||||
metricMessagesCached prometheus.Gauge
|
||||
metricMessagePublishDurationMillis prometheus.Gauge
|
||||
metricFirebasePublishedSuccess prometheus.Counter
|
||||
metricFirebasePublishedFailure prometheus.Counter
|
||||
metricEmailsPublishedSuccess prometheus.Counter
|
||||
metricEmailsPublishedFailure prometheus.Counter
|
||||
metricEmailsReceivedSuccess prometheus.Counter
|
||||
metricEmailsReceivedFailure prometheus.Counter
|
||||
metricCallsMadeSuccess prometheus.Counter
|
||||
metricCallsMadeFailure prometheus.Counter
|
||||
metricUnifiedPushPublishedSuccess prometheus.Counter
|
||||
metricMatrixPublishedSuccess prometheus.Counter
|
||||
metricMatrixPublishedFailure prometheus.Counter
|
||||
metricAttachmentsTotalSize prometheus.Gauge
|
||||
metricVisitors prometheus.Gauge
|
||||
metricSubscribers prometheus.Gauge
|
||||
metricTopics prometheus.Gauge
|
||||
metricUsers prometheus.Gauge
|
||||
metricHTTPRequests *prometheus.CounterVec
|
||||
)
|
||||
|
||||
func initMetrics() {
|
||||
metricMessagesPublishedSuccess = prometheus.NewCounter(prometheus.CounterOpts{
|
||||
Name: "ntfy_messages_published_success",
|
||||
})
|
||||
metricMessagesPublishedFailure = prometheus.NewCounter(prometheus.CounterOpts{
|
||||
Name: "ntfy_messages_published_failure",
|
||||
})
|
||||
metricMessagesCached = prometheus.NewGauge(prometheus.GaugeOpts{
|
||||
Name: "ntfy_messages_cached_total",
|
||||
})
|
||||
metricMessagePublishDurationMillis = prometheus.NewGauge(prometheus.GaugeOpts{
|
||||
Name: "ntfy_message_publish_duration_ms",
|
||||
})
|
||||
metricFirebasePublishedSuccess = prometheus.NewCounter(prometheus.CounterOpts{
|
||||
Name: "ntfy_firebase_published_success",
|
||||
})
|
||||
metricFirebasePublishedFailure = prometheus.NewCounter(prometheus.CounterOpts{
|
||||
Name: "ntfy_firebase_published_failure",
|
||||
})
|
||||
metricEmailsPublishedSuccess = prometheus.NewCounter(prometheus.CounterOpts{
|
||||
Name: "ntfy_emails_sent_success",
|
||||
})
|
||||
metricEmailsPublishedFailure = prometheus.NewCounter(prometheus.CounterOpts{
|
||||
Name: "ntfy_emails_sent_failure",
|
||||
})
|
||||
metricEmailsReceivedSuccess = prometheus.NewCounter(prometheus.CounterOpts{
|
||||
Name: "ntfy_emails_received_success",
|
||||
})
|
||||
metricEmailsReceivedFailure = prometheus.NewCounter(prometheus.CounterOpts{
|
||||
Name: "ntfy_emails_received_failure",
|
||||
})
|
||||
metricCallsMadeSuccess = prometheus.NewCounter(prometheus.CounterOpts{
|
||||
Name: "ntfy_calls_made_success",
|
||||
})
|
||||
metricCallsMadeFailure = prometheus.NewCounter(prometheus.CounterOpts{
|
||||
Name: "ntfy_calls_made_failure",
|
||||
})
|
||||
metricUnifiedPushPublishedSuccess = prometheus.NewCounter(prometheus.CounterOpts{
|
||||
Name: "ntfy_unifiedpush_published_success",
|
||||
})
|
||||
metricMatrixPublishedSuccess = prometheus.NewCounter(prometheus.CounterOpts{
|
||||
Name: "ntfy_matrix_published_success",
|
||||
})
|
||||
metricMatrixPublishedFailure = prometheus.NewCounter(prometheus.CounterOpts{
|
||||
Name: "ntfy_matrix_published_failure",
|
||||
})
|
||||
metricAttachmentsTotalSize = prometheus.NewGauge(prometheus.GaugeOpts{
|
||||
Name: "ntfy_attachments_total_size",
|
||||
})
|
||||
metricVisitors = prometheus.NewGauge(prometheus.GaugeOpts{
|
||||
Name: "ntfy_visitors_total",
|
||||
})
|
||||
metricUsers = prometheus.NewGauge(prometheus.GaugeOpts{
|
||||
Name: "ntfy_users_total",
|
||||
})
|
||||
metricSubscribers = prometheus.NewGauge(prometheus.GaugeOpts{
|
||||
Name: "ntfy_subscribers_total",
|
||||
})
|
||||
metricTopics = prometheus.NewGauge(prometheus.GaugeOpts{
|
||||
Name: "ntfy_topics_total",
|
||||
})
|
||||
metricHTTPRequests = prometheus.NewCounterVec(prometheus.CounterOpts{
|
||||
Name: "ntfy_http_requests_total",
|
||||
}, []string{"http_code", "ntfy_code", "http_method"})
|
||||
prometheus.MustRegister(
|
||||
metricMessagesPublishedSuccess,
|
||||
metricMessagesPublishedFailure,
|
||||
metricMessagesCached,
|
||||
metricMessagePublishDurationMillis,
|
||||
metricFirebasePublishedSuccess,
|
||||
metricFirebasePublishedFailure,
|
||||
metricEmailsPublishedSuccess,
|
||||
metricEmailsPublishedFailure,
|
||||
metricEmailsReceivedSuccess,
|
||||
metricEmailsReceivedFailure,
|
||||
metricCallsMadeSuccess,
|
||||
metricCallsMadeFailure,
|
||||
metricUnifiedPushPublishedSuccess,
|
||||
metricMatrixPublishedSuccess,
|
||||
metricMatrixPublishedFailure,
|
||||
metricAttachmentsTotalSize,
|
||||
metricVisitors,
|
||||
metricUsers,
|
||||
metricSubscribers,
|
||||
metricTopics,
|
||||
metricHTTPRequests,
|
||||
)
|
||||
}
|
||||
|
||||
// minc increments a prometheus.Counter if it is non-nil
|
||||
func minc(counter prometheus.Counter) {
|
||||
if counter != nil {
|
||||
counter.Inc()
|
||||
}
|
||||
}
|
||||
|
||||
// mset sets a prometheus.Gauge if it is non-nil
|
||||
func mset[T int | int64 | float64](gauge prometheus.Gauge, value T) {
|
||||
if gauge != nil {
|
||||
gauge.Set(float64(value))
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package server
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"heckel.io/ntfy/v2/user"
|
||||
"heckel.io/ntfy/v2/util"
|
||||
)
|
||||
|
||||
@@ -12,6 +13,7 @@ const (
|
||||
contextRateVisitor contextKey = iota + 2586
|
||||
contextTopic
|
||||
contextMatrixPushKey
|
||||
contextVisitorIP // Client IP extracted in maybeAuthenticate; reused by the abuse ban-feed (see ban.Service.Record)
|
||||
)
|
||||
|
||||
func (s *Server) limitRequests(next handleFunc) handleFunc {
|
||||
@@ -105,7 +107,7 @@ func (s *Server) ensureCallsEnabled(next handleFunc) handleFunc {
|
||||
|
||||
func (s *Server) ensureEmailsEnabled(next handleFunc) handleFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||
if s.mailSender == nil || s.userManager == nil {
|
||||
if s.mailer == nil || s.userManager == nil {
|
||||
return errHTTPNotFound
|
||||
}
|
||||
return next(w, r, v)
|
||||
@@ -139,3 +141,31 @@ func (s *Server) withAccountSync(next handleFunc) handleFunc {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) authorizeTopicWrite(next handleFunc) handleFunc {
|
||||
return s.authorizeTopic(next, user.PermissionWrite)
|
||||
}
|
||||
|
||||
func (s *Server) authorizeTopicRead(next handleFunc) handleFunc {
|
||||
return s.authorizeTopic(next, user.PermissionRead)
|
||||
}
|
||||
|
||||
func (s *Server) authorizeTopic(next handleFunc, perm user.Permission) handleFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||
if s.userManager == nil {
|
||||
return next(w, r, v)
|
||||
}
|
||||
topics, _, err := s.topicsFromPath(v, r.URL.Path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
u := v.User()
|
||||
for _, t := range topics {
|
||||
if err := s.userManager.Authorize(u, t.ID, perm); err != nil {
|
||||
logvr(v, r).With(t).Err(err).Debug("Access to topic %s not authorized", t.ID)
|
||||
return errHTTPForbidden.With(t)
|
||||
}
|
||||
}
|
||||
return next(w, r, v)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -237,10 +237,41 @@ func (s *Server) handleAccountBillingSubscriptionCreateSuccess(w http.ResponseWr
|
||||
if err := s.updateSubscriptionAndTier(r, v, u, tier, sess.Customer.ID, sub.ID, string(sub.Status), string(interval), sub.CurrentPeriodEnd, sub.CancelAt); err != nil {
|
||||
return err
|
||||
}
|
||||
// Offer email recovery: auto-send a verification link to the billing email (best-effort).
|
||||
// Provisioned users can't reset their password, so recovery setup doesn't apply to them.
|
||||
if sess.CustomerDetails != nil && !u.Provisioned {
|
||||
s.maybeEnqueueBillingEmailVerification(r, v, u.ID, sess.CustomerDetails.Email)
|
||||
}
|
||||
http.Redirect(w, r, s.config.BaseURL+accountPath, http.StatusSeeOther)
|
||||
return nil
|
||||
}
|
||||
|
||||
// maybeEnqueueBillingEmailVerification sends an email-verification link to a paying user's
|
||||
// billing email, so they can use it for password recovery -- but only if they have no verified
|
||||
// email yet and the billing email is not already the recovery email on another account. On a
|
||||
// collision (or any other skip), the generic "no recovery email set" warning on the account page
|
||||
// nudges the user to add one. This is best-effort: failures are logged, never surfaced.
|
||||
func (s *Server) maybeEnqueueBillingEmailVerification(r *http.Request, v *visitor, userID, billingEmail string) {
|
||||
if s.mailer == nil || s.config.BaseURL == "" || billingEmail == "" || !emailAddressRegex.MatchString(billingEmail) {
|
||||
return
|
||||
}
|
||||
emails, err := s.userManager.Emails(userID)
|
||||
if err != nil {
|
||||
logvr(v, r).Tag(tagStripe).Err(err).Warn("Failed to load emails for billing verification")
|
||||
return
|
||||
} else if len(emails) > 0 {
|
||||
return // User already has a verified email -- don't nag
|
||||
}
|
||||
if _, err := s.userManager.UserIDByPrimaryEmail(billingEmail); err == nil {
|
||||
logvr(v, r).Tag(tagStripe).Debug("Billing email is primary on another account, skipping auto-verification")
|
||||
return // Collision: skip + let the generic no-recovery-email warning nudge instead
|
||||
}
|
||||
logvr(v, r).Tag(tagStripe).Field("email", billingEmail).Info("Sending verification link to billing email")
|
||||
if err := s.enqueueEmailVerification(userID, billingEmail); err != nil {
|
||||
logvr(v, r).Tag(tagStripe).Err(err).Warn("Failed to enqueue billing email verification")
|
||||
}
|
||||
}
|
||||
|
||||
// handleAccountBillingSubscriptionUpdate updates an existing Stripe subscription to a new price, and updates
|
||||
// a user's tier accordingly. This endpoint only works if there is an existing subscription.
|
||||
func (s *Server) handleAccountBillingSubscriptionUpdate(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
//go:build !nopayments
|
||||
|
||||
package server
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/mock"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/stripe/stripe-go/v74"
|
||||
"heckel.io/ntfy/v2/user"
|
||||
)
|
||||
|
||||
// stripeCheckoutMock wires up a testStripeAPI for a successful checkout of user u, with the given
|
||||
// billing email on the session's CustomerDetails.
|
||||
func stripeCheckoutMock(u *user.User, billingEmail string) *testStripeAPI {
|
||||
m := &testStripeAPI{}
|
||||
m.On("GetSession", "SOMETOKEN").Return(&stripe.CheckoutSession{
|
||||
ClientReferenceID: u.ID,
|
||||
Customer: &stripe.Customer{ID: "acct_5555"},
|
||||
Subscription: &stripe.Subscription{ID: "sub_1234"},
|
||||
CustomerDetails: &stripe.CheckoutSessionCustomerDetails{Email: billingEmail},
|
||||
}, nil)
|
||||
m.On("GetSubscription", "sub_1234").Return(&stripe.Subscription{
|
||||
ID: "sub_1234",
|
||||
Status: stripe.SubscriptionStatusActive,
|
||||
CurrentPeriodEnd: 123456789,
|
||||
Items: &stripe.SubscriptionItemList{
|
||||
Data: []*stripe.SubscriptionItem{
|
||||
{Price: &stripe.Price{ID: "price_1234", Recurring: &stripe.PriceRecurring{Interval: stripe.PriceRecurringIntervalMonth}}},
|
||||
},
|
||||
},
|
||||
}, nil)
|
||||
m.On("UpdateCustomer", "acct_5555", mock.Anything).Return(&stripe.Customer{}, nil)
|
||||
return m
|
||||
}
|
||||
|
||||
func newCheckoutEmailTestServer(t *testing.T, databaseURL string) (*Server, *captureMailer, *user.User) {
|
||||
c := newTestConfigWithAuthFile(t, databaseURL)
|
||||
c.StripeSecretKey = "secret key"
|
||||
c.BaseURL = "https://ntfy.example.com"
|
||||
c.SMTPSenderAddr = "localhost:25"
|
||||
c.SMTPSenderFrom = "noreply@example.com"
|
||||
s := newTestServer(t, c)
|
||||
mailer := newCaptureMailer()
|
||||
s.mailer = mailer
|
||||
require.Nil(t, s.userManager.AddTier(&user.Tier{
|
||||
ID: "ti_123", Code: "starter", StripeMonthlyPriceID: "price_1234", MessageLimit: 100, MessageExpiryDuration: time.Hour,
|
||||
}))
|
||||
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false))
|
||||
u, err := s.userManager.User("phil")
|
||||
require.Nil(t, err)
|
||||
return s, mailer, u
|
||||
}
|
||||
|
||||
func TestPayments_Checkout_SendsBillingEmailVerification(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
s, mailer, u := newCheckoutEmailTestServer(t, databaseURL)
|
||||
defer s.closeDatabases()
|
||||
s.stripe = stripeCheckoutMock(u, "billing@example.com")
|
||||
|
||||
rr := request(t, s, "GET", "/v1/account/billing/subscription/success/SOMETOKEN", "", nil)
|
||||
require.Equal(t, 303, rr.Code)
|
||||
|
||||
// A verification link was auto-sent to the billing email; clicking it verifies + sets primary
|
||||
link := mailer.verifyLinks["billing@example.com"]
|
||||
require.NotEmpty(t, link)
|
||||
token := tokenFromLink(t, link, "https://ntfy.example.com/account/email/verify/")
|
||||
require.Equal(t, 200, request(t, s, "POST", "/v1/account/email/verify", fmt.Sprintf(`{"token":"%s"}`, token), nil).Code)
|
||||
|
||||
emails, err := s.userManager.Emails(u.ID)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, []string{"billing@example.com"}, emails.Strings())
|
||||
primary, err := s.userManager.PrimaryEmail(u.ID)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, "billing@example.com", primary)
|
||||
})
|
||||
}
|
||||
|
||||
func TestPayments_Checkout_SkipsBillingEmailWhenAlreadyVerified(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
s, mailer, u := newCheckoutEmailTestServer(t, databaseURL)
|
||||
defer s.closeDatabases()
|
||||
s.stripe = stripeCheckoutMock(u, "billing@example.com")
|
||||
|
||||
// User already has a verified email -> no auto-send on checkout
|
||||
require.Nil(t, s.userManager.AddEmail(u.ID, "existing@example.com"))
|
||||
|
||||
rr := request(t, s, "GET", "/v1/account/billing/subscription/success/SOMETOKEN", "", nil)
|
||||
require.Equal(t, 303, rr.Code)
|
||||
require.Empty(t, mailer.verifyLinks)
|
||||
})
|
||||
}
|
||||
|
||||
func TestPayments_Checkout_SkipsBillingEmailWhenPrimaryElsewhere(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
s, mailer, u := newCheckoutEmailTestServer(t, databaseURL)
|
||||
defer s.closeDatabases()
|
||||
s.stripe = stripeCheckoutMock(u, "billing@example.com")
|
||||
|
||||
// The billing email is already the recovery email on another account -> skip
|
||||
require.Nil(t, s.userManager.AddUser("alice", "alice", user.RoleUser, false))
|
||||
alice, err := s.userManager.User("alice")
|
||||
require.Nil(t, err)
|
||||
require.Nil(t, s.userManager.AddEmail(alice.ID, "billing@example.com"))
|
||||
require.Nil(t, s.userManager.SetPrimaryEmail(alice.ID, "billing@example.com"))
|
||||
|
||||
rr := request(t, s, "GET", "/v1/account/billing/subscription/success/SOMETOKEN", "", nil)
|
||||
require.Equal(t, 303, rr.Code)
|
||||
require.Empty(t, mailer.verifyLinks)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"text/template/parse"
|
||||
"time"
|
||||
|
||||
"gopkg.in/yaml.v2"
|
||||
"heckel.io/ntfy/v2/model"
|
||||
"heckel.io/ntfy/v2/template/gotext"
|
||||
"heckel.io/ntfy/v2/util"
|
||||
"heckel.io/ntfy/v2/util/sprig"
|
||||
)
|
||||
|
||||
var (
|
||||
//go:embed templates
|
||||
templatesFs embed.FS // Contains template config files (e.g. grafana.yml, github.yml, ...)
|
||||
templatesDir = "templates"
|
||||
|
||||
templateNameRegex = regexp.MustCompile(`^[-_A-Za-z0-9]+$`)
|
||||
|
||||
// templatePrintfLargeSizeRegex matches a printf directive whose width or precision is a star
|
||||
// (taken from an argument) or has four or more digits, i.e. is at least 1000. It deliberately
|
||||
// scans the flag/width/precision characters after a % without requiring a well-formed
|
||||
// directive: fmt pads even malformed ones (e.g. "%000 9999999#" emits 10 MB), so anything
|
||||
// unrecognized must still be caught.
|
||||
templatePrintfLargeSizeRegex = regexp.MustCompile(`%[-+# 0-9.*\[\]]*(\*|[0-9]{4})`)
|
||||
|
||||
// templateMaxExecutionTime is the wall-clock deadline for a single template render, a DoS guard
|
||||
// (GHSA-rhwf-xgc9-m9fp). It is a var (not a const) solely so tests can raise it; it is never
|
||||
// mutated in production.
|
||||
templateMaxExecutionTime = 100 * time.Millisecond
|
||||
)
|
||||
|
||||
const (
|
||||
templateMaxOutputBytes = 1024 * 1024 // Maximum number of bytes a template can output, used to prevent DoS attacks
|
||||
templateMaxTemplateBytes = 32 * 1024 // Maximum size of a template (inline or from a template file), used to prevent DoS attacks
|
||||
templateFileExtension = ".yml" // Template files must end with this extension
|
||||
)
|
||||
|
||||
func (s *Server) handleBodyAsTemplatedTextMessage(ctx context.Context, m *model.Message, template templateMode, body *util.PeekedReadCloser, priorityStr string) error {
|
||||
body, err := util.Peek(body, max(s.config.MessageSizeLimit, jsonBodyBytesLimit))
|
||||
if err != nil {
|
||||
return err
|
||||
} else if body.LimitReached {
|
||||
return errHTTPEntityTooLargeJSONBody
|
||||
}
|
||||
peekedBody := strings.TrimSpace(string(body.PeekedBytes))
|
||||
if template.FileMode() {
|
||||
if err := s.renderTemplateFromFile(ctx, m, template.FileName(), peekedBody); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
if err := s.renderTemplateFromParams(ctx, m, peekedBody, priorityStr); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if len(m.Title) > s.config.MessageSizeLimit || len(m.Message) > s.config.MessageSizeLimit {
|
||||
return errHTTPBadRequestTemplateMessageTooLarge
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// renderTemplateFromFile transforms the JSON message body according to a template from the filesystem.
|
||||
// The template file must be in the templates directory, or in the configured template directory.
|
||||
func (s *Server) renderTemplateFromFile(ctx context.Context, m *model.Message, templateName, peekedBody string) error {
|
||||
if !templateNameRegex.MatchString(templateName) {
|
||||
return errHTTPBadRequestTemplateFileNotFound
|
||||
}
|
||||
templateContent, _ := templatesFs.ReadFile(filepath.Join(templatesDir, templateName+templateFileExtension)) // Read from the embedded filesystem first
|
||||
if s.config.TemplateDir != "" {
|
||||
if b, _ := os.ReadFile(filepath.Join(s.config.TemplateDir, templateName+templateFileExtension)); len(b) > 0 {
|
||||
templateContent = b
|
||||
}
|
||||
}
|
||||
if len(templateContent) == 0 {
|
||||
return errHTTPBadRequestTemplateFileNotFound
|
||||
}
|
||||
var tpl templateFile
|
||||
if err := yaml.Unmarshal(templateContent, &tpl); err != nil {
|
||||
return errHTTPBadRequestTemplateFileInvalid
|
||||
}
|
||||
var err error
|
||||
if tpl.Message != nil {
|
||||
if m.Message, err = s.renderTemplate(ctx, templateName+" (message)", *tpl.Message, peekedBody); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if tpl.Title != nil {
|
||||
if m.Title, err = s.renderTemplate(ctx, templateName+" (title)", *tpl.Title, peekedBody); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if tpl.Priority != nil {
|
||||
renderedPriority, err := s.renderTemplate(ctx, templateName+" (priority)", *tpl.Priority, peekedBody)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if m.Priority, err = util.ParsePriority(renderedPriority); err != nil {
|
||||
return errHTTPBadRequestPriorityInvalid
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// renderTemplateFromParams transforms the JSON message body according to the inline template in the
|
||||
// message, title, and priority parameters.
|
||||
func (s *Server) renderTemplateFromParams(ctx context.Context, m *model.Message, peekedBody string, priorityStr string) error {
|
||||
var err error
|
||||
if m.Message, err = s.renderTemplate(ctx, "priority query parameter", m.Message, peekedBody); err != nil {
|
||||
return err
|
||||
}
|
||||
if m.Title, err = s.renderTemplate(ctx, "title query parameter", m.Title, peekedBody); err != nil {
|
||||
return err
|
||||
}
|
||||
if priorityStr != "" {
|
||||
renderedPriority, err := s.renderTemplate(ctx, "priority query parameter", priorityStr, peekedBody)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if m.Priority, err = util.ParsePriority(renderedPriority); err != nil {
|
||||
return errHTTPBadRequestPriorityInvalid
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// renderTemplate renders a template with the given JSON source data.
|
||||
func (s *Server) renderTemplate(ctx context.Context, name, tpl, source string) (string, error) {
|
||||
if len(tpl) > templateMaxTemplateBytes {
|
||||
return "", errHTTPBadRequestTemplateTooLarge
|
||||
}
|
||||
var data any
|
||||
if err := json.Unmarshal([]byte(source), &data); err != nil {
|
||||
return "", errHTTPBadRequestTemplateMessageNotJSON
|
||||
}
|
||||
t, err := gotext.New("").Funcs(sprig.TxtFuncMap()).Funcs(gotext.FuncMap{"printf": templatePrintf}).Parse(tpl)
|
||||
if err != nil {
|
||||
return "", errHTTPBadRequestTemplateInvalid.Wrap("%s", err.Error())
|
||||
}
|
||||
if templateUsesDisallowedFeatures(t) {
|
||||
return "", errHTTPBadRequestTemplateDisallowedFunctionCalls
|
||||
}
|
||||
// Bail out of runaway templates (GHSA-rhwf-xgc9-m9fp). The deadline starts here, after the body
|
||||
// has already been read, so a slow upload is not counted against it. Deriving from the request
|
||||
// context means a client disconnect aborts the render too.
|
||||
execCtx, cancel := context.WithTimeout(ctx, templateMaxExecutionTime)
|
||||
defer cancel()
|
||||
var buf bytes.Buffer
|
||||
limitWriter := util.NewLimitWriter(&buf, util.NewFixedLimiter(templateMaxOutputBytes))
|
||||
if err := t.ExecuteContext(execCtx, limitWriter, data); err != nil {
|
||||
if errors.Is(err, context.DeadlineExceeded) {
|
||||
return "", errHTTPBadRequestTemplateExecutionTimeout
|
||||
}
|
||||
return "", errHTTPBadRequestTemplateExecuteFailed.Wrap("template %s: %s", name, err.Error())
|
||||
}
|
||||
return strings.TrimSpace(strings.ReplaceAll(buf.String(), "\\n", "\n")), nil // replace any remaining "\n" (those outside of template curly braces) with newlines
|
||||
}
|
||||
|
||||
// templateUsesDisallowedFeatures reports whether the parsed template defines or invokes a
|
||||
// sub-template ({{define}}/{{block}}/{{template}}) or uses the {{call}} builtin. None are useful for
|
||||
// ntfy's JSON-data templates. Checking the parse tree (rather than the raw string) catches every
|
||||
// syntactic form -- e.g. {{if call .x}} or {{$y := call .x}} -- that a regex would miss.
|
||||
func templateUsesDisallowedFeatures(t *gotext.Template) bool {
|
||||
if len(t.Templates()) > 1 { // {{define}}/{{block}} create additional associated templates
|
||||
return true
|
||||
}
|
||||
return treeContainsDisallowedNode(t.Root)
|
||||
}
|
||||
|
||||
// treeContainsDisallowedNode reports whether the parse tree contains a {{template}}/{{block}}
|
||||
// invocation or a {{call}} builtin, descending into pipes and command arguments (where {{call}} can
|
||||
// appear anywhere a function is allowed).
|
||||
func treeContainsDisallowedNode(node parse.Node) bool {
|
||||
switch n := node.(type) {
|
||||
case *parse.ListNode:
|
||||
if n == nil {
|
||||
return false
|
||||
}
|
||||
for _, child := range n.Nodes {
|
||||
if treeContainsDisallowedNode(child) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
case *parse.ActionNode:
|
||||
return treeContainsDisallowedNode(n.Pipe)
|
||||
case *parse.RangeNode:
|
||||
return treeContainsDisallowedNode(n.Pipe) || treeContainsDisallowedNode(n.List) || treeContainsDisallowedNode(n.ElseList)
|
||||
case *parse.IfNode:
|
||||
return treeContainsDisallowedNode(n.Pipe) || treeContainsDisallowedNode(n.List) || treeContainsDisallowedNode(n.ElseList)
|
||||
case *parse.WithNode:
|
||||
return treeContainsDisallowedNode(n.Pipe) || treeContainsDisallowedNode(n.List) || treeContainsDisallowedNode(n.ElseList)
|
||||
case *parse.TemplateNode: // {{template}} or {{block}} invocation
|
||||
return true
|
||||
case *parse.ChainNode: // A term followed by field accesses, e.g. (call .x).y
|
||||
return treeContainsDisallowedNode(n.Node)
|
||||
case *parse.PipeNode:
|
||||
if n == nil {
|
||||
return false
|
||||
}
|
||||
for _, cmd := range n.Cmds {
|
||||
if treeContainsDisallowedNode(cmd) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
case *parse.CommandNode:
|
||||
for _, arg := range n.Args {
|
||||
if treeContainsDisallowedNode(arg) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
case *parse.IdentifierNode: // a function name; {{call}} is the disallowed builtin
|
||||
return n.Ident == "call"
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// templatePrintf is the template builtin printf, guarded against memory amplification: fmt
|
||||
// allows widths and precisions up to 1e6 per verb, so a small template like
|
||||
// {{printf "%999999d%999999d..." ...}} can allocate gigabytes inside a single fmt call -- and the
|
||||
// executor's cancellation context is only checked between template nodes, never inside one.
|
||||
// Widths and precisions of 1000 or more are therefore rejected, as is the star (*) form, which
|
||||
// takes the width from an argument. Combined with the template size limit, this bounds a single
|
||||
// render to a few MB. Registered via Funcs, which takes precedence over the builtin, and checked
|
||||
// at call time so a format string assembled during execution is covered too.
|
||||
func templatePrintf(format string, args ...any) (string, error) {
|
||||
if templatePrintfLargeSizeRegex.MatchString(strings.ReplaceAll(format, "%%", "")) { // Strip escaped percent signs, they take no width
|
||||
return "", errors.New("printf width or precision too large")
|
||||
}
|
||||
return fmt.Sprintf(format, args...), nil
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestServer_MessageTemplate_TooLarge(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
t.Parallel()
|
||||
s := newTestServer(t, newTestConfig(t, databaseURL))
|
||||
response := request(t, s, "PUT", "/mytopic", `{"foo":"bar"}`, map[string]string{
|
||||
"X-Message": "{{.foo}}" + strings.Repeat("x", 33*1024),
|
||||
"X-Template": "1",
|
||||
})
|
||||
require.Equal(t, 400, response.Code)
|
||||
require.Equal(t, 40056, toHTTPError(t, response.Body.String()).Code)
|
||||
})
|
||||
}
|
||||
|
||||
func TestServer_MessageTemplate_PrintfWidthTooLarge(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
t.Parallel()
|
||||
s := newTestServer(t, newTestConfig(t, databaseURL))
|
||||
// A handful of 1MB-wide verbs would allocate several MB inside a single fmt call, where
|
||||
// the executor's context is never checked; the printf guard must reject the call before
|
||||
// fmt runs, not after the limit writer sees the output
|
||||
response := request(t, s, "PUT", "/mytopic", `{"n":1}`, map[string]string{
|
||||
"X-Message": `{{printf "%1000000d%1000000d%1000000d" .n .n .n}}`,
|
||||
"X-Template": "1",
|
||||
})
|
||||
require.Equal(t, 400, response.Code)
|
||||
require.Equal(t, 40045, toHTTPError(t, response.Body.String()).Code)
|
||||
require.Contains(t, response.Body.String(), "printf width or precision too large")
|
||||
})
|
||||
}
|
||||
|
||||
func TestServer_MessageTemplate_PrintfWidthTooLarge_DynamicFormat(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
t.Parallel()
|
||||
s := newTestServer(t, newTestConfig(t, databaseURL))
|
||||
// The format string is assembled at execution time, so the guard must inspect the actual
|
||||
// argument, not the template source
|
||||
response := request(t, s, "PUT", "/mytopic", `{"n":1}`, map[string]string{
|
||||
"X-Message": `{{$f := print "%" "999999" "d" "%" "999999" "d"}}{{printf $f .n .n}}`,
|
||||
"X-Template": "1",
|
||||
})
|
||||
require.Equal(t, 400, response.Code)
|
||||
require.Contains(t, response.Body.String(), "printf width or precision too large")
|
||||
})
|
||||
}
|
||||
|
||||
func TestServer_MessageTemplate_PrintfStarWidthTooLarge(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
t.Parallel()
|
||||
s := newTestServer(t, newTestConfig(t, databaseURL))
|
||||
// Star widths take the width from an argument; sprig's math functions (int64 results)
|
||||
// make large integer arguments reachable from a template
|
||||
response := request(t, s, "PUT", "/mytopic", `{"n":1}`, map[string]string{
|
||||
"X-Message": `{{printf "%*d" (mul 1000 2000) 1}}`,
|
||||
"X-Template": "1",
|
||||
})
|
||||
require.Equal(t, 400, response.Code)
|
||||
require.Contains(t, response.Body.String(), "printf width or precision too large")
|
||||
})
|
||||
}
|
||||
|
||||
func TestServer_MessageTemplate_PrintfSmallWidthStillWorks(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
t.Parallel()
|
||||
s := newTestServer(t, newTestConfig(t, databaseURL))
|
||||
response := request(t, s, "PUT", "/mytopic", `{"n":7}`, map[string]string{
|
||||
"X-Message": `{{printf "%05d" 7}}`,
|
||||
"X-Template": "1",
|
||||
})
|
||||
require.Equal(t, 200, response.Code)
|
||||
require.Equal(t, "00007", toMessage(t, response.Body.String()).Message)
|
||||
})
|
||||
}
|
||||
|
||||
func Test_templatePrintf(t *testing.T) {
|
||||
tests := []struct {
|
||||
format string
|
||||
args []any
|
||||
want string // Empty means the call must be rejected
|
||||
}{
|
||||
{"%d", []any{5}, "5"},
|
||||
{"%05d", []any{5}, "00005"},
|
||||
{"%-8.3f|", []any{1.5}, "1.500 |"},
|
||||
{"%1000d", []any{1}, ""}, // Rejected: four digits
|
||||
{"%.1000s", []any{"x"}, ""},
|
||||
{"%*d", []any{500, 1}, ""}, // Rejected: star width
|
||||
{"%.*s", []any{400, "x"}, ""}, // Rejected: star precision
|
||||
{"%[1]1000000d", []any{1}, ""}, // Rejected: explicit arg index does not hide the width
|
||||
{"%[2]*[1]d", []any{6, 12}, ""}, // Rejected: star width behind an arg index
|
||||
{"100%% of 2024 values", nil, "100% of 2024 values"}, // Literal digits are not a width
|
||||
}
|
||||
for _, test := range tests {
|
||||
out, err := templatePrintf(test.format, test.args...)
|
||||
if test.want == "" {
|
||||
require.Error(t, err, "format %q must be rejected", test.format)
|
||||
require.Contains(t, err.Error(), "too large")
|
||||
} else {
|
||||
require.Nil(t, err, "format %q", test.format)
|
||||
require.Equal(t, test.want, out)
|
||||
}
|
||||
}
|
||||
|
||||
// The largest allowed width still produces bounded output
|
||||
out, err := templatePrintf("%999d", 1)
|
||||
require.Nil(t, err)
|
||||
require.Len(t, out, 999)
|
||||
}
|
||||
|
||||
func TestServer_MessageTemplate_DisallowedCallInChain(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
t.Parallel()
|
||||
s := newTestServer(t, newTestConfig(t, databaseURL))
|
||||
// {{call}} behind a field access parses into a ChainNode. JSON data cannot produce a
|
||||
// function value, so this cannot be exploited today, but the ban must catch every
|
||||
// syntactic form rather than relying on the call failing at runtime.
|
||||
response := request(t, s, "PUT", "/mytopic", `{"fn":1}`, map[string]string{
|
||||
"X-Message": `{{(call .fn).x}}`,
|
||||
"X-Template": "1",
|
||||
})
|
||||
require.Equal(t, 400, response.Code)
|
||||
require.Equal(t, 40044, toHTTPError(t, response.Body.String()).Code)
|
||||
})
|
||||
}
|
||||
+705
-34
@@ -16,12 +16,14 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime/debug"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||
"github.com/stretchr/testify/require"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
dbtest "heckel.io/ntfy/v2/db/test"
|
||||
@@ -264,6 +266,27 @@ func TestServer_StaticSites(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestServer_WebApp_MagicLinkLandingPagesNoIndexHeaders(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
s := newTestServer(t, newTestConfig(t, databaseURL))
|
||||
|
||||
// Magic-link landing pages carry a one-time token in the path, so the response must not
|
||||
// leak the token via the Referer header and must not be indexed
|
||||
for _, path := range []string{"/account/email/verify/sometoken", "/account/password/reset/sometoken"} {
|
||||
rr := request(t, s, "GET", path, "", nil)
|
||||
require.Equal(t, 200, rr.Code, path)
|
||||
require.Equal(t, "no-referrer", rr.Header().Get("Referrer-Policy"), path)
|
||||
require.Equal(t, "noindex", rr.Header().Get("X-Robots-Tag"), path)
|
||||
}
|
||||
|
||||
// Ordinary web app routes do not set these headers
|
||||
rr := request(t, s, "GET", "/", "", nil)
|
||||
require.Equal(t, 200, rr.Code)
|
||||
require.Empty(t, rr.Header().Get("Referrer-Policy"))
|
||||
require.Empty(t, rr.Header().Get("X-Robots-Tag"))
|
||||
})
|
||||
}
|
||||
|
||||
func TestServer_WebEnabled(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
conf := newTestConfig(t, databaseURL)
|
||||
@@ -302,6 +325,40 @@ func TestServer_WebEnabled(t *testing.T) {
|
||||
require.Equal(t, 200, rr.Code)
|
||||
})
|
||||
}
|
||||
|
||||
// TestServer_MetricsEnabled ensures that the /metrics endpoint serves the registered ntfy metrics
|
||||
// once the metrics handler is set (as Serve does when enable-metrics is configured).
|
||||
func TestServer_MetricsEnabled(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
s := newTestServer(t, newTestConfig(t, databaseURL))
|
||||
s.metricsHandler = promhttp.Handler() // Serve sets this when enable-metrics is configured
|
||||
|
||||
// Count at least one request first: Prometheus only reports a CounterVec such as
|
||||
// ntfy_http_requests_total once it has children
|
||||
request(t, s, "GET", "/v1/health", "", nil)
|
||||
|
||||
rr := request(t, s, "GET", "/metrics", "", nil)
|
||||
require.Equal(t, 200, rr.Code)
|
||||
require.Contains(t, rr.Body.String(), "ntfy_messages_published_success")
|
||||
require.Contains(t, rr.Body.String(), "ntfy_http_requests_total")
|
||||
})
|
||||
}
|
||||
|
||||
// TestServer_MetricsDisabled ensures that the ntfy metrics are not exposed when the metrics handler
|
||||
// is unset (the default). The collectors are always registered with the Prometheus registry, so a
|
||||
// nil metrics handler is the only thing keeping them off the wire.
|
||||
func TestServer_MetricsDisabled(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
conf := newTestConfig(t, databaseURL)
|
||||
conf.WebRoot = "" // Disable the web app, so its catch-all does not mask the /metrics route
|
||||
s := newTestServer(t, conf)
|
||||
|
||||
rr := request(t, s, "GET", "/metrics", "", nil)
|
||||
require.Equal(t, 404, rr.Code)
|
||||
require.NotContains(t, rr.Body.String(), "ntfy_messages_published_success")
|
||||
})
|
||||
}
|
||||
|
||||
func TestServer_PublishLargeMessage(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
c := newTestConfig(t, databaseURL)
|
||||
@@ -740,7 +797,7 @@ func TestServer_PublishMessageInHeaderWithNewlines(t *testing.T) {
|
||||
func TestServer_PublishInvalidTopic(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
s := newTestServer(t, newTestConfig(t, databaseURL))
|
||||
s.smtpSender = &testMailer{}
|
||||
s.mailer = &testMailer{}
|
||||
response := request(t, s, "PUT", "/docs", "fail", nil)
|
||||
require.Equal(t, 40010, toHTTPError(t, response.Body.String()).Code)
|
||||
})
|
||||
@@ -1231,7 +1288,7 @@ func TestServer_StatsResetter_MessageLimiter_EmailsLimiter(t *testing.T) {
|
||||
|
||||
c := newTestConfigWithAuthFile(t, databaseURL)
|
||||
s := newTestServer(t, c)
|
||||
s.smtpSender = &testMailer{}
|
||||
s.mailer = &testMailer{}
|
||||
|
||||
// Publish some messages, and check stats
|
||||
for i := 0; i < 3; i++ {
|
||||
@@ -1315,18 +1372,20 @@ func TestServer_DailyMessageQuotaFromDatabase(t *testing.T) {
|
||||
}
|
||||
|
||||
type testMailer struct {
|
||||
count int
|
||||
mu sync.Mutex
|
||||
count int
|
||||
lastTo string
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func (t *testMailer) Send(v *visitor, m *model.Message, to string) error {
|
||||
func (t *testMailer) SendNotification(to string, m *model.Message, senderIP string) error {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
t.count++
|
||||
t.lastTo = to
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *testMailer) Counts() (total int64, success int64, failure int64) {
|
||||
func (t *testMailer) NotificationCounts() (total int64, success int64, failure int64) {
|
||||
return 0, 0, 0
|
||||
}
|
||||
|
||||
@@ -1336,6 +1395,16 @@ func (t *testMailer) Count() int {
|
||||
return t.count
|
||||
}
|
||||
|
||||
func (t *testMailer) LastTo() string {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
return t.lastTo
|
||||
}
|
||||
|
||||
func (t *testMailer) SendEmailVerification(to, link string) error { return nil }
|
||||
|
||||
func (t *testMailer) SendPasswordReset(to, link string) error { return nil }
|
||||
|
||||
func TestServer_PublishTooManyRequests_Defaults(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
s := newTestServer(t, newTestConfig(t, databaseURL))
|
||||
@@ -1461,7 +1530,7 @@ func TestServer_PublishTooManyRequests_ShortReplenish(t *testing.T) {
|
||||
func TestServer_PublishTooManyEmails_Defaults(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
s := newTestServer(t, newTestConfig(t, databaseURL))
|
||||
s.smtpSender = &testMailer{}
|
||||
s.mailer = &testMailer{}
|
||||
for i := 0; i < 16; i++ {
|
||||
response := request(t, s, "PUT", "/mytopic", fmt.Sprintf("message %d", i), map[string]string{
|
||||
"E-Mail": "test@example.com",
|
||||
@@ -1481,7 +1550,7 @@ func TestServer_PublishTooManyEmails_Replenish(t *testing.T) {
|
||||
c := newTestConfig(t, databaseURL)
|
||||
c.VisitorEmailLimitReplenish = 500 * time.Millisecond
|
||||
s := newTestServer(t, c)
|
||||
s.smtpSender = &testMailer{}
|
||||
s.mailer = &testMailer{}
|
||||
for i := 0; i < 16; i++ {
|
||||
response := request(t, s, "PUT", "/mytopic", fmt.Sprintf("message %d", i), map[string]string{
|
||||
"E-Mail": "test@example.com",
|
||||
@@ -1509,7 +1578,7 @@ func TestServer_PublishTooManyEmails_Replenish(t *testing.T) {
|
||||
func TestServer_PublishDelayedEmail_Fail(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
s := newTestServer(t, newTestConfig(t, databaseURL))
|
||||
s.smtpSender = &testMailer{}
|
||||
s.mailer = &testMailer{}
|
||||
response := request(t, s, "PUT", "/mytopic", "fail", map[string]string{
|
||||
"E-Mail": "test@example.com",
|
||||
"Delay": "20 min",
|
||||
@@ -1546,7 +1615,7 @@ func TestServer_PublishEmailNoMailer_Fail(t *testing.T) {
|
||||
func TestServer_PublishEmailAddressInvalid(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
s := newTestServer(t, newTestConfig(t, databaseURL))
|
||||
s.smtpSender = &testMailer{}
|
||||
s.mailer = &testMailer{}
|
||||
addresses := []string{
|
||||
"test@example.com, other@example.com",
|
||||
"invalidaddress",
|
||||
@@ -1572,7 +1641,7 @@ func TestServer_PublishEmailVerify_VerifiedAddress(t *testing.T) {
|
||||
conf := newTestConfigWithAuthFile(t, databaseURL)
|
||||
conf.SMTPSenderVerify = true
|
||||
s := newTestServer(t, conf)
|
||||
s.smtpSender = &testMailer{}
|
||||
s.mailer = &testMailer{}
|
||||
defer s.closeDatabases()
|
||||
|
||||
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false))
|
||||
@@ -1602,7 +1671,7 @@ func TestServer_PublishEmailVerify_BoolValue(t *testing.T) {
|
||||
conf := newTestConfigWithAuthFile(t, databaseURL)
|
||||
conf.SMTPSenderVerify = true
|
||||
s := newTestServer(t, conf)
|
||||
s.smtpSender = &testMailer{}
|
||||
s.mailer = &testMailer{}
|
||||
defer s.closeDatabases()
|
||||
|
||||
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false))
|
||||
@@ -1628,17 +1697,100 @@ func TestServer_PublishEmailVerify_BoolValue(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestServer_PublishEmailVerify_BoolValue_NoVerify(t *testing.T) {
|
||||
func TestServer_PublishEmailVerify_BoolValueUsesPrimary(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
conf := newTestConfigWithAuthFile(t, databaseURL)
|
||||
conf.SMTPSenderVerify = true
|
||||
s := newTestServer(t, conf)
|
||||
mailer := &testMailer{}
|
||||
s.mailer = mailer
|
||||
defer s.closeDatabases()
|
||||
|
||||
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false))
|
||||
u, err := s.userManager.User("phil")
|
||||
require.Nil(t, err)
|
||||
// Two verified emails; the primary is NOT the alphabetically-first one
|
||||
require.Nil(t, s.userManager.AddEmail(u.ID, "aaa@example.com"))
|
||||
require.Nil(t, s.userManager.AddEmail(u.ID, "zzz@example.com"))
|
||||
require.Nil(t, s.userManager.SetPrimaryEmail(u.ID, "zzz@example.com"))
|
||||
|
||||
// "yes" must resolve to the primary email, not emails[0] (alphabetically first)
|
||||
response := request(t, s, "PUT", "/mytopic", "hi", map[string]string{
|
||||
"Email": "yes",
|
||||
"Authorization": util.BasicAuth("phil", "phil"),
|
||||
})
|
||||
require.Equal(t, 200, response.Code)
|
||||
waitFor(t, func() bool { return mailer.LastTo() != "" }) // E-Mail publishing happens in a Go routine
|
||||
require.Equal(t, "zzz@example.com", mailer.LastTo())
|
||||
})
|
||||
}
|
||||
|
||||
func TestServer_PublishEmailVerify_BoolValueNoVerifyUsesPrimary(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
conf := newTestConfigWithAuthFile(t, databaseURL)
|
||||
// smtp-sender-verify intentionally left false (the default)
|
||||
s := newTestServer(t, conf)
|
||||
mailer := &testMailer{}
|
||||
s.mailer = mailer
|
||||
defer s.closeDatabases()
|
||||
|
||||
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false))
|
||||
u, err := s.userManager.User("phil")
|
||||
require.Nil(t, err)
|
||||
require.Nil(t, s.userManager.AddEmail(u.ID, "aaa@example.com"))
|
||||
require.Nil(t, s.userManager.AddEmail(u.ID, "zzz@example.com"))
|
||||
require.Nil(t, s.userManager.SetPrimaryEmail(u.ID, "zzz@example.com"))
|
||||
|
||||
// Even with smtp-sender-verify off, "yes" resolves to the user's primary verified address
|
||||
response := request(t, s, "PUT", "/mytopic", "hi", map[string]string{
|
||||
"Email": "yes",
|
||||
"Authorization": util.BasicAuth("phil", "phil"),
|
||||
})
|
||||
require.Equal(t, 200, response.Code)
|
||||
waitFor(t, func() bool { return mailer.LastTo() != "" }) // E-Mail publishing happens in a Go routine
|
||||
require.Equal(t, "zzz@example.com", mailer.LastTo())
|
||||
})
|
||||
}
|
||||
|
||||
func TestServer_PublishEmailVerify_BoolValueAnonymousRejected(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
s := newTestServer(t, newTestConfig(t, databaseURL))
|
||||
s.smtpSender = &testMailer{}
|
||||
s.mailer = &testMailer{}
|
||||
|
||||
// "yes" without smtp-sender-verify should fail with invalid address
|
||||
// "yes" requires an authenticated user (it means "my primary"); anonymous is rejected
|
||||
response := request(t, s, "PUT", "/mytopic", "hi", map[string]string{
|
||||
"Email": "yes",
|
||||
})
|
||||
require.Equal(t, 400, response.Code)
|
||||
require.Equal(t, 40050, toHTTPError(t, response.Body.String()).Code)
|
||||
require.Equal(t, 40053, toHTTPError(t, response.Body.String()).Code)
|
||||
})
|
||||
}
|
||||
|
||||
func TestServer_PublishEmailVerify_BoolValueProvisionedUsesPrimary(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
hash, err := user.HashPassword("provpass", user.DefaultUserPasswordBcryptCost)
|
||||
require.Nil(t, err)
|
||||
conf := newTestConfigWithAuthFile(t, databaseURL)
|
||||
conf.AuthUsers = []*user.User{{Name: "prov", Hash: hash, Role: user.RoleUser}}
|
||||
s := newTestServer(t, conf)
|
||||
mailer := &testMailer{}
|
||||
s.mailer = mailer
|
||||
defer s.closeDatabases()
|
||||
|
||||
prov, err := s.userManager.User("prov")
|
||||
require.Nil(t, err)
|
||||
require.Nil(t, s.userManager.AddEmail(prov.ID, "aaa@example.com"))
|
||||
require.Nil(t, s.userManager.AddEmail(prov.ID, "zzz@example.com"))
|
||||
require.Nil(t, s.userManager.SetPrimaryEmail(prov.ID, "zzz@example.com"))
|
||||
|
||||
// A provisioned user's "yes" resolves to their chosen primary, not the alphabetically-first
|
||||
response := request(t, s, "PUT", "/mytopic", "hi", map[string]string{
|
||||
"Email": "yes",
|
||||
"Authorization": util.BasicAuth("prov", "provpass"),
|
||||
})
|
||||
require.Equal(t, 200, response.Code)
|
||||
waitFor(t, func() bool { return mailer.LastTo() != "" }) // E-Mail publishing happens in a Go routine
|
||||
require.Equal(t, "zzz@example.com", mailer.LastTo())
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1647,7 +1799,7 @@ func TestServer_PublishEmailVerify_Anonymous(t *testing.T) {
|
||||
conf := newTestConfigWithAuthFile(t, databaseURL)
|
||||
conf.SMTPSenderVerify = true
|
||||
s := newTestServer(t, conf)
|
||||
s.smtpSender = &testMailer{}
|
||||
s.mailer = &testMailer{}
|
||||
defer s.closeDatabases()
|
||||
|
||||
// Anonymous user should be rejected
|
||||
@@ -1664,7 +1816,7 @@ func TestServer_PublishEmailVerify_NoVerifiedEmails(t *testing.T) {
|
||||
conf := newTestConfigWithAuthFile(t, databaseURL)
|
||||
conf.SMTPSenderVerify = true
|
||||
s := newTestServer(t, conf)
|
||||
s.smtpSender = &testMailer{}
|
||||
s.mailer = &testMailer{}
|
||||
defer s.closeDatabases()
|
||||
|
||||
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false))
|
||||
@@ -1682,7 +1834,7 @@ func TestServer_PublishEmailVerify_NoVerifiedEmails(t *testing.T) {
|
||||
func TestServer_PublishEmailVerify_Disabled_Backwards_Compatible(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
s := newTestServer(t, newTestConfig(t, databaseURL))
|
||||
s.smtpSender = &testMailer{}
|
||||
s.mailer = &testMailer{}
|
||||
|
||||
// Without smtp-sender-verify, any email address should work (backwards compatible)
|
||||
response := request(t, s, "PUT", "/mytopic", "hi", map[string]string{
|
||||
@@ -1706,11 +1858,11 @@ func TestServer_AccountEmailVerify_UserWithoutTier(t *testing.T) {
|
||||
// Create a user without a tier
|
||||
require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser, false))
|
||||
|
||||
// Verify email request should NOT return 401
|
||||
response := request(t, s, "PUT", "/v1/account/email/verify", `{"email":"ben@example.com"}`, map[string]string{
|
||||
// Starting email verification should NOT return 401
|
||||
response := request(t, s, "PUT", "/v1/account/email", `{"email":"ben@example.com"}`, map[string]string{
|
||||
"Authorization": util.BasicAuth("ben", "ben"),
|
||||
})
|
||||
// The request will fail (SMTP not available), but it must NOT be a 401
|
||||
// The request may fail (SMTP not available), but it must NOT be a 401
|
||||
require.NotEqual(t, 401, response.Code)
|
||||
})
|
||||
}
|
||||
@@ -1731,7 +1883,7 @@ func TestServer_AccountEmailVerify_UserWithoutTier_EmailLimitZero(t *testing.T)
|
||||
require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser, false))
|
||||
|
||||
// Should be rejected with 401 since email sending is disabled
|
||||
response := request(t, s, "PUT", "/v1/account/email/verify", `{"email":"ben@example.com"}`, map[string]string{
|
||||
response := request(t, s, "PUT", "/v1/account/email", `{"email":"ben@example.com"}`, map[string]string{
|
||||
"Authorization": util.BasicAuth("ben", "ben"),
|
||||
})
|
||||
require.Equal(t, 401, response.Code)
|
||||
@@ -2139,7 +2291,7 @@ func TestServer_PublishAsJSON_WithEmail(t *testing.T) {
|
||||
t.Parallel()
|
||||
mailer := &testMailer{}
|
||||
s := newTestServer(t, newTestConfig(t, databaseURL))
|
||||
s.smtpSender = mailer
|
||||
s.mailer = mailer
|
||||
body := `{"topic":"mytopic","message":"A message","email":"phil@example.com"}`
|
||||
response := request(t, s, "PUT", "/", body, nil)
|
||||
require.Equal(t, 200, response.Code)
|
||||
@@ -2659,6 +2811,201 @@ func TestServer_PublishAttachmentBandwidthLimit(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestServer_PollOrderAcrossTopics(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
// Replaying several topics at once concatenates each topic's messages and then sorts the
|
||||
// lot by Time, which has second granularity. That sort must not reorder messages that
|
||||
// share a timestamp, or a topic's own messages come back out of publish order. See #1297.
|
||||
//
|
||||
// The messages have to straddle a second boundary: if every timestamp is identical the
|
||||
// concatenation is already sorted and Go's pdqsort leaves it alone, hiding the bug.
|
||||
s := newTestServer(t, newTestConfig(t, databaseURL))
|
||||
|
||||
const perBatch = 10
|
||||
publish := func(batch int) {
|
||||
for _, topic := range []string{"topicA", "topicB"} {
|
||||
for i := 0; i < perBatch; i++ {
|
||||
body := fmt.Sprintf("%s-%02d", topic, batch*perBatch+i)
|
||||
require.Equal(t, 200, request(t, s, "PUT", "/"+topic, body, nil).Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
publish(0)
|
||||
time.Sleep(1100 * time.Millisecond) // Cross a second boundary, so Time is not all-equal
|
||||
publish(1)
|
||||
|
||||
response := request(t, s, "GET", "/topicA,topicB/json?poll=1", "", nil)
|
||||
require.Equal(t, 200, response.Code)
|
||||
messages := toMessages(t, response.Body.String())
|
||||
require.Equal(t, 4*perBatch, len(messages))
|
||||
|
||||
// Each topic's own messages must appear in publish order, whatever the interleaving
|
||||
lastSeen := map[string]int{"topicA": -1, "topicB": -1}
|
||||
for _, m := range messages {
|
||||
topic, seqStr, found := strings.Cut(m.Message, "-")
|
||||
require.True(t, found)
|
||||
seq, err := strconv.Atoi(seqStr)
|
||||
require.Nil(t, err)
|
||||
require.Greater(t, seq, lastSeen[topic], "%s came back out of publish order", m.Message)
|
||||
lastSeen[topic] = seq
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestServer_PublishTitleTooLarge(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
// Title has no length limit of its own, unlike the body, so it is capped here. Prod p999
|
||||
// is 212 bytes and only 16 of ~3M cached messages exceed 1 KB.
|
||||
s := newTestServer(t, newTestConfig(t, databaseURL))
|
||||
|
||||
require.Equal(t, 200, request(t, s, "PUT", "/mytopic", "x", map[string]string{
|
||||
"Title": strings.Repeat("t", messageTitleSizeLimit),
|
||||
}).Code)
|
||||
|
||||
response := request(t, s, "PUT", "/mytopic", "x", map[string]string{
|
||||
"Title": strings.Repeat("t", messageTitleSizeLimit+1),
|
||||
})
|
||||
require.Equal(t, 400, response.Code)
|
||||
require.Equal(t, 40057, toHTTPError(t, response.Body.String()).Code)
|
||||
})
|
||||
}
|
||||
|
||||
func TestServer_PublishTagsTooLarge(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
// Same for tags, measured across all of them: prod p999 is 244 bytes and only 197 of ~3M
|
||||
// cached messages exceed 512.
|
||||
s := newTestServer(t, newTestConfig(t, databaseURL))
|
||||
|
||||
require.Equal(t, 200, request(t, s, "PUT", "/mytopic", "x", map[string]string{
|
||||
"Tags": strings.Repeat("g", messageTagsSizeLimit),
|
||||
}).Code)
|
||||
|
||||
response := request(t, s, "PUT", "/mytopic", "x", map[string]string{
|
||||
"Tags": strings.Repeat("g", messageTagsSizeLimit+1),
|
||||
})
|
||||
require.Equal(t, 400, response.Code)
|
||||
require.Equal(t, 40058, toHTTPError(t, response.Body.String()).Code)
|
||||
})
|
||||
}
|
||||
|
||||
func TestServer_PollSizeLimit(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
// A poll without "since" replays the entire cache, which is unbounded in size. The cap is
|
||||
// a byte budget rather than a message count, because message sizes vary ~20x in practice:
|
||||
// a count cap truncates cheap high-volume topics while barely touching the expensive
|
||||
// large-message ones it is meant to catch. The newest messages are kept.
|
||||
c := newTestConfig(t, databaseURL)
|
||||
c.MessagePollSizeLimit = 3500 // Fits three 1000-byte messages, not four
|
||||
s := newTestServer(t, c)
|
||||
|
||||
for i := 0; i < 6; i++ {
|
||||
body := fmt.Sprintf("%04d%s", i, strings.Repeat("x", 996)) // 1000 bytes, ordered prefix
|
||||
require.Equal(t, 200, request(t, s, "PUT", "/mytopic", body, nil).Code)
|
||||
}
|
||||
|
||||
response := request(t, s, "GET", "/mytopic/json?poll=1", "", nil)
|
||||
require.Equal(t, 200, response.Code)
|
||||
require.Equal(t, "1", response.Header().Get("X-Messages-Truncated"))
|
||||
messages := toMessages(t, response.Body.String())
|
||||
require.Equal(t, 3, len(messages))
|
||||
require.Equal(t, "0003", messages[0].Message[:4]) // newest three, oldest first
|
||||
require.Equal(t, "0004", messages[1].Message[:4])
|
||||
require.Equal(t, "0005", messages[2].Message[:4])
|
||||
|
||||
// A topic under the budget is served whole, with no truncation header
|
||||
require.Equal(t, 200, request(t, s, "PUT", "/othertopic", "small", nil).Code)
|
||||
response = request(t, s, "GET", "/othertopic/json?poll=1", "", nil)
|
||||
require.Equal(t, 200, response.Code)
|
||||
require.Empty(t, response.Header().Get("X-Messages-Truncated"))
|
||||
require.Equal(t, 1, len(toMessages(t, response.Body.String())))
|
||||
})
|
||||
}
|
||||
|
||||
func TestServer_PollSizeLimitCountsTitle(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
// Title is user-controlled and has no length limit of its own, so it has to count against
|
||||
// the replay budget too; otherwise a topic of title-heavy messages sails past the cap.
|
||||
c := newTestConfig(t, databaseURL)
|
||||
c.MessagePollSizeLimit = 1600 // Fits one 500-byte title + 500-byte body, not two
|
||||
s := newTestServer(t, c)
|
||||
|
||||
for i := 0; i < 4; i++ {
|
||||
body := fmt.Sprintf("%04d%s", i, strings.Repeat("b", 496)) // 500 bytes
|
||||
title := fmt.Sprintf("%04d%s", i, strings.Repeat("t", 496)) // 500 bytes
|
||||
require.Equal(t, 200, request(t, s, "PUT", "/mytopic", body, map[string]string{"Title": title}).Code)
|
||||
}
|
||||
|
||||
response := request(t, s, "GET", "/mytopic/json?poll=1", "", nil)
|
||||
require.Equal(t, 200, response.Code)
|
||||
require.Equal(t, "1", response.Header().Get("X-Messages-Truncated"))
|
||||
messages := toMessages(t, response.Body.String())
|
||||
require.Equal(t, 1, len(messages)) // 3 if the title were not counted
|
||||
require.Equal(t, "0003", messages[0].Message[:4])
|
||||
})
|
||||
}
|
||||
|
||||
func TestServer_PollSizeLimitCountsEveryField(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
// Every field a publisher can grow has to count against the replay budget, not just the
|
||||
// body and title: tags, click, icon and actions are all user-controlled, so anything left
|
||||
// out is a hole the budget can be walked through.
|
||||
c := newTestConfig(t, databaseURL)
|
||||
c.MessagePollSizeLimit = 900 // Two messages fit if only body+title count; one if all fields do
|
||||
s := newTestServer(t, c)
|
||||
|
||||
tags := make([]string, 5)
|
||||
for i := range tags {
|
||||
tags[i] = strings.Repeat("g", 79) // 395 bytes of tags
|
||||
}
|
||||
for i := 0; i < 3; i++ {
|
||||
require.Equal(t, 200, request(t, s, "PUT", "/mytopic", fmt.Sprintf("%04d%s", i, strings.Repeat("b", 196)), map[string]string{
|
||||
"Title": strings.Repeat("t", 200),
|
||||
"Tags": strings.Join(tags, ","),
|
||||
"Click": "https://example.com/" + strings.Repeat("c", 180),
|
||||
}).Code)
|
||||
}
|
||||
|
||||
response := request(t, s, "GET", "/mytopic/json?poll=1", "", nil)
|
||||
require.Equal(t, 200, response.Code)
|
||||
require.Equal(t, "1", response.Header().Get("X-Messages-Truncated"))
|
||||
messages := toMessages(t, response.Body.String())
|
||||
require.Equal(t, 1, len(messages)) // 2 if only body+title were counted
|
||||
require.Equal(t, "0002", messages[0].Message[:4])
|
||||
})
|
||||
}
|
||||
|
||||
func TestServer_PollBandwidthLimit(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
// A poll without "since" replays the entire cache, so a topic that is cheap to fill is
|
||||
// expensive to read over and over. Replayed bytes are charged against the same daily
|
||||
// budget as attachment traffic. One message per poll keeps the accounting coarse: any
|
||||
// shortfall hits the very first message, so the request is rejected before anything is
|
||||
// written rather than truncated mid-stream.
|
||||
c := newTestConfig(t, databaseURL)
|
||||
c.VisitorAttachmentDailyBandwidthLimit = 9000 // Enough for two replays of the ~4 KB topic below, not three
|
||||
s := newTestServer(t, c)
|
||||
|
||||
require.Equal(t, 200, request(t, s, "PUT", "/mytopic", util.RandomString(4000), nil).Code)
|
||||
|
||||
// Two full replays fit in the budget
|
||||
for i := 1; i <= 2; i++ {
|
||||
response := request(t, s, "GET", "/mytopic/json?poll=1", "", nil)
|
||||
require.Equal(t, 200, response.Code)
|
||||
require.Equal(t, 1, len(toMessages(t, response.Body.String())))
|
||||
}
|
||||
|
||||
// The third is rejected before a single byte is written
|
||||
response := request(t, s, "GET", "/mytopic/json?poll=1", "", nil)
|
||||
require.Equal(t, 429, response.Code)
|
||||
require.Equal(t, 42905, toHTTPError(t, response.Body.String()).Code)
|
||||
|
||||
// A subscription that replays nothing is not charged against the budget
|
||||
response = request(t, s, "GET", "/mytopic/json?poll=1&since=none", "", nil)
|
||||
require.Equal(t, 200, response.Code)
|
||||
require.Empty(t, strings.TrimSpace(response.Body.String()))
|
||||
})
|
||||
}
|
||||
|
||||
func TestServer_PublishAttachmentBandwidthLimitUploadOnly(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
content := util.RandomString(5000) // > 4096
|
||||
@@ -2738,7 +3085,7 @@ func TestServer_Visitor_XForwardedFor_None(t *testing.T) {
|
||||
r, _ := http.NewRequest("GET", "/bla", nil)
|
||||
r.RemoteAddr = "8.9.10.11:1234"
|
||||
r.Header.Set("X-Forwarded-For", " ") // Spaces, not empty!
|
||||
v, err := s.maybeAuthenticate(r)
|
||||
_, v, err := s.maybeAuthenticate(r)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, "8.9.10.11", v.ip.String())
|
||||
})
|
||||
@@ -2752,7 +3099,7 @@ func TestServer_Visitor_XForwardedFor_Single(t *testing.T) {
|
||||
r, _ := http.NewRequest("GET", "/bla", nil)
|
||||
r.RemoteAddr = "8.9.10.11:1234"
|
||||
r.Header.Set("X-Forwarded-For", "1.1.1.1")
|
||||
v, err := s.maybeAuthenticate(r)
|
||||
_, v, err := s.maybeAuthenticate(r)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, "1.1.1.1", v.ip.String())
|
||||
})
|
||||
@@ -2766,7 +3113,7 @@ func TestServer_Visitor_XForwardedFor_Multiple(t *testing.T) {
|
||||
r, _ := http.NewRequest("GET", "/bla", nil)
|
||||
r.RemoteAddr = "8.9.10.11:1234"
|
||||
r.Header.Set("X-Forwarded-For", "1.2.3.4 , 2.4.4.2,234.5.2.1 ")
|
||||
v, err := s.maybeAuthenticate(r)
|
||||
_, v, err := s.maybeAuthenticate(r)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, "234.5.2.1", v.ip.String())
|
||||
})
|
||||
@@ -2781,7 +3128,7 @@ func TestServer_Visitor_Custom_ClientIP_Header(t *testing.T) {
|
||||
r, _ := http.NewRequest("GET", "/bla", nil)
|
||||
r.RemoteAddr = "8.9.10.11:1234"
|
||||
r.Header.Set("X-Client-IP", "1.2.3.4")
|
||||
v, err := s.maybeAuthenticate(r)
|
||||
_, v, err := s.maybeAuthenticate(r)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, "1.2.3.4", v.ip.String())
|
||||
})
|
||||
@@ -2796,7 +3143,7 @@ func TestServer_Visitor_Custom_ClientIP_Header_IPv6(t *testing.T) {
|
||||
r, _ := http.NewRequest("GET", "/bla", nil)
|
||||
r.RemoteAddr = "[2001:db8:9999::1]:1234"
|
||||
r.Header.Set("X-Client-IP", "2001:db8:7777::1")
|
||||
v, err := s.maybeAuthenticate(r)
|
||||
_, v, err := s.maybeAuthenticate(r)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, "2001:db8:7777::1", v.ip.String())
|
||||
})
|
||||
@@ -2812,7 +3159,7 @@ func TestServer_Visitor_Custom_Forwarded_Header(t *testing.T) {
|
||||
r, _ := http.NewRequest("GET", "/bla", nil)
|
||||
r.RemoteAddr = "8.9.10.11:1234"
|
||||
r.Header.Set("Forwarded", " for=5.6.7.8, by=example.com;for=1.2.3.4")
|
||||
v, err := s.maybeAuthenticate(r)
|
||||
_, v, err := s.maybeAuthenticate(r)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, "5.6.7.8", v.ip.String())
|
||||
})
|
||||
@@ -2828,7 +3175,7 @@ func TestServer_Visitor_Custom_Forwarded_Header_IPv6(t *testing.T) {
|
||||
r, _ := http.NewRequest("GET", "/bla", nil)
|
||||
r.RemoteAddr = "[2001:db8:2222::1]:1234"
|
||||
r.Header.Set("Forwarded", " for=[2001:db8:1111::1], by=example.com;for=[2001:db8:3333::1]")
|
||||
v, err := s.maybeAuthenticate(r)
|
||||
_, v, err := s.maybeAuthenticate(r)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, "2001:db8:3333::1", v.ip.String())
|
||||
})
|
||||
@@ -3522,6 +3869,184 @@ func TestServer_MessageTemplate_Range(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestServer_MessageTemplate_ExecutionTimeout(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
t.Parallel()
|
||||
s := newTestServer(t, newTestConfig(t, databaseURL))
|
||||
// Nested range over a 1000-element JSON field with a no-output body: no Write ever happens,
|
||||
// so the write-triggered TimeoutWriter never fires. Must be bounded by the executor's
|
||||
// wall-clock deadline instead (GHSA-rhwf-xgc9-m9fp).
|
||||
elems := make([]string, 1000)
|
||||
for i := range elems {
|
||||
elems[i] = "0"
|
||||
}
|
||||
jsonBody := `{"a":[` + strings.Join(elems, ",") + `]}`
|
||||
msg := `{{range .a}}{{range $.a}}` + strings.Repeat(`{{$x := .}}`, 100) + `{{end}}{{end}}done`
|
||||
start := time.Now()
|
||||
response := request(t, s, "POST", "/mytopic", jsonBody, map[string]string{
|
||||
"X-Message": msg,
|
||||
"X-Template": "1",
|
||||
})
|
||||
elapsed := time.Since(start)
|
||||
require.Equal(t, 400, response.Code)
|
||||
require.Equal(t, 40055, toHTTPError(t, response.Body.String()).Code)
|
||||
require.Less(t, elapsed, 500*time.Millisecond, "template must be interrupted by the deadline, not run to completion (took %s)", elapsed)
|
||||
})
|
||||
}
|
||||
|
||||
// TestServer_MessageTemplate_DataDrivenNestedRange_TimesOut is the regression for the exact hole the
|
||||
// old write-triggered TimeoutWriter missed: a nested {{range}} over a JSON array field with a
|
||||
// no-output body calls no function, so only the executor's wall-clock deadline can stop it
|
||||
// (GHSA-rhwf-xgc9-m9fp).
|
||||
func TestServer_MessageTemplate_DataDrivenNestedRange_TimesOut(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
t.Parallel()
|
||||
s := newTestServer(t, newTestConfig(t, databaseURL))
|
||||
elems := make([]string, 1000)
|
||||
for i := range elems {
|
||||
elems[i] = "0"
|
||||
}
|
||||
jsonBody := `{"a":[` + strings.Join(elems, ",") + `]}`
|
||||
msg := `{{range .a}}{{range $.a}}{{range $.a}}{{$x := .}}{{end}}{{end}}{{end}}done`
|
||||
start := time.Now()
|
||||
response := request(t, s, "POST", "/mytopic", jsonBody, map[string]string{
|
||||
"X-Message": msg,
|
||||
"X-Template": "1",
|
||||
})
|
||||
elapsed := time.Since(start)
|
||||
require.Equal(t, 400, response.Code)
|
||||
require.Equal(t, 40055, toHTTPError(t, response.Body.String()).Code)
|
||||
require.Less(t, elapsed, 500*time.Millisecond, "data-driven nested range should be cut off by the deadline (took %s)", elapsed)
|
||||
})
|
||||
}
|
||||
|
||||
// TestServer_MessageTemplate_ExpensiveFunctionLoop_TimesOut ensures the deadline also bounds loops
|
||||
// whose body calls an expensive function (hashing a large string), where a single call between
|
||||
// deadline checks could otherwise overshoot (GHSA-rhwf-xgc9-m9fp).
|
||||
func TestServer_MessageTemplate_ExpensiveFunctionLoop_TimesOut(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
t.Parallel()
|
||||
s := newTestServer(t, newTestConfig(t, databaseURL))
|
||||
msg := `{{$big := repeat 990 "0123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789"}}{{range until 1000}}{{range until 1000}}{{$h := sha512sum $big}}{{end}}{{end}}`
|
||||
start := time.Now()
|
||||
response := request(t, s, "POST", "/mytopic", `{}`, map[string]string{
|
||||
"X-Message": msg,
|
||||
"X-Template": "1",
|
||||
})
|
||||
elapsed := time.Since(start)
|
||||
require.Equal(t, 400, response.Code)
|
||||
require.Equal(t, 40055, toHTTPError(t, response.Body.String()).Code)
|
||||
require.Less(t, elapsed, 1500*time.Millisecond, "expensive-function loop should be cut off by the deadline (took %s)", elapsed)
|
||||
})
|
||||
}
|
||||
|
||||
// TestServer_MessageTemplate_NestedLoopPoC_TimesOut is the exact proof-of-concept from the advisory:
|
||||
// a range over a runtime-computed slice, nested, must be bounded by the deadline (GHSA-rhwf-xgc9-m9fp).
|
||||
func TestServer_MessageTemplate_NestedLoopPoC_TimesOut(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
t.Parallel()
|
||||
s := newTestServer(t, newTestConfig(t, databaseURL))
|
||||
start := time.Now()
|
||||
response := request(t, s, "POST", "/mytopic", `{}`, map[string]string{
|
||||
"X-Message": `{{$x := until 10000}}{{range $x}}{{range $x}}{{end}}{{end}}done`,
|
||||
"X-Template": "1",
|
||||
})
|
||||
elapsed := time.Since(start)
|
||||
require.Equal(t, 400, response.Code)
|
||||
require.Equal(t, 40055, toHTTPError(t, response.Body.String()).Code)
|
||||
require.Less(t, elapsed, 500*time.Millisecond, "advisory PoC should be cut off by the deadline (took %s)", elapsed)
|
||||
})
|
||||
}
|
||||
|
||||
func TestServer_MessageTemplate_GenuineError_NotTimeout(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
t.Parallel()
|
||||
s := newTestServer(t, newTestConfig(t, databaseURL))
|
||||
// A real runtime error (len of an int) must map to execute-failed, not the timeout code.
|
||||
response := request(t, s, "POST", "/mytopic", `{}`, map[string]string{
|
||||
"X-Message": `{{ len 5 }}`,
|
||||
"X-Template": "1",
|
||||
})
|
||||
require.Equal(t, 400, response.Code)
|
||||
require.Equal(t, 40045, toHTTPError(t, response.Body.String()).Code)
|
||||
})
|
||||
}
|
||||
|
||||
// slowBody delivers its data after a delay, simulating a slow client upload of the request body.
|
||||
type slowBody struct {
|
||||
data []byte
|
||||
delay time.Duration
|
||||
done bool
|
||||
}
|
||||
|
||||
func (b *slowBody) Read(p []byte) (int, error) {
|
||||
if b.done {
|
||||
return 0, io.EOF
|
||||
}
|
||||
time.Sleep(b.delay)
|
||||
n := copy(p, b.data)
|
||||
b.done = true
|
||||
return n, nil
|
||||
}
|
||||
|
||||
func (b *slowBody) Close() error { return nil }
|
||||
|
||||
// TestServer_MessageTemplate_SlowUpload_NotCountedAgainstDeadline verifies that a slow request-body
|
||||
// upload does not consume the template execution deadline: the body is fully read (util.Peek)
|
||||
// before the deadline starts, so a trivial template still renders even when the upload alone took
|
||||
// longer than the deadline (GHSA-rhwf-xgc9-m9fp).
|
||||
func TestServer_MessageTemplate_SlowUpload_NotCountedAgainstDeadline(t *testing.T) {
|
||||
s := newTestServer(t, newTestConfig(t, ""))
|
||||
start := time.Now()
|
||||
// The template runs in ~1ms, far under the deadline, so on correct code it renders fine; the
|
||||
// point is that the deadline starts at execution, not when the (slow) upload began.
|
||||
response := request(t, s, "POST", "/mytopic", `{"foo":"bar"}`, map[string]string{
|
||||
"Template": "yes",
|
||||
"X-Message": `{{range until 5000}}{{$x := .}}{{end}}hello {{.foo}}`,
|
||||
}, func(r *http.Request) {
|
||||
r.Body = &slowBody{data: []byte(`{"foo":"bar"}`), delay: 3 * templateMaxExecutionTime}
|
||||
})
|
||||
elapsed := time.Since(start)
|
||||
require.Greater(t, elapsed, templateMaxExecutionTime, "the slow upload must outlast the exec deadline for this test to be meaningful")
|
||||
require.Equal(t, 200, response.Code) // Would be 40055 if upload time counted against the deadline
|
||||
m := toMessage(t, response.Body.String())
|
||||
require.Equal(t, "hello bar", m.Message)
|
||||
}
|
||||
|
||||
// TestServer_MessageTemplate_ClientDisconnect_CancelsRender verifies that canceling the request
|
||||
// context (e.g. the client disconnecting) aborts an in-progress template render. The execution
|
||||
// deadline is raised well above the cancel delay for this test so that cancellation -- not the
|
||||
// deadline -- is what stops the render: a runaway template is canceled 500ms in and must abort
|
||||
// shortly after (well under the raised deadline), yielding the generic execute-failed code (40045),
|
||||
// not the timeout code (40055).
|
||||
//
|
||||
// Not parallel: it temporarily raises the package-global templateMaxExecutionTime. Non-parallel
|
||||
// tests run in their own phase (parallel tests are paused), so the override is race-free.
|
||||
func TestServer_MessageTemplate_ClientDisconnect_CancelsRender(t *testing.T) {
|
||||
origDeadline := templateMaxExecutionTime
|
||||
templateMaxExecutionTime = 30 * time.Second // large enough that only the cancel can stop the render
|
||||
defer func() { templateMaxExecutionTime = origDeadline }()
|
||||
|
||||
s := newTestServer(t, newTestConfig(t, ""))
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
go func() {
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
cancel()
|
||||
}()
|
||||
start := time.Now()
|
||||
response := request(t, s, "POST", "/mytopic", `{}`, map[string]string{
|
||||
"X-Message": `{{$x := until 10000}}{{range $x}}{{range $x}}{{end}}{{end}}done`,
|
||||
"X-Template": "1",
|
||||
}, func(r *http.Request) {
|
||||
*r = *r.WithContext(ctx)
|
||||
})
|
||||
elapsed := time.Since(start)
|
||||
require.Equal(t, 400, response.Code)
|
||||
require.Equal(t, 40045, toHTTPError(t, response.Body.String()).Code, "a canceled render should map to execute-failed, not the timeout code 40055")
|
||||
require.Greater(t, elapsed, 500*time.Millisecond, "render must still be running when the cancel fires (took %s)", elapsed)
|
||||
require.Less(t, elapsed, 700*time.Millisecond, "request-context cancel should abort the render promptly after firing (took %s)", elapsed)
|
||||
}
|
||||
|
||||
func TestServer_MessageTemplate_ExceedMessageSize_TemplatedMessageOK(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
t.Parallel()
|
||||
@@ -3616,11 +4141,18 @@ func TestServer_MessageTemplate_DisallowedCalls(t *testing.T) {
|
||||
`{{- template ""}}`,
|
||||
`{{-
|
||||
template ""}}`,
|
||||
`{{ call abc}}`,
|
||||
`{{ define "aa"}}`,
|
||||
`We cannot {{define "aa"}}`,
|
||||
`{{ call "aa"}}`,
|
||||
`{{define "aa"}}hi{{end}}`,
|
||||
`We cannot {{define "aa"}}hi{{end}}`,
|
||||
`We cannot {{ call "aa"}}`,
|
||||
`We cannot {{- template "aa"}}`,
|
||||
`{{block "aa" .}}hi{{end}}`,
|
||||
`We cannot {{- block "aa" .}}hi{{end}}`,
|
||||
// call is a function, not a keyword, so it can hide in non-leading positions that a
|
||||
// raw-string regex misses -- the parse-tree walk catches all of them.
|
||||
`{{if call .x}}x{{end}}`,
|
||||
`{{$y := call .x}}`,
|
||||
`{{index (call .x) 0}}`,
|
||||
}
|
||||
for _, disallowedTemplate := range disallowedTemplates {
|
||||
messageTemplate := disallowedTemplate
|
||||
@@ -4026,6 +4558,40 @@ func TestServer_DeleteMessage(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestServer_DeleteMessage_GET(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
t.Parallel()
|
||||
s := newTestServer(t, newTestConfig(t, databaseURL))
|
||||
|
||||
// Publish a message with a sequence ID
|
||||
response := request(t, s, "PUT", "/mytopic/seq123", "original message", nil)
|
||||
require.Equal(t, 200, response.Code)
|
||||
msg := toMessage(t, response.Body.String())
|
||||
require.Equal(t, "seq123", msg.SequenceID)
|
||||
require.Equal(t, "message", msg.Event)
|
||||
|
||||
// Delete the message using GET method (/topic/seq/delete)
|
||||
response = request(t, s, "GET", "/mytopic/seq123/delete", "", nil)
|
||||
require.Equal(t, 200, response.Code)
|
||||
deleteMsg := toMessage(t, response.Body.String())
|
||||
require.Equal(t, "seq123", deleteMsg.SequenceID)
|
||||
require.Equal(t, "message_delete", deleteMsg.Event)
|
||||
|
||||
// Poll and verify both messages are returned
|
||||
response = request(t, s, "GET", "/mytopic/json?poll=1", "", nil)
|
||||
require.Equal(t, 200, response.Code)
|
||||
lines := strings.Split(strings.TrimSpace(response.Body.String()), "\n")
|
||||
require.Equal(t, 2, len(lines))
|
||||
|
||||
msg1 := toMessage(t, lines[0])
|
||||
msg2 := toMessage(t, lines[1])
|
||||
require.Equal(t, "message", msg1.Event)
|
||||
require.Equal(t, "message_delete", msg2.Event)
|
||||
require.Equal(t, "seq123", msg1.SequenceID)
|
||||
require.Equal(t, "seq123", msg2.SequenceID)
|
||||
})
|
||||
}
|
||||
|
||||
func TestServer_ClearMessage(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
t.Parallel()
|
||||
@@ -4079,6 +4645,33 @@ func TestServer_ClearMessage_ReadEndpoint(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestServer_ClearMessage_GET(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
t.Parallel()
|
||||
s := newTestServer(t, newTestConfig(t, databaseURL))
|
||||
|
||||
// 1. Test GET /topic/seq-id/clear
|
||||
response := request(t, s, "PUT", "/mytopic/seq456", "original message 1", nil)
|
||||
require.Equal(t, 200, response.Code)
|
||||
|
||||
response = request(t, s, "GET", "/mytopic/seq456/clear", "", nil)
|
||||
require.Equal(t, 200, response.Code)
|
||||
clearMsg1 := toMessage(t, response.Body.String())
|
||||
require.Equal(t, "seq456", clearMsg1.SequenceID)
|
||||
require.Equal(t, "message_clear", clearMsg1.Event)
|
||||
|
||||
// 2. Test GET /topic/seq-id/read
|
||||
response = request(t, s, "PUT", "/mytopic/seq789", "original message 2", nil)
|
||||
require.Equal(t, 200, response.Code)
|
||||
|
||||
response = request(t, s, "GET", "/mytopic/seq789/read", "", nil)
|
||||
require.Equal(t, 200, response.Code)
|
||||
clearMsg2 := toMessage(t, response.Body.String())
|
||||
require.Equal(t, "seq789", clearMsg2.SequenceID)
|
||||
require.Equal(t, "message_clear", clearMsg2.Event)
|
||||
})
|
||||
}
|
||||
|
||||
func TestServer_UpdateMessage(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
t.Parallel()
|
||||
@@ -4286,6 +4879,41 @@ func TestServer_DeleteScheduledMessage(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestServer_DeleteScheduledMessage_GET(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
t.Parallel()
|
||||
s := newTestServer(t, newTestConfig(t, databaseURL))
|
||||
|
||||
// Publish a scheduled message (future delivery)
|
||||
response := request(t, s, "PUT", "/mytopic/delete-sched-seq?delay=1h", "scheduled message to delete", nil)
|
||||
require.Equal(t, 200, response.Code)
|
||||
msg := toMessage(t, response.Body.String())
|
||||
require.Equal(t, "delete-sched-seq", msg.SequenceID)
|
||||
|
||||
// Verify scheduled message exists
|
||||
response = request(t, s, "GET", "/mytopic/json?poll=1&scheduled=1", "", nil)
|
||||
require.Equal(t, 200, response.Code)
|
||||
messages := toMessages(t, response.Body.String())
|
||||
require.Equal(t, 1, len(messages))
|
||||
require.Equal(t, "scheduled message to delete", messages[0].Message)
|
||||
|
||||
// Delete the scheduled message using GET method (/topic/seq/delete)
|
||||
response = request(t, s, "GET", "/mytopic/delete-sched-seq/delete", "", nil)
|
||||
require.Equal(t, 200, response.Code)
|
||||
deleteMsg := toMessage(t, response.Body.String())
|
||||
require.Equal(t, "delete-sched-seq", deleteMsg.SequenceID)
|
||||
require.Equal(t, "message_delete", deleteMsg.Event)
|
||||
|
||||
// Verify scheduled message was deleted, only delete event remains
|
||||
response = request(t, s, "GET", "/mytopic/json?poll=1&scheduled=1", "", nil)
|
||||
require.Equal(t, 200, response.Code)
|
||||
messages = toMessages(t, response.Body.String())
|
||||
require.Equal(t, 1, len(messages))
|
||||
require.Equal(t, "message_delete", messages[0].Event)
|
||||
require.Equal(t, "delete-sched-seq", messages[0].SequenceID)
|
||||
})
|
||||
}
|
||||
|
||||
func TestServer_UpdateScheduledMessage_TopicScoped(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
t.Parallel()
|
||||
@@ -4786,3 +5414,46 @@ func TestServer_Publish_InvalidUTF8WithFirebase(t *testing.T) {
|
||||
require.Equal(t, "\uFFFDclipse", sender.Messages()[0].Data["title"])
|
||||
require.Equal(t, "probl\uFFFDme", sender.Messages()[0].Data["tags"])
|
||||
}
|
||||
|
||||
func TestServer_BanFeed_RateLimitedIPBanned(t *testing.T) {
|
||||
// Real requests: exhaust the visitor request limit so ntfy returns 429s, and confirm the
|
||||
// client IP is written to the ban file after it breaches the per-status ban limit.
|
||||
banFile := filepath.Join(t.TempDir(), "ntfy-ban.log")
|
||||
c := newTestConfig(t, "")
|
||||
c.BanFile = banFile
|
||||
c.BanWindow = time.Minute
|
||||
c.BanThreshold = 2 // Ban after the weighted budget of 2 is exhausted
|
||||
c.BanWeights = map[string]int{"*": 1} // Every rejection costs 1 strike
|
||||
c.VisitorRequestLimitBurst = 2 // 429 quickly
|
||||
s := newTestServer(t, c)
|
||||
got429 := 0
|
||||
for i := 0; i < 10; i++ {
|
||||
rr := request(t, s, "PUT", "/mytopic", "x", nil)
|
||||
if rr.Code == 429 {
|
||||
got429++
|
||||
}
|
||||
}
|
||||
require.Greater(t, got429, 2)
|
||||
s.ban.Close() // Writes are async (runWriteLoop); Close flushes the buffer before we read
|
||||
data, err := os.ReadFile(banFile)
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, string(data), "9.9.9.9 9.9.9.9/32 429 42901") // <ip> <prefix> <http> <ntfy-code>
|
||||
}
|
||||
|
||||
func TestServer_BanFeed_SuccessfulRequestsNotBanned(t *testing.T) {
|
||||
// Real requests that all succeed (200) must never trigger a ban, even with a low "*" fallback.
|
||||
banFile := filepath.Join(t.TempDir(), "ntfy-ban.log")
|
||||
c := newTestConfig(t, "")
|
||||
c.BanFile = banFile
|
||||
c.BanWindow = time.Minute
|
||||
c.BanThreshold = 3 // Low threshold that would catch 200s if 2xx were not skipped
|
||||
c.BanWeights = map[string]int{"*": 1} // Every rejection costs 1 strike
|
||||
c.VisitorRequestLimitBurst = 100 // Stay under the request limit so every request is 200
|
||||
s := newTestServer(t, c)
|
||||
for i := 0; i < 10; i++ {
|
||||
rr := request(t, s, "PUT", "/mytopic", fmt.Sprintf("m%d", i), nil)
|
||||
require.Equal(t, 200, rr.Code)
|
||||
}
|
||||
s.ban.Close() // Flush any buffered bans (there should be none) before asserting no file
|
||||
require.NoFileExists(t, banFile)
|
||||
}
|
||||
|
||||
+15
-176
@@ -1,54 +1,13 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"text/template"
|
||||
|
||||
"heckel.io/ntfy/v2/log"
|
||||
"heckel.io/ntfy/v2/metrics"
|
||||
"heckel.io/ntfy/v2/model"
|
||||
"heckel.io/ntfy/v2/twilio"
|
||||
"heckel.io/ntfy/v2/user"
|
||||
"heckel.io/ntfy/v2/util"
|
||||
)
|
||||
|
||||
// defaultTwilioCallFormatTemplate is the default TwiML template used for Twilio calls.
|
||||
// It can be overridden in the server configuration's twilio-call-format field.
|
||||
//
|
||||
// The format uses Go template syntax with the following fields:
|
||||
// {{.Topic}}, {{.Title}}, {{.Message}}, {{.Priority}}, {{.Tags}}, {{.Sender}}
|
||||
// String fields are automatically XML-escaped.
|
||||
var defaultTwilioCallFormatTemplate = template.Must(template.New("twiml").Parse(`
|
||||
<Response>
|
||||
<Pause length="1"/>
|
||||
<Say loop="3">
|
||||
You have a message from notify on topic {{.Topic}}. Message:
|
||||
<break time="1s"/>
|
||||
{{.Message}}
|
||||
<break time="1s"/>
|
||||
End of message.
|
||||
<break time="1s"/>
|
||||
This message was sent by user {{.Sender}}. It will be repeated three times.
|
||||
To unsubscribe from calls like this, remove your phone number in the notify web app.
|
||||
<break time="3s"/>
|
||||
</Say>
|
||||
<Say>Goodbye.</Say>
|
||||
</Response>`))
|
||||
|
||||
// twilioCallData holds the data passed to the Twilio call format template
|
||||
type twilioCallData struct {
|
||||
Topic string
|
||||
Title string
|
||||
Message string
|
||||
Priority int
|
||||
Tags []string
|
||||
Sender string
|
||||
}
|
||||
|
||||
// convertPhoneNumber checks if the given phone number is verified for the given user, and if so, returns the verified
|
||||
// phone number. It also converts a boolean string ("yes", "1", "true") to the first verified phone number.
|
||||
// If the user is anonymous, it will return an error.
|
||||
@@ -67,149 +26,29 @@ func (s *Server) convertPhoneNumber(u *user.User, phoneNumber string) (string, *
|
||||
} else if util.Contains(phoneNumbers, phoneNumber) {
|
||||
return phoneNumber, nil
|
||||
}
|
||||
for _, p := range phoneNumbers {
|
||||
if p == phoneNumber {
|
||||
return phoneNumber, nil
|
||||
}
|
||||
}
|
||||
return "", errHTTPBadRequestPhoneNumberNotVerified
|
||||
}
|
||||
|
||||
// callPhone calls the Twilio API to make a phone call to the given phone number, using the given message.
|
||||
// Failures will be logged, but not returned to the caller.
|
||||
func (s *Server) callPhone(v *visitor, r *http.Request, m *model.Message, to string) {
|
||||
func (s *Server) callPhone(v *visitor, m *model.Message, to string) {
|
||||
u, sender := v.User(), m.Sender.String()
|
||||
if u != nil {
|
||||
sender = u.Name
|
||||
}
|
||||
tmpl := defaultTwilioCallFormatTemplate
|
||||
if s.config.TwilioCallFormat != nil {
|
||||
tmpl = s.config.TwilioCallFormat
|
||||
}
|
||||
tags := make([]string, len(m.Tags))
|
||||
for i, tag := range m.Tags {
|
||||
tags[i] = xmlEscapeText(tag)
|
||||
}
|
||||
templateData := &twilioCallData{
|
||||
Topic: xmlEscapeText(m.Topic),
|
||||
Title: xmlEscapeText(m.Title),
|
||||
Message: xmlEscapeText(m.Message),
|
||||
logvm(v, m).Tag(tagTwilio).Field("twilio_to", to).Info("Making phone call to %s", to)
|
||||
err := s.twilio.Call(to, &twilio.CallData{
|
||||
Topic: m.Topic,
|
||||
Title: m.Title,
|
||||
Message: m.Message,
|
||||
Priority: m.Priority,
|
||||
Tags: tags,
|
||||
Sender: xmlEscapeText(sender),
|
||||
}
|
||||
var bodyBuf bytes.Buffer
|
||||
if err := tmpl.Execute(&bodyBuf, templateData); err != nil {
|
||||
logvrm(v, r, m).Tag(tagTwilio).Err(err).Warn("Error executing Twilio call format template")
|
||||
minc(metricCallsMadeFailure)
|
||||
Tags: m.Tags,
|
||||
Sender: sender,
|
||||
})
|
||||
if err != nil {
|
||||
logvm(v, m).Tag(tagTwilio).Field("twilio_to", to).Err(err).Warn("Unable to call phone %s: %v", to, err.Error())
|
||||
metrics.CallsMadeFailure.Inc()
|
||||
return
|
||||
}
|
||||
body := bodyBuf.String()
|
||||
data := url.Values{}
|
||||
data.Set("From", s.config.TwilioPhoneNumber)
|
||||
data.Set("To", to)
|
||||
data.Set("Twiml", body)
|
||||
ev := logvrm(v, r, m).Tag(tagTwilio).Field("twilio_to", to).FieldIf("twilio_body", body, log.TraceLevel).Debug("Sending Twilio request")
|
||||
response, err := s.callPhoneInternal(data)
|
||||
if err != nil {
|
||||
ev.Field("twilio_response", response).Err(err).Warn("Error sending Twilio request")
|
||||
minc(metricCallsMadeFailure)
|
||||
return
|
||||
}
|
||||
ev.FieldIf("twilio_response", response, log.TraceLevel).Debug("Received successful Twilio response")
|
||||
minc(metricCallsMadeSuccess)
|
||||
}
|
||||
|
||||
func (s *Server) callPhoneInternal(data url.Values) (string, error) {
|
||||
requestURL := fmt.Sprintf("%s/2010-04-01/Accounts/%s/Calls.json", s.config.TwilioCallsBaseURL, s.config.TwilioAccount)
|
||||
req, err := http.NewRequest(http.MethodPost, requestURL, strings.NewReader(data.Encode()))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("User-Agent", "ntfy/"+s.config.BuildVersion)
|
||||
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("Authorization", util.BasicAuth(s.config.TwilioAccount, s.config.TwilioAuthToken))
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
response, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(response), nil
|
||||
}
|
||||
|
||||
func (s *Server) verifyPhoneNumber(v *visitor, r *http.Request, phoneNumber, channel string) error {
|
||||
ev := logvr(v, r).Tag(tagTwilio).Field("twilio_to", phoneNumber).Field("twilio_channel", channel).Debug("Sending phone verification")
|
||||
data := url.Values{}
|
||||
data.Set("To", phoneNumber)
|
||||
data.Set("Channel", channel)
|
||||
requestURL := fmt.Sprintf("%s/v2/Services/%s/Verifications", s.config.TwilioVerifyBaseURL, s.config.TwilioVerifyService)
|
||||
req, err := http.NewRequest(http.MethodPost, requestURL, strings.NewReader(data.Encode()))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("User-Agent", "ntfy/"+s.config.BuildVersion)
|
||||
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("Authorization", util.BasicAuth(s.config.TwilioAccount, s.config.TwilioAuthToken))
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
response, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
ev.Err(err).Warn("Error sending Twilio phone verification request")
|
||||
return err
|
||||
}
|
||||
ev.FieldIf("twilio_response", string(response), log.TraceLevel).Debug("Received Twilio phone verification response")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) verifyPhoneNumberCheck(v *visitor, r *http.Request, phoneNumber, code string) error {
|
||||
ev := logvr(v, r).Tag(tagTwilio).Field("twilio_to", phoneNumber).Debug("Checking phone verification")
|
||||
data := url.Values{}
|
||||
data.Set("To", phoneNumber)
|
||||
data.Set("Code", code)
|
||||
requestURL := fmt.Sprintf("%s/v2/Services/%s/VerificationCheck", s.config.TwilioVerifyBaseURL, s.config.TwilioVerifyService)
|
||||
req, err := http.NewRequest(http.MethodPost, requestURL, strings.NewReader(data.Encode()))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("User-Agent", "ntfy/"+s.config.BuildVersion)
|
||||
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("Authorization", util.BasicAuth(s.config.TwilioAccount, s.config.TwilioAuthToken))
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
} else if resp.StatusCode != http.StatusOK {
|
||||
if ev.IsTrace() {
|
||||
response, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ev.Field("twilio_response", string(response))
|
||||
}
|
||||
ev.Warn("Twilio phone verification failed with status code %d", resp.StatusCode)
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
return errHTTPGonePhoneVerificationExpired
|
||||
}
|
||||
return errHTTPInternalError
|
||||
}
|
||||
response, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if ev.IsTrace() {
|
||||
ev.Field("twilio_response", string(response)).Trace("Received successful Twilio phone verification response")
|
||||
} else if ev.IsDebug() {
|
||||
ev.Debug("Received successful Twilio phone verification response")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func xmlEscapeText(text string) string {
|
||||
var buf bytes.Buffer
|
||||
_ = xml.EscapeText(&buf, []byte(text))
|
||||
return buf.String()
|
||||
metrics.CallsMadeSuccess.Inc()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"heckel.io/ntfy/v2/util"
|
||||
)
|
||||
|
||||
// handleWebApp serves the embedded web app's index for client-side (SPA) routes that the
|
||||
// browser router resolves, so the app shell loads and the client-side router takes over.
|
||||
func (s *Server) handleWebApp(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||
r.URL.Path = webAppIndex
|
||||
return s.handleStatic(w, r, v)
|
||||
}
|
||||
|
||||
// handleWebAppNoIndex serves the web app index for the magic-link landing pages, whose path
|
||||
// carries a one-time token. The response is marked no-referrer (so the token can't leak to third
|
||||
// parties via the Referer header) and noindex (so it never gets indexed).
|
||||
func (s *Server) handleWebAppNoIndex(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||
w.Header().Set("Referrer-Policy", "no-referrer")
|
||||
w.Header().Set("X-Robots-Tag", "noindex")
|
||||
return s.handleWebApp(w, r, v)
|
||||
}
|
||||
|
||||
func (s *Server) handleConfig(w http.ResponseWriter, _ *http.Request, _ *visitor) error {
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
return s.writeJSON(w, s.configResponse())
|
||||
}
|
||||
|
||||
func (s *Server) handleWebConfig(w http.ResponseWriter, _ *http.Request, _ *visitor) error {
|
||||
b, err := json.MarshalIndent(s.configResponse(), "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/javascript")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
_, err = io.WriteString(w, fmt.Sprintf("// Generated server configuration\nvar config = %s;\n", string(b)))
|
||||
return err
|
||||
}
|
||||
|
||||
// handleWebManifest serves the web app manifest for the progressive web app (PWA)
|
||||
func (s *Server) handleWebManifest(w http.ResponseWriter, _ *http.Request, _ *visitor) error {
|
||||
response := &webManifestResponse{
|
||||
Name: "ntfy",
|
||||
Description: "ntfy lets you send push notifications via scripts from any computer or phone",
|
||||
ShortName: "ntfy",
|
||||
Scope: "/",
|
||||
StartURL: s.config.WebRoot,
|
||||
Display: "standalone",
|
||||
BackgroundColor: "#ffffff",
|
||||
ThemeColor: "#317f6f",
|
||||
Icons: []*webManifestIcon{
|
||||
{SRC: "/static/images/pwa-192x192.png", Sizes: "192x192", Type: "image/png"},
|
||||
{SRC: "/static/images/pwa-512x512.png", Sizes: "512x512", Type: "image/png"},
|
||||
},
|
||||
}
|
||||
return s.writeJSONWithContentType(w, response, "application/manifest+json")
|
||||
}
|
||||
|
||||
// handleStatic returns all static resources (excluding the docs), including the web app
|
||||
func (s *Server) handleStatic(w http.ResponseWriter, r *http.Request, _ *visitor) error {
|
||||
r.URL.Path = webSiteDir + r.URL.Path
|
||||
util.Gzip(http.FileServer(http.FS(webFsCached))).ServeHTTP(w, r)
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleDocs returns static resources related to the docs
|
||||
func (s *Server) handleDocs(w http.ResponseWriter, r *http.Request, _ *visitor) error {
|
||||
util.Gzip(http.FileServer(http.FS(docsStaticCached))).ServeHTTP(w, r)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) configResponse() *apiConfigResponse {
|
||||
return &apiConfigResponse{
|
||||
BaseURL: "", // Will translate to window.location.origin
|
||||
AppRoot: s.config.WebRoot,
|
||||
EnableLogin: s.config.EnableLogin,
|
||||
RequireLogin: s.config.RequireLogin,
|
||||
EnableSignup: s.config.EnableSignup,
|
||||
EnablePayments: s.config.StripeSecretKey != "",
|
||||
EnableCalls: s.config.TwilioAccount != "",
|
||||
EnableEmails: s.config.SMTPSenderFrom != "",
|
||||
EnableResetPassword: s.config.SMTPSenderFrom != "" && s.config.BaseURL != "", // Reset links need SMTP + an absolute base-url
|
||||
EnableReservations: s.config.EnableReservations,
|
||||
EnableWebPush: s.config.WebPushPublicKey != "",
|
||||
BillingContact: s.config.BillingContact,
|
||||
WebPushPublicKey: s.config.WebPushPublicKey,
|
||||
DisallowedTopics: s.config.DisallowedTopics,
|
||||
ConfigHash: s.config.Hash(),
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
|
||||
"github.com/emersion/go-smtp"
|
||||
"github.com/microcosm-cc/bluemonday"
|
||||
"heckel.io/ntfy/v2/metrics"
|
||||
"heckel.io/ntfy/v2/model"
|
||||
)
|
||||
|
||||
@@ -180,7 +181,7 @@ func (s *smtpSession) Data(r io.Reader) error {
|
||||
s.backend.mu.Lock()
|
||||
s.backend.success++
|
||||
s.backend.mu.Unlock()
|
||||
minc(metricEmailsReceivedSuccess)
|
||||
metrics.EmailsReceivedSuccess.Inc()
|
||||
return nil
|
||||
})
|
||||
}
|
||||
@@ -238,7 +239,7 @@ func (s *smtpSession) withFailCount(fn func() error) error {
|
||||
// We do not want to spam the log with WARN messages.
|
||||
logem(s.conn).Err(err).Debug("Incoming mail error")
|
||||
s.backend.failure++
|
||||
minc(metricEmailsReceivedFailure)
|
||||
metrics.EmailsReceivedFailure.Inc()
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
+67
-20
@@ -29,6 +29,17 @@ type publishMessage struct {
|
||||
Delay string `json:"delay"`
|
||||
}
|
||||
|
||||
// dispatchOpts selects which delivery targets fire for a published message, beyond delivery
|
||||
// to local subscribers (see Server.dispatch)
|
||||
type dispatchOpts struct {
|
||||
firebase bool // Send to Firebase (if configured)
|
||||
email string // Send an email to this address (if a mailer is configured)
|
||||
call string // Call this phone number (if Twilio is configured)
|
||||
upstream bool // Forward a poll request to the upstream server (if configured)
|
||||
webPush bool // Publish to web push endpoints (if configured)
|
||||
async bool // Deliver to local subscribers in a goroutine, logging errors instead of returning them
|
||||
}
|
||||
|
||||
// messageEncoder is a function that knows how to encode a message
|
||||
type messageEncoder func(msg *model.Message) (string, error)
|
||||
|
||||
@@ -185,6 +196,7 @@ type apiAccessResetRequest struct {
|
||||
type apiAccountCreateRequest struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
Email string `json:"email"` // Optional; if set (and SMTP configured), a verification link is sent
|
||||
}
|
||||
|
||||
type apiAccountPasswordChangeRequest struct {
|
||||
@@ -216,6 +228,14 @@ type apiAccountTokenResponse struct {
|
||||
Provisioned bool `json:"provisioned,omitempty"` // True if this token was provisioned by the server config
|
||||
}
|
||||
|
||||
// apiAccountLoginResponse is the body of POST /v1/account/login: it authenticates a
|
||||
// username-or-email + password, mints a session token, and returns the token together with the
|
||||
// canonical username (which may differ from the identifier the user typed, e.g. a primary email).
|
||||
type apiAccountLoginResponse struct {
|
||||
Token string `json:"token"`
|
||||
Username string `json:"username"`
|
||||
}
|
||||
|
||||
type apiAccountPhoneNumberVerifyRequest struct {
|
||||
Number string `json:"number"`
|
||||
Channel string `json:"channel"`
|
||||
@@ -226,13 +246,29 @@ type apiAccountPhoneNumberAddRequest struct {
|
||||
Code string `json:"code"` // Only set when adding a phone number
|
||||
}
|
||||
|
||||
type apiAccountEmailVerifyRequest struct {
|
||||
// apiAccountEmailRequest carries an email address for the add/delete/set-primary/resend
|
||||
// endpoints (all of which identify an email by address in the JSON body).
|
||||
type apiAccountEmailRequest struct {
|
||||
Email string `json:"email"`
|
||||
}
|
||||
|
||||
type apiAccountEmailAddRequest struct {
|
||||
Email string `json:"email"`
|
||||
Code string `json:"code"`
|
||||
// apiAccountEmailVerifyRequest carries the raw magic-link token submitted (unauthenticated)
|
||||
// from the verification landing page.
|
||||
type apiAccountEmailVerifyRequest struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
|
||||
// apiAccountPasswordResetRequest is the body of the (unauthenticated) reset-request endpoint.
|
||||
// The identifier is a username or a primary email address.
|
||||
type apiAccountPasswordResetRequest struct {
|
||||
Identifier string `json:"identifier"`
|
||||
}
|
||||
|
||||
// apiAccountPasswordResetConfirmRequest is the body of the (unauthenticated) reset-confirm
|
||||
// endpoint, submitted from the set-new-password landing page.
|
||||
type apiAccountPasswordResetConfirmRequest struct {
|
||||
Token string `json:"token"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
type apiAccountTier struct {
|
||||
@@ -271,6 +307,15 @@ type apiAccountReservation struct {
|
||||
Everyone string `json:"everyone"`
|
||||
}
|
||||
|
||||
// apiAccountEmailInfo describes one email address on the account, as returned by GET /v1/account.
|
||||
// Verified addresses have pending=false; exactly one verified address may be primary (the
|
||||
// recovery email). Pending addresses are awaiting a magic-link click and are never primary.
|
||||
type apiAccountEmailInfo struct {
|
||||
Address string `json:"address"`
|
||||
Primary bool `json:"primary,omitempty"`
|
||||
Pending bool `json:"pending,omitempty"`
|
||||
}
|
||||
|
||||
type apiAccountBilling struct {
|
||||
Customer bool `json:"customer"`
|
||||
Subscription bool `json:"subscription"`
|
||||
@@ -286,12 +331,14 @@ type apiAccountResponse struct {
|
||||
SyncTopic string `json:"sync_topic,omitempty"`
|
||||
Provisioned bool `json:"provisioned,omitempty"`
|
||||
Language string `json:"language,omitempty"`
|
||||
DateFormat string `json:"date_format,omitempty"`
|
||||
TimeFormat string `json:"time_format,omitempty"`
|
||||
Notification *user.NotificationPrefs `json:"notification,omitempty"`
|
||||
Subscriptions []*user.Subscription `json:"subscriptions,omitempty"`
|
||||
Reservations []*apiAccountReservation `json:"reservations,omitempty"`
|
||||
Tokens []*apiAccountTokenResponse `json:"tokens,omitempty"`
|
||||
PhoneNumbers []string `json:"phone_numbers,omitempty"`
|
||||
Emails []string `json:"emails,omitempty"`
|
||||
Emails []*apiAccountEmailInfo `json:"emails,omitempty"`
|
||||
Tier *apiAccountTier `json:"tier,omitempty"`
|
||||
Limits *apiAccountLimits `json:"limits,omitempty"`
|
||||
Stats *apiAccountStats `json:"stats,omitempty"`
|
||||
@@ -304,21 +351,21 @@ type apiAccountReservationRequest struct {
|
||||
}
|
||||
|
||||
type apiConfigResponse struct {
|
||||
BaseURL string `json:"base_url"`
|
||||
AppRoot string `json:"app_root"`
|
||||
EnableLogin bool `json:"enable_login"`
|
||||
RequireLogin bool `json:"require_login"`
|
||||
EnableSignup bool `json:"enable_signup"`
|
||||
EnablePayments bool `json:"enable_payments"`
|
||||
EnableCalls bool `json:"enable_calls"`
|
||||
EnableEmails bool `json:"enable_emails"`
|
||||
EnableEmailVerify bool `json:"enable_email_verify"`
|
||||
EnableReservations bool `json:"enable_reservations"`
|
||||
EnableWebPush bool `json:"enable_web_push"`
|
||||
BillingContact string `json:"billing_contact"`
|
||||
WebPushPublicKey string `json:"web_push_public_key"`
|
||||
DisallowedTopics []string `json:"disallowed_topics"`
|
||||
ConfigHash string `json:"config_hash"`
|
||||
BaseURL string `json:"base_url"`
|
||||
AppRoot string `json:"app_root"`
|
||||
EnableLogin bool `json:"enable_login"`
|
||||
RequireLogin bool `json:"require_login"`
|
||||
EnableSignup bool `json:"enable_signup"`
|
||||
EnablePayments bool `json:"enable_payments"`
|
||||
EnableCalls bool `json:"enable_calls"`
|
||||
EnableEmails bool `json:"enable_emails"`
|
||||
EnableResetPassword bool `json:"enable_reset_password"`
|
||||
EnableReservations bool `json:"enable_reservations"`
|
||||
EnableWebPush bool `json:"enable_web_push"`
|
||||
BillingContact string `json:"billing_contact"`
|
||||
WebPushPublicKey string `json:"web_push_public_key"`
|
||||
DisallowedTopics []string `json:"disallowed_topics"`
|
||||
ConfigHash string `json:"config_hash"`
|
||||
}
|
||||
|
||||
type apiAccountBillingPrices struct {
|
||||
|
||||
+8
-6
@@ -65,8 +65,8 @@ type visitor struct {
|
||||
callsLimiter *util.FixedLimiter // Rate limiter for calls
|
||||
subscriptionLimiter *util.FixedLimiter // Fixed limiter for active subscriptions (ongoing connections)
|
||||
topicCreationLimiter *rate.Limiter // Rate limiter for inserting new topics into the in-memory topic map
|
||||
bandwidthLimiter *util.RateLimiter // Limiter for attachment bandwidth downloads
|
||||
accountLimiter *rate.Limiter // Rate limiter for account creation, may be nil
|
||||
bandwidthLimiter *util.RateLimiter // Limiter for attachment downloads and cached-message replay (polls)
|
||||
accountLimiter *rate.Limiter // Rate limiter for account actions (signup, password-reset requests), may be nil
|
||||
authLimiter *rate.Limiter // Limiter for incorrect login attempts, may be nil
|
||||
firebase time.Time // Next allowed Firebase message
|
||||
seen time.Time // Last seen time of this visitor (needed for removal of stale visitors)
|
||||
@@ -280,8 +280,9 @@ func (v *visitor) AuthFailed() {
|
||||
}
|
||||
}
|
||||
|
||||
// AccountCreationAllowed returns true if a new account can be created
|
||||
func (v *visitor) AccountCreationAllowed() bool {
|
||||
// AccountActionAllowed returns true if a rate-limited account action (signup or password-reset
|
||||
// request) is currently allowed for this visitor
|
||||
func (v *visitor) AccountActionAllowed() bool {
|
||||
v.mu.RLock() // limiters could be replaced!
|
||||
defer v.mu.RUnlock()
|
||||
if v.accountLimiter == nil || (v.accountLimiter != nil && v.accountLimiter.Tokens() < 1) {
|
||||
@@ -290,8 +291,9 @@ func (v *visitor) AccountCreationAllowed() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// AccountCreated decreases the account limiter. This is to be called after an account was created.
|
||||
func (v *visitor) AccountCreated() {
|
||||
// AccountActionPerformed decreases the account limiter. This is to be called after a rate-limited
|
||||
// account action (signup or password-reset request).
|
||||
func (v *visitor) AccountActionPerformed() {
|
||||
v.mu.RLock() // limiters could be replaced!
|
||||
defer v.mu.RUnlock()
|
||||
if v.accountLimiter != nil {
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
go1.27.0
|
||||
@@ -0,0 +1,109 @@
|
||||
# `template/gotext/` -- vendored `text/template` with context cancellation
|
||||
|
||||
This directory is a **verbatim copy of Go's standard-library `text/template` package**, plus one
|
||||
small patch that adds context-aware execution (`ExecuteContext`). It exists for exactly one reason:
|
||||
to stop **user-supplied** message templates (`Template: yes`, see the [templating docs](https://ntfy.sh/docs/publish/#message-templating))
|
||||
from burning CPU.
|
||||
|
||||
- **Source:** Go stdlib `text/template` (+ `internal/fmtsort`), `$(go env GOROOT)/src`
|
||||
- **Version:** pinned in the repo-root [`.go-version`](../../.go-version); recorded in `GENERATED_FROM`
|
||||
- **Local modifications:** `patches/` (see [The patch](#the-patch))
|
||||
- **Update mechanism:** Manual -- `make update-template`, then commit (never autorolled; see [Updating](#updating-when-bumping-the-go-toolchain))
|
||||
|
||||
## Why this exists
|
||||
|
||||
ntfy lets users send a Go template that is rendered against a JSON body. Go's `text/template`
|
||||
**cannot be interrupted mid-execution** -- there is no context, no deadline, no cancellation
|
||||
([golang/go#31107](https://github.com/golang/go/issues/31107) proposed `ExecuteContext` but was
|
||||
declined, over a bundled context-*values* feature, not cancellation itself). So a crafted template
|
||||
with a tight or nested `{{range}}` (e.g. ranging over a large JSON array with a big loop body that
|
||||
writes no output) can run for tens of seconds on a single request. That is a CPU denial of service
|
||||
(GHSA-rhwf-xgc9-m9fp).
|
||||
|
||||
There is no way to add an interrupt from the outside -- the executor's per-node `walk` loop is
|
||||
unexported. The only robust fix is to patch the executor itself. Rather than reach for fragile
|
||||
heuristics (guessing iteration counts, wrapping every function, etc.), we vendor the package and add
|
||||
the cancellation half of #31107 as a patch: `ExecuteContext(ctx, ...)` that aborts with `ctx.Err()`
|
||||
when `ctx` is canceled or its deadline passes. The check is a **single poll inside `walk`** of an
|
||||
atomic flag that a `context.AfterFunc` watcher flips -- so it bounds CPU for *any* template shape
|
||||
(cheap loops and expensive functions alike), it is exact (observed within one node), and it adds no
|
||||
measurable overhead. If #31107's cancellation half ever lands upstream, this fork can be deleted and
|
||||
the call site keeps compiling unchanged.
|
||||
|
||||
The one user-facing execution site (`server/server_template.go` `renderTemplate`) wraps execution in
|
||||
`context.WithTimeout` and calls `ExecuteContext`, mapping `context.DeadlineExceeded` to a `400`.
|
||||
Trusted templates (operator config: Twilio, `cmd/serve.go`) keep using the standard library -- they
|
||||
are not user-supplied.
|
||||
|
||||
## What's here
|
||||
|
||||
| File | Origin |
|
||||
|------|--------|
|
||||
| `*.go` (`exec.go`, `funcs.go`, `template.go`, `option.go`, `helper.go`, `doc.go`) | verbatim from `$(go env GOROOT)/src/text/template/`, enumerated with `go list` so files added/removed upstream are picked up automatically |
|
||||
| `fmtsort/sort.go` | verbatim from `$(go env GOROOT)/src/internal/fmtsort/` -- `exec.go` needs it, and `internal/...` packages can't be imported from outside GOROOT, so it comes along |
|
||||
| `patches/0001-exec-context.patch` | our only real change (see below) |
|
||||
| `GENERATED_FROM` | the exact Go version `make update-template` last regenerated this copy from; provenance, written by that target |
|
||||
|
||||
The Go toolchain version this copy is pinned to lives in the repo-root [`.go-version`](../../.go-version)
|
||||
file (the single source of truth, also consumed by CI and the `make` targets below). `GENERATED_FROM`
|
||||
must equal it -- `make check` fails otherwise (see below).
|
||||
|
||||
We do **not** vendor `text/template/parse` -- it's a normal importable stdlib package and stays a
|
||||
plain import.
|
||||
|
||||
## The patch
|
||||
|
||||
`patches/` is a quilt-style ordered series (apply `0001-*`, then `0002-*`, ...). Today there is just
|
||||
`0001-exec-context.patch` -- small, purely additive, and touching only `exec.go`:
|
||||
|
||||
- adds `ctx context.Context` and a shared `cancelled *atomic.Bool` to the executor `state`
|
||||
- adds `ExecuteContext` / `ExecuteTemplateContext`; `Execute` / `ExecuteTemplate` become
|
||||
`context.Background()` wrappers, so their behavior and cost are unchanged
|
||||
- when `ctx.Done() != nil`, arms one `context.AfterFunc` watcher that flips the flag; `walk` polls it
|
||||
per node and aborts via a `cancelError` that `errRecover` strips to the bare `ctx.Err()`
|
||||
(`errors.Is(err, context.DeadlineExceeded)`)
|
||||
|
||||
The flag is a `*atomic.Bool` (not a value) because `walkTemplate` copies `state` for nested
|
||||
`{{template}}` invocations; a shared pointer keeps one flag across all copies and avoids `go vet`
|
||||
copylocks. `template.go` is unchanged -- the context is per-call, not stored on the `Template`.
|
||||
|
||||
Two *mechanical* transforms are applied by `make update-template` with `sed`, **not** the patch --
|
||||
renaming the package to `gotext`, and rewriting the `internal/fmtsort` import to
|
||||
`heckel.io/ntfy/v2/template/gotext/fmtsort`. Keeping them out of the patch means they apply to
|
||||
whatever files `go list` returns, so they survive upstream files being added or removed. (These two
|
||||
transforms are also the only difference between our patch and the upstream `text/template` diff.)
|
||||
|
||||
Keeping the patch tiny (cancellation only, on one stable file) is deliberate: it makes re-basing
|
||||
onto a new Go release cheap.
|
||||
|
||||
## Updating (when bumping the Go toolchain)
|
||||
|
||||
The copy is **pinned to the Go version in the root `.go-version`**, so it's not frozen -- re-syncing
|
||||
on a Go bump pulls in all upstream fixes for free. `.go-version` is authoritative and hand-edited; to
|
||||
bump the toolchain: edit `.go-version`, install that toolchain
|
||||
(`go install golang.org/dl/<version>@latest && <version> download`), then re-sync:
|
||||
|
||||
```
|
||||
make update-template # copies the files from your GOROOT and re-applies patches/*.patch
|
||||
```
|
||||
|
||||
`make update-template` **errors** unless your local Go matches `.go-version` -- it validates against
|
||||
the pin, it never writes it. If the patch hunks no longer apply against the new release, refresh the
|
||||
patch as part of the bump.
|
||||
|
||||
`make template-check` (wired into `make check`) has two layers:
|
||||
|
||||
1. **Marker check (ungated, runs on any toolchain):** fails if `GENERATED_FROM` != `.go-version`, i.e.
|
||||
someone bumped the pin but forgot `make update-template` (or vice versa). This catches the common
|
||||
mistake locally, on any developer's Go.
|
||||
2. **Content check (gated to the pinned Go):** re-derives the copy from `GOROOT + patches` and diffs it
|
||||
against what's committed, catching hand-edits and patch problems. It no-ops on a non-pinned
|
||||
toolchain so it never fails spuriously.
|
||||
|
||||
CI installs exactly `.go-version` (`go-version-file`), so both layers run there. `make release`
|
||||
additionally refuses to run off the pinned Go, so the content check is never skipped for a release.
|
||||
|
||||
## License
|
||||
|
||||
These files are copyright The Go Authors, under the BSD-3-Clause license (headers preserved in each
|
||||
file). That is compatible with ntfy's Apache-2.0 / GPLv2 licensing.
|
||||
@@ -0,0 +1,502 @@
|
||||
// Copyright 2011 The Go Authors. All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
/*
|
||||
Package template implements data-driven templates for generating textual output.
|
||||
|
||||
To generate HTML output, see [html/template], which has the same interface
|
||||
as this package but automatically secures HTML output against certain attacks.
|
||||
|
||||
Templates are executed by applying them to a data structure. Annotations in the
|
||||
template refer to elements of the data structure (typically a field of a struct
|
||||
or a key in a map) to control execution and derive values to be displayed.
|
||||
Execution of the template walks the structure and sets the cursor, represented
|
||||
by a period '.' and called "dot", to the value at the current location in the
|
||||
structure as execution proceeds.
|
||||
|
||||
The security model used by this package assumes that template authors are
|
||||
trusted. The package does not auto-escape output, so injecting code into
|
||||
a template can lead to arbitrary code execution if the template is executed
|
||||
by an untrusted source.
|
||||
|
||||
The input text for a template is UTF-8-encoded text in any format.
|
||||
"Actions"--data evaluations or control structures--are delimited by
|
||||
"{{" and "}}"; all text outside actions is copied to the output unchanged.
|
||||
|
||||
Once parsed, a template may be executed safely in parallel, although if parallel
|
||||
executions share a Writer the output may be interleaved.
|
||||
|
||||
Here is a trivial example that prints "17 items are made of wool".
|
||||
|
||||
type Inventory struct {
|
||||
Material string
|
||||
Count uint
|
||||
}
|
||||
sweaters := Inventory{"wool", 17}
|
||||
tmpl, err := template.New("test").Parse("{{.Count}} items are made of {{.Material}}")
|
||||
if err != nil { panic(err) }
|
||||
err = tmpl.Execute(os.Stdout, sweaters)
|
||||
if err != nil { panic(err) }
|
||||
|
||||
More intricate examples appear below.
|
||||
|
||||
Text and spaces
|
||||
|
||||
By default, all text between actions is copied verbatim when the template is
|
||||
executed. For example, the string " items are made of " in the example above
|
||||
appears on standard output when the program is run.
|
||||
|
||||
However, to aid in formatting template source code, if an action's left
|
||||
delimiter (by default "{{") is followed immediately by a minus sign and white
|
||||
space, all trailing white space is trimmed from the immediately preceding text.
|
||||
Similarly, if the right delimiter ("}}") is preceded by white space and a minus
|
||||
sign, all leading white space is trimmed from the immediately following text.
|
||||
In these trim markers, the white space must be present:
|
||||
"{{- 3}}" is like "{{3}}" but trims the immediately preceding text, while
|
||||
"{{-3}}" parses as an action containing the number -3.
|
||||
|
||||
For instance, when executing the template whose source is
|
||||
|
||||
"{{23 -}} < {{- 45}}"
|
||||
|
||||
the generated output would be
|
||||
|
||||
"23<45"
|
||||
|
||||
For this trimming, the definition of white space characters is the same as in Go:
|
||||
space, horizontal tab, carriage return, and newline.
|
||||
|
||||
Actions
|
||||
|
||||
Here is the list of actions. "Arguments" and "pipelines" are evaluations of
|
||||
data, defined in detail in the corresponding sections that follow.
|
||||
|
||||
*/
|
||||
// {{/* a comment */}}
|
||||
// {{- /* a comment with white space trimmed from preceding and following text */ -}}
|
||||
// A comment; discarded. May contain newlines.
|
||||
// Comments do not nest and must start and end at the
|
||||
// delimiters, as shown here.
|
||||
/*
|
||||
|
||||
{{pipeline}}
|
||||
The default textual representation (the same as would be
|
||||
printed by fmt.Print) of the value of the pipeline is copied
|
||||
to the output.
|
||||
|
||||
{{if pipeline}} T1 {{end}}
|
||||
If the value of the pipeline is empty, no output is generated;
|
||||
otherwise, T1 is executed. The empty values are false, 0, any
|
||||
nil pointer or interface value, and any array, slice, map, or
|
||||
string of length zero.
|
||||
Dot is unaffected.
|
||||
|
||||
{{if pipeline}} T1 {{else}} T0 {{end}}
|
||||
If the value of the pipeline is empty, T0 is executed;
|
||||
otherwise, T1 is executed. Dot is unaffected.
|
||||
|
||||
{{if pipeline}} T1 {{else if pipeline}} T0 {{end}}
|
||||
To simplify the appearance of if-else chains, the else action
|
||||
of an if may include another if directly; the effect is exactly
|
||||
the same as writing
|
||||
{{if pipeline}} T1 {{else}}{{if pipeline}} T0 {{end}}{{end}}
|
||||
|
||||
{{range pipeline}} T1 {{end}}
|
||||
The value of the pipeline must be an array, slice, map, iter.Seq,
|
||||
iter.Seq2, integer or channel.
|
||||
If the value of the pipeline has length zero, nothing is output;
|
||||
otherwise, dot is set to the successive elements of the array,
|
||||
slice, or map and T1 is executed. If the value is a map and the
|
||||
keys are of basic type with a defined order, the elements will be
|
||||
visited in sorted key order.
|
||||
|
||||
{{range pipeline}} T1 {{else}} T0 {{end}}
|
||||
The value of the pipeline must be an array, slice, map, iter.Seq,
|
||||
iter.Seq2, integer or channel.
|
||||
If the value of the pipeline has length zero, dot is unaffected and
|
||||
T0 is executed; otherwise, dot is set to the successive elements
|
||||
of the array, slice, or map and T1 is executed.
|
||||
|
||||
{{break}}
|
||||
The innermost {{range pipeline}} loop is ended early, stopping the
|
||||
current iteration and bypassing all remaining iterations.
|
||||
|
||||
{{continue}}
|
||||
The current iteration of the innermost {{range pipeline}} loop is
|
||||
stopped, and the loop starts the next iteration.
|
||||
|
||||
{{template "name"}}
|
||||
The template with the specified name is executed with nil data.
|
||||
|
||||
{{template "name" pipeline}}
|
||||
The template with the specified name is executed with dot set
|
||||
to the value of the pipeline.
|
||||
|
||||
{{block "name" pipeline}} T1 {{end}}
|
||||
A block is shorthand for defining a template
|
||||
{{define "name"}} T1 {{end}}
|
||||
and then executing it in place
|
||||
{{template "name" pipeline}}
|
||||
The typical use is to define a set of root templates that are
|
||||
then customized by redefining the block templates within.
|
||||
|
||||
{{with pipeline}} T1 {{end}}
|
||||
If the value of the pipeline is empty, no output is generated;
|
||||
otherwise, dot is set to the value of the pipeline and T1 is
|
||||
executed.
|
||||
|
||||
{{with pipeline}} T1 {{else}} T0 {{end}}
|
||||
If the value of the pipeline is empty, dot is unaffected and T0
|
||||
is executed; otherwise, dot is set to the value of the pipeline
|
||||
and T1 is executed.
|
||||
|
||||
{{with pipeline}} T1 {{else with pipeline}} T0 {{end}}
|
||||
To simplify the appearance of with-else chains, the else action
|
||||
of a with may include another with directly; the effect is exactly
|
||||
the same as writing
|
||||
{{with pipeline}} T1 {{else}}{{with pipeline}} T0 {{end}}{{end}}
|
||||
|
||||
|
||||
Arguments
|
||||
|
||||
An argument is a simple value, denoted by one of the following.
|
||||
|
||||
- A boolean, string, character, integer, floating-point, imaginary
|
||||
or complex constant in Go syntax. These behave like Go's untyped
|
||||
constants. Note that, as in Go, whether a large integer constant
|
||||
overflows when assigned or passed to a function can depend on whether
|
||||
the host machine's ints are 32 or 64 bits.
|
||||
- The keyword nil, representing an untyped Go nil.
|
||||
- The character '.' (period):
|
||||
|
||||
.
|
||||
|
||||
The result is the value of dot.
|
||||
- A variable name, which is a (possibly empty) alphanumeric string
|
||||
preceded by a dollar sign, such as
|
||||
|
||||
$piOver2
|
||||
|
||||
or
|
||||
|
||||
$
|
||||
|
||||
The result is the value of the variable.
|
||||
Variables are described below.
|
||||
- The name of a field of the data, which must be a struct, preceded
|
||||
by a period, such as
|
||||
|
||||
.Field
|
||||
|
||||
The result is the value of the field. Field invocations may be
|
||||
chained:
|
||||
|
||||
.Field1.Field2
|
||||
|
||||
Fields can also be evaluated on variables, including chaining:
|
||||
|
||||
$x.Field1.Field2
|
||||
- The name of a key of the data, which must be a map, preceded
|
||||
by a period, such as
|
||||
|
||||
.Key
|
||||
|
||||
The result is the map element value indexed by the key.
|
||||
Key invocations may be chained and combined with fields to any
|
||||
depth:
|
||||
|
||||
.Field1.Key1.Field2.Key2
|
||||
|
||||
Although the key must be an alphanumeric identifier, unlike with
|
||||
field names they do not need to start with an upper case letter.
|
||||
Keys can also be evaluated on variables, including chaining:
|
||||
|
||||
$x.key1.key2
|
||||
- The name of a niladic method of the data, preceded by a period,
|
||||
such as
|
||||
|
||||
.Method
|
||||
|
||||
The result is the value of invoking the method with dot as the
|
||||
receiver, dot.Method(). Such a method must have one return value (of
|
||||
any type) or two return values, the second of which is an error.
|
||||
If it has two and the returned error is non-nil, execution terminates
|
||||
and an error is returned to the caller as the value of Execute.
|
||||
Method invocations may be chained and combined with fields and keys
|
||||
to any depth:
|
||||
|
||||
.Field1.Key1.Method1.Field2.Key2.Method2
|
||||
|
||||
Methods can also be evaluated on variables, including chaining:
|
||||
|
||||
$x.Method1.Field
|
||||
- The name of a niladic function, such as
|
||||
|
||||
fun
|
||||
|
||||
The result is the value of invoking the function, fun(). The return
|
||||
types and values behave as in methods. Functions and function
|
||||
names are described below.
|
||||
- A parenthesized instance of one the above, for grouping. The result
|
||||
may be accessed by a field or map key invocation.
|
||||
|
||||
print (.F1 arg1) (.F2 arg2)
|
||||
(.StructValuedMethod "arg").Field
|
||||
|
||||
Arguments may evaluate to any type; if they are pointers the implementation
|
||||
automatically indirects to the base type when required.
|
||||
If an evaluation yields a function value, such as a function-valued
|
||||
field of a struct, the function is not invoked automatically, but it
|
||||
can be used as a truth value for an if action and the like. To invoke
|
||||
it, use the call function, defined below.
|
||||
|
||||
Pipelines
|
||||
|
||||
A pipeline is a possibly chained sequence of "commands". A command is a simple
|
||||
value (argument) or a function or method call, possibly with multiple arguments:
|
||||
|
||||
Argument
|
||||
The result is the value of evaluating the argument.
|
||||
.Method [Argument...]
|
||||
The method can be alone or the last element of a chain but,
|
||||
unlike methods in the middle of a chain, it can take arguments.
|
||||
The result is the value of calling the method with the
|
||||
arguments:
|
||||
dot.Method(Argument1, etc.)
|
||||
functionName [Argument...]
|
||||
The result is the value of calling the function associated
|
||||
with the name:
|
||||
function(Argument1, etc.)
|
||||
Functions and function names are described below.
|
||||
|
||||
A pipeline may be "chained" by separating a sequence of commands with pipeline
|
||||
characters '|'. In a chained pipeline, the result of each command is
|
||||
passed as the last argument of the following command. The output of the final
|
||||
command in the pipeline is the value of the pipeline.
|
||||
|
||||
The output of a command will be either one value or two values, the second of
|
||||
which has type error. If that second value is present and evaluates to
|
||||
non-nil, execution terminates and the error is returned to the caller of
|
||||
Execute.
|
||||
|
||||
Variables
|
||||
|
||||
A pipeline inside an action may initialize a variable to capture the result.
|
||||
The initialization has syntax
|
||||
|
||||
$variable := pipeline
|
||||
|
||||
where $variable is the name of the variable. An action that declares a
|
||||
variable produces no output.
|
||||
|
||||
Variables previously declared can also be assigned, using the syntax
|
||||
|
||||
$variable = pipeline
|
||||
|
||||
If a "range" action initializes a variable, the variable is set to the
|
||||
successive elements of the iteration. Also, a "range" may declare two
|
||||
variables, separated by a comma:
|
||||
|
||||
range $index, $element := pipeline
|
||||
|
||||
in which case $index and $element are set to the successive values of the
|
||||
array/slice index or map key and element, respectively. Note that if there is
|
||||
only one variable, it is assigned the element; this is opposite to the
|
||||
convention in Go range clauses.
|
||||
|
||||
A variable's scope extends to the "end" action of the control structure ("if",
|
||||
"with", or "range") in which it is declared, or to the end of the template if
|
||||
there is no such control structure. A template invocation does not inherit
|
||||
variables from the point of its invocation.
|
||||
|
||||
When execution begins, $ is set to the data argument passed to Execute, that is,
|
||||
to the starting value of dot.
|
||||
|
||||
Examples
|
||||
|
||||
Here are some example one-line templates demonstrating pipelines and variables.
|
||||
All produce the quoted word "output":
|
||||
|
||||
{{"\"output\""}}
|
||||
A string constant.
|
||||
{{`"output"`}}
|
||||
A raw string constant.
|
||||
{{printf "%q" "output"}}
|
||||
A function call.
|
||||
{{"output" | printf "%q"}}
|
||||
A function call whose final argument comes from the previous
|
||||
command.
|
||||
{{printf "%q" (print "out" "put")}}
|
||||
A parenthesized argument.
|
||||
{{"put" | printf "%s%s" "out" | printf "%q"}}
|
||||
A more elaborate call.
|
||||
{{"output" | printf "%s" | printf "%q"}}
|
||||
A longer chain.
|
||||
{{with "output"}}{{printf "%q" .}}{{end}}
|
||||
A with action using dot.
|
||||
{{with $x := "output" | printf "%q"}}{{$x}}{{end}}
|
||||
A with action that creates and uses a variable.
|
||||
{{with $x := "output"}}{{printf "%q" $x}}{{end}}
|
||||
A with action that uses the variable in another action.
|
||||
{{with $x := "output"}}{{$x | printf "%q"}}{{end}}
|
||||
The same, but pipelined.
|
||||
|
||||
Functions
|
||||
|
||||
During execution functions are found in two function maps: first in the
|
||||
template, then in the global function map. By default, no functions are defined
|
||||
in the template but the Funcs method can be used to add them.
|
||||
|
||||
Predefined global functions are named as follows.
|
||||
|
||||
and
|
||||
Returns the boolean AND of its arguments by returning the
|
||||
first empty argument or the last argument. That is,
|
||||
"and x y" behaves as "if x then y else x."
|
||||
Evaluation proceeds through the arguments left to right
|
||||
and returns when the result is determined.
|
||||
call
|
||||
Returns the result of calling the first argument, which
|
||||
must be a function, with the remaining arguments as parameters.
|
||||
Thus "call .X.Y 1 2" is, in Go notation, dot.X.Y(1, 2) where
|
||||
Y is a func-valued field, map entry, or the like.
|
||||
The first argument must be the result of an evaluation
|
||||
that yields a value of function type (as distinct from
|
||||
a predefined function such as print). The function must
|
||||
return either one or two result values, the second of which
|
||||
is of type error. If the arguments don't match the function
|
||||
or the returned error value is non-nil, execution stops.
|
||||
html
|
||||
Returns the escaped HTML equivalent of the textual
|
||||
representation of its arguments. This function is unavailable
|
||||
in html/template, with a few exceptions.
|
||||
index
|
||||
Returns the result of indexing its first argument by the
|
||||
following arguments. Thus "index x 1 2 3" is, in Go syntax,
|
||||
x[1][2][3]. Each indexed item must be a map, slice, or array.
|
||||
slice
|
||||
slice returns the result of slicing its first argument by the
|
||||
remaining arguments. Thus "slice x 1 2" is, in Go syntax, x[1:2],
|
||||
while "slice x" is x[:], "slice x 1" is x[1:], and "slice x 1 2 3"
|
||||
is x[1:2:3]. The first argument must be a string, slice, or array.
|
||||
js
|
||||
Returns the escaped JavaScript equivalent of the textual
|
||||
representation of its arguments.
|
||||
len
|
||||
Returns the integer length of its argument.
|
||||
not
|
||||
Returns the boolean negation of its single argument.
|
||||
or
|
||||
Returns the boolean OR of its arguments by returning the
|
||||
first non-empty argument or the last argument, that is,
|
||||
"or x y" behaves as "if x then x else y".
|
||||
Evaluation proceeds through the arguments left to right
|
||||
and returns when the result is determined.
|
||||
print
|
||||
An alias for fmt.Sprint
|
||||
printf
|
||||
An alias for fmt.Sprintf
|
||||
println
|
||||
An alias for fmt.Sprintln
|
||||
urlquery
|
||||
Returns the escaped value of the textual representation of
|
||||
its arguments in a form suitable for embedding in a URL query.
|
||||
This function is unavailable in html/template, with a few
|
||||
exceptions.
|
||||
|
||||
The boolean functions take any zero value to be false and a non-zero
|
||||
value to be true.
|
||||
|
||||
There is also a set of binary comparison operators defined as
|
||||
functions:
|
||||
|
||||
eq
|
||||
Returns the boolean truth of arg1 == arg2
|
||||
ne
|
||||
Returns the boolean truth of arg1 != arg2
|
||||
lt
|
||||
Returns the boolean truth of arg1 < arg2
|
||||
le
|
||||
Returns the boolean truth of arg1 <= arg2
|
||||
gt
|
||||
Returns the boolean truth of arg1 > arg2
|
||||
ge
|
||||
Returns the boolean truth of arg1 >= arg2
|
||||
|
||||
For simpler multi-way equality tests, eq (only) accepts two or more
|
||||
arguments and compares the second and subsequent to the first,
|
||||
returning in effect
|
||||
|
||||
arg1==arg2 || arg1==arg3 || arg1==arg4 ...
|
||||
|
||||
(Unlike with || in Go, however, eq is a function call and all the
|
||||
arguments will be evaluated.)
|
||||
|
||||
The comparison functions work on any values whose type Go defines as
|
||||
comparable. For basic types such as integers, the rules are relaxed:
|
||||
size and exact type are ignored, so any integer value, signed or unsigned,
|
||||
may be compared with any other integer value. (The arithmetic value is compared,
|
||||
not the bit pattern, so all negative integers are less than all unsigned integers.)
|
||||
However, as usual, one may not compare an int with a float32 and so on.
|
||||
|
||||
Associated templates
|
||||
|
||||
Each template is named by a string specified when it is created. Also, each
|
||||
template is associated with zero or more other templates that it may invoke by
|
||||
name; such associations are transitive and form a name space of templates.
|
||||
|
||||
A template may use a template invocation to instantiate another associated
|
||||
template; see the explanation of the "template" action above. The name must be
|
||||
that of a template associated with the template that contains the invocation.
|
||||
|
||||
Nested template definitions
|
||||
|
||||
When parsing a template, another template may be defined and associated with the
|
||||
template being parsed. Template definitions must appear at the top level of the
|
||||
template, much like global variables in a Go program.
|
||||
|
||||
The syntax of such definitions is to surround each template declaration with a
|
||||
"define" and "end" action.
|
||||
|
||||
The define action names the template being created by providing a string
|
||||
constant. Here is a simple example:
|
||||
|
||||
{{define "T1"}}ONE{{end}}
|
||||
{{define "T2"}}TWO{{end}}
|
||||
{{define "T3"}}{{template "T1"}} {{template "T2"}}{{end}}
|
||||
{{template "T3"}}
|
||||
|
||||
This defines two templates, T1 and T2, and a third T3 that invokes the other two
|
||||
when it is executed. Finally it invokes T3. If executed this template will
|
||||
produce the text
|
||||
|
||||
ONE TWO
|
||||
|
||||
By construction, a template may reside in only one association. If it's
|
||||
necessary to have a template addressable from multiple associations, the
|
||||
template definition must be parsed multiple times to create distinct *Template
|
||||
values, or must be copied with [Template.Clone] or [Template.AddParseTree].
|
||||
|
||||
Parse may be called multiple times to assemble the various associated templates;
|
||||
see [ParseFiles], [ParseGlob], [Template.ParseFiles] and [Template.ParseGlob]
|
||||
for simple ways to parse related templates stored in files.
|
||||
|
||||
A template may be executed directly or through [Template.ExecuteTemplate], which executes
|
||||
an associated template identified by name. To invoke our example above, we
|
||||
might write,
|
||||
|
||||
err := tmpl.Execute(os.Stdout, "no data needed")
|
||||
if err != nil {
|
||||
log.Fatalf("execution failed: %s", err)
|
||||
}
|
||||
|
||||
or to invoke a particular template explicitly by name,
|
||||
|
||||
err := tmpl.ExecuteTemplate(os.Stdout, "T2", "no data needed")
|
||||
if err != nil {
|
||||
log.Fatalf("execution failed: %s", err)
|
||||
}
|
||||
|
||||
*/
|
||||
package gotext
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,154 @@
|
||||
// Copyright 2018 The Go Authors. All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
// Package fmtsort provides a general stable ordering mechanism
|
||||
// for maps, on behalf of the fmt and text/template packages.
|
||||
// It is not guaranteed to be efficient and works only for types
|
||||
// that are valid map keys.
|
||||
package fmtsort
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"reflect"
|
||||
"slices"
|
||||
)
|
||||
|
||||
// Note: Throughout this package we avoid calling reflect.Value.Interface as
|
||||
// it is not always legal to do so and it's easier to avoid the issue than to face it.
|
||||
|
||||
// SortedMap is a slice of KeyValue pairs that simplifies sorting
|
||||
// and iterating over map entries.
|
||||
//
|
||||
// Each KeyValue pair contains a map key and its corresponding value.
|
||||
type SortedMap []KeyValue
|
||||
|
||||
// KeyValue holds a single key and value pair found in a map.
|
||||
type KeyValue struct {
|
||||
Key, Value reflect.Value
|
||||
}
|
||||
|
||||
// Sort accepts a map and returns a SortedMap that has the same keys and
|
||||
// values but in a stable sorted order according to the keys, modulo issues
|
||||
// raised by unorderable key values such as NaNs.
|
||||
//
|
||||
// The ordering rules are more general than with Go's < operator:
|
||||
//
|
||||
// - when applicable, nil compares low
|
||||
// - ints, floats, and strings order by <
|
||||
// - NaN compares less than non-NaN floats
|
||||
// - bool compares false before true
|
||||
// - complex compares real, then imag
|
||||
// - pointers compare by machine address
|
||||
// - channel values compare by machine address
|
||||
// - structs compare each field in turn
|
||||
// - arrays compare each element in turn.
|
||||
// Otherwise identical arrays compare by length.
|
||||
// - interface values compare first by reflect.Type describing the concrete type
|
||||
// and then by concrete value as described in the previous rules.
|
||||
func Sort(mapValue reflect.Value) SortedMap {
|
||||
if mapValue.Type().Kind() != reflect.Map {
|
||||
return nil
|
||||
}
|
||||
// Note: this code is arranged to not panic even in the presence
|
||||
// of a concurrent map update. The runtime is responsible for
|
||||
// yelling loudly if that happens. See issue 33275.
|
||||
n := mapValue.Len()
|
||||
sorted := make(SortedMap, 0, n)
|
||||
iter := mapValue.MapRange()
|
||||
for iter.Next() {
|
||||
sorted = append(sorted, KeyValue{iter.Key(), iter.Value()})
|
||||
}
|
||||
slices.SortStableFunc(sorted, func(a, b KeyValue) int {
|
||||
return compare(a.Key, b.Key)
|
||||
})
|
||||
return sorted
|
||||
}
|
||||
|
||||
// compare compares two values of the same type. It returns -1, 0, 1
|
||||
// according to whether a > b (1), a == b (0), or a < b (-1).
|
||||
// If the types differ, it returns -1.
|
||||
// See the comment on Sort for the comparison rules.
|
||||
func compare(aVal, bVal reflect.Value) int {
|
||||
aType, bType := aVal.Type(), bVal.Type()
|
||||
if aType != bType {
|
||||
return -1 // No good answer possible, but don't return 0: they're not equal.
|
||||
}
|
||||
switch aVal.Kind() {
|
||||
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
|
||||
return cmp.Compare(aVal.Int(), bVal.Int())
|
||||
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr:
|
||||
return cmp.Compare(aVal.Uint(), bVal.Uint())
|
||||
case reflect.String:
|
||||
return cmp.Compare(aVal.String(), bVal.String())
|
||||
case reflect.Float32, reflect.Float64:
|
||||
return cmp.Compare(aVal.Float(), bVal.Float())
|
||||
case reflect.Complex64, reflect.Complex128:
|
||||
a, b := aVal.Complex(), bVal.Complex()
|
||||
if c := cmp.Compare(real(a), real(b)); c != 0 {
|
||||
return c
|
||||
}
|
||||
return cmp.Compare(imag(a), imag(b))
|
||||
case reflect.Bool:
|
||||
a, b := aVal.Bool(), bVal.Bool()
|
||||
switch {
|
||||
case a == b:
|
||||
return 0
|
||||
case a:
|
||||
return 1
|
||||
default:
|
||||
return -1
|
||||
}
|
||||
case reflect.Pointer, reflect.UnsafePointer:
|
||||
return cmp.Compare(aVal.Pointer(), bVal.Pointer())
|
||||
case reflect.Chan:
|
||||
if c, ok := nilCompare(aVal, bVal); ok {
|
||||
return c
|
||||
}
|
||||
return cmp.Compare(aVal.Pointer(), bVal.Pointer())
|
||||
case reflect.Struct:
|
||||
for i := 0; i < aVal.NumField(); i++ {
|
||||
if c := compare(aVal.Field(i), bVal.Field(i)); c != 0 {
|
||||
return c
|
||||
}
|
||||
}
|
||||
return 0
|
||||
case reflect.Array:
|
||||
for i := 0; i < aVal.Len(); i++ {
|
||||
if c := compare(aVal.Index(i), bVal.Index(i)); c != 0 {
|
||||
return c
|
||||
}
|
||||
}
|
||||
return 0
|
||||
case reflect.Interface:
|
||||
if c, ok := nilCompare(aVal, bVal); ok {
|
||||
return c
|
||||
}
|
||||
c := compare(reflect.ValueOf(aVal.Elem().Type()), reflect.ValueOf(bVal.Elem().Type()))
|
||||
if c != 0 {
|
||||
return c
|
||||
}
|
||||
return compare(aVal.Elem(), bVal.Elem())
|
||||
default:
|
||||
// Certain types cannot appear as keys (maps, funcs, slices), but be explicit.
|
||||
panic("bad type in compare: " + aType.String())
|
||||
}
|
||||
}
|
||||
|
||||
// nilCompare checks whether either value is nil. If not, the boolean is false.
|
||||
// If either value is nil, the boolean is true and the integer is the comparison
|
||||
// value. The comparison is defined to be 0 if both are nil, otherwise the one
|
||||
// nil value compares low. Both arguments must represent a chan, func,
|
||||
// interface, map, pointer, or slice.
|
||||
func nilCompare(aVal, bVal reflect.Value) (int, bool) {
|
||||
if aVal.IsNil() {
|
||||
if bVal.IsNil() {
|
||||
return 0, true
|
||||
}
|
||||
return -1, true
|
||||
}
|
||||
if bVal.IsNil() {
|
||||
return 1, true
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
@@ -0,0 +1,774 @@
|
||||
// Copyright 2011 The Go Authors. All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package gotext
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/url"
|
||||
"reflect"
|
||||
"strings"
|
||||
"sync"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// FuncMap is the type of the map defining the mapping from names to functions.
|
||||
// Each function must have either a single return value, or two return values of
|
||||
// which the second has type error. In that case, if the second (error)
|
||||
// return value evaluates to non-nil during execution, execution terminates and
|
||||
// Execute returns that error.
|
||||
//
|
||||
// Errors returned by Execute wrap the underlying error; call [errors.AsType] to
|
||||
// unwrap them.
|
||||
//
|
||||
// When template execution invokes a function with an argument list, that list
|
||||
// must be assignable to the function's parameter types. Functions meant to
|
||||
// apply to arguments of arbitrary type can use parameters of type interface{} or
|
||||
// of type [reflect.Value]. Similarly, functions meant to return a result of arbitrary
|
||||
// type can return interface{} or [reflect.Value].
|
||||
type FuncMap map[string]any
|
||||
|
||||
// builtins returns the FuncMap.
|
||||
// It is not a global variable so the linker can dead code eliminate
|
||||
// more when this isn't called. See golang.org/issue/36021.
|
||||
// TODO: revert this back to a global map once golang.org/issue/2559 is fixed.
|
||||
func builtins() FuncMap {
|
||||
return FuncMap{
|
||||
"and": and,
|
||||
"call": emptyCall,
|
||||
"html": HTMLEscaper,
|
||||
"index": index,
|
||||
"slice": slice,
|
||||
"js": JSEscaper,
|
||||
"len": length,
|
||||
"not": not,
|
||||
"or": or,
|
||||
"print": fmt.Sprint,
|
||||
"printf": fmt.Sprintf,
|
||||
"println": fmt.Sprintln,
|
||||
"urlquery": URLQueryEscaper,
|
||||
|
||||
// Comparisons
|
||||
"eq": eq, // ==
|
||||
"ge": ge, // >=
|
||||
"gt": gt, // >
|
||||
"le": le, // <=
|
||||
"lt": lt, // <
|
||||
"ne": ne, // !=
|
||||
}
|
||||
}
|
||||
|
||||
// builtinFuncs lazily computes & caches the builtinFuncs map.
|
||||
var builtinFuncs = sync.OnceValue(func() map[string]reflect.Value {
|
||||
funcMap := builtins()
|
||||
m := make(map[string]reflect.Value, len(funcMap))
|
||||
addValueFuncs(m, funcMap)
|
||||
return m
|
||||
})
|
||||
|
||||
// addValueFuncs adds to values the functions in funcs, converting them to reflect.Values.
|
||||
func addValueFuncs(out map[string]reflect.Value, in FuncMap) {
|
||||
for name, fn := range in {
|
||||
if !goodName(name) {
|
||||
panic(fmt.Errorf("function name %q is not a valid identifier", name))
|
||||
}
|
||||
v := reflect.ValueOf(fn)
|
||||
if v.Kind() != reflect.Func {
|
||||
panic("value for " + name + " not a function")
|
||||
}
|
||||
if err := goodFunc(name, v.Type()); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
out[name] = v
|
||||
}
|
||||
}
|
||||
|
||||
// addFuncs adds to values the functions in funcs. It does no checking of the input -
|
||||
// call addValueFuncs first.
|
||||
func addFuncs(out, in FuncMap) {
|
||||
for name, fn := range in {
|
||||
out[name] = fn
|
||||
}
|
||||
}
|
||||
|
||||
// goodFunc reports whether the function or method has the right result signature.
|
||||
func goodFunc(name string, typ reflect.Type) error {
|
||||
// We allow functions with 1 result or 2 results where the second is an error.
|
||||
switch numOut := typ.NumOut(); {
|
||||
case numOut == 1:
|
||||
return nil
|
||||
case numOut == 2 && typ.Out(1) == errorType:
|
||||
return nil
|
||||
case numOut == 2:
|
||||
return fmt.Errorf("invalid function signature for %s: second return value should be error; is %s", name, typ.Out(1))
|
||||
default:
|
||||
return fmt.Errorf("function %s has %d return values; should be 1 or 2", name, typ.NumOut())
|
||||
}
|
||||
}
|
||||
|
||||
// goodName reports whether the function name is a valid identifier.
|
||||
func goodName(name string) bool {
|
||||
if name == "" {
|
||||
return false
|
||||
}
|
||||
for i, r := range name {
|
||||
switch {
|
||||
case r == '_':
|
||||
case i == 0 && !unicode.IsLetter(r):
|
||||
return false
|
||||
case !unicode.IsLetter(r) && !unicode.IsDigit(r):
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// findFunction looks for a function in the template, and global map.
|
||||
func findFunction(name string, tmpl *Template) (v reflect.Value, isBuiltin, ok bool) {
|
||||
if tmpl != nil && tmpl.common != nil {
|
||||
tmpl.muFuncs.RLock()
|
||||
defer tmpl.muFuncs.RUnlock()
|
||||
if fn := tmpl.execFuncs[name]; fn.IsValid() {
|
||||
return fn, false, true
|
||||
}
|
||||
}
|
||||
if fn := builtinFuncs()[name]; fn.IsValid() {
|
||||
return fn, true, true
|
||||
}
|
||||
return reflect.Value{}, false, false
|
||||
}
|
||||
|
||||
// prepareArg checks if value can be used as an argument of type argType, and
|
||||
// converts an invalid value to appropriate zero if possible.
|
||||
func prepareArg(value reflect.Value, argType reflect.Type) (reflect.Value, error) {
|
||||
if !value.IsValid() {
|
||||
if !canBeNil(argType) {
|
||||
return reflect.Value{}, fmt.Errorf("value is nil; should be of type %s", argType)
|
||||
}
|
||||
value = reflect.Zero(argType)
|
||||
}
|
||||
if value.Type().AssignableTo(argType) {
|
||||
return value, nil
|
||||
}
|
||||
if intLike(value.Kind()) && intLike(argType.Kind()) && value.Type().ConvertibleTo(argType) {
|
||||
value = value.Convert(argType)
|
||||
return value, nil
|
||||
}
|
||||
return reflect.Value{}, fmt.Errorf("value has type %s; should be %s", value.Type(), argType)
|
||||
}
|
||||
|
||||
func intLike(typ reflect.Kind) bool {
|
||||
switch typ {
|
||||
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
|
||||
return true
|
||||
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// indexArg checks if a reflect.Value can be used as an index, and converts it to int if possible.
|
||||
func indexArg(index reflect.Value, cap int) (int, error) {
|
||||
var x int64
|
||||
switch index.Kind() {
|
||||
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
|
||||
x = index.Int()
|
||||
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr:
|
||||
x = int64(index.Uint())
|
||||
case reflect.Invalid:
|
||||
return 0, fmt.Errorf("cannot index slice/array with nil")
|
||||
default:
|
||||
return 0, fmt.Errorf("cannot index slice/array with type %s", index.Type())
|
||||
}
|
||||
if x < 0 || int(x) < 0 || int(x) > cap {
|
||||
return 0, fmt.Errorf("index out of range: %d", x)
|
||||
}
|
||||
return int(x), nil
|
||||
}
|
||||
|
||||
// Indexing.
|
||||
|
||||
// index returns the result of indexing its first argument by the following
|
||||
// arguments. Thus "index x 1 2 3" is, in Go syntax, x[1][2][3]. Each
|
||||
// indexed item must be a map, slice, or array.
|
||||
func index(item reflect.Value, indexes ...reflect.Value) (reflect.Value, error) {
|
||||
item = indirectInterface(item)
|
||||
if !item.IsValid() {
|
||||
return reflect.Value{}, fmt.Errorf("index of untyped nil")
|
||||
}
|
||||
for _, index := range indexes {
|
||||
index = indirectInterface(index)
|
||||
var isNil bool
|
||||
if item, isNil = indirect(item); isNil {
|
||||
return reflect.Value{}, fmt.Errorf("index of nil pointer")
|
||||
}
|
||||
switch item.Kind() {
|
||||
case reflect.Array, reflect.Slice, reflect.String:
|
||||
x, err := indexArg(index, item.Len())
|
||||
if err != nil {
|
||||
return reflect.Value{}, err
|
||||
}
|
||||
item = item.Index(x)
|
||||
case reflect.Map:
|
||||
index, err := prepareArg(index, item.Type().Key())
|
||||
if err != nil {
|
||||
return reflect.Value{}, err
|
||||
}
|
||||
if x := item.MapIndex(index); x.IsValid() {
|
||||
item = x
|
||||
} else {
|
||||
item = reflect.Zero(item.Type().Elem())
|
||||
}
|
||||
case reflect.Invalid:
|
||||
// the loop holds invariant: item.IsValid()
|
||||
panic("unreachable")
|
||||
default:
|
||||
return reflect.Value{}, fmt.Errorf("can't index item of type %s", item.Type())
|
||||
}
|
||||
}
|
||||
return item, nil
|
||||
}
|
||||
|
||||
// Slicing.
|
||||
|
||||
// slice returns the result of slicing its first argument by the remaining
|
||||
// arguments. Thus "slice x 1 2" is, in Go syntax, x[1:2], while "slice x"
|
||||
// is x[:], "slice x 1" is x[1:], and "slice x 1 2 3" is x[1:2:3]. The first
|
||||
// argument must be a string, slice, or array.
|
||||
func slice(item reflect.Value, indexes ...reflect.Value) (reflect.Value, error) {
|
||||
item = indirectInterface(item)
|
||||
if !item.IsValid() {
|
||||
return reflect.Value{}, fmt.Errorf("slice of untyped nil")
|
||||
}
|
||||
var isNil bool
|
||||
if item, isNil = indirect(item); isNil {
|
||||
return reflect.Value{}, fmt.Errorf("slice of nil pointer")
|
||||
}
|
||||
if len(indexes) > 3 {
|
||||
return reflect.Value{}, fmt.Errorf("too many slice indexes: %d", len(indexes))
|
||||
}
|
||||
var cap int
|
||||
switch item.Kind() {
|
||||
case reflect.String:
|
||||
if len(indexes) == 3 {
|
||||
return reflect.Value{}, fmt.Errorf("cannot 3-index slice a string")
|
||||
}
|
||||
cap = item.Len()
|
||||
case reflect.Array, reflect.Slice:
|
||||
cap = item.Cap()
|
||||
default:
|
||||
return reflect.Value{}, fmt.Errorf("can't slice item of type %s", item.Type())
|
||||
}
|
||||
// set default values for cases item[:], item[i:].
|
||||
idx := [3]int{0, item.Len()}
|
||||
for i, index := range indexes {
|
||||
x, err := indexArg(index, cap)
|
||||
if err != nil {
|
||||
return reflect.Value{}, err
|
||||
}
|
||||
idx[i] = x
|
||||
}
|
||||
// given item[i:j], make sure i <= j.
|
||||
if idx[0] > idx[1] {
|
||||
return reflect.Value{}, fmt.Errorf("invalid slice index: %d > %d", idx[0], idx[1])
|
||||
}
|
||||
if len(indexes) < 3 {
|
||||
return item.Slice(idx[0], idx[1]), nil
|
||||
}
|
||||
// given item[i:j:k], make sure i <= j <= k.
|
||||
if idx[1] > idx[2] {
|
||||
return reflect.Value{}, fmt.Errorf("invalid slice index: %d > %d", idx[1], idx[2])
|
||||
}
|
||||
return item.Slice3(idx[0], idx[1], idx[2]), nil
|
||||
}
|
||||
|
||||
// Length
|
||||
|
||||
// length returns the length of the item, with an error if it has no defined length.
|
||||
func length(item reflect.Value) (int, error) {
|
||||
item, isNil := indirect(item)
|
||||
if isNil {
|
||||
return 0, fmt.Errorf("len of nil pointer")
|
||||
}
|
||||
switch item.Kind() {
|
||||
case reflect.Array, reflect.Chan, reflect.Map, reflect.Slice, reflect.String:
|
||||
return item.Len(), nil
|
||||
}
|
||||
return 0, fmt.Errorf("len of type %s", item.Type())
|
||||
}
|
||||
|
||||
// Function invocation
|
||||
|
||||
func emptyCall(fn reflect.Value, args ...reflect.Value) reflect.Value {
|
||||
panic("unreachable") // implemented as a special case in evalCall
|
||||
}
|
||||
|
||||
// call returns the result of evaluating the first argument as a function.
|
||||
// The function must return 1 result, or 2 results, the second of which is an error.
|
||||
func call(name string, fn reflect.Value, args ...reflect.Value) (reflect.Value, error) {
|
||||
fn = indirectInterface(fn)
|
||||
if !fn.IsValid() {
|
||||
return reflect.Value{}, fmt.Errorf("call of nil")
|
||||
}
|
||||
typ := fn.Type()
|
||||
if typ.Kind() != reflect.Func {
|
||||
return reflect.Value{}, fmt.Errorf("non-function %s of type %s", name, typ)
|
||||
}
|
||||
|
||||
if err := goodFunc(name, typ); err != nil {
|
||||
return reflect.Value{}, err
|
||||
}
|
||||
numIn := typ.NumIn()
|
||||
var dddType reflect.Type
|
||||
if typ.IsVariadic() {
|
||||
if len(args) < numIn-1 {
|
||||
return reflect.Value{}, fmt.Errorf("wrong number of args for %s: got %d want at least %d", name, len(args), numIn-1)
|
||||
}
|
||||
dddType = typ.In(numIn - 1).Elem()
|
||||
} else {
|
||||
if len(args) != numIn {
|
||||
return reflect.Value{}, fmt.Errorf("wrong number of args for %s: got %d want %d", name, len(args), numIn)
|
||||
}
|
||||
}
|
||||
argv := make([]reflect.Value, len(args))
|
||||
for i, arg := range args {
|
||||
arg = indirectInterface(arg)
|
||||
// Compute the expected type. Clumsy because of variadics.
|
||||
argType := dddType
|
||||
if !typ.IsVariadic() || i < numIn-1 {
|
||||
argType = typ.In(i)
|
||||
}
|
||||
|
||||
var err error
|
||||
if argv[i], err = prepareArg(arg, argType); err != nil {
|
||||
return reflect.Value{}, fmt.Errorf("arg %d: %w", i, err)
|
||||
}
|
||||
}
|
||||
return safeCall(fn, argv)
|
||||
}
|
||||
|
||||
// safeCall runs fun.Call(args), and returns the resulting value and error, if
|
||||
// any. If the call panics, the panic value is returned as an error.
|
||||
func safeCall(fun reflect.Value, args []reflect.Value) (val reflect.Value, err error) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
if e, ok := r.(error); ok {
|
||||
err = e
|
||||
} else {
|
||||
err = fmt.Errorf("%v", r)
|
||||
}
|
||||
}
|
||||
}()
|
||||
ret := fun.Call(args)
|
||||
if len(ret) == 2 && !ret[1].IsNil() {
|
||||
return ret[0], ret[1].Interface().(error)
|
||||
}
|
||||
return ret[0], nil
|
||||
}
|
||||
|
||||
// Boolean logic.
|
||||
|
||||
func truth(arg reflect.Value) bool {
|
||||
t, _ := isTrue(indirectInterface(arg))
|
||||
return t
|
||||
}
|
||||
|
||||
// and computes the Boolean AND of its arguments, returning
|
||||
// the first false argument it encounters, or the last argument.
|
||||
func and(arg0 reflect.Value, args ...reflect.Value) reflect.Value {
|
||||
panic("unreachable") // implemented as a special case in evalCall
|
||||
}
|
||||
|
||||
// or computes the Boolean OR of its arguments, returning
|
||||
// the first true argument it encounters, or the last argument.
|
||||
func or(arg0 reflect.Value, args ...reflect.Value) reflect.Value {
|
||||
panic("unreachable") // implemented as a special case in evalCall
|
||||
}
|
||||
|
||||
// not returns the Boolean negation of its argument.
|
||||
func not(arg reflect.Value) bool {
|
||||
return !truth(arg)
|
||||
}
|
||||
|
||||
// Comparison.
|
||||
|
||||
// TODO: Perhaps allow comparison between signed and unsigned integers.
|
||||
|
||||
var (
|
||||
errBadComparisonType = errors.New("invalid type for comparison")
|
||||
errNoComparison = errors.New("missing argument for comparison")
|
||||
)
|
||||
|
||||
type kind int
|
||||
|
||||
const (
|
||||
invalidKind kind = iota
|
||||
boolKind
|
||||
complexKind
|
||||
intKind
|
||||
floatKind
|
||||
stringKind
|
||||
uintKind
|
||||
)
|
||||
|
||||
func basicKind(v reflect.Value) (kind, error) {
|
||||
switch v.Kind() {
|
||||
case reflect.Bool:
|
||||
return boolKind, nil
|
||||
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
|
||||
return intKind, nil
|
||||
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr:
|
||||
return uintKind, nil
|
||||
case reflect.Float32, reflect.Float64:
|
||||
return floatKind, nil
|
||||
case reflect.Complex64, reflect.Complex128:
|
||||
return complexKind, nil
|
||||
case reflect.String:
|
||||
return stringKind, nil
|
||||
}
|
||||
return invalidKind, errBadComparisonType
|
||||
}
|
||||
|
||||
// isNil returns true if v is the zero reflect.Value, or nil of its type.
|
||||
func isNil(v reflect.Value) bool {
|
||||
if !v.IsValid() {
|
||||
return true
|
||||
}
|
||||
switch v.Kind() {
|
||||
case reflect.Chan, reflect.Func, reflect.Interface, reflect.Map, reflect.Pointer, reflect.Slice:
|
||||
return v.IsNil()
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// canCompare reports whether v1 and v2 are both the same kind, or one is nil.
|
||||
// Called only when dealing with nillable types, or there's about to be an error.
|
||||
func canCompare(v1, v2 reflect.Value) bool {
|
||||
k1 := v1.Kind()
|
||||
k2 := v2.Kind()
|
||||
if k1 == k2 {
|
||||
return true
|
||||
}
|
||||
// We know the type can be compared to nil.
|
||||
return k1 == reflect.Invalid || k2 == reflect.Invalid
|
||||
}
|
||||
|
||||
// eq evaluates the comparison a == b || a == c || ...
|
||||
func eq(arg1 reflect.Value, arg2 ...reflect.Value) (bool, error) {
|
||||
arg1 = indirectInterface(arg1)
|
||||
if len(arg2) == 0 {
|
||||
return false, errNoComparison
|
||||
}
|
||||
k1, _ := basicKind(arg1)
|
||||
for _, arg := range arg2 {
|
||||
arg = indirectInterface(arg)
|
||||
k2, _ := basicKind(arg)
|
||||
truth := false
|
||||
if k1 != k2 {
|
||||
// Special case: Can compare integer values regardless of type's sign.
|
||||
switch {
|
||||
case k1 == intKind && k2 == uintKind:
|
||||
truth = arg1.Int() >= 0 && uint64(arg1.Int()) == arg.Uint()
|
||||
case k1 == uintKind && k2 == intKind:
|
||||
truth = arg.Int() >= 0 && arg1.Uint() == uint64(arg.Int())
|
||||
default:
|
||||
if arg1.IsValid() && arg.IsValid() {
|
||||
return false, fmt.Errorf("incompatible types for comparison: %v and %v", arg1.Type(), arg.Type())
|
||||
}
|
||||
}
|
||||
} else {
|
||||
switch k1 {
|
||||
case boolKind:
|
||||
truth = arg1.Bool() == arg.Bool()
|
||||
case complexKind:
|
||||
truth = arg1.Complex() == arg.Complex()
|
||||
case floatKind:
|
||||
truth = arg1.Float() == arg.Float()
|
||||
case intKind:
|
||||
truth = arg1.Int() == arg.Int()
|
||||
case stringKind:
|
||||
truth = arg1.String() == arg.String()
|
||||
case uintKind:
|
||||
truth = arg1.Uint() == arg.Uint()
|
||||
default:
|
||||
if !canCompare(arg1, arg) {
|
||||
return false, fmt.Errorf("non-comparable types %s: %v, %s: %v", arg1, arg1.Type(), arg.Type(), arg)
|
||||
}
|
||||
if isNil(arg1) || isNil(arg) {
|
||||
truth = isNil(arg) == isNil(arg1)
|
||||
} else {
|
||||
if !arg.Type().Comparable() {
|
||||
return false, fmt.Errorf("non-comparable type %s: %v", arg, arg.Type())
|
||||
}
|
||||
truth = arg1.Interface() == arg.Interface()
|
||||
}
|
||||
}
|
||||
}
|
||||
if truth {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// ne evaluates the comparison a != b.
|
||||
func ne(arg1, arg2 reflect.Value) (bool, error) {
|
||||
// != is the inverse of ==.
|
||||
equal, err := eq(arg1, arg2)
|
||||
return !equal, err
|
||||
}
|
||||
|
||||
// lt evaluates the comparison a < b.
|
||||
func lt(arg1, arg2 reflect.Value) (bool, error) {
|
||||
arg1 = indirectInterface(arg1)
|
||||
k1, err := basicKind(arg1)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
arg2 = indirectInterface(arg2)
|
||||
k2, err := basicKind(arg2)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
truth := false
|
||||
if k1 != k2 {
|
||||
// Special case: Can compare integer values regardless of type's sign.
|
||||
switch {
|
||||
case k1 == intKind && k2 == uintKind:
|
||||
truth = arg1.Int() < 0 || uint64(arg1.Int()) < arg2.Uint()
|
||||
case k1 == uintKind && k2 == intKind:
|
||||
truth = arg2.Int() >= 0 && arg1.Uint() < uint64(arg2.Int())
|
||||
default:
|
||||
return false, fmt.Errorf("incompatible types for comparison: %v and %v", arg1.Type(), arg2.Type())
|
||||
}
|
||||
} else {
|
||||
switch k1 {
|
||||
case boolKind, complexKind:
|
||||
return false, errBadComparisonType
|
||||
case floatKind:
|
||||
truth = arg1.Float() < arg2.Float()
|
||||
case intKind:
|
||||
truth = arg1.Int() < arg2.Int()
|
||||
case stringKind:
|
||||
truth = arg1.String() < arg2.String()
|
||||
case uintKind:
|
||||
truth = arg1.Uint() < arg2.Uint()
|
||||
default:
|
||||
panic("invalid kind")
|
||||
}
|
||||
}
|
||||
return truth, nil
|
||||
}
|
||||
|
||||
// le evaluates the comparison <= b.
|
||||
func le(arg1, arg2 reflect.Value) (bool, error) {
|
||||
// <= is < or ==.
|
||||
lessThan, err := lt(arg1, arg2)
|
||||
if lessThan || err != nil {
|
||||
return lessThan, err
|
||||
}
|
||||
return eq(arg1, arg2)
|
||||
}
|
||||
|
||||
// gt evaluates the comparison a > b.
|
||||
func gt(arg1, arg2 reflect.Value) (bool, error) {
|
||||
// > is the inverse of <=.
|
||||
lessOrEqual, err := le(arg1, arg2)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return !lessOrEqual, nil
|
||||
}
|
||||
|
||||
// ge evaluates the comparison a >= b.
|
||||
func ge(arg1, arg2 reflect.Value) (bool, error) {
|
||||
// >= is the inverse of <.
|
||||
lessThan, err := lt(arg1, arg2)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return !lessThan, nil
|
||||
}
|
||||
|
||||
// HTML escaping.
|
||||
|
||||
var (
|
||||
htmlQuot = []byte(""") // shorter than """
|
||||
htmlApos = []byte("'") // shorter than "'" and apos was not in HTML until HTML5
|
||||
htmlAmp = []byte("&")
|
||||
htmlLt = []byte("<")
|
||||
htmlGt = []byte(">")
|
||||
htmlNull = []byte("\uFFFD")
|
||||
)
|
||||
|
||||
// HTMLEscape writes to w the escaped HTML equivalent of the plain text data b.
|
||||
func HTMLEscape(w io.Writer, b []byte) {
|
||||
last := 0
|
||||
for i, c := range b {
|
||||
var html []byte
|
||||
switch c {
|
||||
case '\000':
|
||||
html = htmlNull
|
||||
case '"':
|
||||
html = htmlQuot
|
||||
case '\'':
|
||||
html = htmlApos
|
||||
case '&':
|
||||
html = htmlAmp
|
||||
case '<':
|
||||
html = htmlLt
|
||||
case '>':
|
||||
html = htmlGt
|
||||
default:
|
||||
continue
|
||||
}
|
||||
w.Write(b[last:i])
|
||||
w.Write(html)
|
||||
last = i + 1
|
||||
}
|
||||
w.Write(b[last:])
|
||||
}
|
||||
|
||||
// HTMLEscapeString returns the escaped HTML equivalent of the plain text data s.
|
||||
func HTMLEscapeString(s string) string {
|
||||
// Avoid allocation if we can.
|
||||
if !strings.ContainsAny(s, "'\"&<>\000") {
|
||||
return s
|
||||
}
|
||||
var b strings.Builder
|
||||
HTMLEscape(&b, []byte(s))
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// HTMLEscaper returns the escaped HTML equivalent of the textual
|
||||
// representation of its arguments.
|
||||
func HTMLEscaper(args ...any) string {
|
||||
return HTMLEscapeString(evalArgs(args))
|
||||
}
|
||||
|
||||
// JavaScript escaping.
|
||||
|
||||
var (
|
||||
jsLowUni = []byte(`\u00`)
|
||||
hex = []byte("0123456789ABCDEF")
|
||||
|
||||
jsBackslash = []byte(`\\`)
|
||||
jsApos = []byte(`\'`)
|
||||
jsQuot = []byte(`\"`)
|
||||
jsLt = []byte(`\u003C`)
|
||||
jsGt = []byte(`\u003E`)
|
||||
jsAmp = []byte(`\u0026`)
|
||||
jsEq = []byte(`\u003D`)
|
||||
)
|
||||
|
||||
// JSEscape writes to w the escaped JavaScript equivalent of the plain text data b.
|
||||
func JSEscape(w io.Writer, b []byte) {
|
||||
last := 0
|
||||
for i := 0; i < len(b); i++ {
|
||||
c := b[i]
|
||||
|
||||
if !jsIsSpecial(rune(c)) {
|
||||
// fast path: nothing to do
|
||||
continue
|
||||
}
|
||||
w.Write(b[last:i])
|
||||
|
||||
if c < utf8.RuneSelf {
|
||||
// Quotes, slashes and angle brackets get quoted.
|
||||
// Control characters get written as \u00XX.
|
||||
switch c {
|
||||
case '\\':
|
||||
w.Write(jsBackslash)
|
||||
case '\'':
|
||||
w.Write(jsApos)
|
||||
case '"':
|
||||
w.Write(jsQuot)
|
||||
case '<':
|
||||
w.Write(jsLt)
|
||||
case '>':
|
||||
w.Write(jsGt)
|
||||
case '&':
|
||||
w.Write(jsAmp)
|
||||
case '=':
|
||||
w.Write(jsEq)
|
||||
default:
|
||||
w.Write(jsLowUni)
|
||||
t, b := c>>4, c&0x0f
|
||||
w.Write(hex[t : t+1])
|
||||
w.Write(hex[b : b+1])
|
||||
}
|
||||
} else {
|
||||
// Unicode rune.
|
||||
r, size := utf8.DecodeRune(b[i:])
|
||||
if unicode.IsPrint(r) {
|
||||
w.Write(b[i : i+size])
|
||||
} else {
|
||||
fmt.Fprintf(w, "\\u%04X", r)
|
||||
}
|
||||
i += size - 1
|
||||
}
|
||||
last = i + 1
|
||||
}
|
||||
w.Write(b[last:])
|
||||
}
|
||||
|
||||
// JSEscapeString returns the escaped JavaScript equivalent of the plain text data s.
|
||||
func JSEscapeString(s string) string {
|
||||
// Avoid allocation if we can.
|
||||
if strings.IndexFunc(s, jsIsSpecial) < 0 {
|
||||
return s
|
||||
}
|
||||
var b strings.Builder
|
||||
JSEscape(&b, []byte(s))
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func jsIsSpecial(r rune) bool {
|
||||
switch r {
|
||||
case '\\', '\'', '"', '<', '>', '&', '=':
|
||||
return true
|
||||
}
|
||||
return r < ' ' || utf8.RuneSelf <= r
|
||||
}
|
||||
|
||||
// JSEscaper returns the escaped JavaScript equivalent of the textual
|
||||
// representation of its arguments.
|
||||
func JSEscaper(args ...any) string {
|
||||
return JSEscapeString(evalArgs(args))
|
||||
}
|
||||
|
||||
// URLQueryEscaper returns the escaped value of the textual representation of
|
||||
// its arguments in a form suitable for embedding in a URL query.
|
||||
func URLQueryEscaper(args ...any) string {
|
||||
return url.QueryEscape(evalArgs(args))
|
||||
}
|
||||
|
||||
// evalArgs formats the list of arguments into a string. It is therefore equivalent to
|
||||
//
|
||||
// fmt.Sprint(args...)
|
||||
//
|
||||
// except that each argument is indirected (if a pointer), as required,
|
||||
// using the same rules as the default string evaluation during template
|
||||
// execution.
|
||||
func evalArgs(args []any) string {
|
||||
ok := false
|
||||
var s string
|
||||
// Fast path for simple common case.
|
||||
if len(args) == 1 {
|
||||
s, ok = args[0].(string)
|
||||
}
|
||||
if !ok {
|
||||
for i, arg := range args {
|
||||
a, ok := printableValue(reflect.ValueOf(arg))
|
||||
if ok {
|
||||
args[i] = a
|
||||
} // else let fmt do its thing
|
||||
}
|
||||
s = fmt.Sprint(args...)
|
||||
}
|
||||
return s
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
// Copyright 2011 The Go Authors. All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
// Helper functions to make constructing templates easier.
|
||||
|
||||
package gotext
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// Functions and methods to parse templates.
|
||||
|
||||
// Must is a helper that wraps a call to a function returning ([*Template], error)
|
||||
// and panics if the error is non-nil. It is intended for use in variable
|
||||
// initializations such as
|
||||
//
|
||||
// var t = template.Must(template.New("name").Parse("text"))
|
||||
func Must(t *Template, err error) *Template {
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
// ParseFiles creates a new [Template] and parses the template definitions from
|
||||
// the named files. The returned template's name will have the base name and
|
||||
// parsed contents of the first file. There must be at least one file.
|
||||
// If an error occurs, parsing stops and the returned *Template is nil.
|
||||
//
|
||||
// When parsing multiple files with the same name in different directories,
|
||||
// the last one mentioned will be the one that results.
|
||||
// For instance, ParseFiles("a/foo", "b/foo") stores "b/foo" as the template
|
||||
// named "foo", while "a/foo" is unavailable.
|
||||
func ParseFiles(filenames ...string) (*Template, error) {
|
||||
return parseFiles(nil, readFileOS, filenames...)
|
||||
}
|
||||
|
||||
// ParseFiles parses the named files and associates the resulting templates with
|
||||
// t. If an error occurs, parsing stops and the returned template is nil;
|
||||
// otherwise it is t. There must be at least one file.
|
||||
// Since the templates created by ParseFiles are named by the base
|
||||
// (see [filepath.Base]) names of the argument files, t should usually have the
|
||||
// name of one of the (base) names of the files. If it does not, depending on
|
||||
// t's contents before calling ParseFiles, t.Execute may fail. In that
|
||||
// case use t.ExecuteTemplate to execute a valid template.
|
||||
//
|
||||
// When parsing multiple files with the same name in different directories,
|
||||
// the last one mentioned will be the one that results.
|
||||
func (t *Template) ParseFiles(filenames ...string) (*Template, error) {
|
||||
t.init()
|
||||
return parseFiles(t, readFileOS, filenames...)
|
||||
}
|
||||
|
||||
// parseFiles is the helper for the method and function. If the argument
|
||||
// template is nil, it is created from the first file.
|
||||
func parseFiles(t *Template, readFile func(string) (string, []byte, error), filenames ...string) (*Template, error) {
|
||||
if len(filenames) == 0 {
|
||||
// Not really a problem, but be consistent.
|
||||
return nil, fmt.Errorf("template: no files named in call to ParseFiles")
|
||||
}
|
||||
for _, filename := range filenames {
|
||||
name, b, err := readFile(filename)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s := string(b)
|
||||
// First template becomes return value if not already defined,
|
||||
// and we use that one for subsequent New calls to associate
|
||||
// all the templates together. Also, if this file has the same name
|
||||
// as t, this file becomes the contents of t, so
|
||||
// t, err := New(name).Funcs(xxx).ParseFiles(name)
|
||||
// works. Otherwise we create a new template associated with t.
|
||||
var tmpl *Template
|
||||
if t == nil {
|
||||
t = New(name)
|
||||
}
|
||||
if name == t.Name() {
|
||||
tmpl = t
|
||||
} else {
|
||||
tmpl = t.New(name)
|
||||
}
|
||||
_, err = tmpl.Parse(s)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
// ParseGlob creates a new [Template] and parses the template definitions from
|
||||
// the files identified by the pattern. The files are matched according to the
|
||||
// semantics of [filepath.Match], and the pattern must match at least one file.
|
||||
// The returned template will have the [filepath.Base] name and (parsed)
|
||||
// contents of the first file matched by the pattern. ParseGlob is equivalent to
|
||||
// calling [ParseFiles] with the list of files matched by the pattern.
|
||||
//
|
||||
// When parsing multiple files with the same name in different directories,
|
||||
// the last one mentioned will be the one that results.
|
||||
func ParseGlob(pattern string) (*Template, error) {
|
||||
return parseGlob(nil, pattern)
|
||||
}
|
||||
|
||||
// ParseGlob parses the template definitions in the files identified by the
|
||||
// pattern and associates the resulting templates with t. The files are matched
|
||||
// according to the semantics of [filepath.Match], and the pattern must match at
|
||||
// least one file. ParseGlob is equivalent to calling [Template.ParseFiles] with
|
||||
// the list of files matched by the pattern.
|
||||
//
|
||||
// When parsing multiple files with the same name in different directories,
|
||||
// the last one mentioned will be the one that results.
|
||||
func (t *Template) ParseGlob(pattern string) (*Template, error) {
|
||||
t.init()
|
||||
return parseGlob(t, pattern)
|
||||
}
|
||||
|
||||
// parseGlob is the implementation of the function and method ParseGlob.
|
||||
func parseGlob(t *Template, pattern string) (*Template, error) {
|
||||
filenames, err := filepath.Glob(pattern)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(filenames) == 0 {
|
||||
return nil, fmt.Errorf("template: pattern matches no files: %#q", pattern)
|
||||
}
|
||||
return parseFiles(t, readFileOS, filenames...)
|
||||
}
|
||||
|
||||
// ParseFS is like [Template.ParseFiles] or [Template.ParseGlob] but reads from the file system fsys
|
||||
// instead of the host operating system's file system.
|
||||
// It accepts a list of glob patterns (see [path.Match]).
|
||||
// (Note that most file names serve as glob patterns matching only themselves.)
|
||||
func ParseFS(fsys fs.FS, patterns ...string) (*Template, error) {
|
||||
return parseFS(nil, fsys, patterns)
|
||||
}
|
||||
|
||||
// ParseFS is like [Template.ParseFiles] or [Template.ParseGlob] but reads from the file system fsys
|
||||
// instead of the host operating system's file system.
|
||||
// It accepts a list of glob patterns (see [path.Match]).
|
||||
// (Note that most file names serve as glob patterns matching only themselves.)
|
||||
func (t *Template) ParseFS(fsys fs.FS, patterns ...string) (*Template, error) {
|
||||
t.init()
|
||||
return parseFS(t, fsys, patterns)
|
||||
}
|
||||
|
||||
func parseFS(t *Template, fsys fs.FS, patterns []string) (*Template, error) {
|
||||
var filenames []string
|
||||
for _, pattern := range patterns {
|
||||
list, err := fs.Glob(fsys, pattern)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(list) == 0 {
|
||||
return nil, fmt.Errorf("template: pattern matches no files: %#q", pattern)
|
||||
}
|
||||
filenames = append(filenames, list...)
|
||||
}
|
||||
return parseFiles(t, readFileFS(fsys), filenames...)
|
||||
}
|
||||
|
||||
func readFileOS(file string) (name string, b []byte, err error) {
|
||||
name = filepath.Base(file)
|
||||
b, err = os.ReadFile(file)
|
||||
return
|
||||
}
|
||||
|
||||
func readFileFS(fsys fs.FS) func(string) (string, []byte, error) {
|
||||
return func(file string) (name string, b []byte, err error) {
|
||||
name = path.Base(file)
|
||||
b, err = fs.ReadFile(fsys, file)
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
// Copyright 2015 The Go Authors. All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
// This file contains the code to handle template options.
|
||||
|
||||
package gotext
|
||||
|
||||
import "strings"
|
||||
|
||||
// missingKeyAction defines how to respond to indexing a map with a key that is not present.
|
||||
type missingKeyAction int
|
||||
|
||||
const (
|
||||
mapInvalid missingKeyAction = iota // Return an invalid reflect.Value.
|
||||
mapZeroValue // Return the zero value for the map element.
|
||||
mapError // Error out
|
||||
)
|
||||
|
||||
type option struct {
|
||||
missingKey missingKeyAction
|
||||
}
|
||||
|
||||
// Option sets options for the template. Options are described by
|
||||
// strings, either a simple string or "key=value". There can be at
|
||||
// most one equals sign in an option string. If the option string
|
||||
// is unrecognized or otherwise invalid, Option panics.
|
||||
//
|
||||
// Known options:
|
||||
//
|
||||
// missingkey: Control the behavior during execution if a map is
|
||||
// indexed with a key that is not present in the map.
|
||||
//
|
||||
// "missingkey=default" or "missingkey=invalid"
|
||||
// The default behavior: Do nothing and continue execution.
|
||||
// If printed, the result of the index operation is the string
|
||||
// "<no value>".
|
||||
// "missingkey=zero"
|
||||
// The operation returns the zero value for the map type's element.
|
||||
// "missingkey=error"
|
||||
// Execution stops immediately with an error.
|
||||
func (t *Template) Option(opt ...string) *Template {
|
||||
t.init()
|
||||
for _, s := range opt {
|
||||
t.setOption(s)
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
func (t *Template) setOption(opt string) {
|
||||
if opt == "" {
|
||||
panic("empty option string")
|
||||
}
|
||||
// key=value
|
||||
if key, value, ok := strings.Cut(opt, "="); ok {
|
||||
switch key {
|
||||
case "missingkey":
|
||||
switch value {
|
||||
case "invalid", "default":
|
||||
t.option.missingKey = mapInvalid
|
||||
return
|
||||
case "zero":
|
||||
t.option.missingKey = mapZeroValue
|
||||
return
|
||||
case "error":
|
||||
t.option.missingKey = mapError
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
panic("unrecognized option: " + opt)
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
--- a/exec.go 2026-07-10 01:31:35.188129862 +0200
|
||||
+++ b/exec.go 2026-07-10 01:31:35.189129894 +0200
|
||||
@@ -5,14 +5,17 @@
|
||||
package gotext
|
||||
|
||||
import (
|
||||
+ "context"
|
||||
"errors"
|
||||
"fmt"
|
||||
- "heckel.io/ntfy/v2/template/gotext/fmtsort"
|
||||
"io"
|
||||
"reflect"
|
||||
"runtime"
|
||||
"strings"
|
||||
+ "sync/atomic"
|
||||
"text/template/parse"
|
||||
+
|
||||
+ "heckel.io/ntfy/v2/template/gotext/fmtsort"
|
||||
)
|
||||
|
||||
// maxExecDepth specifies the maximum stack depth of templates within
|
||||
@@ -32,11 +35,13 @@
|
||||
// template so that multiple executions of the same template
|
||||
// can execute in parallel.
|
||||
type state struct {
|
||||
- tmpl *Template
|
||||
- wr io.Writer
|
||||
- node parse.Node // current node, for errors
|
||||
- vars []variable // push-down stack of variable values.
|
||||
- depth int // the height of the stack of executing templates.
|
||||
+ tmpl *Template
|
||||
+ ctx context.Context // ctx-ex: execution context; Execute uses context.Background.
|
||||
+ wr io.Writer
|
||||
+ node parse.Node // current node, for errors
|
||||
+ vars []variable // push-down stack of variable values.
|
||||
+ depth int // the height of the stack of executing templates.
|
||||
+ cancelled *atomic.Bool // ctx-ex: shared flag set by the context.AfterFunc watcher; nil if ctx cannot be canceled
|
||||
}
|
||||
|
||||
// variable holds the dynamic value of a variable such as $, $x etc.
|
||||
@@ -160,6 +165,14 @@
|
||||
})
|
||||
}
|
||||
|
||||
+// cancelError is the wrapper type used internally when execution is aborted
|
||||
+// because the context is done. Like writeError, it is stripped in errRecover
|
||||
+// so the caller receives the original ctx.Err(). It is not an implementation
|
||||
+// of error, so it cannot escape from the package as an error value.
|
||||
+type cancelError struct {
|
||||
+ Err error // Original context error.
|
||||
+}
|
||||
+
|
||||
// errRecover is the handler that turns panics into returns from the top
|
||||
// level of Parse.
|
||||
func errRecover(errp *error) {
|
||||
@@ -170,6 +183,8 @@
|
||||
panic(e)
|
||||
case writeError:
|
||||
*errp = err.Err // Strip the wrapper.
|
||||
+ case cancelError:
|
||||
+ *errp = err.Err // Strip the wrapper; return the context error.
|
||||
case ExecError:
|
||||
*errp = err // Keep the wrapper.
|
||||
default:
|
||||
@@ -186,11 +201,19 @@
|
||||
// A template may be executed safely in parallel, although if parallel
|
||||
// executions share a Writer the output may be interleaved.
|
||||
func (t *Template) ExecuteTemplate(wr io.Writer, name string, data any) error {
|
||||
+ return t.ExecuteTemplateContext(context.Background(), wr, name, data)
|
||||
+}
|
||||
+
|
||||
+// ExecuteTemplateContext is like [Template.ExecuteTemplate], but aborts and
|
||||
+// returns ctx.Err() if ctx is canceled or its deadline is exceeded before
|
||||
+// execution completes. See [Template.ExecuteContext] for the cancellation
|
||||
+// semantics.
|
||||
+func (t *Template) ExecuteTemplateContext(ctx context.Context, wr io.Writer, name string, data any) error {
|
||||
tmpl := t.Lookup(name)
|
||||
if tmpl == nil {
|
||||
return fmt.Errorf("template: no template %q associated with template %q", name, t.name)
|
||||
}
|
||||
- return tmpl.Execute(wr, data)
|
||||
+ return tmpl.ExecuteContext(ctx, wr, data)
|
||||
}
|
||||
|
||||
// Execute applies a parsed template to the specified data object,
|
||||
@@ -204,10 +227,27 @@
|
||||
// If data is a [reflect.Value], the template applies to the concrete
|
||||
// value that the reflect.Value holds, as in [fmt.Print].
|
||||
func (t *Template) Execute(wr io.Writer, data any) error {
|
||||
- return t.execute(wr, data)
|
||||
+ return t.executeContext(context.Background(), wr, data)
|
||||
}
|
||||
|
||||
-func (t *Template) execute(wr io.Writer, data any) (err error) {
|
||||
+// ExecuteContext is like [Template.Execute], but aborts and returns ctx.Err()
|
||||
+// (either [context.Canceled] or [context.DeadlineExceeded], retrievable with
|
||||
+// [errors.Is]) if ctx is canceled or its deadline is exceeded before execution
|
||||
+// completes.
|
||||
+//
|
||||
+// Cancellation is observed between node evaluations as the template is walked,
|
||||
+// so long-running renders -- including tight or nested {{range}} loops that
|
||||
+// write no output -- are aborted promptly. A template blocked inside a single
|
||||
+// function call is not interrupted until that call returns. Partial results may
|
||||
+// already have been written to wr.
|
||||
+func (t *Template) ExecuteContext(ctx context.Context, wr io.Writer, data any) error {
|
||||
+ if err := ctx.Err(); err != nil {
|
||||
+ return err
|
||||
+ }
|
||||
+ return t.executeContext(ctx, wr, data)
|
||||
+}
|
||||
+
|
||||
+func (t *Template) executeContext(ctx context.Context, wr io.Writer, data any) (err error) {
|
||||
defer errRecover(&err)
|
||||
value, ok := data.(reflect.Value)
|
||||
if !ok {
|
||||
@@ -215,9 +255,20 @@
|
||||
}
|
||||
state := &state{
|
||||
tmpl: t,
|
||||
+ ctx: ctx,
|
||||
wr: wr,
|
||||
vars: []variable{{"$", value}},
|
||||
}
|
||||
+ // If the context can be canceled, watch it with a single context.AfterFunc
|
||||
+ // callback that flips an atomic flag; walk polls that flag per node (a cheap
|
||||
+ // monomorphic atomic load) instead of calling ctx.Err() every node.
|
||||
+ // Contexts that can never be canceled (Background, TODO) have a nil Done
|
||||
+ // channel, so the default Execute path installs nothing and pays nothing.
|
||||
+ if ctx.Done() != nil {
|
||||
+ state.cancelled = new(atomic.Bool)
|
||||
+ stop := context.AfterFunc(ctx, func() { state.cancelled.Store(true) })
|
||||
+ defer stop()
|
||||
+ }
|
||||
if t.Tree == nil || t.Root == nil {
|
||||
state.errorf("%q is an incomplete or empty template", t.Name())
|
||||
}
|
||||
@@ -260,6 +311,12 @@
|
||||
// generating output as they go.
|
||||
func (s *state) walk(dot reflect.Value, node parse.Node) {
|
||||
s.at(node)
|
||||
+ // Abort if the context has been canceled or its deadline has passed. The
|
||||
+ // flag is set by the watcher installed in executeContext; observing it here
|
||||
+ // interrupts any template shape, including loops that write no output.
|
||||
+ if s.cancelled != nil && s.cancelled.Load() {
|
||||
+ panic(cancelError{s.ctx.Err()})
|
||||
+ }
|
||||
switch node := node.(type) {
|
||||
case *parse.ActionNode:
|
||||
// Do not pop variables so they persist until next end.
|
||||
@@ -0,0 +1,238 @@
|
||||
// Copyright 2011 The Go Authors. All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package gotext
|
||||
|
||||
import (
|
||||
"maps"
|
||||
"reflect"
|
||||
"sync"
|
||||
"text/template/parse"
|
||||
)
|
||||
|
||||
// common holds the information shared by related templates.
|
||||
type common struct {
|
||||
tmpl map[string]*Template // Map from name to defined templates.
|
||||
muTmpl sync.RWMutex // protects tmpl
|
||||
option option
|
||||
// We use two maps, one for parsing and one for execution.
|
||||
// This separation makes the API cleaner since it doesn't
|
||||
// expose reflection to the client.
|
||||
muFuncs sync.RWMutex // protects parseFuncs and execFuncs
|
||||
parseFuncs FuncMap
|
||||
execFuncs map[string]reflect.Value
|
||||
}
|
||||
|
||||
// Template is the representation of a parsed template. The *parse.Tree
|
||||
// field is exported only for use by [html/template] and should be treated
|
||||
// as unexported by all other clients.
|
||||
type Template struct {
|
||||
name string
|
||||
*parse.Tree
|
||||
*common
|
||||
leftDelim string
|
||||
rightDelim string
|
||||
}
|
||||
|
||||
// New allocates a new, undefined template with the given name.
|
||||
func New(name string) *Template {
|
||||
t := &Template{
|
||||
name: name,
|
||||
}
|
||||
t.init()
|
||||
return t
|
||||
}
|
||||
|
||||
// Name returns the name of the template.
|
||||
func (t *Template) Name() string {
|
||||
return t.name
|
||||
}
|
||||
|
||||
// New allocates a new, undefined template associated with the given one and with the same
|
||||
// delimiters. The association, which is transitive, allows one template to
|
||||
// invoke another with a {{template}} action.
|
||||
//
|
||||
// Because associated templates share underlying data, template construction
|
||||
// cannot be done safely in parallel. Once the templates are constructed, they
|
||||
// can be executed in parallel.
|
||||
func (t *Template) New(name string) *Template {
|
||||
t.init()
|
||||
nt := &Template{
|
||||
name: name,
|
||||
common: t.common,
|
||||
leftDelim: t.leftDelim,
|
||||
rightDelim: t.rightDelim,
|
||||
}
|
||||
return nt
|
||||
}
|
||||
|
||||
// init guarantees that t has a valid common structure.
|
||||
func (t *Template) init() {
|
||||
if t.common == nil {
|
||||
c := new(common)
|
||||
c.tmpl = make(map[string]*Template)
|
||||
c.parseFuncs = make(FuncMap)
|
||||
c.execFuncs = make(map[string]reflect.Value)
|
||||
t.common = c
|
||||
}
|
||||
}
|
||||
|
||||
// Clone returns a duplicate of the template, including all associated
|
||||
// templates. The actual representation is not copied, but the name space of
|
||||
// associated templates is, so further calls to [Template.Parse] in the copy will add
|
||||
// templates to the copy but not to the original. Clone can be used to prepare
|
||||
// common templates and use them with variant definitions for other templates
|
||||
// by adding the variants after the clone is made.
|
||||
func (t *Template) Clone() (*Template, error) {
|
||||
nt := t.copy(nil)
|
||||
nt.init()
|
||||
if t.common == nil {
|
||||
return nt, nil
|
||||
}
|
||||
nt.option = t.option
|
||||
t.muTmpl.RLock()
|
||||
defer t.muTmpl.RUnlock()
|
||||
for k, v := range t.tmpl {
|
||||
if k == t.name {
|
||||
nt.tmpl[t.name] = nt
|
||||
continue
|
||||
}
|
||||
// The associated templates share nt's common structure.
|
||||
tmpl := v.copy(nt.common)
|
||||
nt.tmpl[k] = tmpl
|
||||
}
|
||||
t.muFuncs.RLock()
|
||||
defer t.muFuncs.RUnlock()
|
||||
maps.Copy(nt.parseFuncs, t.parseFuncs)
|
||||
maps.Copy(nt.execFuncs, t.execFuncs)
|
||||
return nt, nil
|
||||
}
|
||||
|
||||
// copy returns a shallow copy of t, with common set to the argument.
|
||||
func (t *Template) copy(c *common) *Template {
|
||||
return &Template{
|
||||
name: t.name,
|
||||
Tree: t.Tree,
|
||||
common: c,
|
||||
leftDelim: t.leftDelim,
|
||||
rightDelim: t.rightDelim,
|
||||
}
|
||||
}
|
||||
|
||||
// AddParseTree associates the argument parse tree with the template t, giving
|
||||
// it the specified name. If the template has not been defined, this tree becomes
|
||||
// its definition. If it has been defined and already has that name, the existing
|
||||
// definition is replaced; otherwise a new template is created, defined, and returned.
|
||||
func (t *Template) AddParseTree(name string, tree *parse.Tree) (*Template, error) {
|
||||
t.init()
|
||||
t.muTmpl.Lock()
|
||||
defer t.muTmpl.Unlock()
|
||||
nt := t
|
||||
if name != t.name {
|
||||
nt = t.New(name)
|
||||
}
|
||||
// Even if nt == t, we need to install it in the common.tmpl map.
|
||||
if t.associate(nt, tree) || nt.Tree == nil {
|
||||
nt.Tree = tree
|
||||
}
|
||||
return nt, nil
|
||||
}
|
||||
|
||||
// Templates returns a slice of defined templates associated with t.
|
||||
func (t *Template) Templates() []*Template {
|
||||
if t.common == nil {
|
||||
return nil
|
||||
}
|
||||
// Return a slice so we don't expose the map.
|
||||
t.muTmpl.RLock()
|
||||
defer t.muTmpl.RUnlock()
|
||||
m := make([]*Template, 0, len(t.tmpl))
|
||||
for _, v := range t.tmpl {
|
||||
m = append(m, v)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// Delims sets the action delimiters to the specified strings, to be used in
|
||||
// subsequent calls to [Template.Parse], [Template.ParseFiles], or [Template.ParseGlob]. Nested template
|
||||
// definitions will inherit the settings. An empty delimiter stands for the
|
||||
// corresponding default: {{ or }}.
|
||||
// The return value is the template, so calls can be chained.
|
||||
func (t *Template) Delims(left, right string) *Template {
|
||||
t.init()
|
||||
t.leftDelim = left
|
||||
t.rightDelim = right
|
||||
return t
|
||||
}
|
||||
|
||||
// Funcs adds the elements of the argument map to the template's function map.
|
||||
// Any function used in the template must be added before the template is
|
||||
// parsed. Funcs may be called more than once, including after parsing (for
|
||||
// example, after [Template.Clone]), to replace a function of the same name;
|
||||
// the replacement is used when the template is executed.
|
||||
// It panics if a value in the map is not a function with appropriate return
|
||||
// type or if the name cannot be used syntactically as a function in a template.
|
||||
// The return value is the template, so calls can be chained.
|
||||
func (t *Template) Funcs(funcMap FuncMap) *Template {
|
||||
t.init()
|
||||
t.muFuncs.Lock()
|
||||
defer t.muFuncs.Unlock()
|
||||
addValueFuncs(t.execFuncs, funcMap)
|
||||
addFuncs(t.parseFuncs, funcMap)
|
||||
return t
|
||||
}
|
||||
|
||||
// Lookup returns the template with the given name that is associated with t.
|
||||
// It returns nil if there is no such template or the template has no definition.
|
||||
func (t *Template) Lookup(name string) *Template {
|
||||
if t.common == nil {
|
||||
return nil
|
||||
}
|
||||
t.muTmpl.RLock()
|
||||
defer t.muTmpl.RUnlock()
|
||||
return t.tmpl[name]
|
||||
}
|
||||
|
||||
// Parse parses text as a template body for t.
|
||||
// Named template definitions ({{define ...}} or {{block ...}} statements) in text
|
||||
// define additional templates associated with t and are removed from the
|
||||
// definition of t itself.
|
||||
//
|
||||
// Templates can be redefined in successive calls to Parse.
|
||||
// A template definition with a body containing only white space and comments
|
||||
// is considered empty and will not replace an existing template's body.
|
||||
// This allows using Parse to add new named template definitions without
|
||||
// overwriting the main template body.
|
||||
func (t *Template) Parse(text string) (*Template, error) {
|
||||
t.init()
|
||||
t.muFuncs.RLock()
|
||||
trees, err := parse.Parse(t.name, text, t.leftDelim, t.rightDelim, t.parseFuncs, builtins())
|
||||
t.muFuncs.RUnlock()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Add the newly parsed trees, including the one for t, into our common structure.
|
||||
for name, tree := range trees {
|
||||
if _, err := t.AddParseTree(name, tree); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
// associate installs the new template into the group of templates associated
|
||||
// with t. The two are already known to share the common structure.
|
||||
// The boolean return value reports whether to store this tree as t.Tree.
|
||||
func (t *Template) associate(new *Template, tree *parse.Tree) bool {
|
||||
if new.common != t.common {
|
||||
panic("internal error: associate not common")
|
||||
}
|
||||
if old := t.tmpl[new.name]; old != nil && parse.IsEmptyTree(tree.Root) && old.Tree != nil {
|
||||
// If a template by that name exists,
|
||||
// don't replace it with an empty template.
|
||||
return false
|
||||
}
|
||||
t.tmpl[new.name] = new
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
// Package twilio talks to the Twilio API to make phone calls (for the "Call" feature) and to
|
||||
// verify phone numbers. It holds the Twilio configuration, so that this functionality is
|
||||
// decoupled from the ntfy server.
|
||||
package twilio
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"heckel.io/ntfy/v2/log"
|
||||
"heckel.io/ntfy/v2/util"
|
||||
)
|
||||
|
||||
const (
|
||||
tagTwilio = "twilio"
|
||||
)
|
||||
|
||||
// Client is the Twilio API client
|
||||
type Client struct {
|
||||
config *Config
|
||||
}
|
||||
|
||||
// NewClient creates a new Twilio Client with the given config
|
||||
func NewClient(config *Config) *Client {
|
||||
return &Client{config: config}
|
||||
}
|
||||
|
||||
// Call calls the Twilio API to make a phone call to the given phone number, using the given data
|
||||
func (c *Client) Call(to string, data *CallData) error {
|
||||
tmpl := defaultCallFormatTemplate
|
||||
if c.config.CallFormat != nil {
|
||||
tmpl = c.config.CallFormat
|
||||
}
|
||||
var bodyBuf bytes.Buffer
|
||||
if err := tmpl.Execute(&bodyBuf, data.escaped()); err != nil {
|
||||
log.Tag(tagTwilio).Err(err).Warn("Error executing Twilio call format template")
|
||||
return err
|
||||
}
|
||||
body := bodyBuf.String()
|
||||
form := url.Values{}
|
||||
form.Set("From", c.config.PhoneNumber)
|
||||
form.Set("To", to)
|
||||
form.Set("Twiml", body)
|
||||
ev := log.Tag(tagTwilio).
|
||||
Field("twilio_to", to).
|
||||
FieldIf("twilio_body", body, log.TraceLevel).
|
||||
Debug("Sending Twilio request")
|
||||
requestURL := fmt.Sprintf("%s/2010-04-01/Accounts/%s/Calls.json", c.config.CallsBaseURL, c.config.Account)
|
||||
response, code, err := c.request(requestURL, form)
|
||||
if err != nil {
|
||||
ev.Field("twilio_response", response).Err(err).Warn("Error sending Twilio request")
|
||||
return err
|
||||
} else if !success(code) {
|
||||
// Twilio rejects calls with a 4xx, e.g. for an invalid phone number, or if the account
|
||||
// is out of funds. Without this check, a rejected call would be counted as a success.
|
||||
ev.Field("twilio_status", code).Field("twilio_response", response).Warn("Twilio call failed with status code %d", code)
|
||||
return fmt.Errorf("twilio call failed with status code %d", code)
|
||||
}
|
||||
ev.FieldIf("twilio_response", response, log.TraceLevel).Debug("Received successful Twilio response")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Verify calls the Twilio Verify API to send a verification code to the given phone
|
||||
// number, via the given channel ("sms" or "call")
|
||||
func (c *Client) Verify(phoneNumber, channel string) error {
|
||||
ev := log.Tag(tagTwilio).Field("twilio_to", phoneNumber).Field("twilio_channel", channel).Debug("Sending phone verification")
|
||||
form := url.Values{}
|
||||
form.Set("To", phoneNumber)
|
||||
form.Set("Channel", channel)
|
||||
requestURL := fmt.Sprintf("%s/v2/Services/%s/Verifications", c.config.VerifyBaseURL, c.config.VerifyService)
|
||||
response, code, err := c.request(requestURL, form)
|
||||
if err != nil {
|
||||
ev.Err(err).Warn("Error sending Twilio phone verification request")
|
||||
return err
|
||||
} else if !success(code) {
|
||||
// Without this check, a rejected verification would look like a success to the caller,
|
||||
// and the user would be told to wait for an SMS that was never sent.
|
||||
ev.Field("twilio_status", code).Field("twilio_response", response).Warn("Twilio phone verification request failed with status code %d", code)
|
||||
return fmt.Errorf("twilio phone verification request failed with status code %d", code)
|
||||
}
|
||||
ev.FieldIf("twilio_response", response, log.TraceLevel).Debug("Received Twilio phone verification response")
|
||||
return nil
|
||||
}
|
||||
|
||||
// CheckVerify calls the Twilio Verify API to check the verification code for the given
|
||||
// phone number. It returns ErrVerificationExpired if the code has expired or never existed.
|
||||
func (c *Client) CheckVerify(phoneNumber, code string) error {
|
||||
ev := log.Tag(tagTwilio).Field("twilio_to", phoneNumber).Debug("Checking phone verification")
|
||||
form := url.Values{}
|
||||
form.Set("To", phoneNumber)
|
||||
form.Set("Code", code)
|
||||
requestURL := fmt.Sprintf("%s/v2/Services/%s/VerificationCheck", c.config.VerifyBaseURL, c.config.VerifyService)
|
||||
req, err := c.newRequest(requestURL, form)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
if ev.IsTrace() {
|
||||
response, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ev.Field("twilio_response", string(response))
|
||||
}
|
||||
ev.Warn("Twilio phone verification failed with status code %d", resp.StatusCode)
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
return ErrVerificationExpired
|
||||
}
|
||||
return fmt.Errorf("twilio phone verification failed with status code %d", resp.StatusCode)
|
||||
}
|
||||
response, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if ev.IsTrace() {
|
||||
ev.Field("twilio_response", string(response)).Trace("Received successful Twilio phone verification response")
|
||||
} else if ev.IsDebug() {
|
||||
ev.Debug("Received successful Twilio phone verification response")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// request POSTs the given form to the given Twilio API URL, and returns the raw response body
|
||||
// and status code. It does not treat a non-2xx status code as an error; that is up to the
|
||||
// caller. The response body is returned even if the request failed, so that it can be logged.
|
||||
func (c *Client) request(requestURL string, form url.Values) (string, int, error) {
|
||||
req, err := c.newRequest(requestURL, form)
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
response, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", resp.StatusCode, err
|
||||
}
|
||||
return string(response), resp.StatusCode, nil
|
||||
}
|
||||
|
||||
// success reports whether the given HTTP status code indicates success. Note that the Twilio
|
||||
// Calls API returns 201 Created (not 200 OK) for a successfully queued call.
|
||||
func success(code int) bool {
|
||||
return code >= 200 && code <= 299
|
||||
}
|
||||
|
||||
// newRequest creates a form-encoded POST request against the Twilio API, with the auth and
|
||||
// User-Agent headers set
|
||||
func (c *Client) newRequest(requestURL string, form url.Values) (*http.Request, error) {
|
||||
req, err := http.NewRequest(http.MethodPost, requestURL, strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("User-Agent", "ntfy/"+c.config.BuildVersion)
|
||||
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("Authorization", util.BasicAuth(c.config.Account, c.config.AuthToken))
|
||||
return req, nil
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
package twilio
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"testing"
|
||||
"text/template"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestClient_Call_Success(t *testing.T) {
|
||||
var body string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
require.Equal(t, "/2010-04-01/Accounts/AC1234567890/Calls.json", r.URL.Path)
|
||||
require.Equal(t, "Basic QUMxMjM0NTY3ODkwOkFBRUFBMTIzNDU2Nzg5MA==", r.Header.Get("Authorization"))
|
||||
require.Equal(t, "application/x-www-form-urlencoded", r.Header.Get("Content-Type"))
|
||||
require.Equal(t, "ntfy/1.2.3", r.Header.Get("User-Agent"))
|
||||
b, err := io.ReadAll(r.Body)
|
||||
require.Nil(t, err)
|
||||
body = string(b)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
c := NewClient(testConfig(server.URL))
|
||||
require.Nil(t, c.Call("+11122233344", &CallData{Topic: "mytopic", Message: "hi there", Sender: "phil"}))
|
||||
|
||||
form, err := url.ParseQuery(body)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, "+1234567890", form.Get("From"))
|
||||
require.Equal(t, "+11122233344", form.Get("To"))
|
||||
require.Contains(t, form.Get("Twiml"), "You have a message from notify on topic mytopic. Message:")
|
||||
require.Contains(t, form.Get("Twiml"), "hi there")
|
||||
require.Contains(t, form.Get("Twiml"), "This message was sent by user phil.")
|
||||
}
|
||||
|
||||
// TestClient_Call_EscapesXML ensures that user-controlled fields cannot break out of the
|
||||
// TwiML document, i.e. that a message containing XML is escaped rather than interpreted
|
||||
func TestClient_Call_EscapesXML(t *testing.T) {
|
||||
var body string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
b, err := io.ReadAll(r.Body)
|
||||
require.Nil(t, err)
|
||||
body = string(b)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
c := NewClient(testConfig(server.URL))
|
||||
data := &CallData{
|
||||
Topic: "mytopic",
|
||||
Message: `</Say><Say>evil</Say>`,
|
||||
Tags: []string{"<tag>"},
|
||||
Sender: `phil & "friends"`,
|
||||
}
|
||||
require.Nil(t, c.Call("+11122233344", data))
|
||||
|
||||
form, err := url.ParseQuery(body)
|
||||
require.Nil(t, err)
|
||||
twiml := form.Get("Twiml")
|
||||
require.NotContains(t, twiml, "<Say>evil</Say>")
|
||||
require.Contains(t, twiml, "</Say><Say>evil</Say>")
|
||||
require.Contains(t, twiml, "phil & "friends"")
|
||||
// The caller's data must not be modified by the escaping
|
||||
require.Equal(t, `</Say><Say>evil</Say>`, data.Message)
|
||||
require.Equal(t, []string{"<tag>"}, data.Tags)
|
||||
}
|
||||
|
||||
func TestClient_Call_CustomCallFormat(t *testing.T) {
|
||||
var body string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
b, err := io.ReadAll(r.Body)
|
||||
require.Nil(t, err)
|
||||
body = string(b)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
conf := testConfig(server.URL)
|
||||
conf.CallFormat = template.Must(template.New("twiml").Parse(`<Response><Say>{{.Message}} von {{.Sender}}</Say></Response>`))
|
||||
c := NewClient(conf)
|
||||
require.Nil(t, c.Call("+11122233344", &CallData{Topic: "mytopic", Message: "hi there", Sender: "phil"}))
|
||||
|
||||
form, err := url.ParseQuery(body)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, "<Response><Say>hi there von phil</Say></Response>", form.Get("Twiml"))
|
||||
}
|
||||
|
||||
// TestClient_Call_RendersAllFields covers the fields that the default TwiML template does not
|
||||
// use, i.e. Title, Priority and Tags, including the escaping of every tag
|
||||
func TestClient_Call_RendersAllFields(t *testing.T) {
|
||||
var body string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
b, err := io.ReadAll(r.Body)
|
||||
require.Nil(t, err)
|
||||
body = string(b)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
conf := testConfig(server.URL)
|
||||
conf.CallFormat = template.Must(template.New("twiml").Parse(`<Response><Say>{{.Title}}/{{.Priority}}{{range .Tags}}/{{.}}{{end}}</Say></Response>`))
|
||||
c := NewClient(conf)
|
||||
data := &CallData{
|
||||
Topic: "mytopic",
|
||||
Title: "a <title>",
|
||||
Priority: 5,
|
||||
Tags: []string{"<one>", "two & three"},
|
||||
}
|
||||
require.Nil(t, c.Call("+11122233344", data))
|
||||
|
||||
form, err := url.ParseQuery(body)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, "<Response><Say>a <title>/5/<one>/two & three</Say></Response>", form.Get("Twiml"))
|
||||
}
|
||||
|
||||
func TestClient_Call_TemplateError(t *testing.T) {
|
||||
conf := testConfig("http://dummy.invalid")
|
||||
conf.CallFormat = template.Must(template.New("twiml").Parse(`{{.DoesNotExist}}`))
|
||||
c := NewClient(conf)
|
||||
require.Error(t, c.Call("+11122233344", &CallData{Topic: "mytopic"}))
|
||||
}
|
||||
|
||||
// TestClient_Call_Created ensures that a 201 Created is treated as a success. The Twilio Calls
|
||||
// API returns 201 (not 200) for a successfully queued call, so this must not be an error.
|
||||
func TestClient_Call_Created(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
w.Write([]byte(`{"status":"queued"}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
c := NewClient(testConfig(server.URL))
|
||||
require.Nil(t, c.Call("+11122233344", &CallData{Topic: "mytopic", Message: "hi there"}))
|
||||
}
|
||||
|
||||
// TestClient_Call_TwilioError ensures that a non-2xx response from Twilio is returned as an
|
||||
// error, so that the server counts it as a failure instead of a success. Twilio rejects calls
|
||||
// with a 4xx, e.g. for an invalid "To" number, or when the account is out of funds.
|
||||
func TestClient_Call_TwilioError(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
w.Write([]byte(`{"code":21211,"message":"Invalid 'To' Phone Number: +invalid"}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
c := NewClient(testConfig(server.URL))
|
||||
err := c.Call("+invalid", &CallData{Topic: "mytopic", Message: "hi there"})
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "400")
|
||||
}
|
||||
|
||||
func TestClient_Call_TwilioServerError(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
c := NewClient(testConfig(server.URL))
|
||||
require.Error(t, c.Call("+11122233344", &CallData{Topic: "mytopic", Message: "hi there"}))
|
||||
}
|
||||
|
||||
// TestClient_Call_TransportError ensures that a call to an unreachable Twilio API returns an
|
||||
// error, so that the server can count it as a failure
|
||||
func TestClient_Call_TransportError(t *testing.T) {
|
||||
c := NewClient(testConfig(closedServerURL(t)))
|
||||
require.Error(t, c.Call("+11122233344", &CallData{Topic: "mytopic", Message: "hi there"}))
|
||||
}
|
||||
|
||||
func TestClient_Call_InvalidBaseURL(t *testing.T) {
|
||||
c := NewClient(testConfig("://invalid"))
|
||||
require.Error(t, c.Call("+11122233344", &CallData{Topic: "mytopic", Message: "hi there"}))
|
||||
}
|
||||
|
||||
// TestClient_Verify_Created ensures that a 201 Created is treated as a success. The Twilio
|
||||
// Verify API returns 201 (not 200) when it creates a verification, so this must not be an error.
|
||||
func TestClient_Verify_Created(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
w.Write([]byte(`{"status":"pending"}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
c := NewClient(testConfig(server.URL))
|
||||
require.Nil(t, c.Verify("+12223334444", "sms"))
|
||||
}
|
||||
|
||||
// TestClient_Verify_TwilioError ensures that a non-2xx response from Twilio is returned as an
|
||||
// error. Without this, no SMS is sent, but the user is still told to check their phone.
|
||||
func TestClient_Verify_TwilioError(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
w.Write([]byte(`{"code":60200,"message":"Invalid parameter"}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
c := NewClient(testConfig(server.URL))
|
||||
err := c.Verify("+12223334444", "sms")
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "400")
|
||||
}
|
||||
|
||||
func TestClient_Verify_Unauthorized(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
c := NewClient(testConfig(server.URL))
|
||||
require.Error(t, c.Verify("+12223334444", "sms"))
|
||||
}
|
||||
|
||||
func TestClient_Verify_TransportError(t *testing.T) {
|
||||
c := NewClient(testConfig(closedServerURL(t)))
|
||||
require.Error(t, c.Verify("+12223334444", "sms"))
|
||||
}
|
||||
|
||||
func TestClient_CheckVerify_TransportError(t *testing.T) {
|
||||
c := NewClient(testConfig(closedServerURL(t)))
|
||||
err := c.CheckVerify("+12223334444", "123456")
|
||||
require.Error(t, err)
|
||||
require.False(t, errors.Is(err, ErrVerificationExpired))
|
||||
}
|
||||
|
||||
func TestClient_Verify_Success(t *testing.T) {
|
||||
var body string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
require.Equal(t, "/v2/Services/VA1234567890/Verifications", r.URL.Path)
|
||||
require.Equal(t, "Basic QUMxMjM0NTY3ODkwOkFBRUFBMTIzNDU2Nzg5MA==", r.Header.Get("Authorization"))
|
||||
b, err := io.ReadAll(r.Body)
|
||||
require.Nil(t, err)
|
||||
body = string(b)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
c := NewClient(testConfig(server.URL))
|
||||
require.Nil(t, c.Verify("+12223334444", "sms"))
|
||||
require.Equal(t, "Channel=sms&To=%2B12223334444", body)
|
||||
}
|
||||
|
||||
func TestClient_CheckVerify_Success(t *testing.T) {
|
||||
var body string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
require.Equal(t, "/v2/Services/VA1234567890/VerificationCheck", r.URL.Path)
|
||||
b, err := io.ReadAll(r.Body)
|
||||
require.Nil(t, err)
|
||||
body = string(b)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
c := NewClient(testConfig(server.URL))
|
||||
require.Nil(t, c.CheckVerify("+12223334444", "123456"))
|
||||
require.Equal(t, "Code=123456&To=%2B12223334444", body)
|
||||
}
|
||||
|
||||
// TestClient_CheckVerify_Expired ensures that a 404 from the Twilio Verify API is
|
||||
// mapped to ErrVerificationExpired, which the server turns into an HTTP 410
|
||||
func TestClient_CheckVerify_Expired(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
c := NewClient(testConfig(server.URL))
|
||||
err := c.CheckVerify("+12223334444", "123456")
|
||||
require.True(t, errors.Is(err, ErrVerificationExpired))
|
||||
}
|
||||
|
||||
func TestClient_CheckVerify_OtherError(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
c := NewClient(testConfig(server.URL))
|
||||
err := c.CheckVerify("+12223334444", "123456")
|
||||
require.Error(t, err)
|
||||
require.False(t, errors.Is(err, ErrVerificationExpired))
|
||||
}
|
||||
|
||||
// closedServerURL returns the URL of a server that is not listening anymore, to simulate an
|
||||
// unreachable Twilio API
|
||||
func closedServerURL(t *testing.T) string {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Fatal("Should not be called")
|
||||
}))
|
||||
server.Close()
|
||||
return server.URL
|
||||
}
|
||||
|
||||
func testConfig(baseURL string) *Config {
|
||||
return &Config{
|
||||
Account: "AC1234567890",
|
||||
AuthToken: "AAEAA1234567890",
|
||||
PhoneNumber: "+1234567890",
|
||||
CallsBaseURL: baseURL,
|
||||
VerifyBaseURL: baseURL,
|
||||
VerifyService: "VA1234567890",
|
||||
BuildVersion: "1.2.3",
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package twilio
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/xml"
|
||||
"errors"
|
||||
"text/template"
|
||||
)
|
||||
|
||||
// ErrVerificationExpired is returned by CheckVerify if the verification code has
|
||||
// expired, or if it never existed in the first place
|
||||
var ErrVerificationExpired = errors.New("phone number verification expired or does not exist")
|
||||
|
||||
// Config holds the Twilio configuration for the client
|
||||
type Config struct {
|
||||
Account string // Twilio account SID, e.g. AC123...
|
||||
AuthToken string // Twilio auth token
|
||||
PhoneNumber string // Twilio number to use for outgoing calls
|
||||
CallsBaseURL string // Base URL of the Twilio Calls API
|
||||
VerifyBaseURL string // Base URL of the Twilio Verify API
|
||||
VerifyService string // Twilio Verify service ID, e.g. VA123...
|
||||
CallFormat *template.Template // TwiML template for calls; if nil, defaultCallFormatTemplate is used
|
||||
BuildVersion string // ntfy version, used for the User-Agent header
|
||||
}
|
||||
|
||||
// defaultCallFormatTemplate is the default TwiML template used for Twilio calls.
|
||||
// It can be overridden in the server configuration's twilio-call-format field.
|
||||
//
|
||||
// The format uses Go template syntax with the following fields:
|
||||
// {{.Topic}}, {{.Title}}, {{.Message}}, {{.Priority}}, {{.Tags}}, {{.Sender}}
|
||||
// String fields are automatically XML-escaped.
|
||||
var defaultCallFormatTemplate = template.Must(template.New("twiml").Parse(`
|
||||
<Response>
|
||||
<Pause length="1"/>
|
||||
<Say loop="3">
|
||||
You have a message from notify on topic {{.Topic}}. Message:
|
||||
<break time="1s"/>
|
||||
{{.Message}}
|
||||
<break time="1s"/>
|
||||
End of message.
|
||||
<break time="1s"/>
|
||||
This message was sent by user {{.Sender}}. It will be repeated three times.
|
||||
To unsubscribe from calls like this, remove your phone number in the notify web app.
|
||||
<break time="3s"/>
|
||||
</Say>
|
||||
<Say>Goodbye.</Say>
|
||||
</Response>`))
|
||||
|
||||
// CallData holds the data passed to the Twilio call format template. String fields are
|
||||
// XML-escaped before the template is executed, so callers pass them unescaped.
|
||||
type CallData struct {
|
||||
Topic string
|
||||
Title string
|
||||
Message string
|
||||
Priority int
|
||||
Tags []string
|
||||
Sender string
|
||||
}
|
||||
|
||||
// escaped returns a copy of the call data with all string fields XML-escaped
|
||||
func (d *CallData) escaped() *CallData {
|
||||
tags := make([]string, len(d.Tags))
|
||||
for i, tag := range d.Tags {
|
||||
tags[i] = xmlEscapeText(tag)
|
||||
}
|
||||
return &CallData{
|
||||
Topic: xmlEscapeText(d.Topic),
|
||||
Title: xmlEscapeText(d.Title),
|
||||
Message: xmlEscapeText(d.Message),
|
||||
Priority: d.Priority,
|
||||
Tags: tags,
|
||||
Sender: xmlEscapeText(d.Sender),
|
||||
}
|
||||
}
|
||||
|
||||
func xmlEscapeText(text string) string {
|
||||
var buf bytes.Buffer
|
||||
_ = xml.EscapeText(&buf, []byte(text))
|
||||
return buf.String()
|
||||
}
|
||||
+352
-25
@@ -2,6 +2,7 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"crypto/subtle"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
@@ -32,6 +33,7 @@ const (
|
||||
tokenLength = 32
|
||||
tokenMaxCount = 60 // Only keep this many tokens in the table per user
|
||||
tag = "user_manager"
|
||||
schemaStore = "user" // Store name in the schema_version table (see db/schema)
|
||||
)
|
||||
|
||||
// Default constants that may be overridden by configs
|
||||
@@ -40,6 +42,7 @@ const (
|
||||
DefaultUserPasswordBcryptCost = 10
|
||||
DefaultAccessCacheEnabled = false
|
||||
DefaultAccessCacheReloadInterval = 87 * time.Second
|
||||
DefaultExpiredMagicLinkReapInterval = time.Hour // How often expired email-verify/password-reset links are swept
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -72,6 +75,9 @@ func newManager(d *db.DB, queries queries, config *Config) (*Manager, error) {
|
||||
if config.AccessCacheReloadInterval <= 0 {
|
||||
config.AccessCacheReloadInterval = DefaultAccessCacheReloadInterval
|
||||
}
|
||||
if config.ExpiredMagicLinkReapInterval <= 0 {
|
||||
config.ExpiredMagicLinkReapInterval = DefaultExpiredMagicLinkReapInterval
|
||||
}
|
||||
manager := &Manager{
|
||||
config: config,
|
||||
db: d,
|
||||
@@ -91,6 +97,7 @@ func newManager(d *db.DB, queries queries, config *Config) (*Manager, error) {
|
||||
go manager.asyncAccessCacheReloadLoop(manager.config.AccessCacheReloadInterval)
|
||||
}
|
||||
go manager.asyncQueueWriteLoop(manager.config.QueueWriterInterval)
|
||||
go manager.asyncExpiredMagicLinkReapLoop(manager.config.ExpiredMagicLinkReapInterval)
|
||||
return manager, nil
|
||||
}
|
||||
|
||||
@@ -128,24 +135,45 @@ func (a *Manager) asyncAccessCacheReloadLoop(interval time.Duration) {
|
||||
}
|
||||
}
|
||||
|
||||
// Authenticate checks username and password and returns a User if correct, and the user has not been
|
||||
// marked as deleted. The method returns in constant-ish time, regardless of whether the user exists or
|
||||
// the password is correct or incorrect.
|
||||
func (a *Manager) Authenticate(username, password string) (*User, error) {
|
||||
if username == Everyone {
|
||||
// asyncExpiredMagicLinkReapLoop periodically deletes expired email-verification and
|
||||
// password-reset links so the user_magic_link table does not accumulate dead rows. Expiry is
|
||||
// already enforced on read, so this is housekeeping only; it replaces the old in-memory
|
||||
// expireLoop that lived in mail.Sender.
|
||||
func (a *Manager) asyncExpiredMagicLinkReapLoop(interval time.Duration) {
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-a.quit:
|
||||
return
|
||||
case <-ticker.C:
|
||||
if err := a.deleteExpiredMagicLinks(); err != nil {
|
||||
log.Tag(tag).Err(err).Warn("Reaping expired magic links failed")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Authenticate checks a login identifier (a username or a verified primary email) and password, and
|
||||
// returns a User if correct and not marked as deleted. The identifier is resolved in a single query
|
||||
// via userByNameOrEmail, so a user can log in with either their username or their primary
|
||||
// email. The method returns in constant-ish time (one query, one bcrypt compare), regardless of
|
||||
// whether the identifier exists or the password is correct or incorrect.
|
||||
func (a *Manager) Authenticate(identifier, password string) (*User, error) {
|
||||
if identifier == Everyone {
|
||||
return nil, ErrUnauthenticated
|
||||
}
|
||||
user, err := a.User(username)
|
||||
user, err := a.userByNameOrEmail(identifier)
|
||||
if err != nil {
|
||||
log.Tag(tag).Field("user_name", username).Err(err).Trace("Authentication of user failed (1)")
|
||||
log.Tag(tag).Field("user_name", identifier).Err(err).Trace("Authentication of user failed (1)")
|
||||
bcrypt.CompareHashAndPassword([]byte(userAuthIntentionalSlowDownHash), []byte("intentional slow-down to avoid timing attacks"))
|
||||
return nil, ErrUnauthenticated
|
||||
} else if user.Deleted {
|
||||
log.Tag(tag).Field("user_name", username).Trace("Authentication of user failed (2): user marked deleted")
|
||||
log.Tag(tag).Field("user_name", identifier).Trace("Authentication of user failed (2): user marked deleted")
|
||||
bcrypt.CompareHashAndPassword([]byte(userAuthIntentionalSlowDownHash), []byte("intentional slow-down to avoid timing attacks"))
|
||||
return nil, ErrUnauthenticated
|
||||
} else if err := bcrypt.CompareHashAndPassword([]byte(user.Hash), []byte(password)); err != nil {
|
||||
log.Tag(tag).Field("user_name", username).Err(err).Trace("Authentication of user failed (3)")
|
||||
log.Tag(tag).Field("user_name", identifier).Err(err).Trace("Authentication of user failed (3)")
|
||||
return nil, ErrUnauthenticated
|
||||
}
|
||||
return user, nil
|
||||
@@ -494,6 +522,34 @@ func (a *Manager) UserByID(id string) (*User, error) {
|
||||
return a.readUser(rows)
|
||||
}
|
||||
|
||||
// UserByEmailOrUsername resolves an identifier to a single user, trying it first as a primary
|
||||
// email address and then as a username. A verified, owned email takes precedence over a
|
||||
// freely-chosen username, so a look-alike username cannot shadow the email's real owner. Returns
|
||||
// ErrUserNotFound if neither matches.
|
||||
func (a *Manager) UserByEmailOrUsername(identifier string) (*User, error) {
|
||||
if userID, err := a.UserIDByPrimaryEmail(identifier); err == nil {
|
||||
if u, err := a.UserByID(userID); err == nil {
|
||||
return u, nil
|
||||
}
|
||||
}
|
||||
return a.User(identifier)
|
||||
}
|
||||
|
||||
// userByNameOrEmail resolves a login identifier to a single user in one query, matching it
|
||||
// against the username first and a verified primary email address second. This is the INVERSE
|
||||
// precedence of UserByEmailOrUsername (used by password reset): at login a freely-chosen username
|
||||
// must win over a look-alike primary email, so a user whose username happens to equal another
|
||||
// account's email is not locked out of their own account. Because Authenticate still gates the match
|
||||
// on a password check, returning the username owner here never grants access to the email owner's
|
||||
// account. Returns ErrUserNotFound if neither matches.
|
||||
func (a *Manager) userByNameOrEmail(identifier string) (*User, error) {
|
||||
rows, err := a.db.Query(a.queries.selectUserByNameOrPrimaryEmail, identifier, identifier, identifier)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return a.readUser(rows)
|
||||
}
|
||||
|
||||
// userByToken returns the user with the given token if it exists and is not expired, or ErrUserNotFound otherwise
|
||||
func (a *Manager) userByToken(token string) (*User, error) {
|
||||
rows, err := a.db.Query(a.queries.selectUserByToken, token, time.Now().Unix())
|
||||
@@ -630,7 +686,7 @@ func (a *Manager) maybeHashPassword(password string, hashed bool) (string, error
|
||||
}
|
||||
return password, nil
|
||||
}
|
||||
return hashPassword(password, a.config.BcryptCost)
|
||||
return HashPassword(password, a.config.BcryptCost)
|
||||
}
|
||||
|
||||
// Authorize returns nil if the given user has access to the given topic using the desired
|
||||
@@ -639,6 +695,13 @@ func (a *Manager) Authorize(user *User, topic string, perm Permission) error {
|
||||
if user != nil && user.Role == RoleAdmin {
|
||||
return nil // Admin can do everything
|
||||
}
|
||||
// A user always has full access to their own sync topic, which the apps use
|
||||
// to sync subscriptions/settings across devices. Without this, an
|
||||
// auth-default-access of "deny-all" locks the user out of their own sync
|
||||
// topic (no ACL entry is created for it at user creation). See #733.
|
||||
if user != nil && user.SyncTopic != "" && subtle.ConstantTimeCompare([]byte(topic), []byte(user.SyncTopic)) == 1 {
|
||||
return nil
|
||||
}
|
||||
username := Everyone
|
||||
if user != nil {
|
||||
username = user.Name
|
||||
@@ -896,7 +959,9 @@ func (a *Manager) RemoveReservations(username string, topics ...string) error {
|
||||
|
||||
// Reservations returns all user-owned topics, and the associated everyone-access
|
||||
func (a *Manager) Reservations(username string) ([]Reservation, error) {
|
||||
return a.reservationsTx(a.db.ReadOnly(), username)
|
||||
// Read from the primary, not a replica: this backs GET /account, which the web app refetches
|
||||
// immediately after a sync event. Replication lag would otherwise show stale data.
|
||||
return a.reservationsTx(a.db, username)
|
||||
}
|
||||
|
||||
func (a *Manager) reservationsTx(tx db.Querier, username string) ([]Reservation, error) {
|
||||
@@ -1159,7 +1224,8 @@ func (a *Manager) Token(userID, token string) (*Token, error) {
|
||||
|
||||
// Tokens returns all existing tokens for the user with the given user ID
|
||||
func (a *Manager) Tokens(userID string) ([]*Token, error) {
|
||||
rows, err := a.db.ReadOnly().Query(a.queries.selectTokens, userID)
|
||||
// Primary read: backs GET /account (read-your-writes after a sync event).
|
||||
rows, err := a.db.Query(a.queries.selectTokens, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -1372,7 +1438,8 @@ func (a *Manager) readTier(rows *sql.Rows) (*Tier, error) {
|
||||
|
||||
// PhoneNumbers returns all phone numbers for the user with the given user ID
|
||||
func (a *Manager) PhoneNumbers(userID string) ([]string, error) {
|
||||
rows, err := a.db.ReadOnly().Query(a.queries.selectPhoneNumbers, userID)
|
||||
// Primary read: backs GET /account (read-your-writes after a sync event).
|
||||
rows, err := a.db.Query(a.queries.selectPhoneNumbers, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -1420,14 +1487,17 @@ func (a *Manager) readPhoneNumber(rows *sql.Rows) (string, error) {
|
||||
return phoneNumber, nil
|
||||
}
|
||||
|
||||
// Emails returns all verified email addresses for the user with the given user ID
|
||||
func (a *Manager) Emails(userID string) ([]string, error) {
|
||||
rows, err := a.db.ReadOnly().Query(a.queries.selectEmails, userID)
|
||||
// Emails returns all verified email addresses for the user with the given user ID, each carrying
|
||||
// whether it is the primary (recovery) address. Because the primary flag is included, callers that
|
||||
// need it (e.g. the account view) do not need a separate PrimaryEmail call.
|
||||
func (a *Manager) Emails(userID string) (Emails, error) {
|
||||
// Primary read: backs GET /account (read-your-writes after a sync event).
|
||||
rows, err := a.db.Query(a.queries.selectEmails, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
emails := make([]string, 0)
|
||||
emails := make(Emails, 0)
|
||||
for {
|
||||
email, err := a.readEmail(rows)
|
||||
if errors.Is(err, ErrEmailNotFound) {
|
||||
@@ -1451,23 +1521,280 @@ func (a *Manager) AddEmail(userID, email string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// RemoveEmail deletes a verified email address from the user with the given user ID
|
||||
// RemoveEmail deletes a verified email address from the user with the given user ID.
|
||||
// Removing the primary email leaves the account with no primary -- there is deliberately
|
||||
// no auto-promotion of another verified address; the user is nudged to pick a new one.
|
||||
func (a *Manager) RemoveEmail(userID, email string) error {
|
||||
_, err := a.db.Exec(a.queries.deleteEmail, userID, email)
|
||||
return err
|
||||
}
|
||||
|
||||
func (a *Manager) readEmail(rows *sql.Rows) (string, error) {
|
||||
var email string
|
||||
// PrimaryEmail returns the user's primary (recovery) email address, or an empty string if
|
||||
// the user has not designated one.
|
||||
func (a *Manager) PrimaryEmail(userID string) (string, error) {
|
||||
var email sql.NullString
|
||||
// Primary read: backs GET /account (read-your-writes after a sync event).
|
||||
err := a.db.QueryRow(a.queries.selectPrimaryEmail, userID).Scan(&email)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return "", nil
|
||||
} else if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return email.String, nil
|
||||
}
|
||||
|
||||
// UserIDByPrimaryEmail returns the ID of the (at most one) account for which the given address
|
||||
// is the primary email. Returns ErrUserNotFound if no account claims it as primary. Used by the
|
||||
// password-reset request flow to resolve an email identifier to a single account.
|
||||
func (a *Manager) UserIDByPrimaryEmail(email string) (string, error) {
|
||||
var userID string
|
||||
err := a.db.ReadOnly().QueryRow(a.queries.selectUserIDByPrimary, email).Scan(&userID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return "", ErrUserNotFound
|
||||
} else if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return userID, nil
|
||||
}
|
||||
|
||||
// PendingEmails returns the user's unverified (pending) email addresses, i.e. addresses with
|
||||
// an outstanding email-verification magic link.
|
||||
func (a *Manager) PendingEmails(userID string) ([]string, error) {
|
||||
// Primary read: backs GET /account (read-your-writes after a sync event).
|
||||
rows, err := a.db.Query(a.queries.selectPendingEmails, string(MagicLinkKindEmailVerify), userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
emails := make([]string, 0)
|
||||
for rows.Next() {
|
||||
var email string
|
||||
if err := rows.Scan(&email); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
emails = append(emails, email)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return emails, nil
|
||||
}
|
||||
|
||||
// SetPrimaryEmail marks a verified email address as the user's primary (recovery) email,
|
||||
// clearing any previous primary in the same transaction. Returns ErrEmailNotFound if the
|
||||
// address is not verified on the account, or ErrEmailPrimaryElsewhere if it is already the
|
||||
// primary email on another account (enforced by the global partial unique index).
|
||||
func (a *Manager) SetPrimaryEmail(userID, email string) error {
|
||||
return db.ExecTx(a.db, func(tx *sql.Tx) error {
|
||||
if _, err := tx.Exec(a.queries.updateEmailClearPrimary, userID); err != nil {
|
||||
return err
|
||||
}
|
||||
res, err := tx.Exec(a.queries.updateEmailSetPrimary, userID, email)
|
||||
if err != nil {
|
||||
if isUniqueConstraintError(err) {
|
||||
return ErrEmailPrimaryElsewhere
|
||||
}
|
||||
return err
|
||||
}
|
||||
affected, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if affected == 0 {
|
||||
return ErrEmailNotFound // Address not verified on this account
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// AddMagicLink generates a fresh magic-link token of the given kind, stores it (hashed, replacing
|
||||
// any existing link in the same scope), and returns the RAW token for use in the emailed link.
|
||||
// Only the hash is persisted; the raw token is never stored. email is the address being verified
|
||||
// for email_verify, and "" for password_reset.
|
||||
//
|
||||
// The scope replaced is, for email_verify, the (user_id, email) pair (one pending verification per
|
||||
// address); for password_reset, the user_id (one active reset per account). The replace-delete and
|
||||
// the insert run in one transaction so a re-request atomically supersedes the old token.
|
||||
func (a *Manager) AddMagicLink(kind MagicLinkKind, userID, email string, ttl time.Duration) (string, error) {
|
||||
token := generateLinkToken()
|
||||
now := time.Now()
|
||||
m := &MagicLink{
|
||||
TokenHash: hashToken(token),
|
||||
Kind: kind,
|
||||
UserID: userID,
|
||||
Email: email,
|
||||
Expires: now.Add(ttl).Unix(),
|
||||
Created: now.Unix(),
|
||||
}
|
||||
err := db.ExecTx(a.db, func(tx *sql.Tx) error {
|
||||
switch m.Kind {
|
||||
case MagicLinkKindEmailVerify:
|
||||
if _, err := tx.Exec(a.queries.deleteMagicLinkEmailVerify, string(MagicLinkKindEmailVerify), m.UserID, m.Email); err != nil {
|
||||
return err
|
||||
}
|
||||
case MagicLinkKindPasswordReset:
|
||||
if _, err := tx.Exec(a.queries.deleteMagicLinkResetPassword, string(MagicLinkKindPasswordReset), m.UserID); err != nil {
|
||||
return err
|
||||
}
|
||||
default:
|
||||
return ErrInvalidArgument
|
||||
}
|
||||
if _, err := tx.Exec(a.queries.insertMagicLink, m.TokenHash, string(m.Kind), m.UserID, nullString(m.Email), m.Expires, m.Created); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return token, nil
|
||||
}
|
||||
|
||||
// MagicLinkByToken looks up a magic link by its raw token (hashing it first). See MagicLinkByHash.
|
||||
func (a *Manager) MagicLinkByToken(rawToken string) (*MagicLink, error) {
|
||||
return a.MagicLinkByHash(hashToken(rawToken))
|
||||
}
|
||||
|
||||
// MagicLinkByHash looks up a magic link by the hex SHA-256 of its raw token, returning
|
||||
// ErrMagicLinkNotFound if none exists. Callers must assert the returned Kind matches the flow
|
||||
// they serve and check Expires themselves.
|
||||
func (a *Manager) MagicLinkByHash(tokenHash string) (*MagicLink, error) {
|
||||
var m MagicLink
|
||||
var kind string
|
||||
var email sql.NullString
|
||||
err := a.db.ReadOnly().QueryRow(a.queries.selectMagicLinkByHash, tokenHash).Scan(&m.TokenHash, &kind, &m.UserID, &email, &m.Expires, &m.Created)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrMagicLinkNotFound
|
||||
} else if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m.Kind = MagicLinkKind(kind)
|
||||
m.Email = email.String
|
||||
return &m, nil
|
||||
}
|
||||
|
||||
// DeleteMagicLinkByToken deletes a magic link identified by its raw token (single-use consume).
|
||||
// Used to enforce single use after a reset is performed (email verification deletes the row
|
||||
// inside VerifyEmail's transaction).
|
||||
func (a *Manager) DeleteMagicLinkByToken(rawToken string) error {
|
||||
_, err := a.db.Exec(a.queries.deleteMagicLinkByHash, hashToken(rawToken))
|
||||
return err
|
||||
}
|
||||
|
||||
// DeleteEmailVerification removes any pending email verification for (userID, email). Used when
|
||||
// an unverified (pending) address is cancelled/deleted from the account.
|
||||
func (a *Manager) DeleteEmailVerification(userID, email string) error {
|
||||
_, err := a.db.Exec(a.queries.deleteMagicLinkEmailVerify, string(MagicLinkKindEmailVerify), userID, email)
|
||||
return err
|
||||
}
|
||||
|
||||
// VerifyEmail consumes an email-verification magic link, identified by its raw token: after
|
||||
// validating the token (kind + expiry), it deletes the link, adds the address to the user's
|
||||
// verified emails, and -- if the user has no primary email yet and the address is not already
|
||||
// primary on another account -- promotes the new address to primary. All mutations run in one
|
||||
// transaction. A primary collision simply leaves the address verified but non-primary. Provisioned
|
||||
// users never get a primary (the recovery email is meaningless for them -- they can't reset).
|
||||
// Returns the consumed link.
|
||||
func (a *Manager) VerifyEmail(rawToken string) (*MagicLink, error) {
|
||||
tokenHash := hashToken(rawToken)
|
||||
m, err := a.MagicLinkByHash(tokenHash)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if m.Kind != MagicLinkKindEmailVerify || time.Now().Unix() > m.Expires {
|
||||
return nil, ErrMagicLinkNotFound
|
||||
}
|
||||
err = db.ExecTx(a.db, func(tx *sql.Tx) error {
|
||||
// Single use: delete the link, then add the (idempotent) verified address
|
||||
if _, err := tx.Exec(a.queries.deleteMagicLinkByHash, tokenHash); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(a.queries.insertEmailIgnore, m.UserID, m.Email); err != nil {
|
||||
return err
|
||||
}
|
||||
// Promote to primary only if the user has none yet and the address is globally free.
|
||||
// We check with SELECTs rather than catching a unique violation, because Postgres aborts
|
||||
// the whole transaction on any constraint error (which would undo the verified-email add).
|
||||
var primary sql.NullString
|
||||
err := tx.QueryRow(a.queries.selectPrimaryEmail, m.UserID).Scan(&primary)
|
||||
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
return err
|
||||
}
|
||||
if primary.String != "" {
|
||||
return nil // User already has a primary -- leave it
|
||||
}
|
||||
// If the address is already another account's primary, leave it a verified secondary here
|
||||
var ownerUserID string
|
||||
if err = tx.QueryRow(a.queries.selectUserIDByPrimary, m.Email).Scan(&ownerUserID); err == nil {
|
||||
return nil // Address is primary elsewhere -> not promoted
|
||||
} else if !errors.Is(err, sql.ErrNoRows) {
|
||||
return err // Real query error
|
||||
}
|
||||
// Address is globally free -> promote it to this user's primary
|
||||
if _, err := tx.Exec(a.queries.updateEmailSetPrimary, m.UserID, m.Email); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// ResetPassword consumes a password-reset magic link, identified by its raw token: after
|
||||
// validating the token (kind + expiry), it sets the user's password and deletes the link in one
|
||||
// transaction. Existing access tokens are intentionally left valid (only the password changes).
|
||||
// Returns ErrMagicLinkNotFound if the token is invalid, expired, or not a reset token.
|
||||
func (a *Manager) ResetPassword(rawToken, newPassword string) error {
|
||||
m, err := a.MagicLinkByHash(hashToken(rawToken))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if m.Kind != MagicLinkKindPasswordReset || time.Now().Unix() > m.Expires {
|
||||
return ErrMagicLinkNotFound
|
||||
}
|
||||
u, err := a.UserByID(m.UserID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if u.Provisioned {
|
||||
return ErrProvisionedUserChange // Provisioned users get their password from the config file, not reset
|
||||
}
|
||||
hash, err := HashPassword(newPassword, a.config.BcryptCost)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return db.ExecTx(a.db, func(tx *sql.Tx) error {
|
||||
if err := a.changePasswordHashTx(tx, u.Name, hash); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(a.queries.deleteMagicLinkByHash, m.TokenHash); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// deleteExpiredMagicLinks removes magic links whose expiry has passed. Expiry is also enforced
|
||||
// on read, so this is purely housekeeping to bound table growth; it runs from the reaper loop.
|
||||
func (a *Manager) deleteExpiredMagicLinks() error {
|
||||
_, err := a.db.Exec(a.queries.deleteExpiredMagicLinks, time.Now().Unix())
|
||||
return err
|
||||
}
|
||||
|
||||
func (a *Manager) readEmail(rows *sql.Rows) (*Email, error) {
|
||||
var address string
|
||||
var primary bool
|
||||
if !rows.Next() {
|
||||
return "", ErrEmailNotFound
|
||||
return nil, ErrEmailNotFound
|
||||
}
|
||||
if err := rows.Scan(&email); err != nil {
|
||||
return "", err
|
||||
if err := rows.Scan(&address, &primary); err != nil {
|
||||
return nil, err
|
||||
} else if err := rows.Err(); err != nil {
|
||||
return "", err
|
||||
return nil, err
|
||||
}
|
||||
return email, nil
|
||||
return &Email{Address: address, Primary: primary}, nil
|
||||
}
|
||||
|
||||
// ChangeBilling updates a user's billing fields
|
||||
|
||||
+103
-66
@@ -5,6 +5,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"heckel.io/ntfy/v2/db"
|
||||
"heckel.io/ntfy/v2/db/schema"
|
||||
)
|
||||
|
||||
// PostgreSQL queries
|
||||
@@ -33,6 +34,15 @@ const (
|
||||
LEFT JOIN tier t on t.id = u.tier_id
|
||||
WHERE user_name = $1
|
||||
`
|
||||
postgresSelectUserByNameOrPrimaryEmailQuery = `
|
||||
SELECT u.id, u.user_name, u.pass, u.role, u.prefs, u.sync_topic, u.provisioned, u.stats_messages, u.stats_emails, u.stats_calls, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_interval, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, u.deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.calls_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id
|
||||
FROM "user" u
|
||||
LEFT JOIN tier t on t.id = u.tier_id
|
||||
WHERE u.user_name = $1
|
||||
OR u.id = (SELECT user_id FROM user_email WHERE email = $2 AND is_primary)
|
||||
ORDER BY CASE WHEN u.user_name = $3 THEN 0 ELSE 1 END
|
||||
LIMIT 1
|
||||
`
|
||||
postgresSelectUserByTokenQuery = `
|
||||
SELECT u.id, u.user_name, u.pass, u.role, u.prefs, u.sync_topic, u.provisioned, u.stats_messages, u.stats_emails, u.stats_calls, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_interval, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, u.deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.calls_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id
|
||||
FROM "user" u
|
||||
@@ -217,9 +227,23 @@ const (
|
||||
postgresDeletePhoneNumberQuery = `DELETE FROM user_phone WHERE user_id = $1 AND phone_number = $2`
|
||||
|
||||
// Email queries
|
||||
postgresSelectEmailsQuery = `SELECT email FROM user_email WHERE user_id = $1 ORDER BY email`
|
||||
postgresInsertEmailQuery = `INSERT INTO user_email (user_id, email) VALUES ($1, $2)`
|
||||
postgresDeleteEmailQuery = `DELETE FROM user_email WHERE user_id = $1 AND email = $2`
|
||||
postgresSelectEmailsQuery = `SELECT email, is_primary FROM user_email WHERE user_id = $1 ORDER BY email`
|
||||
postgresInsertEmailQuery = `INSERT INTO user_email (user_id, email) VALUES ($1, $2)`
|
||||
postgresInsertEmailIgnoreQuery = `INSERT INTO user_email (user_id, email) VALUES ($1, $2) ON CONFLICT (user_id, email) DO NOTHING`
|
||||
postgresDeleteEmailQuery = `DELETE FROM user_email WHERE user_id = $1 AND email = $2`
|
||||
postgresSelectPrimaryEmailQuery = `SELECT email FROM user_email WHERE user_id = $1 AND is_primary`
|
||||
postgresSelectUserIDByPrimaryQuery = `SELECT user_id FROM user_email WHERE email = $1 AND is_primary`
|
||||
postgresUpdateEmailSetPrimaryQuery = `UPDATE user_email SET is_primary = TRUE WHERE user_id = $1 AND email = $2`
|
||||
postgresUpdateEmailClearPrimaryQuery = `UPDATE user_email SET is_primary = FALSE WHERE user_id = $1 AND is_primary`
|
||||
|
||||
// Magic link queries (email verification + password reset)
|
||||
postgresInsertMagicLinkQuery = `INSERT INTO user_magic_link (token_hash, kind, user_id, email, expires, created) VALUES ($1, $2, $3, $4, $5, $6)`
|
||||
postgresSelectMagicLinkByHashQuery = `SELECT token_hash, kind, user_id, email, expires, created FROM user_magic_link WHERE token_hash = $1`
|
||||
postgresDeleteMagicLinkByHashQuery = `DELETE FROM user_magic_link WHERE token_hash = $1`
|
||||
postgresDeleteVerifyScopeQuery = `DELETE FROM user_magic_link WHERE kind = $1 AND user_id = $2 AND email = $3`
|
||||
postgresDeleteResetScopeQuery = `DELETE FROM user_magic_link WHERE kind = $1 AND user_id = $2`
|
||||
postgresSelectPendingEmailsQuery = `SELECT email FROM user_magic_link WHERE kind = $1 AND user_id = $2 ORDER BY email`
|
||||
postgresDeleteExpiredMagicLinksQuery = `DELETE FROM user_magic_link WHERE expires < $1`
|
||||
|
||||
// Billing queries
|
||||
postgresUpdateBillingQuery = `
|
||||
@@ -246,73 +270,86 @@ func postgresSelectAccessCacheUsersQuery(n int) string {
|
||||
|
||||
// NewPostgresManager creates a new Manager backed by a PostgreSQL database using an existing connection pool.
|
||||
var postgresQueries = queries{
|
||||
selectUserByID: postgresSelectUserByIDQuery,
|
||||
selectUserByName: postgresSelectUserByNameQuery,
|
||||
selectUserByToken: postgresSelectUserByTokenQuery,
|
||||
selectUserByStripeCustomerID: postgresSelectUserByStripeCustomerIDQuery,
|
||||
selectUsernames: postgresSelectUsernamesQuery,
|
||||
selectUsers: postgresSelectUsersQuery,
|
||||
selectUserCount: postgresSelectUserCountQuery,
|
||||
selectUserIDFromUsername: postgresSelectUserIDFromUsernameQuery,
|
||||
insertUser: postgresInsertUserQuery,
|
||||
updateUserPass: postgresUpdateUserPassQuery,
|
||||
updateUserRole: postgresUpdateUserRoleQuery,
|
||||
updateUserProvisioned: postgresUpdateUserProvisionedQuery,
|
||||
updateUserPrefs: postgresUpdateUserPrefsQuery,
|
||||
updateUserStats: postgresUpdateUserStatsQuery,
|
||||
updateUserStatsResetAll: postgresUpdateUserStatsResetAllQuery,
|
||||
updateUserTier: postgresUpdateUserTierQuery,
|
||||
updateUserDeleted: postgresUpdateUserDeletedQuery,
|
||||
deleteUser: postgresDeleteUserQuery,
|
||||
deleteUserTier: postgresDeleteUserTierQuery,
|
||||
deleteUsersMarked: postgresDeleteUsersMarkedQuery,
|
||||
deleteUsersProvisioned: postgresDeleteUsersProvisionedQuery,
|
||||
selectTopicPerms: postgresSelectTopicPermsQuery,
|
||||
selectAccessCacheAll: postgresSelectAccessCacheAllQuery,
|
||||
selectAccessCacheUsers: postgresSelectAccessCacheUsersQuery,
|
||||
selectUserAllAccess: postgresSelectUserAllAccessQuery,
|
||||
selectUserAccess: postgresSelectUserAccessQuery,
|
||||
selectUserReservations: postgresSelectUserReservationsQuery,
|
||||
selectUserReservationsCount: postgresSelectUserReservationsCountQuery,
|
||||
selectUserReservationsOwner: postgresSelectUserReservationsOwnerQuery,
|
||||
selectUserHasReservation: postgresSelectUserHasReservationQuery,
|
||||
selectOtherAccessCount: postgresSelectOtherAccessCountQuery,
|
||||
upsertUserAccess: postgresUpsertUserAccessQuery,
|
||||
deleteUserAccess: postgresDeleteUserAccessQuery,
|
||||
deleteUserAccessProvisioned: postgresDeleteUserAccessProvisionedQuery,
|
||||
deleteTopicAccess: postgresDeleteTopicAccessQuery,
|
||||
deleteAllAccess: postgresDeleteAllAccessQuery,
|
||||
selectToken: postgresSelectTokenQuery,
|
||||
selectTokens: postgresSelectTokensQuery,
|
||||
selectTokenCount: postgresSelectTokenCountQuery,
|
||||
selectAllProvisionedTokens: postgresSelectAllProvisionedTokensQuery,
|
||||
upsertToken: postgresUpsertTokenQuery,
|
||||
updateToken: postgresUpdateTokenQuery,
|
||||
updateTokenLastAccess: postgresUpdateTokenLastAccessQuery,
|
||||
deleteToken: postgresDeleteTokenQuery,
|
||||
deleteProvisionedToken: postgresDeleteProvisionedTokenQuery,
|
||||
deleteAllProvisionedTokens: postgresDeleteAllProvisionedTokensQuery,
|
||||
deleteAllToken: postgresDeleteAllTokenQuery,
|
||||
deleteExpiredTokens: postgresDeleteExpiredTokensQuery,
|
||||
deleteExcessTokens: postgresDeleteExcessTokensQuery,
|
||||
insertTier: postgresInsertTierQuery,
|
||||
selectTiers: postgresSelectTiersQuery,
|
||||
selectTierByCode: postgresSelectTierByCodeQuery,
|
||||
selectTierByPriceID: postgresSelectTierByPriceIDQuery,
|
||||
updateTier: postgresUpdateTierQuery,
|
||||
deleteTier: postgresDeleteTierQuery,
|
||||
selectPhoneNumbers: postgresSelectPhoneNumbersQuery,
|
||||
insertPhoneNumber: postgresInsertPhoneNumberQuery,
|
||||
deletePhoneNumber: postgresDeletePhoneNumberQuery,
|
||||
selectEmails: postgresSelectEmailsQuery,
|
||||
insertEmail: postgresInsertEmailQuery,
|
||||
deleteEmail: postgresDeleteEmailQuery,
|
||||
updateBilling: postgresUpdateBillingQuery,
|
||||
selectUserByID: postgresSelectUserByIDQuery,
|
||||
selectUserByName: postgresSelectUserByNameQuery,
|
||||
selectUserByNameOrPrimaryEmail: postgresSelectUserByNameOrPrimaryEmailQuery,
|
||||
selectUserByToken: postgresSelectUserByTokenQuery,
|
||||
selectUserByStripeCustomerID: postgresSelectUserByStripeCustomerIDQuery,
|
||||
selectUsernames: postgresSelectUsernamesQuery,
|
||||
selectUsers: postgresSelectUsersQuery,
|
||||
selectUserCount: postgresSelectUserCountQuery,
|
||||
selectUserIDFromUsername: postgresSelectUserIDFromUsernameQuery,
|
||||
insertUser: postgresInsertUserQuery,
|
||||
updateUserPass: postgresUpdateUserPassQuery,
|
||||
updateUserRole: postgresUpdateUserRoleQuery,
|
||||
updateUserProvisioned: postgresUpdateUserProvisionedQuery,
|
||||
updateUserPrefs: postgresUpdateUserPrefsQuery,
|
||||
updateUserStats: postgresUpdateUserStatsQuery,
|
||||
updateUserStatsResetAll: postgresUpdateUserStatsResetAllQuery,
|
||||
updateUserTier: postgresUpdateUserTierQuery,
|
||||
updateUserDeleted: postgresUpdateUserDeletedQuery,
|
||||
deleteUser: postgresDeleteUserQuery,
|
||||
deleteUserTier: postgresDeleteUserTierQuery,
|
||||
deleteUsersMarked: postgresDeleteUsersMarkedQuery,
|
||||
deleteUsersProvisioned: postgresDeleteUsersProvisionedQuery,
|
||||
selectTopicPerms: postgresSelectTopicPermsQuery,
|
||||
selectAccessCacheAll: postgresSelectAccessCacheAllQuery,
|
||||
selectAccessCacheUsers: postgresSelectAccessCacheUsersQuery,
|
||||
selectUserAllAccess: postgresSelectUserAllAccessQuery,
|
||||
selectUserAccess: postgresSelectUserAccessQuery,
|
||||
selectUserReservations: postgresSelectUserReservationsQuery,
|
||||
selectUserReservationsCount: postgresSelectUserReservationsCountQuery,
|
||||
selectUserReservationsOwner: postgresSelectUserReservationsOwnerQuery,
|
||||
selectUserHasReservation: postgresSelectUserHasReservationQuery,
|
||||
selectOtherAccessCount: postgresSelectOtherAccessCountQuery,
|
||||
upsertUserAccess: postgresUpsertUserAccessQuery,
|
||||
deleteUserAccess: postgresDeleteUserAccessQuery,
|
||||
deleteUserAccessProvisioned: postgresDeleteUserAccessProvisionedQuery,
|
||||
deleteTopicAccess: postgresDeleteTopicAccessQuery,
|
||||
deleteAllAccess: postgresDeleteAllAccessQuery,
|
||||
selectToken: postgresSelectTokenQuery,
|
||||
selectTokens: postgresSelectTokensQuery,
|
||||
selectTokenCount: postgresSelectTokenCountQuery,
|
||||
selectAllProvisionedTokens: postgresSelectAllProvisionedTokensQuery,
|
||||
upsertToken: postgresUpsertTokenQuery,
|
||||
updateToken: postgresUpdateTokenQuery,
|
||||
updateTokenLastAccess: postgresUpdateTokenLastAccessQuery,
|
||||
deleteToken: postgresDeleteTokenQuery,
|
||||
deleteProvisionedToken: postgresDeleteProvisionedTokenQuery,
|
||||
deleteAllProvisionedTokens: postgresDeleteAllProvisionedTokensQuery,
|
||||
deleteAllToken: postgresDeleteAllTokenQuery,
|
||||
deleteExpiredTokens: postgresDeleteExpiredTokensQuery,
|
||||
deleteExcessTokens: postgresDeleteExcessTokensQuery,
|
||||
insertTier: postgresInsertTierQuery,
|
||||
selectTiers: postgresSelectTiersQuery,
|
||||
selectTierByCode: postgresSelectTierByCodeQuery,
|
||||
selectTierByPriceID: postgresSelectTierByPriceIDQuery,
|
||||
updateTier: postgresUpdateTierQuery,
|
||||
deleteTier: postgresDeleteTierQuery,
|
||||
selectPhoneNumbers: postgresSelectPhoneNumbersQuery,
|
||||
insertPhoneNumber: postgresInsertPhoneNumberQuery,
|
||||
deletePhoneNumber: postgresDeletePhoneNumberQuery,
|
||||
selectEmails: postgresSelectEmailsQuery,
|
||||
insertEmail: postgresInsertEmailQuery,
|
||||
insertEmailIgnore: postgresInsertEmailIgnoreQuery,
|
||||
deleteEmail: postgresDeleteEmailQuery,
|
||||
selectPrimaryEmail: postgresSelectPrimaryEmailQuery,
|
||||
selectUserIDByPrimary: postgresSelectUserIDByPrimaryQuery,
|
||||
updateEmailSetPrimary: postgresUpdateEmailSetPrimaryQuery,
|
||||
updateEmailClearPrimary: postgresUpdateEmailClearPrimaryQuery,
|
||||
insertMagicLink: postgresInsertMagicLinkQuery,
|
||||
selectMagicLinkByHash: postgresSelectMagicLinkByHashQuery,
|
||||
deleteMagicLinkByHash: postgresDeleteMagicLinkByHashQuery,
|
||||
deleteMagicLinkEmailVerify: postgresDeleteVerifyScopeQuery,
|
||||
deleteMagicLinkResetPassword: postgresDeleteResetScopeQuery,
|
||||
selectPendingEmails: postgresSelectPendingEmailsQuery,
|
||||
deleteExpiredMagicLinks: postgresDeleteExpiredMagicLinksQuery,
|
||||
updateBilling: postgresUpdateBillingQuery,
|
||||
}
|
||||
|
||||
// NewPostgresManager creates a new Manager backed by a PostgreSQL database
|
||||
func NewPostgresManager(d *db.DB, config *Config) (*Manager, error) {
|
||||
if err := setupPostgres(d.Primary()); err != nil {
|
||||
if err := schema.Migrate(d.Primary(), schema.Postgres, schemaStore, postgresCurrentSchemaVersion, postgresCreateTables, postgresMigrations); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return newManager(d, postgresQueries, config)
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"heckel.io/ntfy/v2/db/schema"
|
||||
)
|
||||
|
||||
// Initial PostgreSQL schema
|
||||
@@ -75,23 +74,29 @@ const (
|
||||
CREATE TABLE IF NOT EXISTS user_email (
|
||||
user_id TEXT NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
|
||||
email TEXT NOT NULL,
|
||||
is_primary BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
PRIMARY KEY (user_id, email)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS schema_version (
|
||||
store TEXT PRIMARY KEY,
|
||||
version INT NOT NULL
|
||||
CREATE UNIQUE INDEX idx_user_email_primary_user ON user_email (user_id) WHERE is_primary;
|
||||
CREATE UNIQUE INDEX idx_user_email_primary_addr ON user_email (email) WHERE is_primary;
|
||||
CREATE TABLE IF NOT EXISTS user_magic_link (
|
||||
token_hash TEXT NOT NULL,
|
||||
kind TEXT NOT NULL,
|
||||
user_id TEXT NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
|
||||
email TEXT,
|
||||
expires BIGINT NOT NULL,
|
||||
created BIGINT NOT NULL,
|
||||
PRIMARY KEY (token_hash)
|
||||
);
|
||||
CREATE INDEX idx_magic_link_user_kind ON user_magic_link (user_id, kind);
|
||||
INSERT INTO "user" (id, user_name, pass, role, sync_topic, provisioned, created)
|
||||
VALUES ('` + everyoneID + `', '*', '', 'anonymous', '', false, EXTRACT(EPOCH FROM NOW())::BIGINT)
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
`
|
||||
)
|
||||
|
||||
// Schema table management queries for Postgres
|
||||
const (
|
||||
postgresCurrentSchemaVersion = 7
|
||||
postgresSelectSchemaVersionQuery = `SELECT version FROM schema_version WHERE store = 'user'`
|
||||
postgresInsertSchemaVersionQuery = `INSERT INTO schema_version (store, version) VALUES ('user', $1)`
|
||||
postgresCurrentSchemaVersion = 9
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -102,51 +107,34 @@ const (
|
||||
PRIMARY KEY (user_id, email)
|
||||
);
|
||||
`
|
||||
postgresUpdateSchemaVersionQuery = `UPDATE schema_version SET version = $1 WHERE store = 'user'`
|
||||
|
||||
// 7 -> 8: primary (recovery) email + magic-link table for verification/reset.
|
||||
// No backfill -- existing verified emails stay non-primary.
|
||||
postgresMigrate7To8UpdateQueries = `
|
||||
ALTER TABLE user_email ADD COLUMN is_primary BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
CREATE UNIQUE INDEX idx_user_email_primary_user ON user_email (user_id) WHERE is_primary;
|
||||
CREATE UNIQUE INDEX idx_user_email_primary_addr ON user_email (email) WHERE is_primary;
|
||||
CREATE TABLE IF NOT EXISTS user_magic_link (
|
||||
token_hash TEXT NOT NULL,
|
||||
kind TEXT NOT NULL,
|
||||
user_id TEXT NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
|
||||
email TEXT,
|
||||
expires BIGINT NOT NULL,
|
||||
created BIGINT NOT NULL,
|
||||
PRIMARY KEY (token_hash)
|
||||
);
|
||||
CREATE INDEX idx_magic_link_user_kind ON user_magic_link (user_id, kind);
|
||||
`
|
||||
)
|
||||
|
||||
var postgresMigrations = map[int]func(db *sql.DB) error{
|
||||
6: postgresMigrateFrom6,
|
||||
}
|
||||
var (
|
||||
postgresCreateTables = schema.AsMigrateFunc(postgresCreateTablesQueries)
|
||||
|
||||
func setupPostgres(db *sql.DB) error {
|
||||
var schemaVersion int
|
||||
err := db.QueryRow(postgresSelectSchemaVersionQuery).Scan(&schemaVersion)
|
||||
if err != nil {
|
||||
return setupNewPostgres(db)
|
||||
// postgresMigrations maps a schema version to the migration upgrading it to the next
|
||||
// version. Always append migrations at the end, never insert in the middle.
|
||||
postgresMigrations = map[int]schema.MigrateFunc{
|
||||
6: schema.AsMigrateFunc(postgresMigrate6To7UpdateQueries),
|
||||
7: schema.AsMigrateFunc(postgresMigrate7To8UpdateQueries),
|
||||
8: schema.NopMigrateFunc, // 8 -> 9 repairs a SQLite-only foreign key defect; nothing to do on Postgres
|
||||
}
|
||||
if schemaVersion == postgresCurrentSchemaVersion {
|
||||
return nil
|
||||
} else if schemaVersion > postgresCurrentSchemaVersion {
|
||||
return fmt.Errorf("unexpected schema version: version %d is higher than current version %d", schemaVersion, postgresCurrentSchemaVersion)
|
||||
}
|
||||
for i := schemaVersion; i < postgresCurrentSchemaVersion; i++ {
|
||||
fn, ok := postgresMigrations[i]
|
||||
if !ok {
|
||||
return fmt.Errorf("cannot find migration step from schema version %d to %d", i, i+1)
|
||||
} else if err := fn(db); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func postgresMigrateFrom6(db *sql.DB) error {
|
||||
if _, err := db.Exec(postgresMigrate6To7UpdateQueries); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := db.Exec(postgresUpdateSchemaVersionQuery, 7); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func setupNewPostgres(db *sql.DB) error {
|
||||
if _, err := db.Exec(postgresCreateTablesQueries); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := db.Exec(postgresInsertSchemaVersionQuery, postgresCurrentSchemaVersion); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
)
|
||||
|
||||
+107
-67
@@ -9,6 +9,7 @@ import (
|
||||
_ "github.com/mattn/go-sqlite3" // SQLite driver
|
||||
|
||||
"heckel.io/ntfy/v2/db"
|
||||
"heckel.io/ntfy/v2/db/schema"
|
||||
"heckel.io/ntfy/v2/util"
|
||||
)
|
||||
|
||||
@@ -37,6 +38,15 @@ const (
|
||||
LEFT JOIN tier t on t.id = u.tier_id
|
||||
WHERE user = ?
|
||||
`
|
||||
sqliteSelectUserByNameOrPrimaryEmailQuery = `
|
||||
SELECT u.id, u.user, u.pass, u.role, u.prefs, u.sync_topic, u.provisioned, u.stats_messages, u.stats_emails, u.stats_calls, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_interval, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.calls_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id
|
||||
FROM user u
|
||||
LEFT JOIN tier t on t.id = u.tier_id
|
||||
WHERE u.user = ?
|
||||
OR u.id = (SELECT user_id FROM user_email WHERE email = ? AND is_primary = 1)
|
||||
ORDER BY CASE WHEN u.user = ? THEN 0 ELSE 1 END
|
||||
LIMIT 1
|
||||
`
|
||||
sqliteSelectUserByTokenQuery = `
|
||||
SELECT u.id, u.user, u.pass, u.role, u.prefs, u.sync_topic, u.provisioned, u.stats_messages, u.stats_emails, u.stats_calls, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_interval, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.calls_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id
|
||||
FROM user u
|
||||
@@ -214,9 +224,23 @@ const (
|
||||
sqliteDeletePhoneNumberQuery = `DELETE FROM user_phone WHERE user_id = ? AND phone_number = ?`
|
||||
|
||||
// Email queries
|
||||
sqliteSelectEmailsQuery = `SELECT email FROM user_email WHERE user_id = ? ORDER BY email`
|
||||
sqliteInsertEmailQuery = `INSERT INTO user_email (user_id, email) VALUES (?, ?)`
|
||||
sqliteDeleteEmailQuery = `DELETE FROM user_email WHERE user_id = ? AND email = ?`
|
||||
sqliteSelectEmailsQuery = `SELECT email, is_primary FROM user_email WHERE user_id = ? ORDER BY email`
|
||||
sqliteInsertEmailQuery = `INSERT INTO user_email (user_id, email) VALUES (?, ?)`
|
||||
sqliteInsertEmailIgnoreQuery = `INSERT INTO user_email (user_id, email) VALUES (?, ?) ON CONFLICT (user_id, email) DO NOTHING`
|
||||
sqliteDeleteEmailQuery = `DELETE FROM user_email WHERE user_id = ? AND email = ?`
|
||||
sqliteSelectPrimaryEmailQuery = `SELECT email FROM user_email WHERE user_id = ? AND is_primary = 1`
|
||||
sqliteSelectUserIDByPrimaryQuery = `SELECT user_id FROM user_email WHERE email = ? AND is_primary = 1`
|
||||
sqliteUpdateEmailSetPrimaryQuery = `UPDATE user_email SET is_primary = 1 WHERE user_id = ? AND email = ?`
|
||||
sqliteUpdateEmailClearPrimaryQuery = `UPDATE user_email SET is_primary = 0 WHERE user_id = ? AND is_primary = 1`
|
||||
|
||||
// Magic link queries (email verification + password reset)
|
||||
sqliteInsertMagicLinkQuery = `INSERT INTO user_magic_link (token_hash, kind, user_id, email, expires, created) VALUES (?, ?, ?, ?, ?, ?)`
|
||||
sqliteSelectMagicLinkByHashQuery = `SELECT token_hash, kind, user_id, email, expires, created FROM user_magic_link WHERE token_hash = ?`
|
||||
sqliteDeleteMagicLinkByHashQuery = `DELETE FROM user_magic_link WHERE token_hash = ?`
|
||||
sqliteDeleteVerifyScopeQuery = `DELETE FROM user_magic_link WHERE kind = ? AND user_id = ? AND email = ?`
|
||||
sqliteDeleteResetScopeQuery = `DELETE FROM user_magic_link WHERE kind = ? AND user_id = ?`
|
||||
sqliteSelectPendingEmailsQuery = `SELECT email FROM user_magic_link WHERE kind = ? AND user_id = ? ORDER BY email`
|
||||
sqliteDeleteExpiredMagicLinksQuery = `DELETE FROM user_magic_link WHERE expires < ?`
|
||||
|
||||
// Billing queries
|
||||
sqliteUpdateBillingQuery = `
|
||||
@@ -242,68 +266,81 @@ func sqliteSelectAccessCacheUsersQuery(n int) string {
|
||||
}
|
||||
|
||||
var sqliteQueries = queries{
|
||||
selectUserByID: sqliteSelectUserByIDQuery,
|
||||
selectUserByName: sqliteSelectUserByNameQuery,
|
||||
selectUserByToken: sqliteSelectUserByTokenQuery,
|
||||
selectUserByStripeCustomerID: sqliteSelectUserByStripeCustomerIDQuery,
|
||||
selectUsernames: sqliteSelectUsernamesQuery,
|
||||
selectUsers: sqliteSelectUsersQuery,
|
||||
selectUserCount: sqliteSelectUserCountQuery,
|
||||
selectUserIDFromUsername: sqliteSelectUserIDFromUsernameQuery,
|
||||
insertUser: sqliteInsertUserQuery,
|
||||
updateUserPass: sqliteUpdateUserPassQuery,
|
||||
updateUserRole: sqliteUpdateUserRoleQuery,
|
||||
updateUserProvisioned: sqliteUpdateUserProvisionedQuery,
|
||||
updateUserPrefs: sqliteUpdateUserPrefsQuery,
|
||||
updateUserStats: sqliteUpdateUserStatsQuery,
|
||||
updateUserStatsResetAll: sqliteUpdateUserStatsResetAllQuery,
|
||||
updateUserTier: sqliteUpdateUserTierQuery,
|
||||
updateUserDeleted: sqliteUpdateUserDeletedQuery,
|
||||
deleteUser: sqliteDeleteUserQuery,
|
||||
deleteUserTier: sqliteDeleteUserTierQuery,
|
||||
deleteUsersMarked: sqliteDeleteUsersMarkedQuery,
|
||||
deleteUsersProvisioned: sqliteDeleteUsersProvisionedQuery,
|
||||
selectTopicPerms: sqliteSelectTopicPermsQuery,
|
||||
selectAccessCacheAll: sqliteSelectAccessCacheAllQuery,
|
||||
selectAccessCacheUsers: sqliteSelectAccessCacheUsersQuery,
|
||||
selectUserAllAccess: sqliteSelectUserAllAccessQuery,
|
||||
selectUserAccess: sqliteSelectUserAccessQuery,
|
||||
selectUserReservations: sqliteSelectUserReservationsQuery,
|
||||
selectUserReservationsCount: sqliteSelectUserReservationsCountQuery,
|
||||
selectUserReservationsOwner: sqliteSelectUserReservationsOwnerQuery,
|
||||
selectUserHasReservation: sqliteSelectUserHasReservationQuery,
|
||||
selectOtherAccessCount: sqliteSelectOtherAccessCountQuery,
|
||||
upsertUserAccess: sqliteUpsertUserAccessQuery,
|
||||
deleteUserAccess: sqliteDeleteUserAccessQuery,
|
||||
deleteUserAccessProvisioned: sqliteDeleteUserAccessProvisionedQuery,
|
||||
deleteTopicAccess: sqliteDeleteTopicAccessQuery,
|
||||
deleteAllAccess: sqliteDeleteAllAccessQuery,
|
||||
selectToken: sqliteSelectTokenQuery,
|
||||
selectTokens: sqliteSelectTokensQuery,
|
||||
selectTokenCount: sqliteSelectTokenCountQuery,
|
||||
selectAllProvisionedTokens: sqliteSelectAllProvisionedTokensQuery,
|
||||
upsertToken: sqliteUpsertTokenQuery,
|
||||
updateToken: sqliteUpdateTokenQuery,
|
||||
updateTokenLastAccess: sqliteUpdateTokenLastAccessQuery,
|
||||
deleteToken: sqliteDeleteTokenQuery,
|
||||
deleteProvisionedToken: sqliteDeleteProvisionedTokenQuery,
|
||||
deleteAllProvisionedTokens: sqliteDeleteAllProvisionedTokensQuery,
|
||||
deleteAllToken: sqliteDeleteAllTokenQuery,
|
||||
deleteExpiredTokens: sqliteDeleteExpiredTokensQuery,
|
||||
deleteExcessTokens: sqliteDeleteExcessTokensQuery,
|
||||
insertTier: sqliteInsertTierQuery,
|
||||
selectTiers: sqliteSelectTiersQuery,
|
||||
selectTierByCode: sqliteSelectTierByCodeQuery,
|
||||
selectTierByPriceID: sqliteSelectTierByPriceIDQuery,
|
||||
updateTier: sqliteUpdateTierQuery,
|
||||
deleteTier: sqliteDeleteTierQuery,
|
||||
selectPhoneNumbers: sqliteSelectPhoneNumbersQuery,
|
||||
insertPhoneNumber: sqliteInsertPhoneNumberQuery,
|
||||
deletePhoneNumber: sqliteDeletePhoneNumberQuery,
|
||||
selectEmails: sqliteSelectEmailsQuery,
|
||||
insertEmail: sqliteInsertEmailQuery,
|
||||
deleteEmail: sqliteDeleteEmailQuery,
|
||||
updateBilling: sqliteUpdateBillingQuery,
|
||||
selectUserByID: sqliteSelectUserByIDQuery,
|
||||
selectUserByName: sqliteSelectUserByNameQuery,
|
||||
selectUserByNameOrPrimaryEmail: sqliteSelectUserByNameOrPrimaryEmailQuery,
|
||||
selectUserByToken: sqliteSelectUserByTokenQuery,
|
||||
selectUserByStripeCustomerID: sqliteSelectUserByStripeCustomerIDQuery,
|
||||
selectUsernames: sqliteSelectUsernamesQuery,
|
||||
selectUsers: sqliteSelectUsersQuery,
|
||||
selectUserCount: sqliteSelectUserCountQuery,
|
||||
selectUserIDFromUsername: sqliteSelectUserIDFromUsernameQuery,
|
||||
insertUser: sqliteInsertUserQuery,
|
||||
updateUserPass: sqliteUpdateUserPassQuery,
|
||||
updateUserRole: sqliteUpdateUserRoleQuery,
|
||||
updateUserProvisioned: sqliteUpdateUserProvisionedQuery,
|
||||
updateUserPrefs: sqliteUpdateUserPrefsQuery,
|
||||
updateUserStats: sqliteUpdateUserStatsQuery,
|
||||
updateUserStatsResetAll: sqliteUpdateUserStatsResetAllQuery,
|
||||
updateUserTier: sqliteUpdateUserTierQuery,
|
||||
updateUserDeleted: sqliteUpdateUserDeletedQuery,
|
||||
deleteUser: sqliteDeleteUserQuery,
|
||||
deleteUserTier: sqliteDeleteUserTierQuery,
|
||||
deleteUsersMarked: sqliteDeleteUsersMarkedQuery,
|
||||
deleteUsersProvisioned: sqliteDeleteUsersProvisionedQuery,
|
||||
selectTopicPerms: sqliteSelectTopicPermsQuery,
|
||||
selectAccessCacheAll: sqliteSelectAccessCacheAllQuery,
|
||||
selectAccessCacheUsers: sqliteSelectAccessCacheUsersQuery,
|
||||
selectUserAllAccess: sqliteSelectUserAllAccessQuery,
|
||||
selectUserAccess: sqliteSelectUserAccessQuery,
|
||||
selectUserReservations: sqliteSelectUserReservationsQuery,
|
||||
selectUserReservationsCount: sqliteSelectUserReservationsCountQuery,
|
||||
selectUserReservationsOwner: sqliteSelectUserReservationsOwnerQuery,
|
||||
selectUserHasReservation: sqliteSelectUserHasReservationQuery,
|
||||
selectOtherAccessCount: sqliteSelectOtherAccessCountQuery,
|
||||
upsertUserAccess: sqliteUpsertUserAccessQuery,
|
||||
deleteUserAccess: sqliteDeleteUserAccessQuery,
|
||||
deleteUserAccessProvisioned: sqliteDeleteUserAccessProvisionedQuery,
|
||||
deleteTopicAccess: sqliteDeleteTopicAccessQuery,
|
||||
deleteAllAccess: sqliteDeleteAllAccessQuery,
|
||||
selectToken: sqliteSelectTokenQuery,
|
||||
selectTokens: sqliteSelectTokensQuery,
|
||||
selectTokenCount: sqliteSelectTokenCountQuery,
|
||||
selectAllProvisionedTokens: sqliteSelectAllProvisionedTokensQuery,
|
||||
upsertToken: sqliteUpsertTokenQuery,
|
||||
updateToken: sqliteUpdateTokenQuery,
|
||||
updateTokenLastAccess: sqliteUpdateTokenLastAccessQuery,
|
||||
deleteToken: sqliteDeleteTokenQuery,
|
||||
deleteProvisionedToken: sqliteDeleteProvisionedTokenQuery,
|
||||
deleteAllProvisionedTokens: sqliteDeleteAllProvisionedTokensQuery,
|
||||
deleteAllToken: sqliteDeleteAllTokenQuery,
|
||||
deleteExpiredTokens: sqliteDeleteExpiredTokensQuery,
|
||||
deleteExcessTokens: sqliteDeleteExcessTokensQuery,
|
||||
insertTier: sqliteInsertTierQuery,
|
||||
selectTiers: sqliteSelectTiersQuery,
|
||||
selectTierByCode: sqliteSelectTierByCodeQuery,
|
||||
selectTierByPriceID: sqliteSelectTierByPriceIDQuery,
|
||||
updateTier: sqliteUpdateTierQuery,
|
||||
deleteTier: sqliteDeleteTierQuery,
|
||||
selectPhoneNumbers: sqliteSelectPhoneNumbersQuery,
|
||||
insertPhoneNumber: sqliteInsertPhoneNumberQuery,
|
||||
deletePhoneNumber: sqliteDeletePhoneNumberQuery,
|
||||
selectEmails: sqliteSelectEmailsQuery,
|
||||
insertEmail: sqliteInsertEmailQuery,
|
||||
insertEmailIgnore: sqliteInsertEmailIgnoreQuery,
|
||||
deleteEmail: sqliteDeleteEmailQuery,
|
||||
selectPrimaryEmail: sqliteSelectPrimaryEmailQuery,
|
||||
selectUserIDByPrimary: sqliteSelectUserIDByPrimaryQuery,
|
||||
updateEmailSetPrimary: sqliteUpdateEmailSetPrimaryQuery,
|
||||
updateEmailClearPrimary: sqliteUpdateEmailClearPrimaryQuery,
|
||||
insertMagicLink: sqliteInsertMagicLinkQuery,
|
||||
selectMagicLinkByHash: sqliteSelectMagicLinkByHashQuery,
|
||||
deleteMagicLinkByHash: sqliteDeleteMagicLinkByHashQuery,
|
||||
deleteMagicLinkEmailVerify: sqliteDeleteVerifyScopeQuery,
|
||||
deleteMagicLinkResetPassword: sqliteDeleteResetScopeQuery,
|
||||
selectPendingEmails: sqliteSelectPendingEmailsQuery,
|
||||
deleteExpiredMagicLinks: sqliteDeleteExpiredMagicLinksQuery,
|
||||
updateBilling: sqliteUpdateBillingQuery,
|
||||
}
|
||||
|
||||
// NewSQLiteManager creates a new Manager backed by a SQLite database
|
||||
@@ -315,14 +352,17 @@ func NewSQLiteManager(filename, startupQueries string, config *Config) (*Manager
|
||||
// Open with case-sensitive LIKE. ACL topic matching is done via LIKE (see
|
||||
// selectTopicPerms), and SQLite's LIKE is case-insensitive for ASCII by
|
||||
// default -- without this, an ACL rule for "secret" would also match a
|
||||
// request for "SECRET", which is a security iisue. PostgreSQL's LIKE is
|
||||
// request for "SECRET", which is a security issue. PostgreSQL's LIKE is
|
||||
// already case-sensitive, so this only affects SQLite. The pragma is
|
||||
// applied to every pooled connection by the driver.
|
||||
d, err := sql.Open("sqlite3", fmt.Sprintf("%s?_case_sensitive_like=on", filename))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := setupSQLite(d); err != nil {
|
||||
// Migrations must run before the startup queries: the 5 -> 6 table rebuilds rely on
|
||||
// foreign keys being OFF, which is only guaranteed on fresh connections (the foreign_keys
|
||||
// pragma is enabled as part of the builtin startup queries below)
|
||||
if err := schema.Migrate(d, schema.SQLite, schemaStore, sqliteCurrentSchemaVersion, sqliteCreateTables, sqliteMigrations); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := runSQLiteStartupQueries(d, startupQueries); err != nil {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user