Compare commits
312 Commits
http-clipb
...
postgres-w
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9e4a48b058 | ||
|
|
939b3d1117 | ||
|
|
9cc9891f49 | ||
|
|
0d1f3444f2 | ||
|
|
2716ede6e1 | ||
|
|
ae5e1fe8d8 | ||
|
|
e3a402ed95 | ||
|
|
1abc1005d0 | ||
|
|
909c3fe17b | ||
|
|
07c3e280bf | ||
|
|
b567b4e904 | ||
|
|
60fa50f0d5 | ||
|
|
ceda5ec3d8 | ||
|
|
3d72845c81 | ||
|
|
82e15d84bd | ||
|
|
4e5f95ba0c | ||
|
|
869b972a50 | ||
|
|
bdd20197b3 | ||
|
|
a8dcecdb6d | ||
|
|
5331437664 | ||
|
|
e432bf2886 | ||
|
|
0edad84d86 | ||
|
|
ddf728acd1 | ||
|
|
b1d3671dbb | ||
|
|
3e6b46ec0c | ||
|
|
b16d381626 | ||
|
|
3bd1a1ea03 | ||
|
|
7adb37b94b | ||
|
|
bc08819525 | ||
|
|
a03a37feb1 | ||
|
|
4cd556f5aa | ||
|
|
90aeb811ff | ||
|
|
c6ab380ea4 | ||
|
|
7860f2142c | ||
|
|
18d5d31bd2 | ||
|
|
cfdc364e3f | ||
|
|
763215ecfa | ||
|
|
3f0a7b65ee | ||
|
|
65050ef4dc | ||
|
|
3647d3975c | ||
|
|
06ea1f98ac | ||
|
|
2827df26ee | ||
|
|
fe6ee1efa0 | ||
|
|
14df6462df | ||
|
|
b9f659c8ac | ||
|
|
623fd4f224 | ||
|
|
49991d5aa7 | ||
|
|
1b554d5b08 | ||
|
|
7b0eb3d467 | ||
|
|
6978fa69a8 | ||
|
|
a1da18b99f | ||
|
|
570b188a88 | ||
|
|
b34d23870b | ||
|
|
08eaafa77b | ||
|
|
325983deaf | ||
|
|
fe386e31dd | ||
|
|
bfb47c4046 | ||
|
|
ad334178de | ||
|
|
68e22ebe7d | ||
|
|
23aff6fb06 | ||
|
|
e01e9d6491 | ||
|
|
f382a13109 | ||
|
|
b2f4046574 | ||
|
|
fd836cacf6 | ||
|
|
7207839b2a | ||
|
|
9fbf5e460e | ||
|
|
08bf71b248 | ||
|
|
b3d246d1f8 | ||
|
|
946a2b6fbe | ||
|
|
b57bc5d86e | ||
|
|
62f3d991b4 | ||
|
|
1cf23a6f86 | ||
|
|
63e9b8425f | ||
|
|
4546eb02a1 | ||
|
|
c33f1494f5 | ||
|
|
27a4a71b23 | ||
|
|
11a8d2e3b4 | ||
|
|
f15a74521a | ||
|
|
df22835932 | ||
|
|
eb3549eedc | ||
|
|
cea5fececb | ||
|
|
f686b8c548 | ||
|
|
448c5bfb88 | ||
|
|
5c3fad28be | ||
|
|
f79c84e99e | ||
|
|
1a172eb73b | ||
|
|
b26546b709 | ||
|
|
2ae962d957 | ||
|
|
2343ce46bd | ||
|
|
a12e18cf12 | ||
|
|
64309c0101 | ||
|
|
b9844f48f1 | ||
|
|
77872f1b6a | ||
|
|
7a7c94cd40 | ||
|
|
01ef1d3004 | ||
|
|
d8232e539a | ||
|
|
860954bdc8 | ||
|
|
f4fe62bd91 | ||
|
|
4b474a89b7 | ||
|
|
5ba1c71140 | ||
|
|
de81865c27 | ||
|
|
ed9c1bcb78 | ||
|
|
190d12cd54 | ||
|
|
63bf82e915 | ||
|
|
014b7355c5 | ||
|
|
602f201bae | ||
|
|
2739d8a325 | ||
|
|
b8e01fde33 | ||
|
|
9ecf21c65a | ||
|
|
ac9cfbfaf4 | ||
|
|
c23d201186 | ||
|
|
86157fc7f6 | ||
|
|
279c164bf5 | ||
|
|
743b00e59c | ||
|
|
eddf654b96 | ||
|
|
886be722bc | ||
|
|
6886ca24b1 | ||
|
|
856f150958 | ||
|
|
5a1aa68ead | ||
|
|
cc9f9c0d24 | ||
|
|
8deb2df88d | ||
|
|
603273ab9d | ||
|
|
64b0bd63af | ||
|
|
220372d65a | ||
|
|
353fedb93f | ||
|
|
dfd12528f3 | ||
|
|
6d5cc6aeac | ||
|
|
9f3883eaf0 | ||
|
|
a0ebd64461 | ||
|
|
dafd130fe5 | ||
|
|
11e9e1e6a0 | ||
|
|
b23f6632b1 | ||
|
|
6bacf7dafc | ||
|
|
0e200b96e0 | ||
|
|
3ce56879ae | ||
|
|
48efdffa57 | ||
|
|
9135bb277b | ||
|
|
711899ad35 | ||
|
|
01435d5fea | ||
|
|
8ce2188b28 | ||
|
|
c1ee163cab | ||
|
|
fcf57a04e1 | ||
|
|
0ad06e808f | ||
|
|
2cc4bf7d28 | ||
|
|
5ae3774f19 | ||
|
|
94eb121f38 | ||
|
|
e81be48bf3 | ||
|
|
db4a4776d3 | ||
|
|
3d54260f79 | ||
|
|
a712d78e4c | ||
|
|
ea3008c707 | ||
|
|
2e39a1329c | ||
|
|
ff2b8167b4 | ||
|
|
96638b516c | ||
|
|
dd9b36cf0a | ||
|
|
44f20f6b4c | ||
|
|
a3c16d81f8 | ||
|
|
c0a5a1fb35 | ||
|
|
b5f24b12dc | ||
|
|
898a52ec63 | ||
|
|
7066ba92a6 | ||
|
|
8960459520 | ||
|
|
f581af6d27 | ||
|
|
88016a2549 | ||
|
|
a7603d1dfb | ||
|
|
65e2af9efb | ||
|
|
090cd720c1 | ||
|
|
7bc8edf7c3 | ||
|
|
13768d4289 | ||
|
|
37d71051de | ||
|
|
5ad3de2904 | ||
|
|
66ea25c18b | ||
|
|
1ab7ca876c | ||
|
|
fd8cd5ca91 | ||
|
|
7cffbfcd6d | ||
|
|
dffee9ea7d | ||
|
|
239959e2a4 | ||
|
|
75abf2e245 | ||
|
|
bfbe73aea3 | ||
|
|
b3721c1b71 | ||
|
|
544f7ef1cb | ||
|
|
2dd152df3f | ||
|
|
2856793eff | ||
|
|
16bec00b38 | ||
|
|
f51e99dc80 | ||
|
|
aca9a77498 | ||
|
|
1c2550d749 | ||
|
|
1c32ee7613 | ||
|
|
f356309f70 | ||
|
|
39936a95f8 | ||
|
|
e18d64933f | ||
|
|
20ccee5d7d | ||
|
|
3575abcde3 | ||
|
|
2cd444ece0 | ||
|
|
eca1ed4d8d | ||
|
|
4ce9508fd3 | ||
|
|
4f9f1292f1 | ||
|
|
d64376c5ac | ||
|
|
275487b3fd | ||
|
|
9301546e6d | ||
|
|
49b3c724cf | ||
|
|
7091775234 | ||
|
|
90667e7176 | ||
|
|
414bbb0171 | ||
|
|
6e6d35157d | ||
|
|
c0f57a448d | ||
|
|
b348bce06e | ||
|
|
06577c99f2 | ||
|
|
f48a9aef2c | ||
|
|
293dda3e79 | ||
|
|
f52b29931f | ||
|
|
3dea1b12cf | ||
|
|
d83be77727 | ||
|
|
6215113c91 | ||
|
|
954ac89b51 | ||
|
|
76fbe19fd1 | ||
|
|
b27c4cf95d | ||
|
|
d915552788 | ||
|
|
c66ad4d7e3 | ||
|
|
152a6b96d1 | ||
|
|
f642b09ebf | ||
|
|
ec414d29be | ||
|
|
0fbd3e33f3 | ||
|
|
17870cb471 | ||
|
|
4e03c96108 | ||
|
|
6cacdd47f2 | ||
|
|
a380860cab | ||
|
|
7f842eaeb1 | ||
|
|
3e019bfe88 | ||
|
|
430135606b | ||
|
|
62a79d9802 | ||
|
|
d9d02dbbc1 | ||
|
|
e0a9e3aa56 | ||
|
|
ce878a5d03 | ||
|
|
43fe1b9ad8 | ||
|
|
b59f451c6a | ||
|
|
693d2d630f | ||
|
|
025c2963a0 | ||
|
|
76bf4a3de7 | ||
|
|
b7ae47d61c | ||
|
|
4fa265ed28 | ||
|
|
b531bc95ea | ||
|
|
da6f6f528c | ||
|
|
926d8b981b | ||
|
|
997923dd98 | ||
|
|
8131d0d883 | ||
|
|
a326dc034f | ||
|
|
736905c1e8 | ||
|
|
857d56c281 | ||
|
|
cef61b2c48 | ||
|
|
b483891bcb | ||
|
|
bb9bbdf736 | ||
|
|
9e1636a3f7 | ||
|
|
315326ffc0 | ||
|
|
65188b1f07 | ||
|
|
5fefcd1296 | ||
|
|
4c4a5725d8 | ||
|
|
ab2a686937 | ||
|
|
01bb0eeccd | ||
|
|
7cabc8bcec | ||
|
|
95f4e58ca0 | ||
|
|
692a1fa532 | ||
|
|
8293a24cf9 | ||
|
|
83b5470bc5 | ||
|
|
2aae3577cb | ||
|
|
54ded9db9a | ||
|
|
db2b3a0dd8 | ||
|
|
0b4bcf573e | ||
|
|
f9a88f841e | ||
|
|
c4291cc23e | ||
|
|
fd0595b547 | ||
|
|
b59e18bed8 | ||
|
|
1956ffbf02 | ||
|
|
e647c68cb9 | ||
|
|
53dce3013d | ||
|
|
7615aa86ad | ||
|
|
07ef3e5656 | ||
|
|
2804acf0f5 | ||
|
|
498b632796 | ||
|
|
61e496fc4c | ||
|
|
83e74b014e | ||
|
|
364696e059 | ||
|
|
546c94ba98 | ||
|
|
203d739d38 | ||
|
|
89fd37bc2c | ||
|
|
7856ab5dfb | ||
|
|
773be05bb1 | ||
|
|
c1a6e9a429 | ||
|
|
c9a0a40805 | ||
|
|
439049624c | ||
|
|
a6078037c0 | ||
|
|
9c5c17441f | ||
|
|
b56d250708 | ||
|
|
a714fe2618 | ||
|
|
e863872d0e | ||
|
|
77406f3496 | ||
|
|
157add4835 | ||
|
|
50f3563477 | ||
|
|
e08f3670d1 | ||
|
|
4f6f45a9c0 | ||
|
|
3de04b27ab | ||
|
|
ec1f97b726 | ||
|
|
569d89e8f8 | ||
|
|
f2f146e39b | ||
|
|
18d08298cc | ||
|
|
1916376f8d | ||
|
|
f6bd0a8d51 | ||
|
|
f72f0d800f | ||
|
|
0e79c4bd2a | ||
|
|
16900d2c10 | ||
|
|
03aeb707f2 | ||
|
|
950ba1e2e1 |
16
.github/workflows/release.yaml
vendored
@@ -6,6 +6,22 @@ on:
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:17
|
||||
env:
|
||||
POSTGRES_USER: ntfy
|
||||
POSTGRES_PASSWORD: ntfy
|
||||
POSTGRES_DB: ntfy_test
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd "pg_isready -U ntfy"
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
env:
|
||||
NTFY_TEST_DATABASE_URL: "postgres://ntfy:ntfy@localhost:5432/ntfy_test?sslmode=disable"
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
|
||||
18
.github/workflows/test.yaml
vendored
@@ -3,6 +3,22 @@ on: [ push, pull_request ]
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:17
|
||||
env:
|
||||
POSTGRES_USER: ntfy
|
||||
POSTGRES_PASSWORD: ntfy
|
||||
POSTGRES_DB: ntfy_test
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd "pg_isready -U ntfy"
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
env:
|
||||
NTFY_TEST_DATABASE_URL: "postgres://ntfy:ntfy@localhost:5432/ntfy_test?sslmode=disable"
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
@@ -23,7 +39,7 @@ jobs:
|
||||
- name: Build web app (required for tests)
|
||||
run: make web
|
||||
- name: Run tests, formatting, vetting and linting
|
||||
run: make check
|
||||
run: make checkv
|
||||
- name: Run coverage
|
||||
run: make coverage
|
||||
- name: Upload coverage to codecov.io
|
||||
|
||||
@@ -48,13 +48,15 @@ builds:
|
||||
- id: ntfy_windows_amd64
|
||||
binary: ntfy
|
||||
env:
|
||||
- CGO_ENABLED=0 # explicitly disable, since we don't need go-sqlite3
|
||||
tags: [ noserver ] # don't include server files
|
||||
- CGO_ENABLED=1 # required for go-sqlite3
|
||||
- CC=x86_64-w64-mingw32-gcc # apt install gcc-mingw-w64-x86-64
|
||||
tags: [ sqlite_omit_load_extension,osusergo,netgo ]
|
||||
ldflags:
|
||||
- "-X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}"
|
||||
- "-s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}"
|
||||
goos: [ windows ]
|
||||
goarch: [ amd64 ]
|
||||
- id: ntfy_darwin_all
|
||||
goarch: [amd64 ]
|
||||
-
|
||||
id: ntfy_darwin_all
|
||||
binary: ntfy
|
||||
env:
|
||||
- CGO_ENABLED=0 # explicitly disable, since we don't need go-sqlite3
|
||||
@@ -201,4 +203,4 @@ docker_manifests:
|
||||
- *amd64_image
|
||||
- *arm64v8_image
|
||||
- *armv7_image
|
||||
- *armv6_image
|
||||
- *armv6_image
|
||||
|
||||
@@ -40,6 +40,7 @@ ADD ./log ./log
|
||||
ADD ./server ./server
|
||||
ADD ./user ./user
|
||||
ADD ./util ./util
|
||||
ADD ./payments ./payments
|
||||
RUN make VERSION=$VERSION COMMIT=$COMMIT cli-linux-server
|
||||
|
||||
FROM alpine
|
||||
|
||||
33
Makefile
@@ -1,4 +1,5 @@
|
||||
MAKEFLAGS := --jobs=1
|
||||
NPM := npm
|
||||
PYTHON := python3
|
||||
PIP := pip3
|
||||
VERSION := $(shell git describe --tag)
|
||||
@@ -31,6 +32,7 @@ help:
|
||||
@echo "Build server & client (without GoReleaser):"
|
||||
@echo " make cli-linux-server - Build client & server (no GoReleaser, current arch, Linux)"
|
||||
@echo " make cli-darwin-server - Build client & server (no GoReleaser, current arch, macOS)"
|
||||
@echo " make cli-windows-server - Build client & server (no GoReleaser, amd64 only, Windows)"
|
||||
@echo " make cli-client - Build client only (no GoReleaser, current arch, Linux/macOS/Windows)"
|
||||
@echo
|
||||
@echo "Build dev Docker:"
|
||||
@@ -106,6 +108,7 @@ build-deps-ubuntu:
|
||||
curl \
|
||||
gcc-aarch64-linux-gnu \
|
||||
gcc-arm-linux-gnueabi \
|
||||
gcc-mingw-w64-x86-64 \
|
||||
python3 \
|
||||
python3-venv \
|
||||
jq
|
||||
@@ -135,7 +138,7 @@ web: web-deps web-build
|
||||
|
||||
web-build:
|
||||
cd web \
|
||||
&& npm run build \
|
||||
&& $(NPM) run build \
|
||||
&& mv build/index.html build/app.html \
|
||||
&& rm -rf ../server/site \
|
||||
&& mv build ../server/site \
|
||||
@@ -143,20 +146,20 @@ web-build:
|
||||
../server/site/config.js
|
||||
|
||||
web-deps:
|
||||
cd web && npm install
|
||||
cd web && $(NPM) install
|
||||
# If this fails for .svg files, optimize them with svgo
|
||||
|
||||
web-deps-update:
|
||||
cd web && npm update
|
||||
cd web && $(NPM) update
|
||||
|
||||
web-fmt:
|
||||
cd web && npm run format
|
||||
cd web && $(NPM) run format
|
||||
|
||||
web-fmt-check:
|
||||
cd web && npm run format:check
|
||||
cd web && $(NPM) run format:check
|
||||
|
||||
web-lint:
|
||||
cd web && npm run lint
|
||||
cd web && $(NPM) run lint
|
||||
|
||||
# Main server/client build
|
||||
|
||||
@@ -201,6 +204,16 @@ cli-darwin-server: cli-deps-static-sites
|
||||
-ldflags \
|
||||
"-linkmode=external -s -w -X main.version=$(VERSION) -X main.commit=$(COMMIT) -X main.date=$(shell date +%s)"
|
||||
|
||||
cli-windows-server: cli-deps-static-sites
|
||||
# This is a target to build the CLI (including the server) for Windows.
|
||||
# Use this for Windows development, if you really don't want to install GoReleaser ...
|
||||
mkdir -p dist/ntfy_windows_server server/docs
|
||||
CC=x86_64-w64-mingw32-gcc GOOS=windows GOARCH=amd64 CGO_ENABLED=1 go build \
|
||||
-o dist/ntfy_windows_server/ntfy.exe \
|
||||
-tags sqlite_omit_load_extension,osusergo,netgo \
|
||||
-ldflags \
|
||||
"-s -w -X main.version=$(VERSION) -X main.commit=$(COMMIT) -X main.date=$(shell date +%s)"
|
||||
|
||||
cli-client: cli-deps-static-sites
|
||||
# This is a target to build the CLI (excluding the server) manually. This should work on Linux/macOS/Windows.
|
||||
# Use this for development, if you really don't want to install GoReleaser ...
|
||||
@@ -213,7 +226,7 @@ cli-client: cli-deps-static-sites
|
||||
|
||||
cli-deps: cli-deps-static-sites cli-deps-all cli-deps-gcc
|
||||
|
||||
cli-deps-gcc: cli-deps-gcc-armv6-armv7 cli-deps-gcc-arm64
|
||||
cli-deps-gcc: cli-deps-gcc-armv6-armv7 cli-deps-gcc-arm64 cli-deps-gcc-windows
|
||||
|
||||
cli-deps-static-sites:
|
||||
mkdir -p server/docs server/site
|
||||
@@ -228,8 +241,12 @@ cli-deps-gcc-armv6-armv7:
|
||||
cli-deps-gcc-arm64:
|
||||
which aarch64-linux-gnu-gcc || { echo "ERROR: ARM64 cross compiler not installed. On Ubuntu, run: apt install gcc-aarch64-linux-gnu"; exit 1; }
|
||||
|
||||
cli-deps-gcc-windows:
|
||||
which x86_64-w64-mingw32-gcc || { echo "ERROR: Windows cross compiler not installed. On Ubuntu, run: apt install gcc-mingw-w64-x86-64"; exit 1; }
|
||||
|
||||
cli-deps-update:
|
||||
go get -u
|
||||
go mod tidy
|
||||
go install honnef.co/go/tools/cmd/staticcheck@latest
|
||||
go install golang.org/x/lint/golint@latest
|
||||
go install github.com/goreleaser/goreleaser/v2@latest
|
||||
@@ -248,6 +265,8 @@ cli-build-results:
|
||||
|
||||
check: test web-fmt-check fmt-check vet web-lint lint staticcheck
|
||||
|
||||
checkv: testv web-fmt-check fmt-check vet web-lint lint staticcheck
|
||||
|
||||
test: .PHONY
|
||||
go test $(shell go list ./... | grep -vE 'ntfy/(test|examples|tools)')
|
||||
|
||||
|
||||
21
README.md
@@ -1,3 +1,16 @@
|
||||
<div align="center" markdown="1">
|
||||
<sup>Special thanks to:</sup>
|
||||
<br>
|
||||
<br>
|
||||
<a href="https://go.warp.dev/ntfy">
|
||||
<img alt="Warp sponsorship" width="400" src="https://raw.githubusercontent.com/warpdotdev/brand-assets/refs/heads/main/Github/Sponsor/Warp-Github-LG-02.png">
|
||||
</a>
|
||||
|
||||
### [Warp, built for coding with multiple AI agents.](https://go.warp.dev/ntfy)
|
||||
[Available for MacOS, Linux, & Windows](https://go.warp.dev/ntfy)<br>
|
||||
</div>
|
||||
<hr>
|
||||
|
||||

|
||||
|
||||
# ntfy.sh | Send push notifications to your phone or desktop via PUT/POST
|
||||
@@ -21,6 +34,12 @@ You can access the free version of ntfy at **[ntfy.sh](https://ntfy.sh)**. There
|
||||
available on [Google Play](https://play.google.com/store/apps/details?id=io.heckel.ntfy) or [F-Droid](https://f-droid.org/en/packages/io.heckel.ntfy/),
|
||||
as well as an [open source iOS app](https://github.com/binwiederhier/ntfy-ios) available on the [App Store](https://apps.apple.com/us/app/ntfy/id1625396347).
|
||||
|
||||
<p>
|
||||
<a href="https://play.google.com/store/apps/details?id=io.heckel.ntfy"><img height="50" src="docs/static/img/badge-googleplay.png"></a>
|
||||
<a href="https://f-droid.org/en/packages/io.heckel.ntfy/"><img width="170" src="docs/static/img/badge-fdroid.svg"></a>
|
||||
<a href="https://apps.apple.com/us/app/ntfy/id1625396347"><img height="50" src="docs/static/img/badge-appstore.png"></a>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<img src=".github/images/screenshot-curl.png" height="180">
|
||||
<img src=".github/images/screenshot-web-detail.png" height="180">
|
||||
@@ -67,6 +86,8 @@ Thank you to our commercial sponsors, who help keep the service running and the
|
||||
|
||||
<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:
|
||||
|
||||
<a href="https://github.com/neutralinsomniac"><img src="https://github.com/neutralinsomniac.png" width="40px" /></a>
|
||||
|
||||
@@ -6,5 +6,7 @@ As of today, I only support the latest version of ntfy. Please make sure you sta
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
Please report severe security issues privately via ntfy@heckel.io, [Discord](https://discord.gg/cT7ECsZj9w),
|
||||
or [Matrix](https://matrix.to/#/#ntfy:matrix.org) (my username is `binwiederhier`).
|
||||
Please report security vulnerabilities privately via email to [security@mail.ntfy.sh](mailto:security@mail.ntfy.sh).
|
||||
|
||||
You can also reach me on [Discord](https://discord.gg/cT7ECsZj9w) or [Matrix](https://matrix.to/#/#ntfy:matrix.org)
|
||||
(my username is `binwiederhier`).
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
# default-command:
|
||||
|
||||
# Subscriptions to topics and their actions. This option is primarily used by the systemd service,
|
||||
# or if you cann "ntfy subscribe --from-config" directly.
|
||||
# or if you can "ntfy subscribe --from-config" directly.
|
||||
#
|
||||
# Example:
|
||||
# subscribe:
|
||||
|
||||
@@ -11,6 +11,9 @@ const (
|
||||
DefaultBaseURL = "https://ntfy.sh"
|
||||
)
|
||||
|
||||
// DefaultConfigFile is the default path to the client config file (set in config_*.go)
|
||||
var DefaultConfigFile string
|
||||
|
||||
// Config is the config struct for a Client
|
||||
type Config struct {
|
||||
DefaultHost string `yaml:"default-host"`
|
||||
|
||||
18
client/config_darwin.go
Normal file
@@ -0,0 +1,18 @@
|
||||
//go:build darwin
|
||||
|
||||
package client
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/user"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
func init() {
|
||||
u, err := user.Current()
|
||||
if err == nil && u.Uid == "0" {
|
||||
DefaultConfigFile = "/etc/ntfy/client.yml"
|
||||
} else if configDir, err := os.UserConfigDir(); err == nil {
|
||||
DefaultConfigFile = filepath.Join(configDir, "ntfy", "client.yml")
|
||||
}
|
||||
}
|
||||
18
client/config_unix.go
Normal file
@@ -0,0 +1,18 @@
|
||||
//go:build linux || dragonfly || freebsd || netbsd || openbsd
|
||||
|
||||
package client
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/user"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
func init() {
|
||||
u, err := user.Current()
|
||||
if err == nil && u.Uid == "0" {
|
||||
DefaultConfigFile = "/etc/ntfy/client.yml"
|
||||
} else if configDir, err := os.UserConfigDir(); err == nil {
|
||||
DefaultConfigFile = filepath.Join(configDir, "ntfy", "client.yml")
|
||||
}
|
||||
}
|
||||
14
client/config_windows.go
Normal file
@@ -0,0 +1,14 @@
|
||||
//go:build windows
|
||||
|
||||
package client
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
func init() {
|
||||
if configDir, err := os.UserConfigDir(); err == nil {
|
||||
DefaultConfigFile = filepath.Join(configDir, "ntfy", "client.yml")
|
||||
}
|
||||
}
|
||||
@@ -88,6 +88,11 @@ func WithFilename(filename string) PublishOption {
|
||||
return WithHeader("X-Filename", filename)
|
||||
}
|
||||
|
||||
// WithSequenceID sets a sequence ID for the message, allowing updates to existing notifications
|
||||
func WithSequenceID(sequenceID string) PublishOption {
|
||||
return WithHeader("X-Sequence-ID", sequenceID)
|
||||
}
|
||||
|
||||
// WithEmail instructs the server to also send the message to the given e-mail address
|
||||
func WithEmail(email string) PublishOption {
|
||||
return WithHeader("X-Email", email)
|
||||
|
||||
11
cmd/app.go
@@ -3,11 +3,12 @@ package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"regexp"
|
||||
|
||||
"github.com/urfave/cli/v2"
|
||||
"github.com/urfave/cli/v2/altsrc"
|
||||
"heckel.io/ntfy/v2/log"
|
||||
"os"
|
||||
"regexp"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -15,6 +16,12 @@ const (
|
||||
categoryServer = "Server commands"
|
||||
)
|
||||
|
||||
// Build metadata keys for app.Metadata
|
||||
const (
|
||||
MetadataKeyCommit = "commit"
|
||||
MetadataKeyDate = "date"
|
||||
)
|
||||
|
||||
var commands = make([]*cli.Command, 0)
|
||||
|
||||
var flagsDefault = []cli.Flag{
|
||||
|
||||
@@ -34,6 +34,7 @@ var flagsPublish = append(
|
||||
&cli.BoolFlag{Name: "markdown", Aliases: []string{"md"}, EnvVars: []string{"NTFY_MARKDOWN"}, Usage: "Message is formatted as Markdown"},
|
||||
&cli.StringFlag{Name: "template", Aliases: []string{"tpl"}, EnvVars: []string{"NTFY_TEMPLATE"}, Usage: "use templates to transform JSON message body"},
|
||||
&cli.StringFlag{Name: "filename", Aliases: []string{"name", "n"}, EnvVars: []string{"NTFY_FILENAME"}, Usage: "filename for the attachment"},
|
||||
&cli.StringFlag{Name: "sequence-id", Aliases: []string{"sequence_id", "sid", "S"}, EnvVars: []string{"NTFY_SEQUENCE_ID"}, Usage: "sequence ID for updating notifications"},
|
||||
&cli.StringFlag{Name: "file", Aliases: []string{"f"}, EnvVars: []string{"NTFY_FILE"}, Usage: "file to upload as an attachment"},
|
||||
&cli.StringFlag{Name: "email", Aliases: []string{"mail", "e"}, EnvVars: []string{"NTFY_EMAIL"}, Usage: "also send to e-mail address"},
|
||||
&cli.StringFlag{Name: "user", Aliases: []string{"u"}, EnvVars: []string{"NTFY_USER"}, Usage: "username[:password] used to auth against the server"},
|
||||
@@ -70,6 +71,7 @@ Examples:
|
||||
ntfy pub --icon="http://some.tld/icon.png" 'Icon!' # Send notification with custom icon
|
||||
ntfy pub --attach="http://some.tld/file.zip" files # Send ZIP archive from URL as attachment
|
||||
ntfy pub --file=flower.jpg flowers 'Nice!' # Send image.jpg as attachment
|
||||
ntfy pub -S my-id mytopic 'Update me' # Send with sequence ID for updates
|
||||
echo 'message' | ntfy publish mytopic # Send message from stdin
|
||||
ntfy pub -u phil:mypass secret Psst # Publish with username/password
|
||||
ntfy pub --wait-pid 1234 mytopic # Wait for process 1234 to exit before publishing
|
||||
@@ -101,6 +103,7 @@ func execPublish(c *cli.Context) error {
|
||||
markdown := c.Bool("markdown")
|
||||
template := c.String("template")
|
||||
filename := c.String("filename")
|
||||
sequenceID := c.String("sequence-id")
|
||||
file := c.String("file")
|
||||
email := c.String("email")
|
||||
user := c.String("user")
|
||||
@@ -154,6 +157,9 @@ func execPublish(c *cli.Context) error {
|
||||
if filename != "" {
|
||||
options = append(options, client.WithFilename(filename))
|
||||
}
|
||||
if sequenceID != "" {
|
||||
options = append(options, client.WithSequenceID(sequenceID))
|
||||
}
|
||||
if email != "" {
|
||||
options = append(options, client.WithEmail(email))
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
//go:build darwin || linux || dragonfly || freebsd || netbsd || openbsd
|
||||
// +build darwin linux dragonfly freebsd netbsd openbsd
|
||||
|
||||
package cmd
|
||||
|
||||
|
||||
87
cmd/serve.go
@@ -10,10 +10,9 @@ import (
|
||||
"net"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/signal"
|
||||
"runtime"
|
||||
"strings"
|
||||
"syscall"
|
||||
"text/template"
|
||||
"time"
|
||||
|
||||
"github.com/urfave/cli/v2"
|
||||
@@ -40,6 +39,7 @@ var flagsServe = append(
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "key-file", Aliases: []string{"key_file", "K"}, EnvVars: []string{"NTFY_KEY_FILE"}, Usage: "private key file, if listen-https is set"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "cert-file", Aliases: []string{"cert_file", "E"}, EnvVars: []string{"NTFY_CERT_FILE"}, Usage: "certificate file, if listen-https is set"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "firebase-key-file", Aliases: []string{"firebase_key_file", "F"}, EnvVars: []string{"NTFY_FIREBASE_KEY_FILE"}, Usage: "Firebase credentials file; if set additionally publish to FCM topic"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "database-url", Aliases: []string{"database_url"}, EnvVars: []string{"NTFY_DATABASE_URL"}, Usage: "PostgreSQL connection string for database-backed stores (e.g. postgres://user:pass@host:5432/ntfy)"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "cache-file", Aliases: []string{"cache_file", "C"}, EnvVars: []string{"NTFY_CACHE_FILE"}, Usage: "cache file used for message caching"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "cache-duration", Aliases: []string{"cache_duration", "b"}, EnvVars: []string{"NTFY_CACHE_DURATION"}, Value: util.FormatDuration(server.DefaultCacheDuration), Usage: "buffer messages for this time to allow `since` requests"}),
|
||||
altsrc.NewIntFlag(&cli.IntFlag{Name: "cache-batch-size", Aliases: []string{"cache_batch_size"}, EnvVars: []string{"NTFY_BATCH_SIZE"}, Usage: "max size of messages to batch together when writing to message cache (if zero, writes are synchronous)"}),
|
||||
@@ -63,6 +63,7 @@ var flagsServe = append(
|
||||
altsrc.NewBoolFlag(&cli.BoolFlag{Name: "enable-signup", Aliases: []string{"enable_signup"}, EnvVars: []string{"NTFY_ENABLE_SIGNUP"}, Value: false, Usage: "allows users to sign up via the web app, or API"}),
|
||||
altsrc.NewBoolFlag(&cli.BoolFlag{Name: "enable-login", Aliases: []string{"enable_login"}, EnvVars: []string{"NTFY_ENABLE_LOGIN"}, Value: false, Usage: "allows users to log in via the web app, or API"}),
|
||||
altsrc.NewBoolFlag(&cli.BoolFlag{Name: "enable-reservations", Aliases: []string{"enable_reservations"}, EnvVars: []string{"NTFY_ENABLE_RESERVATIONS"}, Value: false, Usage: "allows users to reserve topics (if their tier allows it)"}),
|
||||
altsrc.NewBoolFlag(&cli.BoolFlag{Name: "require-login", Aliases: []string{"require_login"}, EnvVars: []string{"NTFY_REQUIRE_LOGIN"}, Value: false, Usage: "all actions via the web app requires a login"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "upstream-base-url", Aliases: []string{"upstream_base_url"}, EnvVars: []string{"NTFY_UPSTREAM_BASE_URL"}, Value: "", Usage: "forward poll request to an upstream server, this is needed for iOS push notifications for self-hosted servers"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "upstream-access-token", Aliases: []string{"upstream_access_token"}, EnvVars: []string{"NTFY_UPSTREAM_ACCESS_TOKEN"}, Value: "", Usage: "access token to use for the upstream server; needed only if upstream rate limits are exceeded or upstream server requires auth"}),
|
||||
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"}),
|
||||
@@ -76,6 +77,7 @@ var flagsServe = append(
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "twilio-auth-token", Aliases: []string{"twilio_auth_token"}, EnvVars: []string{"NTFY_TWILIO_AUTH_TOKEN"}, Usage: "Twilio auth token"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "twilio-phone-number", Aliases: []string{"twilio_phone_number"}, EnvVars: []string{"NTFY_TWILIO_PHONE_NUMBER"}, Usage: "Twilio number to use for outgoing calls"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "twilio-verify-service", Aliases: []string{"twilio_verify_service"}, EnvVars: []string{"NTFY_TWILIO_VERIFY_SERVICE"}, Usage: "Twilio Verify service ID, used for phone number verification"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "twilio-call-format", Aliases: []string{"twilio_call_format"}, EnvVars: []string{"NTFY_TWILIO_CALL_FORMAT"}, Usage: "Twilio/TwiML format string for phone calls"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "message-size-limit", Aliases: []string{"message_size_limit"}, EnvVars: []string{"NTFY_MESSAGE_SIZE_LIMIT"}, Value: util.FormatSize(server.DefaultMessageSizeLimit), Usage: "size limit for the message (see docs for limitations)"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "message-delay-limit", Aliases: []string{"message_delay_limit"}, EnvVars: []string{"NTFY_MESSAGE_DELAY_LIMIT"}, Value: util.FormatDuration(server.DefaultMessageDelayMax), Usage: "max duration a message can be scheduled into the future"}),
|
||||
altsrc.NewIntFlag(&cli.IntFlag{Name: "global-topic-limit", Aliases: []string{"global_topic_limit", "T"}, EnvVars: []string{"NTFY_GLOBAL_TOPIC_LIMIT"}, Value: server.DefaultTotalTopicLimit, Usage: "total number of topics allowed"}),
|
||||
@@ -142,6 +144,7 @@ func execServe(c *cli.Context) error {
|
||||
keyFile := c.String("key-file")
|
||||
certFile := c.String("cert-file")
|
||||
firebaseKeyFile := c.String("firebase-key-file")
|
||||
databaseURL := c.String("database-url")
|
||||
webPushPrivateKey := c.String("web-push-private-key")
|
||||
webPushPublicKey := c.String("web-push-public-key")
|
||||
webPushFile := c.String("web-push-file")
|
||||
@@ -171,6 +174,7 @@ func execServe(c *cli.Context) error {
|
||||
webRoot := c.String("web-root")
|
||||
enableSignup := c.Bool("enable-signup")
|
||||
enableLogin := c.Bool("enable-login")
|
||||
requireLogin := c.Bool("require-login")
|
||||
enableReservations := c.Bool("enable-reservations")
|
||||
upstreamBaseURL := c.String("upstream-base-url")
|
||||
upstreamAccessToken := c.String("upstream-access-token")
|
||||
@@ -185,6 +189,7 @@ func execServe(c *cli.Context) error {
|
||||
twilioAuthToken := c.String("twilio-auth-token")
|
||||
twilioPhoneNumber := c.String("twilio-phone-number")
|
||||
twilioVerifyService := c.String("twilio-verify-service")
|
||||
twilioCallFormat := c.String("twilio-call-format")
|
||||
messageSizeLimitStr := c.String("message-size-limit")
|
||||
messageDelayLimitStr := c.String("message-delay-limit")
|
||||
totalTopicLimit := c.Int("global-topic-limit")
|
||||
@@ -281,8 +286,8 @@ func execServe(c *cli.Context) error {
|
||||
return errors.New("if set, FCM key file must exist")
|
||||
} else if firebaseKeyFile != "" && !server.FirebaseAvailable {
|
||||
return errors.New("cannot set firebase-key-file, support for Firebase is not available (nofirebase)")
|
||||
} else if webPushPublicKey != "" && (webPushPrivateKey == "" || webPushFile == "" || webPushEmailAddress == "" || baseURL == "") {
|
||||
return errors.New("if web push is enabled, web-push-private-key, web-push-public-key, web-push-file, web-push-email-address, and base-url should be set. run 'ntfy webpush keys' to generate keys")
|
||||
} else if webPushPublicKey != "" && (webPushPrivateKey == "" || (webPushFile == "" && databaseURL == "") || webPushEmailAddress == "" || baseURL == "") {
|
||||
return errors.New("if web push is enabled, web-push-private-key, web-push-public-key, web-push-file (or database-url), web-push-email-address, and base-url should be set. run 'ntfy webpush keys' to generate keys")
|
||||
} else if keepaliveInterval < 5*time.Second {
|
||||
return errors.New("keepalive interval cannot be lower than five seconds")
|
||||
} else if managerInterval < 5*time.Second {
|
||||
@@ -318,10 +323,12 @@ func execServe(c *cli.Context) error {
|
||||
return errors.New("if upstream-base-url is set, base-url must also be set")
|
||||
} else if upstreamBaseURL != "" && baseURL != "" && baseURL == upstreamBaseURL {
|
||||
return errors.New("base-url and upstream-base-url cannot be identical, you'll likely want to set upstream-base-url to https://ntfy.sh, see https://ntfy.sh/docs/config/#ios-instant-notifications")
|
||||
} else if authFile == "" && (enableSignup || enableLogin || enableReservations || stripeSecretKey != "") {
|
||||
return errors.New("cannot set enable-signup, enable-login, enable-reserve-topics, or stripe-secret-key if auth-file is not set")
|
||||
} else if authFile == "" && (enableSignup || enableLogin || requireLogin || enableReservations || stripeSecretKey != "") {
|
||||
return errors.New("cannot set enable-signup, enable-login, require-login, enable-reserve-topics, or stripe-secret-key if auth-file is not set")
|
||||
} else if enableSignup && !enableLogin {
|
||||
return errors.New("cannot set enable-signup without also setting enable-login")
|
||||
} else if requireLogin && !enableLogin {
|
||||
return errors.New("cannot set require-login without also setting enable-login")
|
||||
} else if !payments.Available && (stripeSecretKey != "" || stripeWebhookKey != "") {
|
||||
return errors.New("cannot set stripe-secret-key or stripe-webhook-key, support for payments is not available in this build (nopayments)")
|
||||
} else if stripeSecretKey != "" && (stripeWebhookKey == "" || baseURL == "") {
|
||||
@@ -343,6 +350,8 @@ 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 runtime.GOOS == "windows" && listenUnix != "" {
|
||||
return errors.New("listen-unix is not supported on Windows")
|
||||
}
|
||||
|
||||
// Backwards compatibility
|
||||
@@ -452,6 +461,13 @@ func execServe(c *cli.Context) error {
|
||||
conf.TwilioAuthToken = twilioAuthToken
|
||||
conf.TwilioPhoneNumber = twilioPhoneNumber
|
||||
conf.TwilioVerifyService = twilioVerifyService
|
||||
if twilioCallFormat != "" {
|
||||
tmpl, err := template.New("twiml").Parse(twilioCallFormat)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse twilio-call-format template: %w", err)
|
||||
}
|
||||
conf.TwilioCallFormat = tmpl
|
||||
}
|
||||
conf.MessageSizeLimit = int(messageSizeLimit)
|
||||
conf.MessageDelayMax = messageDelayLimit
|
||||
conf.TotalTopicLimit = totalTopicLimit
|
||||
@@ -475,10 +491,12 @@ func execServe(c *cli.Context) error {
|
||||
conf.BillingContact = billingContact
|
||||
conf.EnableSignup = enableSignup
|
||||
conf.EnableLogin = enableLogin
|
||||
conf.RequireLogin = requireLogin
|
||||
conf.EnableReservations = enableReservations
|
||||
conf.EnableMetrics = enableMetrics
|
||||
conf.MetricsListenHTTP = metricsListenHTTP
|
||||
conf.ProfileListenHTTP = profileListenHTTP
|
||||
conf.DatabaseURL = databaseURL
|
||||
conf.WebPushPrivateKey = webPushPrivateKey
|
||||
conf.WebPushPublicKey = webPushPublicKey
|
||||
conf.WebPushFile = webPushFile
|
||||
@@ -486,7 +504,17 @@ func execServe(c *cli.Context) error {
|
||||
conf.WebPushStartupQueries = webPushStartupQueries
|
||||
conf.WebPushExpiryDuration = webPushExpiryDuration
|
||||
conf.WebPushExpiryWarningDuration = webPushExpiryWarningDuration
|
||||
conf.Version = c.App.Version
|
||||
conf.BuildVersion = c.App.Version
|
||||
conf.BuildDate = maybeFromMetadata(c.App.Metadata, MetadataKeyDate)
|
||||
conf.BuildCommit = maybeFromMetadata(c.App.Metadata, MetadataKeyCommit)
|
||||
|
||||
// Check if we should run as a Windows service
|
||||
if ranAsService, err := maybeRunAsService(conf); err != nil {
|
||||
log.Fatal("%s", err.Error())
|
||||
} else if ranAsService {
|
||||
log.Info("Exiting.")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Set up hot-reloading of config
|
||||
go sigHandlerConfigReload(config)
|
||||
@@ -502,22 +530,6 @@ func execServe(c *cli.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func sigHandlerConfigReload(config string) {
|
||||
sigs := make(chan os.Signal, 1)
|
||||
signal.Notify(sigs, syscall.SIGHUP)
|
||||
for range sigs {
|
||||
log.Info("Partially hot reloading configuration ...")
|
||||
inputSource, err := newYamlSourceFromFile(config, flagsServe)
|
||||
if err != nil {
|
||||
log.Warn("Hot reload failed: %s", err.Error())
|
||||
continue
|
||||
}
|
||||
if err := reloadLogLevel(inputSource); err != nil {
|
||||
log.Warn("Reloading log level failed: %s", err.Error())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func parseIPHostPrefix(host string) (prefixes []netip.Prefix, err error) {
|
||||
// Try parsing as prefix, e.g. 10.0.1.0/24 or 2001:db8::/32
|
||||
prefix, err := netip.ParsePrefix(host)
|
||||
@@ -649,24 +661,17 @@ func parseTokens(users []*user.User, tokensRaw []string) (map[string][]*user.Tok
|
||||
return tokens, nil
|
||||
}
|
||||
|
||||
func reloadLogLevel(inputSource altsrc.InputSourceContext) error {
|
||||
newLevelStr, err := inputSource.String("log-level")
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot load log level: %s", err.Error())
|
||||
func maybeFromMetadata(m map[string]any, key string) string {
|
||||
if m == nil {
|
||||
return ""
|
||||
}
|
||||
overrides, err := inputSource.StringSlice("log-level-overrides")
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot load log level overrides (1): %s", err.Error())
|
||||
v, exists := m[key]
|
||||
if !exists {
|
||||
return ""
|
||||
}
|
||||
log.ResetLevelOverrides()
|
||||
if err := applyLogLevelOverrides(overrides); err != nil {
|
||||
return fmt.Errorf("cannot load log level overrides (2): %s", err.Error())
|
||||
s, ok := v.(string)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
log.SetLevel(log.ToLevel(newLevelStr))
|
||||
if len(overrides) > 0 {
|
||||
log.Info("Log level is %v, %d override(s) in place", strings.ToUpper(newLevelStr), len(overrides))
|
||||
} else {
|
||||
log.Info("Log level is %v", strings.ToUpper(newLevelStr))
|
||||
}
|
||||
return nil
|
||||
return s
|
||||
}
|
||||
|
||||
55
cmd/serve_unix.go
Normal file
@@ -0,0 +1,55 @@
|
||||
//go:build linux || dragonfly || freebsd || netbsd || openbsd
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
|
||||
"github.com/urfave/cli/v2/altsrc"
|
||||
"heckel.io/ntfy/v2/log"
|
||||
"heckel.io/ntfy/v2/server"
|
||||
)
|
||||
|
||||
func sigHandlerConfigReload(config string) {
|
||||
sigs := make(chan os.Signal, 1)
|
||||
signal.Notify(sigs, syscall.SIGHUP)
|
||||
for range sigs {
|
||||
log.Info("Partially hot reloading configuration ...")
|
||||
inputSource, err := newYamlSourceFromFile(config, flagsServe)
|
||||
if err != nil {
|
||||
log.Warn("Hot reload failed: %s", err.Error())
|
||||
continue
|
||||
}
|
||||
if err := reloadLogLevel(inputSource); err != nil {
|
||||
log.Warn("Reloading log level failed: %s", err.Error())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func reloadLogLevel(inputSource altsrc.InputSourceContext) error {
|
||||
newLevelStr, err := inputSource.String("log-level")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
overrides, err := inputSource.StringSlice("log-level-overrides")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
log.ResetLevelOverrides()
|
||||
if err := applyLogLevelOverrides(overrides); err != nil {
|
||||
return err
|
||||
}
|
||||
log.SetLevel(log.ToLevel(newLevelStr))
|
||||
if len(overrides) > 0 {
|
||||
log.Info("Log level is %v, %d override(s) in place", newLevelStr, len(overrides))
|
||||
} else {
|
||||
log.Info("Log level is %v", newLevelStr)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func maybeRunAsService(conf *server.Config) (bool, error) {
|
||||
return false, nil
|
||||
}
|
||||
100
cmd/serve_windows.go
Normal file
@@ -0,0 +1,100 @@
|
||||
//go:build windows && !noserver
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
"golang.org/x/sys/windows/svc"
|
||||
"heckel.io/ntfy/v2/log"
|
||||
"heckel.io/ntfy/v2/server"
|
||||
)
|
||||
|
||||
const serviceName = "ntfy"
|
||||
|
||||
// sigHandlerConfigReload is a no-op on Windows since SIGHUP is not available.
|
||||
// Windows users can restart the service to reload configuration.
|
||||
func sigHandlerConfigReload(config string) {
|
||||
log.Debug("Config hot-reload via SIGHUP is not supported on Windows")
|
||||
}
|
||||
|
||||
// runAsWindowsService runs the ntfy server as a Windows service
|
||||
func runAsWindowsService(conf *server.Config) error {
|
||||
return svc.Run(serviceName, &windowsService{conf: conf})
|
||||
}
|
||||
|
||||
// windowsService implements the svc.Handler interface
|
||||
type windowsService struct {
|
||||
conf *server.Config
|
||||
server *server.Server
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// Execute is the main entry point for the Windows service
|
||||
func (s *windowsService) Execute(args []string, requests <-chan svc.ChangeRequest, status chan<- svc.Status) (bool, uint32) {
|
||||
const cmdsAccepted = svc.AcceptStop | svc.AcceptShutdown
|
||||
status <- svc.Status{State: svc.StartPending}
|
||||
|
||||
// Create and start the server
|
||||
var err error
|
||||
s.mu.Lock()
|
||||
s.server, err = server.New(s.conf)
|
||||
s.mu.Unlock()
|
||||
if err != nil {
|
||||
log.Error("Failed to create server: %s", err.Error())
|
||||
return true, 1
|
||||
}
|
||||
|
||||
// Start server in a goroutine
|
||||
serverErrChan := make(chan error, 1)
|
||||
go func() {
|
||||
serverErrChan <- s.server.Run()
|
||||
}()
|
||||
|
||||
status <- svc.Status{State: svc.Running, Accepts: cmdsAccepted}
|
||||
log.Info("Windows service started")
|
||||
|
||||
for {
|
||||
select {
|
||||
case err := <-serverErrChan:
|
||||
if err != nil {
|
||||
log.Error("Server error: %s", err.Error())
|
||||
return true, 1
|
||||
}
|
||||
return false, 0
|
||||
case req := <-requests:
|
||||
switch req.Cmd {
|
||||
case svc.Interrogate:
|
||||
status <- req.CurrentStatus
|
||||
case svc.Stop, svc.Shutdown:
|
||||
log.Info("Windows service stopping...")
|
||||
status <- svc.Status{State: svc.StopPending}
|
||||
s.mu.Lock()
|
||||
if s.server != nil {
|
||||
s.server.Stop()
|
||||
}
|
||||
s.mu.Unlock()
|
||||
return false, 0
|
||||
default:
|
||||
log.Warn("Unexpected service control request: %d", req.Cmd)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// maybeRunAsService checks if the process is running as a Windows service,
|
||||
// and if so, runs the server as a service. Returns true if it ran as a service.
|
||||
func maybeRunAsService(conf *server.Config) (bool, error) {
|
||||
isService, err := svc.IsWindowsService()
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("failed to detect Windows service mode: %w", err)
|
||||
} else if !isService {
|
||||
return false, nil
|
||||
}
|
||||
log.Info("Running as Windows service")
|
||||
if err := runAsWindowsService(conf); err != nil {
|
||||
return true, fmt.Errorf("failed to run as Windows service: %w", err)
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
@@ -3,28 +3,21 @@ package cmd
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/urfave/cli/v2"
|
||||
"heckel.io/ntfy/v2/client"
|
||||
"heckel.io/ntfy/v2/log"
|
||||
"heckel.io/ntfy/v2/util"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/user"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func init() {
|
||||
commands = append(commands, cmdSubscribe)
|
||||
}
|
||||
|
||||
const (
|
||||
clientRootConfigFileUnixAbsolute = "/etc/ntfy/client.yml"
|
||||
clientUserConfigFileUnixRelative = "ntfy/client.yml"
|
||||
clientUserConfigFileWindowsRelative = "ntfy\\client.yml"
|
||||
)
|
||||
|
||||
var flagsSubscribe = append(
|
||||
append([]cli.Flag{}, flagsDefault...),
|
||||
&cli.StringFlag{Name: "config", Aliases: []string{"c"}, Usage: "client config file"},
|
||||
@@ -310,45 +303,16 @@ func loadConfig(c *cli.Context) (*client.Config, error) {
|
||||
if filename != "" {
|
||||
return client.LoadConfig(filename)
|
||||
}
|
||||
configFile, err := defaultClientConfigFile()
|
||||
if err != nil {
|
||||
log.Warn("Could not determine default client config file: %s", err.Error())
|
||||
} else {
|
||||
if s, _ := os.Stat(configFile); s != nil {
|
||||
return client.LoadConfig(configFile)
|
||||
if client.DefaultConfigFile != "" {
|
||||
if s, _ := os.Stat(client.DefaultConfigFile); s != nil {
|
||||
return client.LoadConfig(client.DefaultConfigFile)
|
||||
}
|
||||
log.Debug("Config file %s not found", configFile)
|
||||
log.Debug("Config file %s not found", client.DefaultConfigFile)
|
||||
}
|
||||
log.Debug("Loading default config")
|
||||
return client.NewConfig(), nil
|
||||
}
|
||||
|
||||
//lint:ignore U1000 Conditionally used in different builds
|
||||
func defaultClientConfigFileUnix() (string, error) {
|
||||
u, err := user.Current()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("could not determine current user: %w", err)
|
||||
}
|
||||
configFile := clientRootConfigFileUnixAbsolute
|
||||
if u.Uid != "0" {
|
||||
homeDir, err := os.UserConfigDir()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("could not determine user config dir: %w", err)
|
||||
}
|
||||
return filepath.Join(homeDir, clientUserConfigFileUnixRelative), nil
|
||||
}
|
||||
return configFile, nil
|
||||
}
|
||||
|
||||
//lint:ignore U1000 Conditionally used in different builds
|
||||
func defaultClientConfigFileWindows() (string, error) {
|
||||
homeDir, err := os.UserConfigDir()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("could not determine user config dir: %w", err)
|
||||
}
|
||||
return filepath.Join(homeDir, clientUserConfigFileWindowsRelative), nil
|
||||
}
|
||||
|
||||
func logMessagePrefix(m *client.Message) string {
|
||||
return fmt.Sprintf("%s/%s", util.ShortTopicURL(m.TopicURL), m.ID)
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//go:build darwin
|
||||
|
||||
package cmd
|
||||
|
||||
const (
|
||||
@@ -10,7 +12,3 @@ or "~/Library/Application Support/ntfy/client.yml" for all other users.`
|
||||
var (
|
||||
scriptLauncher = []string{"sh", "-c"}
|
||||
)
|
||||
|
||||
func defaultClientConfigFile() (string, error) {
|
||||
return defaultClientConfigFileUnix()
|
||||
}
|
||||
|
||||
@@ -12,7 +12,3 @@ or ~/.config/ntfy/client.yml for all other users.`
|
||||
var (
|
||||
scriptLauncher = []string{"sh", "-c"}
|
||||
)
|
||||
|
||||
func defaultClientConfigFile() (string, error) {
|
||||
return defaultClientConfigFileUnix()
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//go:build windows
|
||||
|
||||
package cmd
|
||||
|
||||
const (
|
||||
@@ -9,7 +11,3 @@ const (
|
||||
var (
|
||||
scriptLauncher = []string{"cmd.exe", "/Q", "/C"}
|
||||
)
|
||||
|
||||
func defaultClientConfigFile() (string, error) {
|
||||
return defaultClientConfigFileWindows()
|
||||
}
|
||||
|
||||
25
cmd/user.go
@@ -29,6 +29,7 @@ var flagsUser = append(
|
||||
&cli.StringFlag{Name: "config", Aliases: []string{"c"}, EnvVars: []string{"NTFY_CONFIG_FILE"}, Value: server.DefaultConfigFile, DefaultText: server.DefaultConfigFile, Usage: "config file"},
|
||||
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"}),
|
||||
)
|
||||
|
||||
var cmdUser = &cli.Command{
|
||||
@@ -365,24 +366,32 @@ func createUserManager(c *cli.Context) (*user.Manager, error) {
|
||||
authFile := c.String("auth-file")
|
||||
authStartupQueries := c.String("auth-startup-queries")
|
||||
authDefaultAccess := c.String("auth-default-access")
|
||||
if authFile == "" {
|
||||
return nil, errors.New("option auth-file not set; auth is unconfigured for this server")
|
||||
} else if !util.FileExists(authFile) {
|
||||
return nil, errors.New("auth-file does not exist; please start the server at least once to create it")
|
||||
}
|
||||
databaseURL := c.String("database-url")
|
||||
authDefault, err := user.ParsePermission(authDefaultAccess)
|
||||
if err != nil {
|
||||
return nil, errors.New("if set, auth-default-access must start set to 'read-write', 'read-only', 'write-only' or 'deny-all'")
|
||||
}
|
||||
authConfig := &user.Config{
|
||||
Filename: authFile,
|
||||
StartupQueries: authStartupQueries,
|
||||
DefaultAccess: authDefault,
|
||||
ProvisionEnabled: false, // Hack: Do not re-provision users on manager initialization
|
||||
BcryptCost: user.DefaultUserPasswordBcryptCost,
|
||||
QueueWriterInterval: user.DefaultUserStatsQueueWriterInterval,
|
||||
}
|
||||
return user.NewManager(authConfig)
|
||||
var store user.Store
|
||||
if databaseURL != "" {
|
||||
store, err = user.NewPostgresStore(databaseURL)
|
||||
} else if authFile != "" {
|
||||
if !util.FileExists(authFile) {
|
||||
return nil, errors.New("auth-file does not exist; please start the server at least once to create it")
|
||||
}
|
||||
store, err = user.NewSQLiteStore(authFile, authStartupQueries)
|
||||
} else {
|
||||
return nil, errors.New("option database-url or auth-file not set; auth is unconfigured for this server")
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return user.NewManager(store, authConfig)
|
||||
}
|
||||
|
||||
func readPasswordAndConfirm(c *cli.Context) (string, error) {
|
||||
|
||||
137
docs/config.md
@@ -144,6 +144,20 @@ the message to the subscribers.
|
||||
Subscribers can retrieve cached messaging using the [`poll=1` parameter](subscribe/api.md#poll-for-messages), as well as the
|
||||
[`since=` parameter](subscribe/api.md#fetch-cached-messages).
|
||||
|
||||
## PostgreSQL database
|
||||
By default, ntfy uses SQLite for all database-backed stores. As an alternative, you can configure ntfy to use PostgreSQL
|
||||
by setting the `database-url` option to a PostgreSQL connection string:
|
||||
|
||||
```yaml
|
||||
database-url: "postgres://user:pass@host:5432/ntfy"
|
||||
```
|
||||
|
||||
When `database-url` is set, ntfy will use PostgreSQL for the web push subscription store instead of SQLite. The
|
||||
`web-push-file` option is not required in this case. Support for PostgreSQL for the message cache and user manager
|
||||
will be added in future releases.
|
||||
|
||||
You can also set this via the environment variable `NTFY_DATABASE_URL` or the command line flag `--database-url`.
|
||||
|
||||
## Attachments
|
||||
If desired, you may allow users to upload and [attach files to notifications](publish.md#attachments). To enable
|
||||
this feature, you have to simply configure an attachment cache directory and a base URL (`attachment-cache-dir`, `base-url`).
|
||||
@@ -1013,7 +1027,7 @@ or the root domain:
|
||||
=== "caddy"
|
||||
```
|
||||
# Note that this config is most certainly incomplete. Please help out and let me know what's missing
|
||||
# via Discord/Matrix or in a GitHub issue.
|
||||
# via the contact page (https://ntfy.sh/docs/contact/) or in a GitHub issue.
|
||||
# Note: Caddy automatically handles both HTTP and WebSockets with reverse_proxy
|
||||
|
||||
ntfy.sh, http://nfty.sh {
|
||||
@@ -1029,6 +1043,36 @@ or the root domain:
|
||||
redir @httpget https://{host}{uri}
|
||||
}
|
||||
```
|
||||
|
||||
=== "ferron"
|
||||
``` kdl
|
||||
// /etc/ferron.kdl
|
||||
// Note that this config is most certainly incomplete. Please help out and let me know what's missing
|
||||
// via the contact page (https://ntfy.sh/docs/contact/) or in a GitHub issue.
|
||||
// Note: Ferron automatically handles both HTTP and WebSockets with proxy
|
||||
|
||||
ntfy.sh {
|
||||
auto_tls
|
||||
auto_tls_letsencrypt_production
|
||||
protocols "h1" "h2" "h3"
|
||||
|
||||
proxy "http://127.0.0.1:2586"
|
||||
|
||||
// Redirect HTTP to HTTPS, but only for GET topic addresses, since we want
|
||||
// it to work with curl without the annoying https:// prefix
|
||||
|
||||
no_redirect_to_https #true
|
||||
|
||||
condition "is_get_topic" {
|
||||
is_equal "{method}" "GET"
|
||||
is_regex "{path}" "^/([-_a-z0-9]{0,64}$|docs/|static/)"
|
||||
}
|
||||
|
||||
if "is_get_topic" {
|
||||
no_redirect_to_https #false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Firebase (FCM)
|
||||
!!! info
|
||||
@@ -1111,12 +1155,15 @@ a database to keep track of the browser's subscriptions, and an admin email addr
|
||||
|
||||
- `web-push-public-key` is the generated VAPID public key, e.g. AA1234BBCCddvveekaabcdfqwertyuiopasdfghjklzxcvbnm1234567890
|
||||
- `web-push-private-key` is the generated VAPID private key, e.g. AA2BB1234567890abcdefzxcvbnm1234567890
|
||||
- `web-push-file` is a database file to keep track of browser subscription endpoints, e.g. `/var/cache/ntfy/webpush.db`
|
||||
- `web-push-file` is a database file to keep track of browser subscription endpoints, e.g. `/var/cache/ntfy/webpush.db` (not required if `database-url` is set)
|
||||
- `web-push-email-address` is the admin email address send to the push provider, e.g. `sysadmin@example.com`
|
||||
- `web-push-startup-queries` is an optional list of queries to run on startup`
|
||||
- `web-push-expiry-warning-duration` defines the duration after which unused subscriptions are sent a warning (default is `55d`)
|
||||
- `web-push-expiry-duration` defines the duration after which unused subscriptions will expire (default is `60d`)
|
||||
|
||||
Alternatively, you can use PostgreSQL instead of SQLite for the web push subscription store by setting `database-url`
|
||||
(see [PostgreSQL database](#postgresql-database)).
|
||||
|
||||
Limitations:
|
||||
|
||||
- Like foreground browser notifications, background push notifications require the web app to be served over HTTPS. A _valid_
|
||||
@@ -1142,9 +1189,10 @@ web-push-file: /var/cache/ntfy/webpush.db
|
||||
web-push-email-address: sysadmin@example.com
|
||||
```
|
||||
|
||||
The `web-push-file` is used to store the push subscriptions. Unused subscriptions will send out a warning after 55 days,
|
||||
and will automatically expire after 60 days (default). If the gateway returns an error (e.g. 410 Gone when a user has unsubscribed),
|
||||
subscriptions are also removed automatically.
|
||||
The `web-push-file` is used to store the push subscriptions in a local SQLite database. Alternatively, if `database-url`
|
||||
is set, subscriptions are stored in PostgreSQL and `web-push-file` is not required. Unused subscriptions will send out
|
||||
a warning after 55 days, and will automatically expire after 60 days (default). If the gateway returns an error
|
||||
(e.g. 410 Gone when a user has unsubscribed), subscriptions are also removed automatically.
|
||||
|
||||
The web app refreshes subscriptions on start and regularly on an interval, but this file should be persisted across restarts. If the subscription
|
||||
file is deleted or lost, any web apps that aren't open will not receive new web push notifications until you open then.
|
||||
@@ -1231,10 +1279,85 @@ are the easiest), and then configure the following options:
|
||||
* `twilio-auth-token` is the Twilio auth token, e.g. affebeef258625862586258625862586
|
||||
* `twilio-phone-number` is the outgoing phone number you purchased, e.g. +18775132586
|
||||
* `twilio-verify-service` is the Twilio Verify service SID, e.g. VA12345beefbeef67890beefbeef122586
|
||||
* `twilio-call-format` is the custom Twilio markup ([TwiML](https://www.twilio.com/docs/voice/twiml)) to use for phone calls (optional)
|
||||
|
||||
After you have configured phone calls, create a [tier](#tiers) with a call limit (e.g. `ntfy tier create --call-limit=10 ...`),
|
||||
and then assign it to a user. Users may then use the `X-Call` header to receive a phone call when publishing a message.
|
||||
|
||||
To customize the message that is spoken out loud, set the `twilio-call-format` option with [TwiML](https://www.twilio.com/docs/voice/twiml). The format is
|
||||
rendered as a [Go template](https://pkg.go.dev/text/template), so you can use the following fields from the message:
|
||||
|
||||
* `{{.Topic}}` is the topic name
|
||||
* `{{.Message}}` is the message body
|
||||
* `{{.Title}}` is the message title
|
||||
* `{{.Tags}}` is a list of tags
|
||||
* `{{.Priority}}` is the message priority
|
||||
* `{{.Sender}}` is the IP address or username of the sender
|
||||
|
||||
Here's an example:
|
||||
|
||||
=== "Custom TwiML (English)"
|
||||
``` yaml
|
||||
twilio-account: "AC12345beefbeef67890beefbeef122586"
|
||||
twilio-auth-token: "affebeef258625862586258625862586"
|
||||
twilio-phone-number: "+18775132586"
|
||||
twilio-verify-service: "VA12345beefbeef67890beefbeef122586"
|
||||
twilio-call-format: |
|
||||
<Response>
|
||||
<Pause length="1"/>
|
||||
<Say loop="3">
|
||||
Yo yo yo, you should totally check out this message for {{.Topic}}.
|
||||
{{ if eq .Priority 5 }}
|
||||
It's really really important, dude. So listen up!
|
||||
{{ end }}
|
||||
<break time="1s"/>
|
||||
{{ if neq .Title "" }}
|
||||
Bro, it's titled: {{.Title}}.
|
||||
{{ end }}
|
||||
<break time="1s"/>
|
||||
{{.Message}}
|
||||
<break time="1s"/>
|
||||
That is all.
|
||||
<break time="1s"/>
|
||||
You know who this message is from? It is from {{.Sender}}.
|
||||
<break time="3s"/>
|
||||
</Say>
|
||||
<Say>See ya!</Say>
|
||||
</Response>
|
||||
```
|
||||
|
||||
=== "Custom TwiML (German)"
|
||||
``` yaml
|
||||
twilio-account: "AC12345beefbeef67890beefbeef122586"
|
||||
twilio-auth-token: "affebeef258625862586258625862586"
|
||||
twilio-phone-number: "+18775132586"
|
||||
twilio-verify-service: "VA12345beefbeef67890beefbeef122586"
|
||||
twilio-call-format: |
|
||||
<Response>
|
||||
<Pause length="1"/>
|
||||
<Say loop="3" voice="alice" language="de-DE">
|
||||
Du hast eine Nachricht zum Thema {{.Topic}}.
|
||||
{{ if eq .Priority 5 }}
|
||||
Achtung. Die Nachricht ist sehr wichtig.
|
||||
{{ end }}
|
||||
<break time="1s"/>
|
||||
{{ if neq .Title "" }}
|
||||
Titel der Nachricht: {{.Title}}.
|
||||
{{ end }}
|
||||
<break time="1s"/>
|
||||
Nachricht:
|
||||
<break time="1s"/>
|
||||
{{.Message}}
|
||||
<break time="1s"/>
|
||||
Ende der Nachricht.
|
||||
<break time="1s"/>
|
||||
Diese Nachricht wurde vom Benutzer {{.Sender}} gesendet. Sie wird drei Mal wiederholt.
|
||||
<break time="3s"/>
|
||||
</Say>
|
||||
<Say voice="alice" language="de-DE">Alla mol!</Say>
|
||||
</Response>
|
||||
```
|
||||
|
||||
## Message limits
|
||||
There are a few message limits that you can configure:
|
||||
|
||||
@@ -1531,7 +1654,7 @@ 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 `listen-metrics-http` option to a dedicated
|
||||
To configure the metrics endpoint, either set `enable-metrics` and/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.
|
||||
|
||||
@@ -1650,6 +1773,7 @@ variable before running the `ntfy` command (e.g. `export NTFY_LISTEN_HTTP=:80`).
|
||||
| `key-file` | `NTFY_KEY_FILE` | *filename* | - | HTTPS/TLS private key file, only used if `listen-https` is set. |
|
||||
| `cert-file` | `NTFY_CERT_FILE` | *filename* | - | HTTPS/TLS certificate file, only used if `listen-https` is set. |
|
||||
| `firebase-key-file` | `NTFY_FIREBASE_KEY_FILE` | *filename* | - | If set, also publish messages to a Firebase Cloud Messaging (FCM) topic for your app. This is optional and only required to save battery when using the Android app. See [Firebase (FCM)](#firebase-fcm). |
|
||||
| `database-url` | `NTFY_DATABASE_URL` | *string (connection URL)* | - | PostgreSQL connection string (e.g. `postgres://user:pass@host:5432/ntfy`). If set, uses PostgreSQL for database-backed stores instead of SQLite. Currently applies to the web push store. See [PostgreSQL database](#postgresql-database). |
|
||||
| `cache-file` | `NTFY_CACHE_FILE` | *filename* | - | If set, messages are cached in a local SQLite database instead of only in-memory. This allows for service restarts without losing messages in support of the since= parameter. See [message cache](#message-cache). |
|
||||
| `cache-duration` | `NTFY_CACHE_DURATION` | *duration* | 12h | Duration for which messages will be buffered before they are deleted. This is required to support the `since=...` and `poll=1` parameter. Set this to `0` to disable the cache entirely. |
|
||||
| `cache-startup-queries` | `NTFY_CACHE_STARTUP_QUERIES` | *string (SQL queries)* | - | SQL queries to run during database startup; this is useful for tuning and [enabling WAL mode](#message-cache) |
|
||||
@@ -1698,6 +1822,7 @@ variable before running the `ntfy` command (e.g. `export NTFY_LISTEN_HTTP=:80`).
|
||||
| `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 |
|
||||
| `enable-reservations` | `NTFY_ENABLE_RESERVATIONS` | *boolean* (`true` or `false`) | `false` | Allows users to reserve topics (if their tier allows it) |
|
||||
| `require-login` | `NTFY_REQUIRE_LOGIN` | *boolean* (`true` or `false`) | `false` | All actions via the web app require a login |
|
||||
| `stripe-secret-key` | `NTFY_STRIPE_SECRET_KEY` | *string* | - | Payments: Key used for the Stripe API communication, this enables payments |
|
||||
| `stripe-webhook-key` | `NTFY_STRIPE_WEBHOOK_KEY` | *string* | - | Payments: Key required to validate the authenticity of incoming webhooks from Stripe |
|
||||
| `billing-contact` | `NTFY_BILLING_CONTACT` | *email address* or *website* | - | Payments: Email or website displayed in Upgrade dialog as a billing contact |
|
||||
|
||||
46
docs/contact.md
Normal file
@@ -0,0 +1,46 @@
|
||||
# Contact
|
||||
|
||||
This service is run by [Philipp C. Heckel](https://heckel.io). There are several ways to get in touch with me and the
|
||||
ntfy community. Please choose the appropriate channel based on your needs.
|
||||
|
||||
## Support
|
||||
|
||||
### Community support
|
||||
|
||||
For general questions, feature discussions, and community help, please use one of these public channels:
|
||||
|
||||
| Channel | Link | Description |
|
||||
|-------------------|--------------------------------------------------------------------------------------|------------------------------------------------------------|
|
||||
| **Discord** | [discord.gg/cT7ECsZj9w](https://discord.gg/cT7ECsZj9w) | Real-time chat with the community (I'm `binwiederhier`) |
|
||||
| **Matrix** | [#ntfy:matrix.org](https://matrix.to/#/#ntfy:matrix.org) | Bridged from Discord, same community (I'm `binwiederhier`) |
|
||||
| **Matrix Space** | [#ntfy-space:matrix.org](https://matrix.to/#/#ntfy-space:matrix.org) | Matrix space with all ntfy rooms |
|
||||
| **GitHub Issues** | [github.com/binwiederhier/ntfy/issues](https://github.com/binwiederhier/ntfy/issues) | Bug reports and feature requests |
|
||||
|
||||
!!! info "Why public channels?"
|
||||
Answering questions in public channels benefits the entire community. Other users can learn from the
|
||||
discussion, and answers can be referenced later. This is much more scalable than 1-on-1 support.
|
||||
|
||||
### Paid support
|
||||
|
||||
If you are subscribed to a [ntfy Pro](https://ntfy.sh/#pricing) plan, you are entitled to priority support
|
||||
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 |
|
||||
| **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.
|
||||
|
||||
## Security issues
|
||||
|
||||
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).
|
||||
|
||||
## Other inquiries
|
||||
|
||||
For questions about our [privacy policy](privacy.md), data handling, or to exercise your data rights
|
||||
(access, deletion, etc.), please email [privacy@mail.ntfy.sh](mailto:privacy@mail.ntfy.sh).
|
||||
|
||||
For business inquiries, partnerships, press, or other general questions that don't fit the categories above, please
|
||||
use [contact@mail.ntfy.sh](mailto:contact@mail.ntfy.sh).
|
||||
43
docs/contributing.md
Normal file
@@ -0,0 +1,43 @@
|
||||
# Contributing
|
||||
|
||||
Thank you for your interest in contributing to ntfy! There are many ways to help, whether you're a developer,
|
||||
translator, or just an enthusiastic user.
|
||||
|
||||
## Code contributions
|
||||
|
||||
If you'd like to contribute code to ntfy:
|
||||
|
||||
1. Check out the [development guide](develop.md) to set up your environment
|
||||
2. Look at [open issues](https://github.com/binwiederhier/ntfy/issues) for ideas, or propose your own
|
||||
3. For larger features or architectural changes, please reach out on [Discord/Matrix](contact.md) first to discuss
|
||||
before investing significant time
|
||||
4. Submit a pull request on GitHub
|
||||
|
||||
All contributions are welcome, from small bug fixes to major features.
|
||||
|
||||
## Translations
|
||||
|
||||
Help make ntfy accessible to users around the world! We use Hosted Weblate for translations:
|
||||
|
||||
- **Weblate**: [hosted.weblate.org/projects/ntfy](https://hosted.weblate.org/projects/ntfy/)
|
||||
|
||||
You can start translating immediately without any coding knowledge.
|
||||
|
||||
## Documentation
|
||||
|
||||
Found a typo? Want to improve the docs? Documentation contributions are very welcome:
|
||||
|
||||
- Edit any page directly on GitHub using the edit button
|
||||
- Submit a pull request with your improvements
|
||||
|
||||
## Bug reports and feature requests
|
||||
|
||||
- **GitHub Issues**: [github.com/binwiederhier/ntfy/issues](https://github.com/binwiederhier/ntfy/issues)
|
||||
|
||||
Please search existing issues before creating a new one to avoid duplicates.
|
||||
|
||||
## Code of Conduct
|
||||
|
||||
Please be respectful and constructive in all interactions. See the
|
||||
[Code of Conduct](https://github.com/binwiederhier/ntfy/blob/main/CODE_OF_CONDUCT.md) for details.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
Hurray 🥳 🎉, you are interested in writing code for ntfy! **That's awesome.** 😎
|
||||
|
||||
I tried my very best to write up detailed instructions, but if at any point in time you run into issues, don't
|
||||
hesitate to **contact me on [Discord](https://discord.gg/cT7ECsZj9w) or [Matrix](https://matrix.to/#/#ntfy:matrix.org)**.
|
||||
hesitate to reach out via one of the channels listed on the [contact page](contact.md).
|
||||
|
||||
## ntfy server
|
||||
The ntfy server source code is available [on GitHub](https://github.com/binwiederhier/ntfy). The codebase for the
|
||||
@@ -340,10 +340,6 @@ Then either follow the steps for building with or without Firebase.
|
||||
Without Firebase, you may want to still change the default `app_base_url` in [values.xml](https://github.com/binwiederhier/ntfy-android/blob/main/app/src/main/res/values/values.xml)
|
||||
if you're self-hosting the server. Then run:
|
||||
```
|
||||
# Remove Google dependencies (FCM)
|
||||
sed -i -e '/google-services/d' build.gradle
|
||||
sed -i -e '/google-services/d' app/build.gradle
|
||||
|
||||
# To build an unsigned .apk (app/build/outputs/apk/fdroid/*.apk)
|
||||
./gradlew assembleFdroidRelease
|
||||
|
||||
@@ -351,6 +347,8 @@ sed -i -e '/google-services/d' app/build.gradle
|
||||
./gradlew bundleFdroidRelease
|
||||
```
|
||||
|
||||
The F-Droid flavor automatically excludes Google Services dependencies.
|
||||
|
||||
### Build Play flavor (FCM)
|
||||
!!! info
|
||||
I do build the ntfy Android app using IntelliJ IDEA (Android Studio), so I don't know if these Gradle commands will
|
||||
@@ -441,6 +439,6 @@ To have instant notifications/better notification delivery when using firebase,
|
||||
1. In XCode, find the NTFY app target. **Not** the NSE app target.
|
||||
1. Find the Asset/ folder in the project navigator
|
||||
1. Drag the `GoogleService-Info.plist` file into the Asset/ folder that you get from the firebase console. It can be
|
||||
found in the "Project settings" > "General" > "Your apps" with a button labled "GoogleService-Info.plist"
|
||||
found in the "Project settings" > "General" > "Your apps" with a button labeled "GoogleService-Info.plist"
|
||||
|
||||
After that, you should be all set!
|
||||
|
||||
10
docs/faq.md
@@ -94,11 +94,11 @@ I would be humbled if you helped me carry the server and developer account costs
|
||||
appreciated.
|
||||
|
||||
## Can I email you? Can I DM you on Discord/Matrix?
|
||||
While I love chatting on [Discord](https://discord.gg/cT7ECsZj9w), [Matrix](https://matrix.to/#/#ntfy-space:matrix.org),
|
||||
[Lemmy](https://discuss.ntfy.sh/c/ntfy), or [GitHub](https://github.com/binwiederhier/ntfy/issues), I generally
|
||||
**do not respond to emails about ntfy or direct messages** about ntfy, unless you are paying for a
|
||||
[ntfy Pro](https://ntfy.sh/#pricing) plan, or you are inquiring about business opportunities.
|
||||
For community support, please use the public channels listed on the [contact page](contact.md). I generally
|
||||
**do not respond to direct messages** about ntfy, unless you are paying for a [ntfy Pro](https://ntfy.sh/#pricing)
|
||||
plan (see [paid support](contact.md#paid-support)), or you are inquiring about business
|
||||
opportunities (see [other inquiries](contact.md#other-inquiries)).
|
||||
|
||||
I am sorry, but answering individual questions about ntfy on a 1-on-1 basis is not scalable. Answering your questions
|
||||
in the above-mentioned forums benefits others, since I can link to the discussion at a later point in time, or other users
|
||||
in public forums benefits others, since I can link to the discussion at a later point in time, or other users
|
||||
may be able to help out. I hope you understand.
|
||||
|
||||
@@ -4,7 +4,7 @@ or POST requests. I use it to notify myself when scripts fail, or long-running c
|
||||
|
||||
## Step 1: Get the app
|
||||
<a href="https://play.google.com/store/apps/details?id=io.heckel.ntfy"><img width="170" src="static/img/badge-googleplay.png"></a>
|
||||
<a href="https://f-droid.org/en/packages/io.heckel.ntfy/"><img width="170" src="static/img/badge-fdroid.png"></a>
|
||||
<a href="https://f-droid.org/en/packages/io.heckel.ntfy/"><img width="170" src="static/img/badge-fdroid.svg"></a>
|
||||
<a href="https://apps.apple.com/us/app/ntfy/id1625396347"><img width="150" src="static/img/badge-appstore.png"></a>
|
||||
|
||||
To [receive notifications on your phone](subscribe/phone.md), install the app, either via Google Play, App Store or F-Droid.
|
||||
|
||||
153
docs/install.md
@@ -30,50 +30,56 @@ deb/rpm packages.
|
||||
|
||||
=== "x86_64/amd64"
|
||||
```bash
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.14.0/ntfy_2.14.0_linux_amd64.tar.gz
|
||||
tar zxvf ntfy_2.14.0_linux_amd64.tar.gz
|
||||
sudo cp -a ntfy_2.14.0_linux_amd64/ntfy /usr/local/bin/ntfy
|
||||
sudo mkdir /etc/ntfy && sudo cp ntfy_2.14.0_linux_amd64/{client,server}/*.yml /etc/ntfy
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.17.0/ntfy_2.17.0_linux_amd64.tar.gz
|
||||
tar zxvf ntfy_2.17.0_linux_amd64.tar.gz
|
||||
sudo cp -a ntfy_2.17.0_linux_amd64/ntfy /usr/local/bin/ntfy
|
||||
sudo mkdir /etc/ntfy && sudo cp ntfy_2.17.0_linux_amd64/{client,server}/*.yml /etc/ntfy
|
||||
sudo ntfy serve
|
||||
```
|
||||
|
||||
=== "armv6"
|
||||
```bash
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.14.0/ntfy_2.14.0_linux_armv6.tar.gz
|
||||
tar zxvf ntfy_2.14.0_linux_armv6.tar.gz
|
||||
sudo cp -a ntfy_2.14.0_linux_armv6/ntfy /usr/bin/ntfy
|
||||
sudo mkdir /etc/ntfy && sudo cp ntfy_2.14.0_linux_armv6/{client,server}/*.yml /etc/ntfy
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.17.0/ntfy_2.17.0_linux_armv6.tar.gz
|
||||
tar zxvf ntfy_2.17.0_linux_armv6.tar.gz
|
||||
sudo cp -a ntfy_2.17.0_linux_armv6/ntfy /usr/bin/ntfy
|
||||
sudo mkdir /etc/ntfy && sudo cp ntfy_2.17.0_linux_armv6/{client,server}/*.yml /etc/ntfy
|
||||
sudo ntfy serve
|
||||
```
|
||||
|
||||
=== "armv7/armhf"
|
||||
```bash
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.14.0/ntfy_2.14.0_linux_armv7.tar.gz
|
||||
tar zxvf ntfy_2.14.0_linux_armv7.tar.gz
|
||||
sudo cp -a ntfy_2.14.0_linux_armv7/ntfy /usr/bin/ntfy
|
||||
sudo mkdir /etc/ntfy && sudo cp ntfy_2.14.0_linux_armv7/{client,server}/*.yml /etc/ntfy
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.17.0/ntfy_2.17.0_linux_armv7.tar.gz
|
||||
tar zxvf ntfy_2.17.0_linux_armv7.tar.gz
|
||||
sudo cp -a ntfy_2.17.0_linux_armv7/ntfy /usr/bin/ntfy
|
||||
sudo mkdir /etc/ntfy && sudo cp ntfy_2.17.0_linux_armv7/{client,server}/*.yml /etc/ntfy
|
||||
sudo ntfy serve
|
||||
```
|
||||
|
||||
=== "arm64"
|
||||
```bash
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.14.0/ntfy_2.14.0_linux_arm64.tar.gz
|
||||
tar zxvf ntfy_2.14.0_linux_arm64.tar.gz
|
||||
sudo cp -a ntfy_2.14.0_linux_arm64/ntfy /usr/bin/ntfy
|
||||
sudo mkdir /etc/ntfy && sudo cp ntfy_2.14.0_linux_arm64/{client,server}/*.yml /etc/ntfy
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.17.0/ntfy_2.17.0_linux_arm64.tar.gz
|
||||
tar zxvf ntfy_2.17.0_linux_arm64.tar.gz
|
||||
sudo cp -a ntfy_2.17.0_linux_arm64/ntfy /usr/bin/ntfy
|
||||
sudo mkdir /etc/ntfy && sudo cp ntfy_2.17.0_linux_arm64/{client,server}/*.yml /etc/ntfy
|
||||
sudo ntfy serve
|
||||
```
|
||||
|
||||
## Debian/Ubuntu repository
|
||||
Installation via Debian repository:
|
||||
|
||||
!!! info
|
||||
As of September 2025, **the official ntfy.sh Debian/Ubuntu repository has moved to [archive.ntfy.sh](https://archive.ntfy.sh/apt)**.
|
||||
The old repository [archive.heckel.io](https://archive.heckel.io/apt) is still available for now, but will likely
|
||||
go away soon. I suspect I will phase it out some time in early 2026.
|
||||
|
||||
Installation via Debian/Ubuntu repository (fingerprint `55BA 774A 6F5E E674 31E4 B6B7 CFDB 962D 4F1E C4AF`):
|
||||
|
||||
=== "x86_64/amd64"
|
||||
```bash
|
||||
sudo mkdir -p /etc/apt/keyrings
|
||||
curl -fsSL https://archive.heckel.io/apt/pubkey.txt | sudo gpg --dearmor -o /etc/apt/keyrings/archive.heckel.io.gpg
|
||||
sudo curl -L -o /etc/apt/keyrings/ntfy.gpg https://archive.ntfy.sh/apt/keyring.gpg
|
||||
sudo apt install apt-transport-https
|
||||
sudo sh -c "echo 'deb [arch=amd64 signed-by=/etc/apt/keyrings/archive.heckel.io.gpg] https://archive.heckel.io/apt debian main' \
|
||||
> /etc/apt/sources.list.d/archive.heckel.io.list"
|
||||
echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/ntfy.gpg] https://archive.ntfy.sh/apt stable main" \
|
||||
| sudo tee /etc/apt/sources.list.d/ntfy.list
|
||||
sudo apt update
|
||||
sudo apt install ntfy
|
||||
sudo systemctl enable ntfy
|
||||
@@ -83,10 +89,10 @@ Installation via Debian repository:
|
||||
=== "armv7/armhf"
|
||||
```bash
|
||||
sudo mkdir -p /etc/apt/keyrings
|
||||
curl -fsSL https://archive.heckel.io/apt/pubkey.txt | sudo gpg --dearmor -o /etc/apt/keyrings/archive.heckel.io.gpg
|
||||
sudo curl -L -o /etc/apt/keyrings/ntfy.gpg https://archive.ntfy.sh/apt/keyring.gpg
|
||||
sudo apt install apt-transport-https
|
||||
sudo sh -c "echo 'deb [arch=armhf signed-by=/etc/apt/keyrings/archive.heckel.io.gpg] https://archive.heckel.io/apt debian main' \
|
||||
> /etc/apt/sources.list.d/archive.heckel.io.list"
|
||||
echo "deb [arch=armhf signed-by=/etc/apt/keyrings/ntfy.gpg] https://archive.ntfy.sh/apt stable main" \
|
||||
| sudo tee /etc/apt/sources.list.d/ntfy.list
|
||||
sudo apt update
|
||||
sudo apt install ntfy
|
||||
sudo systemctl enable ntfy
|
||||
@@ -96,10 +102,10 @@ Installation via Debian repository:
|
||||
=== "arm64"
|
||||
```bash
|
||||
sudo mkdir -p /etc/apt/keyrings
|
||||
curl -fsSL https://archive.heckel.io/apt/pubkey.txt | sudo gpg --dearmor -o /etc/apt/keyrings/archive.heckel.io.gpg
|
||||
sudo curl -L -o /etc/apt/keyrings/ntfy.gpg https://archive.ntfy.sh/apt/keyring.gpg
|
||||
sudo apt install apt-transport-https
|
||||
sudo sh -c "echo 'deb [arch=arm64 signed-by=/etc/apt/keyrings/archive.heckel.io.gpg] https://archive.heckel.io/apt debian main' \
|
||||
> /etc/apt/sources.list.d/archive.heckel.io.list"
|
||||
echo "deb [arch=arm64 signed-by=/etc/apt/keyrings/ntfy.gpg] https://archive.ntfy.sh/apt stable main" \
|
||||
| sudo tee /etc/apt/sources.list.d/ntfy.list
|
||||
sudo apt update
|
||||
sudo apt install ntfy
|
||||
sudo systemctl enable ntfy
|
||||
@@ -110,7 +116,7 @@ Manually installing the .deb file:
|
||||
|
||||
=== "x86_64/amd64"
|
||||
```bash
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.14.0/ntfy_2.14.0_linux_amd64.deb
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.17.0/ntfy_2.17.0_linux_amd64.deb
|
||||
sudo dpkg -i ntfy_*.deb
|
||||
sudo systemctl enable ntfy
|
||||
sudo systemctl start ntfy
|
||||
@@ -118,7 +124,7 @@ Manually installing the .deb file:
|
||||
|
||||
=== "armv6"
|
||||
```bash
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.14.0/ntfy_2.14.0_linux_armv6.deb
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.17.0/ntfy_2.17.0_linux_armv6.deb
|
||||
sudo dpkg -i ntfy_*.deb
|
||||
sudo systemctl enable ntfy
|
||||
sudo systemctl start ntfy
|
||||
@@ -126,7 +132,7 @@ Manually installing the .deb file:
|
||||
|
||||
=== "armv7/armhf"
|
||||
```bash
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.14.0/ntfy_2.14.0_linux_armv7.deb
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.17.0/ntfy_2.17.0_linux_armv7.deb
|
||||
sudo dpkg -i ntfy_*.deb
|
||||
sudo systemctl enable ntfy
|
||||
sudo systemctl start ntfy
|
||||
@@ -134,7 +140,7 @@ Manually installing the .deb file:
|
||||
|
||||
=== "arm64"
|
||||
```bash
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.14.0/ntfy_2.14.0_linux_arm64.deb
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.17.0/ntfy_2.17.0_linux_arm64.deb
|
||||
sudo dpkg -i ntfy_*.deb
|
||||
sudo systemctl enable ntfy
|
||||
sudo systemctl start ntfy
|
||||
@@ -144,33 +150,35 @@ Manually installing the .deb file:
|
||||
|
||||
=== "x86_64/amd64"
|
||||
```bash
|
||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.14.0/ntfy_2.14.0_linux_amd64.rpm
|
||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.17.0/ntfy_2.17.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.14.0/ntfy_2.14.0_linux_armv6.rpm
|
||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.17.0/ntfy_2.17.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.14.0/ntfy_2.14.0_linux_armv7.rpm
|
||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.17.0/ntfy_2.17.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.14.0/ntfy_2.14.0_linux_arm64.rpm
|
||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.17.0/ntfy_2.17.0_linux_arm64.rpm
|
||||
sudo systemctl enable ntfy
|
||||
sudo systemctl start ntfy
|
||||
```
|
||||
|
||||
## Arch Linux
|
||||
<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>
|
||||
|
||||
ntfy can be installed using an [AUR package](https://aur.archlinux.org/packages/ntfysh-bin/).
|
||||
You can use an [AUR helper](https://wiki.archlinux.org/title/AUR_helpers) like `paru`, `yay` or others to download,
|
||||
build and install ntfy and keep it up to date.
|
||||
@@ -185,7 +193,9 @@ cd ntfysh-bin
|
||||
makepkg -si
|
||||
```
|
||||
|
||||
## NixOS / Nix
|
||||
## NixOS / Nix
|
||||
<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>
|
||||
|
||||
ntfy is packaged in nixpkgs as `ntfy-sh`. It can be installed by adding the package name to the configuration file and calling `nixos-rebuild`. Alternatively, the following command can be used to install ntfy in the current user environment:
|
||||
```
|
||||
nix-env -iA ntfy-sh
|
||||
@@ -193,20 +203,28 @@ nix-env -iA ntfy-sh
|
||||
|
||||
NixOS also supports [declarative setup of the ntfy server](https://search.nixos.org/options?channel=unstable&show=services.ntfy-sh.enable&from=0&size=50&sort=relevance&type=packages&query=ntfy).
|
||||
|
||||
## FreeBSD
|
||||
<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>
|
||||
|
||||
ntfy is ported to FreeBSD and available via the ports collection as [sysutils/go-ntfy](https://www.freshports.org/sysutils/go-ntfy/). You can install it via `pkg`:
|
||||
```
|
||||
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.14.0/ntfy_2.14.0_darwin_all.tar.gz),
|
||||
To install, please [download the tarball](https://github.com/binwiederhier/ntfy/releases/download/v2.17.0/ntfy_2.17.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.14.0/ntfy_2.14.0_darwin_all.tar.gz > ntfy_2.14.0_darwin_all.tar.gz
|
||||
tar zxvf ntfy_2.14.0_darwin_all.tar.gz
|
||||
sudo cp -a ntfy_2.14.0_darwin_all/ntfy /usr/local/bin/ntfy
|
||||
curl -L https://github.com/binwiederhier/ntfy/releases/download/v2.17.0/ntfy_2.17.0_darwin_all.tar.gz > ntfy_2.17.0_darwin_all.tar.gz
|
||||
tar zxvf ntfy_2.17.0_darwin_all.tar.gz
|
||||
sudo cp -a ntfy_2.17.0_darwin_all/ntfy /usr/local/bin/ntfy
|
||||
mkdir ~/Library/Application\ Support/ntfy
|
||||
cp ntfy_2.14.0_darwin_all/client/client.yml ~/Library/Application\ Support/ntfy/client.yml
|
||||
cp ntfy_2.17.0_darwin_all/client/client.yml ~/Library/Application\ Support/ntfy/client.yml
|
||||
ntfy --help
|
||||
```
|
||||
|
||||
@@ -215,27 +233,38 @@ ntfy --help
|
||||
development as well. Check out the [build instructions](develop.md) for details.
|
||||
|
||||
## Homebrew
|
||||
<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>
|
||||
|
||||
To install the [ntfy CLI](subscribe/cli.md) (`ntfy publish` and `ntfy subscribe` only) via Homebrew (Linux and macOS),
|
||||
simply run:
|
||||
```
|
||||
brew install ntfy
|
||||
```
|
||||
|
||||
|
||||
## Windows
|
||||
The [ntfy CLI](subscribe/cli.md) (`ntfy publish` and `ntfy subscribe` only) is supported on Windows as well.
|
||||
To install, please [download the latest ZIP](https://github.com/binwiederhier/ntfy/releases/download/v2.14.0/ntfy_2.14.0_windows_amd64.zip),
|
||||
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.17.0/ntfy_2.17.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`
|
||||
|
||||
The default path for the client config file is at `%AppData%\ntfy\client.yml` (not created automatically, sample in the ZIP file).
|
||||
Once installed, you can run the ntfy CLI commands like so:
|
||||
|
||||
Also available in [Scoop's](https://scoop.sh) Main repository:
|
||||
```
|
||||
ntfy.exe -h
|
||||
```
|
||||
|
||||
`scoop install ntfy`
|
||||
The default configuration file location on Windows is `%ProgramData%\ntfy\server.yml` (e.g., `C:\ProgramData\ntfy\server.yml`)
|
||||
for the server, and `%AppData%\ntfy\client.yml` for the client. You may need to create the directory and config file manually.
|
||||
|
||||
!!! info
|
||||
There is currently no installer for Windows, and the binary is not signed. If this is desired, please create a
|
||||
[GitHub issue](https://github.com/binwiederhier/ntfy/issues) to let me know.
|
||||
To install the ntfy server as a Windows service, you can use the built-in `sc` command. For example, run this in an
|
||||
elevated command prompt (adjust the path to `ntfy.exe` accordingly):
|
||||
|
||||
```
|
||||
sc create ntfy binPath="C:\path\to\ntfy.exe serve" start=auto
|
||||
sc start ntfy
|
||||
```
|
||||
|
||||
## Docker
|
||||
The [ntfy image](https://hub.docker.com/r/binwiederhier/ntfy) is available for amd64, armv6, armv7 and arm64. It should
|
||||
@@ -301,6 +330,7 @@ services:
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
restart: unless-stopped
|
||||
init: true # needed, if healthcheck is used. Prevents zombie processes
|
||||
```
|
||||
|
||||
If using a non-root user when running the docker version, be sure to chown the server.yml, user.db, and cache.db files and attachments directory to the same uid/gid.
|
||||
@@ -319,7 +349,6 @@ The setup for Kubernetes is very similar to that for Docker, and requires a fair
|
||||
are a few options to mix and match, including a deployment without a cache file, a stateful set with a persistent cache, and a standalone
|
||||
unmanned pod.
|
||||
|
||||
|
||||
=== "deployment"
|
||||
```yaml
|
||||
apiVersion: apps/v1
|
||||
@@ -538,18 +567,18 @@ kubectl apply -k /ntfy
|
||||
cpu: 150m
|
||||
memory: 150Mi
|
||||
volumeMounts:
|
||||
- mountPath: /etc/ntfy
|
||||
subPath: server.yml
|
||||
name: config-volume # generated vie configMapGenerator from kustomization file
|
||||
- mountPath: /var/cache/ntfy
|
||||
name: cache-volume #cache volume mounted to persistent volume
|
||||
volumes:
|
||||
- name: config-volume
|
||||
configMap: # uses configmap generator to parse server.yml to configmap
|
||||
name: server-config
|
||||
- name: cache-volume
|
||||
persistentVolumeClaim: # stores /cache/ntfy in defined pv
|
||||
claimName: ntfy-pvc
|
||||
- mountPath: /etc/ntfy/server.yml
|
||||
subPath: server.yml
|
||||
name: config-volume # generated via configMapGenerator from kustomization file
|
||||
- mountPath: /var/cache/ntfy
|
||||
name: cache-volume # cache volume mounted to persistent volume
|
||||
volumes:
|
||||
- name: config-volume
|
||||
configMap: # uses configmap generator to parse server.yml to configmap
|
||||
name: server-config
|
||||
- name: cache-volume
|
||||
persistentVolumeClaim: # stores /cache/ntfy in defined pv
|
||||
claimName: ntfy-pvc
|
||||
```
|
||||
|
||||
=== "ntfy-pvc.yaml"
|
||||
|
||||
@@ -42,6 +42,7 @@ I've added a ⭐ to projects or posts that have a significant following, or had
|
||||
- [Monibot](https://monibot.io/) - Monibot monitors your websites, servers and applications and notifies you if something goes wrong.
|
||||
- [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
|
||||
|
||||
## Integration via HTTP/SMTP/etc.
|
||||
|
||||
@@ -89,7 +90,7 @@ I've added a ⭐ to projects or posts that have a significant following, or had
|
||||
- [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
|
||||
- [wio-ntfy-ticker](https://github.com/nachotp/wio-ntfy-ticker) - Ticker display for a ntfy.sh topic
|
||||
- [ntfysh-windows](https://github.com/lucas-bortoli/ntfysh-windows) - A ntfy client for Windows Desktop
|
||||
- [ntfysh-windows](https://github.com/mshafer1/ntfysh-windows) - A ntfy client for Windows Desktop
|
||||
- [ntfyr](https://github.com/haxwithaxe/ntfyr) - A simple commandline tool to send notifications to ntfy
|
||||
- [ntfy.py](https://github.com/ioqy/ntfy-client-python) - ntfy.py is a simple nfty.sh client for sending notifications
|
||||
- [wlzntfy](https://github.com/Walzen-Group/ntfy-toaster) - A minimalistic, receive-only toast notification client for Windows 11
|
||||
@@ -175,9 +176,15 @@ I've added a ⭐ to projects or posts that have a significant following, or had
|
||||
- [ntfy-me-mcp](https://github.com/gitmotion/ntfy-me-mcp) - An ntfy MCP server for sending/fetching ntfy notifications to your self-hosted ntfy server from AI Agents (supports secure token auth & more - use with npx or docker!) (Node/Typescript)
|
||||
- [InvaderInformant](https://github.com/patricksthannon/InvaderInformant) - Script for Mac OS systems that monitors new or dropped connections to your network using ntfy (Shell)
|
||||
- [NtfyPwsh](https://github.com/ptmorris1/NtfyPwsh) - PowerShell module to help send messages to ntfy (PowerShell)
|
||||
- [ntfyrr](https://github.com/leukosaima/ntfyrr) - Currently an Overseerr webhook notification to ntfy helper service.
|
||||
- [ntfyrr](https://github.com/leukosaima/ntfyrr) - Overseerr and Maintainerr webhook notification to ntfy helper service (C#)
|
||||
- [ntfy for Sandstorm](https://apps.sandstorm.io/app/c6rk81r4qk6dm3k04x1kxmyccqewhh4npuxeyg1xrpfypn2ddy0h) - ntfy app for the Sandstorm platform
|
||||
- [ntfy-heartbeat-monitor](https://codeberg.org/RockWolf/ntfy-heartbeat-monitor) - Application for implementing heartbeat monitoring/alerting by utilizing ntfy
|
||||
- [ntfy-bridge](https://github.com/AlexGaudon/ntfy-bridge) - An application to bridge Discord messages (or webhooks) to ntfy.
|
||||
- [ntailfy](https://github.com/leukosaima/ntailfy) - ntfy notifications when Tailscale devices connect/disconnect (Go)
|
||||
- [BRun](https://github.com/cbrake/brun) - Native Linux automation platform connecting triggers to actions without containers (Go)
|
||||
- [Uptime Monitor](https://uptime-monitor.org) - Self-hosted, enterprise-grade uptime monitoring and alerting system (TS)
|
||||
- [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)
|
||||
|
||||
## Blog + forum posts
|
||||
|
||||
|
||||
198
docs/privacy.md
@@ -1,12 +1,194 @@
|
||||
# Privacy policy
|
||||
|
||||
I love free software, and I'm doing this because it's fun. I have no bad intentions, and **I will
|
||||
never monetize or sell your information, and this service and software will always stay free and open.**
|
||||
**Last updated:** January 2, 2026
|
||||
|
||||
Neither the server nor the app record any personal information, or share any of the messages and topics with
|
||||
any outside service. All data is exclusively used to make the service function properly. The only external service
|
||||
I use is Firebase Cloud Messaging (FCM) service, which is required to provide instant Android notifications (see
|
||||
[FAQ](faq.md) for details). To avoid FCM altogether, download the F-Droid version.
|
||||
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).
|
||||
|
||||
For debugging purposes, the ntfy server may temporarily log request paths, remote IP addresses or even topics
|
||||
or messages, though typically this is turned off.
|
||||
## Our commitment to privacy
|
||||
|
||||
We love free software, and we're doing this because it's fun. We have no bad intentions, and **we will
|
||||
never monetize or sell your information**. The ntfy service and software will always stay free and open source.
|
||||
If you don't trust us or your messages are sensitive, you can [self-host your own ntfy server](install.md).
|
||||
|
||||
## Information we collect
|
||||
|
||||
### Account information (optional)
|
||||
|
||||
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** - Only if you subscribe to a paid plan (for billing purposes)
|
||||
- **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.
|
||||
|
||||
### Messages and notifications
|
||||
|
||||
- **Message content** - Messages you publish are temporarily cached on our servers (default: 12 hours) to support
|
||||
message polling and to overcome client network disruptions. Messages are deleted after the cache duration expires.
|
||||
- **Attachments** - File attachments are temporarily stored (default: 3 hours) and then automatically deleted.
|
||||
- **Topic names** - The topic names you publish to or subscribe to are processed by our servers.
|
||||
|
||||
### Technical information
|
||||
|
||||
- **IP addresses** - Used for rate limiting to prevent abuse. May be temporarily logged for debugging purposes,
|
||||
though this is typically turned off.
|
||||
- **Access tokens** - If you create access tokens, we store the token value, an optional label, last access time,
|
||||
and the IP address of the last access.
|
||||
- **Web push subscriptions** - If you enable browser notifications, we store your browser's push subscription
|
||||
endpoint to deliver notifications.
|
||||
|
||||
### Billing information (paid plans only)
|
||||
|
||||
If you subscribe to a paid plan, payment processing is handled by Stripe. We store:
|
||||
|
||||
- Stripe customer ID
|
||||
- Subscription status and billing period
|
||||
|
||||
We do not store your credit card numbers or payment details directly. These are handled entirely by Stripe.
|
||||
|
||||
## Third-party services
|
||||
|
||||
To provide the ntfy.sh service, we use the following third-party services:
|
||||
|
||||
### Firebase Cloud Messaging (FCM)
|
||||
|
||||
We use Google's Firebase Cloud Messaging to deliver push notifications to Android and iOS devices. When you
|
||||
receive a notification through the mobile apps (Google Play or App Store versions):
|
||||
|
||||
- Message metadata and content may be transmitted through Google's FCM infrastructure
|
||||
- Google's [privacy policy](https://policies.google.com/privacy) applies to their handling of this data
|
||||
|
||||
**To avoid FCM entirely:** Download the [F-Droid version](https://f-droid.org/en/packages/io.heckel.ntfy/) of
|
||||
the Android app and use a self-hosted server, or use the instant delivery feature with your own server.
|
||||
|
||||
### Twilio (phone calls)
|
||||
|
||||
If you use the phone call notification feature (`X-Call` header), we use Twilio to:
|
||||
|
||||
- Make voice calls to your verified phone number
|
||||
- Send SMS or voice calls for phone number verification
|
||||
|
||||
Your phone number is shared with Twilio to deliver these services. Twilio's
|
||||
[privacy policy](https://www.twilio.com/legal/privacy) applies.
|
||||
|
||||
### 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.
|
||||
|
||||
### Stripe (payments)
|
||||
|
||||
If you subscribe to a paid plan, payments are processed by Stripe. Your payment information is handled directly
|
||||
by Stripe and is subject to Stripe's [privacy policy](https://stripe.com/privacy).
|
||||
|
||||
Note: We have explicitly disabled Stripe's telemetry features in our integration.
|
||||
|
||||
### Web push providers
|
||||
|
||||
If you enable browser notifications in the ntfy web app, push messages are delivered through your browser
|
||||
vendor's push service:
|
||||
|
||||
- Google (Chrome)
|
||||
- Mozilla (Firefox)
|
||||
- Apple (Safari)
|
||||
- Microsoft (Edge)
|
||||
|
||||
Your browser's push subscription endpoint is shared with these providers to deliver notifications.
|
||||
|
||||
## Mobile applications
|
||||
|
||||
### Android app
|
||||
|
||||
The Android app is available from two sources:
|
||||
|
||||
- **Google Play Store** - Uses Firebase Cloud Messaging for push notifications. Firebase Analytics is
|
||||
**explicitly disabled** in our app.
|
||||
- **F-Droid** - Does not include any Google services or Firebase. Uses a foreground service to maintain
|
||||
a direct connection to the server.
|
||||
|
||||
The Android app stores the following data locally on your device:
|
||||
|
||||
- Subscribed topics and their settings
|
||||
- Cached notifications
|
||||
- User credentials (if you add a server with authentication)
|
||||
- Application logs (for debugging, stored locally only)
|
||||
|
||||
### iOS app
|
||||
|
||||
The iOS app uses Firebase Cloud Messaging (via Apple Push Notification service) to deliver notifications.
|
||||
The app stores the following data locally on your device:
|
||||
|
||||
- Subscribed topics
|
||||
- Cached notifications
|
||||
- User credentials (if configured)
|
||||
|
||||
## Web application
|
||||
|
||||
The ntfy web app is a static website that stores all data locally in your browser:
|
||||
|
||||
- **IndexedDB** - Stores your subscriptions and cached notifications
|
||||
- **Local Storage** - Stores your preferences and session information
|
||||
|
||||
No cookies are used for tracking. The web app does not have a backend beyond the ntfy API.
|
||||
|
||||
## Data retention
|
||||
|
||||
| Data type | Retention period |
|
||||
|------------------------|---------------------------------------------------|
|
||||
| Messages | 12 hours (configurable by server operators) |
|
||||
| Attachments | 3 hours (configurable by server operators) |
|
||||
| User accounts | Until you delete your account |
|
||||
| Access tokens | Until you revoke them or delete your account |
|
||||
| Phone numbers | Until you remove them or delete your account |
|
||||
| Web push subscriptions | 60 days of inactivity, then automatically removed |
|
||||
| Server logs | Varies; debugging logs are typically temporary |
|
||||
|
||||
## Self-hosting
|
||||
|
||||
If you prefer complete control over your data, you can [self-host your own ntfy server](install.md).
|
||||
When self-hosting:
|
||||
|
||||
- You control all data storage and retention
|
||||
- You can choose whether to use Firebase, Twilio, email delivery, or any other integrations
|
||||
- No data is shared with ntfy.sh or any third party (unless you configure those integrations)
|
||||
|
||||
The server and all apps are fully open source:
|
||||
|
||||
- Server: [github.com/binwiederhier/ntfy](https://github.com/binwiederhier/ntfy)
|
||||
- Android app: [github.com/binwiederhier/ntfy-android](https://github.com/binwiederhier/ntfy-android)
|
||||
- iOS app: [github.com/binwiederhier/ntfy-ios](https://github.com/binwiederhier/ntfy-ios)
|
||||
|
||||
## Data security
|
||||
|
||||
- All connections to ntfy.sh are encrypted using TLS/HTTPS
|
||||
- Passwords are hashed using bcrypt before storage
|
||||
- Access tokens are generated using cryptographically secure random values
|
||||
- The server does not log message content by default
|
||||
|
||||
## Your rights
|
||||
|
||||
You have the right to:
|
||||
|
||||
- **Access** - View your account information and data
|
||||
- **Delete** - Delete your account and associated data via the web app
|
||||
- **Export** - Your messages are available via the API while cached
|
||||
|
||||
To delete your account, use the account settings in the web app or contact us.
|
||||
|
||||
## Changes to this policy
|
||||
|
||||
We may update this privacy policy from time to time. Changes will be posted on this page with an updated
|
||||
"Last updated" date. You may also review all changes in the [Git history](https://github.com/binwiederhier/ntfy/commits/main/docs/privacy.md).
|
||||
|
||||
For significant changes, we may provide additional notice on Discord/Matrix or through the
|
||||
[announcements](https://ntfy.sh/announcements) ntfy topic.
|
||||
|
||||
## Contact
|
||||
|
||||
For privacy-related inquiries, please email [privacy@mail.ntfy.sh](mailto:privacy@mail.ntfy.sh).
|
||||
|
||||
For all other contact options, see the [contact page](contact.md).
|
||||
|
||||
2607
docs/publish.md
@@ -1174,7 +1174,7 @@ keys $myDict | sortAlpha
|
||||
```
|
||||
|
||||
When supplying multiple dictionaries, the keys will be concatenated. Use the `uniq`
|
||||
function along with `sortAlpha` to get a unqiue, sorted list of keys.
|
||||
function along with `sortAlpha` to get a unique, sorted list of keys.
|
||||
|
||||
```
|
||||
keys $myDict $myOtherDict | uniq | sortAlpha
|
||||
|
||||
273
docs/releases.md
@@ -2,7 +2,237 @@
|
||||
Binaries for all releases can be found on the GitHub releases pages for the [ntfy server](https://github.com/binwiederhier/ntfy/releases)
|
||||
and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/releases).
|
||||
|
||||
### ntfy server v2.14.0
|
||||
## Current stable releases
|
||||
|
||||
| Component | Version | Release date |
|
||||
|------------------|---------|--------------|
|
||||
| ntfy server | v2.17.0 | Feb 8, 2026 |
|
||||
| ntfy Android app | v1.22.2 | Jan 25, 2026 |
|
||||
| ntfy iOS app | v1.3 | Nov 26, 2023 |
|
||||
|
||||
Please check out the release notes for [upcoming releases](#not-released-yet) below.
|
||||
|
||||
## ntfy server v2.17.0
|
||||
Released February 8, 2026
|
||||
|
||||
This release adds support for templating in the priority field, a new "copy" action button to copy values to the clipboard,
|
||||
a red notification dot on the favicon for unread messages, and an admin-only version endpoint. It also includes several
|
||||
crash fixes, web app improvements, and documentation updates.
|
||||
|
||||
❤️ If you like ntfy, **please consider sponsoring me** via [GitHub Sponsors](https://github.com/sponsors/binwiederhier), [Liberapay](https://en.liberapay.com/ntfy/), Bitcoin (`1626wjrw3uWk9adyjCfYwafw4sQWujyjn8`),
|
||||
or by buying a [paid plan via the web app](https://ntfy.sh/app). ntfy will always remain open source.
|
||||
|
||||
**Features:**
|
||||
|
||||
* Server: Support templating in the priority field ([#1426](https://github.com/binwiederhier/ntfy/issues/1426), thanks to [@seantomburke](https://github.com/seantomburke) for reporting)
|
||||
* Server: Add admin-only `GET /v1/version` endpoint returning server version, build commit, and date ([#1599](https://github.com/binwiederhier/ntfy/issues/1599), thanks to [@crivchri](https://github.com/crivchri) for reporting)
|
||||
* Server/Web: [Support "copy" action](publish.md#copy-to-clipboard) button to copy a value to the clipboard ([#1364](https://github.com/binwiederhier/ntfy/issues/1364), thanks to [@SudoWatson](https://github.com/SudoWatson) for reporting)
|
||||
* Web: Show red notification dot on favicon when there are unread messages ([#1017](https://github.com/binwiederhier/ntfy/issues/1017), thanks to [@ad-si](https://github.com/ad-si) for reporting)
|
||||
|
||||
**Bug fixes + maintenance:**
|
||||
|
||||
* Server: Fix crash when commit string is shorter than 7 characters in non-GitHub-Action builds ([#1493](https://github.com/binwiederhier/ntfy/issues/1493), thanks to [@cyrinux](https://github.com/cyrinux) for reporting)
|
||||
* Server: Fix server crash (nil pointer panic) when subscriber disconnects during publish ([#1598](https://github.com/binwiederhier/ntfy/pull/1598))
|
||||
* Server: Fix log spam from `http: response.WriteHeader on hijacked connection` for WebSocket errors ([#1362](https://github.com/binwiederhier/ntfy/issues/1362), thanks to [@bonfiresh](https://github.com/bonfiresh) for reporting)
|
||||
* Server: Use `slices.Contains` from stdlib to simplify code ([#1406](https://github.com/binwiederhier/ntfy/pull/1406), thanks to [@tanhuaan](https://github.com/tanhuaan))
|
||||
* Web: Fix `clear=true` on action buttons not clearing the notification ([#1029](https://github.com/binwiederhier/ntfy/issues/1029), thanks to [@ElFishi](https://github.com/ElFishi) for reporting)
|
||||
* Web: Fix Markdown message line height to match plain text (1.5 instead of 1.2) ([#1139](https://github.com/binwiederhier/ntfy/issues/1139), thanks to [@etfz](https://github.com/etfz) for reporting)
|
||||
* Web: Fix long lines (e.g. JSON) being truncated by adding horizontal scroll ([#1363](https://github.com/binwiederhier/ntfy/issues/1363), thanks to [@v3DJG6GL](https://github.com/v3DJG6GL) for reporting)
|
||||
* Web: Fix Windows notification icon being cut off ([#884](https://github.com/binwiederhier/ntfy/issues/884), thanks to [@ZhangTianrong](https://github.com/ZhangTianrong) for reporting)
|
||||
* Web: Use full URL in curl example on empty topic pages ([#1435](https://github.com/binwiederhier/ntfy/issues/1435), [#1535](https://github.com/binwiederhier/ntfy/pull/1535), thanks to [@elmatadoor](https://github.com/elmatadoor) for reporting and [@jjasghar](https://github.com/jjasghar) for the PR)
|
||||
* Web: Add validation feedback for service URL when adding user ([#1566](https://github.com/binwiederhier/ntfy/issues/1566), thanks to [@jermanuts](https://github.com/jermanuts))
|
||||
* Docs: Remove obsolete `version` field from docker-compose examples ([#1333](https://github.com/binwiederhier/ntfy/issues/1333), thanks to [@seals187](https://github.com/seals187) for reporting and [@cyb3rko](https://github.com/cyb3rko) for fixing)
|
||||
* Docs: Fix Kustomize config in installation docs ([#1367](https://github.com/binwiederhier/ntfy/issues/1367), thanks to [@toby-griffiths](https://github.com/toby-griffiths))
|
||||
* Docs: Use SVG F-Droid badge and add app store badges to README ([#1170](https://github.com/binwiederhier/ntfy/issues/1170), thanks to [@PanderMusubi](https://github.com/PanderMusubi) for reporting)
|
||||
|
||||
## ntfy Android app v1.22.2
|
||||
Released January 20, 2026
|
||||
|
||||
This release adds support for [updating and deleting notifications](publish.md#updating-deleting-notifications) (requires server v2.16.0),
|
||||
as well as [certificate management for self-signed certs and mTLS client certificates](subscribe/phone.md#manage-certificates),
|
||||
and a new connection error dialog to help [troubleshoot connection issues](subscribe/phone.md#troubleshooting).
|
||||
|
||||
<div id="v1221-screenshots-1" class="screenshots">
|
||||
<a href="../../static/img/android-screenshot-notification-update-1.png"><img src="../../static/img/android-screenshot-notification-update-1.png"/></a>
|
||||
<a href="../../static/img/android-screenshot-notification-update-2.png"><img src="../../static/img/android-screenshot-notification-update-2.png"/></a>
|
||||
</div>
|
||||
|
||||
<div id="v1221-screenshots-2" class="screenshots">
|
||||
<a href="../../static/img/android-screenshot-certs-warning-dialog.jpg"><img src="../../static/img/android-screenshot-certs-warning-dialog.jpg"/></a>
|
||||
<a href="../../static/img/android-screenshot-certs-manage.jpg"><img src="../../static/img/android-screenshot-certs-manage.jpg"/></a>
|
||||
<a href="../../static/img/android-screenshot-connection-error-dialog.jpg"><img src="../../static/img/android-screenshot-connection-error-dialog.jpg"/></a>
|
||||
</div>
|
||||
|
||||
**Features:**
|
||||
|
||||
* Support for [updating and deleting notifications](publish.md#updating-deleting-notifications)
|
||||
([#303](https://github.com/binwiederhier/ntfy/issues/303), [#1536](https://github.com/binwiederhier/ntfy/pull/1536),
|
||||
[ntfy-android#151](https://github.com/binwiederhier/ntfy-android/pull/151), thanks to [@wunter8](https://github.com/wunter8)
|
||||
for the initial implementation)
|
||||
* Support for self-signed certs and client certs for mTLS ([#215](https://github.com/binwiederhier/ntfy/issues/215),
|
||||
[#530](https://github.com/binwiederhier/ntfy/issues/530), [ntfy-android#149](https://github.com/binwiederhier/ntfy-android/pull/149),
|
||||
thanks to [@cyb3rko](https://github.com/cyb3rko) for reviewing)
|
||||
* Connection error dialog to help diagnose connection issues
|
||||
|
||||
**Bug fixes + maintenance:**
|
||||
|
||||
* Use server-specific user for attachment downloads ([#1529](https://github.com/binwiederhier/ntfy/issues/1529),
|
||||
thanks to [@ManInDark](https://github.com/ManInDark) for reporting and testing)
|
||||
* Fix crash in sharing dialog (thanks to [@rogeliodh](https://github.com/rogeliodh))
|
||||
* Fix crash when exiting multi-delete in detail view
|
||||
* Fix potential crashes with icon downloader and backuper
|
||||
|
||||
## ntfy server v2.16.0
|
||||
Released January 19, 2026
|
||||
|
||||
This release adds support for updating and deleting notifications, heartbeat-style / dead man's switch notifications,
|
||||
custom Twilio call formats, and makes `ntfy serve` work on Windows. It also adds a "New version available" banner to the web app.
|
||||
|
||||
This one is very exciting, as it brings a lot of highly requested features to ntfy.
|
||||
|
||||
**Features:**
|
||||
|
||||
* Support for [updating and deleting notifications](publish.md#updating-deleting-notifications) ([#303](https://github.com/binwiederhier/ntfy/issues/303), [#1536](https://github.com/binwiederhier/ntfy/pull/1536),
|
||||
[ntfy-android#151](https://github.com/binwiederhier/ntfy-android/pull/151), thanks to [@wunter8](https://github.com/wunter8) for the initial implementation)
|
||||
* Support for heartbeat-style / [dead man's switch](https://en.wikipedia.org/wiki/Dead_man%27s_switch) notifications aka
|
||||
[updating and deleting scheduled notifications](publish.md#scheduled-delivery) ([#1556](https://github.com/binwiederhier/ntfy/pull/1556),
|
||||
[#1142](https://github.com/binwiederhier/ntfy/pull/1142), [#954](https://github.com/binwiederhier/ntfy/issues/954),
|
||||
thanks to [@GamerGirlandCo](https://github.com/GamerGirlandCo) for the initial implementation)
|
||||
* Configure [custom Twilio call format](config.md#phone-calls) for phone calls ([#1289](https://github.com/binwiederhier/ntfy/pull/1289), thanks to [@mmichaa](https://github.com/mmichaa) for the initial implementation)
|
||||
* `ntfy serve` now works on Windows, including support for running it as a Windows service ([#1104](https://github.com/binwiederhier/ntfy/issues/1104),
|
||||
[#1552](https://github.com/binwiederhier/ntfy/pull/1552), originally [#1328](https://github.com/binwiederhier/ntfy/pull/1328),
|
||||
thanks to [@wtf911](https://github.com/wtf911))
|
||||
* Web app: "New version available" banner ([#1554](https://github.com/binwiederhier/ntfy/pull/1554))
|
||||
|
||||
## ntfy Android app v1.21.1
|
||||
Released January 6, 2026
|
||||
|
||||
This is the first feature release in a long time. After all the SDK updates, fixes to comply with the Google Play policies
|
||||
and the framework updates, this release ships a lot of highly requested features: Sending messages through the app (WhatsApp-style),
|
||||
support for passing headers to your proxy, an in-app language switcher, and more.
|
||||
|
||||
<div id="v1211-screenshots" class="screenshots">
|
||||
<a href="../../static/img/android-screenshot-publish-message-bar.jpg"><img src="../../static/img/android-screenshot-publish-message-bar.jpg"/></a>
|
||||
<a href="../../static/img/android-screenshot-publish-dialog.jpg"><img src="../../static/img/android-screenshot-publish-dialog.jpg"/></a>
|
||||
<a href="../../static/img/android-screenshot-custom-headers.jpg"><img src="../../static/img/android-screenshot-custom-headers.jpg"/></a>
|
||||
<a href="../../static/img/android-screenshot-language-selection.jpg"><img src="../../static/img/android-screenshot-language-selection.jpg"/></a>
|
||||
</div>
|
||||
|
||||
If you are waiting for a feature, please 👍 the corresponding [GitHub issue](https://github.com/binwiederhier/ntfy/issues?q=is%3Aissue%20state%3Aopen%20sort%3Areactions-%2B1-desc).
|
||||
If you like ntfy, please consider purchasing [ntfy Pro](https://ntfy.sh/app) to support us.
|
||||
|
||||
**Features:**
|
||||
|
||||
* Allow publishing messages through the message bar and publish dialog ([#98](https://github.com/binwiederhier/ntfy/issues/98), [ntfy-android#144](https://github.com/binwiederhier/ntfy-android/pull/144))
|
||||
* Define custom HTTP headers to support authenticated proxies, tunnels and SSO ([ntfy-android#116](https://github.com/binwiederhier/ntfy-android/issues/116), [#1018](https://github.com/binwiederhier/ntfy/issues/1018), [ntfy-android#132](https://github.com/binwiederhier/ntfy-android/pull/132), [ntfy-android#146](https://github.com/binwiederhier/ntfy-android/pull/146), thanks to [@CrazyWolf13](https://github.com/CrazyWolf13))
|
||||
* Implement UnifiedPush "raise to foreground" requirement ([ntfy-android#98](https://github.com/binwiederhier/ntfy-android/pull/98), [ntfy-android#148](https://github.com/binwiederhier/ntfy-android/pull/148), thanks to [@p1gp1g](https://github.com/p1gp1g))
|
||||
* Language selector to allow overriding the system language ([#1508](https://github.com/binwiederhier/ntfy/issues/1508), [ntfy-android#145](https://github.com/binwiederhier/ntfy-android/pull/145), thanks to [@hudsonm62](https://github.com/hudsonm62) for reporting)
|
||||
* Highlight phone numbers and email addresses in notifications ([#957](https://github.com/binwiederhier/ntfy/issues/957), [ntfy-android#71](https://github.com/binwiederhier/ntfy-android/pull/71), thanks to [@brennenputh](https://github.com/brennenputh), and [@XylenSky](https://github.com/XylenSky) for reporting)
|
||||
* Support for port and display name in [ntfy://](subscribe/phone.md#ntfy-links) links ([ntfy-android#130](https://github.com/binwiederhier/ntfy-android/pull/130), thanks to [@godovski](https://github.com/godovski))
|
||||
|
||||
**Bug fixes + maintenance:**
|
||||
|
||||
* Add support for (technically incorrect) 'image/jpg' MIME type ([ntfy-android#142](https://github.com/binwiederhier/ntfy-android/pull/142), thanks to [@Murilobeluco](https://github.com/Murilobeluco))
|
||||
* Unify "copy to clipboard" notifications, use Android 13 style ([ntfy-android#61](https://github.com/binwiederhier/ntfy-android/pull/61), thanks to [@thgoebel](https://github.com/thgoebel))
|
||||
* Fix crash in user add dialog (onAddUser)
|
||||
* Fix ForegroundServiceDidNotStartInTimeException (attempt 2, see [#1520](https://github.com/binwiederhier/ntfy/issues/1520))
|
||||
* Hide "Exact alarms" setting if battery optimization exemption has been granted ([#1456](https://github.com/binwiederhier/ntfy/issues/1456), thanks for reporting [@HappyLer](https://github.com/HappyLer))
|
||||
|
||||
## ntfy Android app v1.20.0
|
||||
Released December 28, 2025
|
||||
|
||||
This is the last pure maintenance release for now. It'll bring all dependencies and library version to the latest version,
|
||||
and fixes some crashes. I had to drop support for about 4,000 devices (only ~200 installations), because the libraries
|
||||
themselves do not support SDK 21 anymore, which was the previous minimum SDK version (Android 5, 2014). Now the minimum
|
||||
SDK version is 26 (Android 8, 2017).
|
||||
|
||||
**Bug fixes + maintenance:**
|
||||
|
||||
* Updated dependencies, minimum SDK version to 26, clean up legacy code, upgrade Gradle ([ntfy-android#140](https://github.com/binwiederhier/ntfy-android/pull/140),
|
||||
thanks to [@cyb3rko](https://github.com/cyb3rko) for the implementation)
|
||||
* Updated target SDK version to 36 (Android 8, 2017)
|
||||
* Fixed ForegroundServiceDidNotStartInTimeException ([#1520](https://github.com/binwiederhier/ntfy/issues/1520))
|
||||
* Fixed crashes with redrawing the list when temporarily muted topics expire
|
||||
|
||||
## ntfy Android app v1.19.4
|
||||
Released December 21, 2025
|
||||
|
||||
This release upgrades the Android app to use [Material 3](https://m3.material.io/) design components and adds the
|
||||
ability to use [dynamic colors](https://developer.android.com/develop/ui/views/theming/dynamic-colors).
|
||||
**This was a lot of work** and I want to thank [@Bnyro](https://github.com/Bnyro) and [@cyb3rko](https://github.com/cyb3rko) for implementing this. You guys rock!
|
||||
|
||||
**Features:**
|
||||
|
||||
* Moved the user interface to Material 3 and added dynamic color support ([#580](https://github.com/binwiederhier/ntfy/issues/580),
|
||||
[ntfy-android#56](https://github.com/binwiederhier/ntfy-android/pull/56), [ntfy-android#126](https://github.com/binwiederhier/ntfy-android/pull/126),
|
||||
[ntfy-android#135](https://github.com/binwiederhier/ntfy-android/pull/135), thanks to [@Bnyro](https://github.com/Bnyro)
|
||||
and [@cyb3rko](https://github.com/cyb3rko) for the implementation, and to [@RokeJulianLockhart](https://github.com/RokeJulianLockhart) for reporting)
|
||||
|
||||
## ntfy Android app v1.18.0
|
||||
Released December 4, 2025
|
||||
|
||||
**Features:**
|
||||
|
||||
* Added GIF support for preview images ([ntfy-android#76](https://github.com/binwiederhier/ntfy-android/pull/76)/[#532](https://github.com/binwiederhier/ntfy/issues/532), thanks to [@MichaelArkh](https://github.com/MichaelArkh) and [@dimatx](https://github.com/dimatx) for reporting)
|
||||
* Added WebP support for preview images ([ntfy-android#81](https://github.com/binwiederhier/ntfy-android/pull/81)/[ntfy-android#80](https://github.com/binwiederhier/ntfy-android/issues/80), thanks to [@jokakilla](https://github.com/jokakilla))
|
||||
* Added UnifiedPush distributor selection support ([#137](https://github.com/binwiederhier/ntfy-android/pull/137), thanks to [@p1gp1g](https://github.com/p1gp1g))
|
||||
|
||||
**Bug fixes + maintenance:**
|
||||
|
||||
* Remove REQUEST_INSTALL_PACKAGES permission ([#684](https://github.com/binwiederhier/ntfy/issues/684))
|
||||
* Request to ignore battery optimizations before receiving subscription ([ntfy-android#97](https://github.com/binwiederhier/ntfy-android/pull/97), thanks to [@p1gp1g](https://github.com/p1gp1g))
|
||||
|
||||
## ntfy server v2.15.0
|
||||
Released Nov 16, 2025
|
||||
|
||||
This release adds a `require-login` flag to topics, which forces users to log in before they can
|
||||
use the web app. This is useful for self-hosters and will obviously not be enabled on ntfy.sh.
|
||||
|
||||
**Features:**
|
||||
|
||||
* Add `require-login` flag to redirect to login page if not logged in ([#1434](https://github.com/binwiederhier/ntfy/pull/1434)/[#238](https://github.com/binwiederhier/ntfy/issues/238)/[#1329](https://github.com/binwiederhier/ntfy/pull/1329), thanks to [@theatischbein](https://github.com/theatischbein) for implementing most of this)
|
||||
|
||||
**Bug fixes + maintenance:**
|
||||
|
||||
* The official ntfy.sh Debian/Ubuntu repository has moved to [archive.ntfy.sh](https://archive.ntfy.sh) ([#1357](https://github.com/binwiederhier/ntfy/issues/1357)/[#1401](https://github.com/binwiederhier/ntfy/issues/1401), thanks to [@skibbipl](https://github.com/skibbipl) and [@lduesing](https://github.com/lduesing) for reporting)
|
||||
* Add mutex around message cache writes to avoid `database locked` errors ([#1397](https://github.com/binwiederhier/ntfy/pull/1397), [#1391](https://github.com/binwiederhier/ntfy/issues/1391), thanks to [@timofej673](https://github.com/timofej673))
|
||||
* Add build tags `nopayments`, `nofirebase` and `nowebpush` to allow excluding external dependencies, useful for
|
||||
packaging in Debian ([#1420](https://github.com/binwiederhier/ntfy/pull/1420), discussion in [#1258](https://github.com/binwiederhier/ntfy/issues/1258), thanks to [@thekhalifa](https://github.com/thekhalifa) for packaging ntfy for Debian/Ubuntu)
|
||||
* Make copying tokens, phone numbers, etc. possible on HTTP ([#1432](https://github.com/binwiederhier/ntfy/pull/1432)/[#1408](https://github.com/binwiederhier/ntfy/issues/1408)/[#1295](https://github.com/binwiederhier/ntfy/issues/1295), thanks to [@EdwinKM](https://github.com/EdwinKM), [@xxl6097](https://github.com/xxl6097) for reporting)
|
||||
|
||||
## ntfy Android app v1.17.13
|
||||
Released October 21, 2025
|
||||
|
||||
This release makes changes to comply with the Google Play policies. See [#1463](https://github.com/binwiederhier/ntfy/issues/1463)
|
||||
or [ef57cd1](https://github.com/binwiederhier/ntfy-android/commit/ef57cd1374118b3e4d7a7ab496afe337e714fff7) for details.
|
||||
|
||||
The policies do not allow directly or indirectly linking to paid plans or donation links that do not go through Google Play.
|
||||
|
||||
**Changes:**
|
||||
|
||||
* Remove the "Donate" button from menu (all variants)
|
||||
* Change default display name from "ntfy.sh/mytopic" to "mytopic" (all variants)
|
||||
* Remove links to ntfy docs and issue tracker (Play variant only)
|
||||
* Remove how-to links to ntfy.sh in a few places (Play variant only)
|
||||
* Remove "Copy topic address" from subscription menu (Play variant only)
|
||||
|
||||
## ntfy Android app v1.17.8
|
||||
Released September 23, 2025
|
||||
|
||||
This is largely a maintenance update to ensure the SDK is up-to-date.
|
||||
|
||||
**Features:**
|
||||
|
||||
* Markdown is now rendered if "Markdown: yes" was passed ([#310](https://github.com/binwiederhier/ntfy/issues/310), thanks to [@NiNiyas](https://github.com/NiNiyas) for reporting)
|
||||
* You can now disable UnifiedPush so ntfy does not act as a UnifiedPush distributor ([#646](https://github.com/binwiederhier/ntfy/issues/646), thanks to [@ollien](https://github.com/ollien) for reporting and to [@wunter8](https://github.com/wunter8) for implementing)
|
||||
|
||||
**Bug fixes + maintenance:**
|
||||
|
||||
* UnifiedPush subscriptions now include the `Rate-Topics` header to facilitate subscriber-based billing ([#652](https://github.com/binwiederhier/ntfy/issues/652), thanks to [@wunter8](https://github.com/wunter8))
|
||||
* Subscriptions without icons no longer appear to use another subscription's icon ([#634](https://github.com/binwiederhier/ntfy/issues/634), thanks to [@topcaser](https://github.com/topcaser) for reporting and to [@wunter8](https://github.com/wunter8) for fixing)
|
||||
* Bumped all dependencies to the latest versions (no ticket)
|
||||
|
||||
## ntfy server v2.14.0
|
||||
Released August 5, 2025
|
||||
|
||||
This release adds support for [declarative users](config.md#users-via-the-config), [declarative ACL entries](config.md#acl-entries-via-the-config) and [declarative tokens](config.md#tokens-via-the-config). This allows you to define users, ACL entries and tokens in the config file, which is useful for static deployments or deployments that use a configuration management system.
|
||||
@@ -18,7 +248,7 @@ will always remain open source.
|
||||
* [Pre-defined templates](publish.md#pre-defined-templates) and [custom templates](publish.md#custom-templates) for enhanced JSON webhook support ([#1390](https://github.com/binwiederhier/ntfy/pull/1390))
|
||||
* Support of advanced [template functions](publish.md#template-functions) based on the [Sprig](https://github.com/Masterminds/sprig) library ([#1121](https://github.com/binwiederhier/ntfy/issues/1121), thanks to [@davidatkinsondoyle](https://github.com/davidatkinsondoyle) for reporting, to [@wunter8](https://github.com/wunter8) for implementing, and to the Sprig team for their work)
|
||||
|
||||
### ntfy server v2.13.0
|
||||
## ntfy server v2.13.0
|
||||
Released July 10, 2025
|
||||
|
||||
This is a relatively small release, mainly to support IPv6 and to add more sophisticated
|
||||
@@ -37,7 +267,7 @@ ntfy will always remain open source.
|
||||
* Update new languages from Weblate. Thanks to all the contributors!
|
||||
* Added Estonian (Esti), Galician (Galego), Romanian (Română), Slovak (Slovenčina) as new languages to the web app
|
||||
|
||||
### ntfy server v2.12.0
|
||||
## ntfy server v2.12.0
|
||||
Released May 29, 2025
|
||||
|
||||
This is mainly a maintenance release that updates dependencies, though since it's been over a year, there are a few
|
||||
@@ -97,7 +327,7 @@ user support in Discord/Matrix/GitHub! You rock, man!
|
||||
* Update new languages from Weblate. Thanks to all the contributors!
|
||||
* Added Tamil (தமிழ்) as a new language to the web app
|
||||
|
||||
### ntfy server v2.11.0
|
||||
## ntfy server v2.11.0
|
||||
Released May 13, 2024
|
||||
|
||||
This is a tiny release that fixes a database index issue that caused performance issues on ntfy.sh. It also fixes a bug
|
||||
@@ -112,7 +342,7 @@ and [Liberapay](https://en.liberapay.com/ntfy/), or buying a [paid plan via the
|
||||
* Do not set rate visitor for non-eligible topics (no ticket)
|
||||
* Do not cache `config.js` ([#1098](https://github.com/binwiederhier/ntfy/pull/1098), thanks to [@wunter8](https://github.com/wunter8))
|
||||
|
||||
### ntfy server v2.10.0
|
||||
## ntfy server v2.10.0
|
||||
Released Mar 27, 2024
|
||||
|
||||
This release adds support for **message templating** in the ntfy server, which allows you to include a message and/or
|
||||
@@ -123,7 +353,7 @@ This is great for services that let you specify a webhook URL but do not let you
|
||||
|
||||
* [Message templating](publish.md#message-templating): You can now include a message and/or title template that will be filled with values from a JSON body ([#724](https://github.com/binwiederhier/ntfy/issues/724), thanks to [@wunter8](https://github.com/wunter8) for implementing)
|
||||
|
||||
### ntfy server v2.9.0
|
||||
## ntfy server v2.9.0
|
||||
Released Mar 7, 2024
|
||||
|
||||
A small release after a long pause (lots of day job work). This release adds for **larger messages** and **longer
|
||||
@@ -1468,27 +1698,26 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release
|
||||
|
||||
## Not released yet
|
||||
|
||||
### ntfy server v2.15.0 (UNRELEASED)
|
||||
|
||||
**Bug fixes + maintenance:**
|
||||
|
||||
* Add mutex around message cache writes to avoid `database locked` errors ([#1397](https://github.com/binwiederhier/ntfy/pull/1397), [#1391](https://github.com/binwiederhier/ntfy/issues/1391), thanks to [@timofej673](https://github.com/timofej673))
|
||||
* Add build tags `nopayments`, `nofirebase` and `nowebpush` to allow excluding external dependencies, useful for
|
||||
packaging in Debian ([#1420](https://github.com/binwiederhier/ntfy/pull/1420), discussion in [#1258](https://github.com/binwiederhier/ntfy/issues/1258), thanks to [@thekhalifa](https://github.com/thekhalifa) for packaging ntfy for Debian/Ubuntu)
|
||||
* Make copying tokens, phone numbers, etc. possible on HTTP ([#1432](https://github.com/binwiederhier/ntfy/pull/1432)/[#1408](https://github.com/binwiederhier/ntfy/issues/1408)/[#1295](https://github.com/binwiederhier/ntfy/issues/1295), thanks to [@EdwinKM](https://github.com/EdwinKM), [@xxl6097](https://github.com/xxl6097) for reporting)
|
||||
|
||||
### ntfy Android app v1.16.1 (UNRELEASED)
|
||||
### ntfy Android v1.23.x (UNRELEASED)
|
||||
|
||||
**Features:**
|
||||
|
||||
* You can now disable UnifiedPush so ntfy does not act as a UnifiedPush distributor ([#646](https://github.com/binwiederhier/ntfy/issues/646), thanks to [@ollien](https://github.com/ollien) for reporting and to [@wunter8](https://github.com/wunter8) for implementing)
|
||||
* Search within a topic ([#141](https://github.com/binwiederhier/ntfy/issues/141), [ntfy-android#153](https://github.com/binwiederhier/ntfy-android/pull/153), thanks to [@Copephobia](https://github.com/Copephobia) and [@StoyanYonkov](https://github.com/StoyanYonkov) for reporting and sponsoring)
|
||||
* Add "reconnecting to N topics ..." to foreground notification ([#1101](https://github.com/binwiederhier/ntfy/issues/1101), thanks to [@milosivanovic](https://github.com/milosivanovic) for reporting)
|
||||
* Improved default server dialog with full-screen UI and stricter URL validation ([#1582](https://github.com/binwiederhier/ntfy/issues/1582))
|
||||
* Show last notification time for UnifiedPush subscriptions ([#1230](https://github.com/binwiederhier/ntfy/issues/1230), [#1454](https://github.com/binwiederhier/ntfy/issues/1454), thanks to [@Tealk](https://github.com/Tealk) and [@user4andre](https://github.com/user4andre) for reporting)
|
||||
* Support "copy" action button to copy a value to the clipboard ([#1364](https://github.com/binwiederhier/ntfy/issues/1364), thanks to [@SudoWatson](https://github.com/SudoWatson) for reporting)
|
||||
|
||||
**Bug fixes + maintenance:**
|
||||
|
||||
* UnifiedPush subscriptions now include the `Rate-Topics` header to facilitate subscriber-based billing ([#652](https://github.com/binwiederhier/ntfy/issues/652), thanks to [@wunter8](https://github.com/wunter8))
|
||||
* Subscriptions without icons no longer appear to use another subscription's icon ([#634](https://github.com/binwiederhier/ntfy/issues/634), thanks to [@topcaser](https://github.com/topcaser) for reporting and to [@wunter8](https://github.com/wunter8) for fixing)
|
||||
* Bumped all dependencies to the latest versions (no ticket)
|
||||
* Fix `clear=true` on action buttons not marking notification as read ([#1029](https://github.com/binwiederhier/ntfy/issues/1029), thanks to [@ElFishi](https://github.com/ElFishi) for reporting)
|
||||
* Fix crash when default server URL is missing scheme by auto-prepending `https://` ([#1582](https://github.com/binwiederhier/ntfy/issues/1582), thanks to [@hard-zero1](https://github.com/hard-zero1))
|
||||
* Fix notification timestamp to use original send time instead of receive time ([#1112](https://github.com/binwiederhier/ntfy/issues/1112), thanks to [@voruti](https://github.com/voruti) for reporting)
|
||||
* Fix notifications being missed after service restart by using persisted lastNotificationId ([#1591](https://github.com/binwiederhier/ntfy/issues/1591), thanks to @Epifeny for reporting)
|
||||
* Fix crash in settings when fragment is detached during backup/restore or log operations
|
||||
|
||||
**Additional languages:**
|
||||
### ntfy server v2.12.x (UNRELEASED)
|
||||
|
||||
* Swedish (thanks to [@hellbown](https://hosted.weblate.org/user/hellbown/))
|
||||
**Features:**
|
||||
|
||||
* Add PostgreSQL as an alternative database backend for the web push subscription store via `database-url` config option
|
||||
|
||||
47
docs/static/css/extra.css
vendored
@@ -1,10 +1,10 @@
|
||||
:root > * {
|
||||
--md-primary-fg-color: #338574;
|
||||
--md-primary-fg-color: #338574;
|
||||
--md-primary-fg-color--light: #338574;
|
||||
--md-primary-fg-color--dark: #338574;
|
||||
--md-footer-bg-color: #353744;
|
||||
--md-text-font: "Roboto";
|
||||
--md-code-font: "Roboto Mono";
|
||||
--md-primary-fg-color--dark: #338574;
|
||||
--md-footer-bg-color: #353744;
|
||||
--md-text-font: "Roboto";
|
||||
--md-code-font: "Roboto Mono";
|
||||
}
|
||||
|
||||
.md-header__button.md-logo :is(img, svg) {
|
||||
@@ -34,7 +34,7 @@ figure img, figure video {
|
||||
}
|
||||
|
||||
header {
|
||||
background: linear-gradient(150deg, rgba(51,133,116,1) 0%, rgba(86,189,168,1) 100%);
|
||||
background: linear-gradient(150deg, rgba(51, 133, 116, 1) 0%, rgba(86, 189, 168, 1) 100%);
|
||||
}
|
||||
|
||||
body[data-md-color-scheme="default"] header {
|
||||
@@ -92,8 +92,8 @@ figure video {
|
||||
}
|
||||
|
||||
.screenshots img {
|
||||
max-height: 230px;
|
||||
max-width: 300px;
|
||||
max-height: 350px;
|
||||
max-width: 350px;
|
||||
margin: 3px;
|
||||
border-radius: 5px;
|
||||
filter: drop-shadow(2px 2px 2px #ddd);
|
||||
@@ -107,7 +107,7 @@ figure video {
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
position: fixed;
|
||||
left:0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
@@ -119,7 +119,7 @@ figure video {
|
||||
}
|
||||
|
||||
.lightbox.show {
|
||||
background-color: rgba(0,0,0, 0.75);
|
||||
background-color: rgba(0, 0, 0, 0.75);
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
z-index: 1000;
|
||||
@@ -214,3 +214,30 @@ figure video {
|
||||
font-weight: 400;
|
||||
src: url('../fonts/roboto-mono-v22-latin-regular.woff2') format('woff2');
|
||||
}
|
||||
|
||||
/* Community maintained badge */
|
||||
.community-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35em;
|
||||
background-color: rgba(51, 133, 116, 0.1);
|
||||
border: 1px solid rgba(51, 133, 116, 0.3);
|
||||
border-radius: 0.7em;
|
||||
padding: 0.1em 0.7em;
|
||||
font-size: 0.75rem;
|
||||
color: #338574;
|
||||
margin-top: 0;
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
|
||||
.community-badge svg {
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
fill: currentColor;
|
||||
}
|
||||
|
||||
body[data-md-color-scheme="slate"] .community-badge {
|
||||
background-color: rgba(86, 189, 168, 0.15);
|
||||
border-color: rgba(86, 189, 168, 0.4);
|
||||
color: #56bda8;
|
||||
}
|
||||
|
||||
BIN
docs/static/img/android-screenshot-certs-manage.jpg
vendored
Normal file
|
After Width: | Height: | Size: 114 KiB |
BIN
docs/static/img/android-screenshot-certs-warning-dialog.jpg
vendored
Normal file
|
After Width: | Height: | Size: 114 KiB |
BIN
docs/static/img/android-screenshot-connection-error-dialog.jpg
vendored
Normal file
|
After Width: | Height: | Size: 231 KiB |
BIN
docs/static/img/android-screenshot-connection-error-warning.jpg
vendored
Normal file
|
After Width: | Height: | Size: 102 KiB |
BIN
docs/static/img/android-screenshot-custom-headers-add.jpg
vendored
Normal file
|
After Width: | Height: | Size: 80 KiB |
BIN
docs/static/img/android-screenshot-custom-headers.jpg
vendored
Normal file
|
After Width: | Height: | Size: 72 KiB |
BIN
docs/static/img/android-screenshot-language-chinese.jpg
vendored
Normal file
|
After Width: | Height: | Size: 112 KiB |
BIN
docs/static/img/android-screenshot-language-german.jpg
vendored
Normal file
|
After Width: | Height: | Size: 147 KiB |
BIN
docs/static/img/android-screenshot-language-hebrew.jpg
vendored
Normal file
|
After Width: | Height: | Size: 112 KiB |
BIN
docs/static/img/android-screenshot-language-selection.jpg
vendored
Normal file
|
After Width: | Height: | Size: 86 KiB |
BIN
docs/static/img/android-screenshot-notification-update-1.png
vendored
Normal file
|
After Width: | Height: | Size: 80 KiB |
BIN
docs/static/img/android-screenshot-notification-update-2.png
vendored
Normal file
|
After Width: | Height: | Size: 80 KiB |
BIN
docs/static/img/android-screenshot-publish-dialog.jpg
vendored
Normal file
|
After Width: | Height: | Size: 84 KiB |
BIN
docs/static/img/android-screenshot-publish-message-bar.jpg
vendored
Normal file
|
After Width: | Height: | Size: 95 KiB |
240
docs/static/img/badge-fdroid.svg
vendored
Normal file
@@ -0,0 +1,240 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
viewBox="43 43 560 164"
|
||||
version="1.1"
|
||||
id="svg78"
|
||||
sodipodi:docname="get-it-on-en.svg"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg">
|
||||
<sodipodi:namedview
|
||||
id="namedview80"
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#000000"
|
||||
borderopacity="0.25"
|
||||
inkscape:showpageshadow="2"
|
||||
inkscape:pageopacity="0.0"
|
||||
inkscape:pagecheckerboard="0"
|
||||
inkscape:deskcolor="#d1d1d1" />
|
||||
<defs
|
||||
id="defs8">
|
||||
<radialGradient
|
||||
xlink:href="#a"
|
||||
id="b"
|
||||
cx="113"
|
||||
cy="-12.89"
|
||||
r="59.662"
|
||||
fx="113"
|
||||
fy="-12.89"
|
||||
gradientTransform="matrix(0 1.96105 -1.97781 0 254.507 78.763)"
|
||||
gradientUnits="userSpaceOnUse" />
|
||||
<linearGradient
|
||||
id="a">
|
||||
<stop
|
||||
offset="0"
|
||||
style="stop-color:#fff;stop-opacity:.09803922"
|
||||
id="stop3" />
|
||||
<stop
|
||||
offset="1"
|
||||
style="stop-color:#fff;stop-opacity:0"
|
||||
id="stop5" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<g
|
||||
transform="translate(-289,-312.362)"
|
||||
id="g76">
|
||||
<path
|
||||
id="rect10"
|
||||
style="display:inline;overflow:visible;stroke:#a6a6a6;stroke-width:4;marker:none"
|
||||
d="m 352,355.362 h 520 c 11.08,0 20,8.92 20,20 v 124 c 0,11.08 -8.92,20 -20,20 H 352 c -11.08,0 -20,-8.92 -20,-20 v -124 c 0,-11.08 8.92,-20 20,-20 z" />
|
||||
<g
|
||||
aria-label="GET IT ON"
|
||||
id="text14"
|
||||
style="font-size:12.3952px;line-height:100%;font-family:'DejaVu Sans';-inkscape-font-specification:'DejaVu Sans';letter-spacing:0;word-spacing:0;display:inline;overflow:visible;fill:#ffffff;stroke-width:1px;marker:none">
|
||||
<path
|
||||
d="m 529.2627,398.81787 v -6.6817 h -5.49866 v -2.76599 h 8.83117 v 10.68072 q -1.94952,1.383 -4.29895,2.09949 -2.34942,0.69983 -5.01544,0.69983 -5.83191,0 -9.1311,-3.39917 -3.28253,-3.41583 -3.28253,-9.49768 0,-6.09851 3.28253,-9.49768 3.29919,-3.41583 9.1311,-3.41583 2.43274,0 4.61554,0.59985 2.19947,0.59985 4.04901,1.76623 v 3.58246 q -1.86621,-1.58294 -3.96569,-2.38275 -2.09949,-0.7998 -4.41559,-0.7998 -4.56555,0 -6.86499,2.54937 -2.28278,2.54938 -2.28278,7.59815 0,5.0321 2.28278,7.58148 2.29944,2.54938 6.86499,2.54938 1.78289,0 3.18255,-0.29993 1.39966,-0.31659 2.51606,-0.96643 z"
|
||||
style="font-size:34.125px"
|
||||
id="path83" />
|
||||
<path
|
||||
d="m 538.74371,377.48975 h 15.7295 v 2.83264 h -12.36365 v 7.36487 h 11.84711 v 2.83264 h -11.84711 v 9.01446 h 12.66357 v 2.83264 h -16.02942 z"
|
||||
style="font-size:34.125px"
|
||||
id="path85" />
|
||||
<path
|
||||
d="m 556.85596,377.48975 h 21.04486 v 2.83264 h -8.83118 V 402.367 h -3.38251 v -22.04461 h -8.83117 z"
|
||||
style="font-size:34.125px"
|
||||
id="path87" />
|
||||
<path
|
||||
d="m 591.99738,377.48975 h 3.36584 V 402.367 h -3.36584 z"
|
||||
style="font-size:34.125px"
|
||||
id="path89" />
|
||||
<path
|
||||
d="m 598.61243,377.48975 h 21.04486 v 2.83264 h -8.83118 V 402.367 h -3.38251 v -22.04461 h -8.83117 z"
|
||||
style="font-size:34.125px"
|
||||
id="path91" />
|
||||
<path
|
||||
d="m 643.85138,379.77252 q -3.66577,0 -5.83191,2.73267 -2.14947,2.73266 -2.14947,7.44818 0,4.69885 2.14947,7.43152 2.16614,2.73266 5.83191,2.73266 3.66577,0 5.79858,-2.73266 2.14948,-2.73267 2.14948,-7.43152 0,-4.71552 -2.14948,-7.44818 -2.13281,-2.73267 -5.79858,-2.73267 z m 0,-2.73266 q 5.23206,0 8.36462,3.5158 3.13257,3.49915 3.13257,9.39771 0,5.8819 -3.13257,9.3977 -3.13256,3.49915 -8.36462,3.49915 -5.24872,0 -8.39795,-3.49915 -3.13257,-3.49914 -3.13257,-9.3977 0,-5.89856 3.13257,-9.39771 3.14923,-3.5158 8.39795,-3.5158 z"
|
||||
style="font-size:34.125px"
|
||||
id="path93" />
|
||||
<path
|
||||
d="m 660.61395,377.48975 h 4.53223 l 11.03064,20.81158 v -20.81158 h 3.26587 V 402.367 h -4.53223 L 663.87982,381.55542 V 402.367 h -3.26587 z"
|
||||
style="font-size:34.125px"
|
||||
id="path95" />
|
||||
</g>
|
||||
<g
|
||||
aria-label="F-Droid"
|
||||
id="text18"
|
||||
style="font-weight:700;font-size:29.7088px;line-height:100%;font-family:Rokkitt;-inkscape-font-specification:'Rokkitt Bold';letter-spacing:0;word-spacing:0;display:inline;overflow:visible;fill:#ffffff;stroke-width:1px;marker:none">
|
||||
<path
|
||||
d="m 510.81067,481.24332 v 8.11767 h 27.97119 v -8.11767 l -7.23633,-1.3916 v -18.55469 h 23.65723 v -10.43701 h -23.65723 v -18.60108 h 22.03369 l 0.60303,8.07129 h 10.39063 v -18.5083 h -53.76221 v 8.16406 l 7.18994,1.3916 v 48.47413 z"
|
||||
style="font-size:95px;line-height:100%;font-family:'Roboto Slab';-inkscape-font-specification:'Roboto Slab Bold'"
|
||||
id="path98" />
|
||||
<path
|
||||
d="m 599.13098,465.70377 v -10.43702 h -26.16211 v 10.43702 z"
|
||||
style="font-size:95px;line-height:100%;font-family:'Roboto Slab';-inkscape-font-specification:'Roboto Slab Bold'"
|
||||
id="path100" />
|
||||
<path
|
||||
d="m 637.67834,421.82193 h -30.3833 v 8.16406 l 7.18995,1.3916 v 48.47413 l -7.18995,1.3916 v 8.11767 h 30.3833 c 16.51368,0 28.43506,-11.59668 28.43506,-28.15674 v -11.1792 c 0,-16.51367 -11.92138,-28.20312 -28.43506,-28.20312 z m -9.64843,10.43701 h 8.95263 c 9.69483,0 15.53955,7.23633 15.53955,17.67334 v 11.27197 c 0,10.57618 -5.84472,17.76612 -15.53955,17.76612 h -8.95263 z"
|
||||
style="font-size:95px;line-height:100%;font-family:'Roboto Slab';-inkscape-font-specification:'Roboto Slab Bold'"
|
||||
id="path102" />
|
||||
<path
|
||||
d="m 674.09192,481.24332 v 8.11767 h 26.5332 v -8.11767 l -6.49414,-1.3916 v -24.58497 c 1.48438,-2.82959 3.89649,-4.31396 7.88574,-4.12841 l 6.67969,0.3247 1.43799,-12.47802 c -1.29883,-0.46387 -3.43262,-0.74219 -5.33447,-0.74219 -4.87061,0 -8.62793,3.06152 -10.99366,8.25683 l -0.0928,-1.11328 -0.51025,-6.21582 h -19.80713 v 8.16407 l 7.18994,1.3916 v 31.12549 z"
|
||||
style="font-size:95px;line-height:100%;font-family:'Roboto Slab';-inkscape-font-specification:'Roboto Slab Bold'"
|
||||
id="path104" />
|
||||
<path
|
||||
d="m 713.24231,463.80191 v 0.97412 c 0,15.07569 8.85986,25.55908 23.75,25.55908 14.70459,0 23.61084,-10.48339 23.61084,-25.55908 v -0.97412 c 0,-15.0293 -8.85986,-25.55908 -23.70361,-25.55908 -14.79737,0 -23.65723,10.57617 -23.65723,25.55908 z m 13.54492,0.97412 v -0.97412 c 0,-8.90625 3.06152,-15.12207 10.11231,-15.12207 7.05078,0 10.20507,6.21582 10.20507,15.12207 v 0.97412 c 0,9.0918 -3.10791,15.16846 -10.1123,15.16846 -7.18994,0 -10.20508,-6.03027 -10.20508,-15.16846 z"
|
||||
style="font-size:95px;line-height:100%;font-family:'Roboto Slab';-inkscape-font-specification:'Roboto Slab Bold'"
|
||||
id="path106" />
|
||||
<path
|
||||
d="M 786.16223,428.548 V 416.99771 H 772.15344 V 428.548 Z m -20.08545,52.69532 v 8.11767 h 26.57959 v -8.11767 l -6.49414,-1.3916 v -40.68116 h -20.78125 v 8.16407 l 7.23633,1.3916 v 31.12549 z"
|
||||
style="font-size:95px;line-height:100%;font-family:'Roboto Slab';-inkscape-font-specification:'Roboto Slab Bold'"
|
||||
id="path108" />
|
||||
<path
|
||||
d="m 829.76575,483.23795 1.0205,6.12304 h 18.22999 v -8.11767 l -6.49415,-1.3916 v -62.85401 h -20.78125 v 8.16406 l 7.23633,1.39161 v 17.99804 c -3.01513,-4.03564 -7.05078,-6.30859 -12.06054,-6.30859 -12.43164,0 -19.62159,10.62256 -19.62159,26.44043 v 0.97412 c 0,14.84375 7.14356,24.67773 19.52881,24.67773 5.52002,0 9.7876,-2.45849 12.9419,-7.09716 z m -18.92578,-17.58057 v -0.97412 c 0,-9.46289 2.87597,-15.91065 9.50927,-15.91065 3.89649,0 6.77246,1.85547 8.62793,5.05616 v 21.2915 c -1.85547,3.01514 -4.77783,4.68506 -8.7207,4.68506 -6.67969,0 -9.4165,-5.38086 -9.4165,-14.14795 z"
|
||||
style="font-size:95px;line-height:100%;font-family:'Roboto Slab';-inkscape-font-specification:'Roboto Slab Bold'"
|
||||
id="path110" />
|
||||
</g>
|
||||
<path
|
||||
d="m 2.589,1006.862 4.25,5.5"
|
||||
style="fill:#8ab000;fill-opacity:1;fill-rule:evenodd;stroke:#769616;stroke-width:2.5;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
|
||||
transform="matrix(-2.63159,0,0,2.63157,483.158,-2270.475)"
|
||||
id="path20" />
|
||||
<path
|
||||
d="m 2.611,1005.61 c -0.453,0.011 -0.761,0.188 -0.98,0.448 2.027,2.409 2.368,2.792 5.135,6.221 1.02,1.32 2.082,0.638 1.062,-0.681 l -4.25,-5.5 a 1.24,1.24 0 0 0 -0.967,-0.489"
|
||||
style="color:#000000;font-style:normal;font-variant:normal;font-weight:400;font-stretch:normal;font-size:medium;line-height:normal;font-family:sans-serif;text-indent:0;text-align:start;text-decoration:none;text-decoration-line:none;text-decoration-style:solid;text-decoration-color:#000000;letter-spacing:normal;word-spacing:normal;text-transform:none;writing-mode:lr-tb;direction:ltr;baseline-shift:baseline;text-anchor:start;white-space:normal;clip-rule:nonzero;display:inline;overflow:visible;visibility:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#ffffff;fill-opacity:0.298039;fill-rule:evenodd;stroke:none;stroke-width:2.5;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto"
|
||||
transform="matrix(-2.63159,0,0,2.63157,483.158,-2270.475)"
|
||||
id="path22" />
|
||||
<path
|
||||
d="m 1.622,1006.07 a 1.25,1.25 0 0 0 -0.022,1.557 l 4.25,5.5 c 1.02,1.319 1.15,-0.613 1.15,-0.613 0,0 -3.735,-4.51 -5.378,-6.443"
|
||||
style="color:#000000;font-style:normal;font-variant:normal;font-weight:400;font-stretch:normal;font-size:medium;line-height:normal;font-family:sans-serif;text-indent:0;text-align:start;text-decoration:none;text-decoration-line:none;text-decoration-style:solid;text-decoration-color:#000000;letter-spacing:normal;word-spacing:normal;text-transform:none;writing-mode:lr-tb;direction:ltr;baseline-shift:baseline;text-anchor:start;white-space:normal;clip-rule:nonzero;display:inline;overflow:visible;visibility:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#263238;fill-opacity:0.2;fill-rule:evenodd;stroke:none;stroke-width:2.5;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto"
|
||||
transform="matrix(-2.63159,0,0,2.63157,483.158,-2270.475)"
|
||||
id="path24" />
|
||||
<path
|
||||
d="m 2.338,1005.844 c -0.438,0 -0.96,0.142 -0.824,0.799 0.103,0.501 4.66,6.074 4.66,6.074 1.02,1.32 2.494,0.677 1.474,-0.642 l -4.234,-5.473 c -0.26,-0.29 -0.608,-0.744 -1.076,-0.758"
|
||||
style="color:#000000;font-style:normal;font-variant:normal;font-weight:400;font-stretch:normal;font-size:medium;line-height:normal;font-family:sans-serif;text-indent:0;text-align:start;text-decoration:none;text-decoration-line:none;text-decoration-style:solid;text-decoration-color:#000000;letter-spacing:normal;word-spacing:normal;text-transform:none;writing-mode:lr-tb;direction:ltr;baseline-shift:baseline;text-anchor:start;white-space:normal;clip-rule:nonzero;display:inline;overflow:visible;visibility:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#8ab000;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:2.5;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto"
|
||||
transform="matrix(-2.63159,0,0,2.63157,483.158,-2270.475)"
|
||||
id="path26" />
|
||||
<path
|
||||
d="m 2.589,1006.862 4.25,5.5"
|
||||
style="fill:#8ab000;fill-opacity:1;fill-rule:evenodd;stroke:#769616;stroke-width:2.5;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
|
||||
transform="matrix(2.63159,0,0,2.63157,356.842,-2270.475)"
|
||||
id="path28" />
|
||||
<path
|
||||
d="m 2.611,1005.61 c -0.453,0.011 -0.761,0.188 -0.98,0.448 2.027,2.409 2.368,2.792 5.135,6.221 1.02,1.32 2.082,0.638 1.062,-0.681 l -4.25,-5.5 a 1.24,1.24 0 0 0 -0.967,-0.489"
|
||||
style="color:#000000;font-style:normal;font-variant:normal;font-weight:400;font-stretch:normal;font-size:medium;line-height:normal;font-family:sans-serif;text-indent:0;text-align:start;text-decoration:none;text-decoration-line:none;text-decoration-style:solid;text-decoration-color:#000000;letter-spacing:normal;word-spacing:normal;text-transform:none;writing-mode:lr-tb;direction:ltr;baseline-shift:baseline;text-anchor:start;white-space:normal;clip-rule:nonzero;display:inline;overflow:visible;visibility:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#ffffff;fill-opacity:0.298039;fill-rule:evenodd;stroke:none;stroke-width:2.5;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto"
|
||||
transform="matrix(2.63159,0,0,2.63157,356.842,-2270.475)"
|
||||
id="path30" />
|
||||
<path
|
||||
d="m 1.622,1006.07 a 1.25,1.25 0 0 0 -0.022,1.557 l 4.25,5.5 c 1.02,1.319 1.15,-0.613 1.15,-0.613 0,0 -3.735,-4.51 -5.378,-6.443"
|
||||
style="color:#000000;font-style:normal;font-variant:normal;font-weight:400;font-stretch:normal;font-size:medium;line-height:normal;font-family:sans-serif;text-indent:0;text-align:start;text-decoration:none;text-decoration-line:none;text-decoration-style:solid;text-decoration-color:#000000;letter-spacing:normal;word-spacing:normal;text-transform:none;writing-mode:lr-tb;direction:ltr;baseline-shift:baseline;text-anchor:start;white-space:normal;clip-rule:nonzero;display:inline;overflow:visible;visibility:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#263238;fill-opacity:0.2;fill-rule:evenodd;stroke:none;stroke-width:2.5;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto"
|
||||
transform="matrix(2.63159,0,0,2.63157,356.842,-2270.475)"
|
||||
id="path32" />
|
||||
<path
|
||||
d="m 2.338,1005.844 c -0.438,0 -0.96,0.142 -0.824,0.799 0.103,0.501 4.66,6.074 4.66,6.074 1.02,1.32 2.494,0.677 1.474,-0.642 l -4.234,-5.473 c -0.26,-0.29 -0.608,-0.744 -1.076,-0.758"
|
||||
style="color:#000000;font-style:normal;font-variant:normal;font-weight:400;font-stretch:normal;font-size:medium;line-height:normal;font-family:sans-serif;text-indent:0;text-align:start;text-decoration:none;text-decoration-line:none;text-decoration-style:solid;text-decoration-color:#000000;letter-spacing:normal;word-spacing:normal;text-transform:none;writing-mode:lr-tb;direction:ltr;baseline-shift:baseline;text-anchor:start;white-space:normal;clip-rule:nonzero;display:inline;overflow:visible;visibility:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#8ab000;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:2.5;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto"
|
||||
transform="matrix(2.63159,0,0,2.63157,356.842,-2270.475)"
|
||||
id="path34" />
|
||||
<g
|
||||
transform="matrix(2.63159,0,0,2.63157,467.369,-2270.475)"
|
||||
id="g44">
|
||||
<path
|
||||
id="rect36"
|
||||
style="opacity:1;fill:#aeea00;stroke-width:3;stroke-linecap:round;stroke-miterlimit:3"
|
||||
d="m -34,1010.36 h 32 c 1.662,0 3,1.338 3,3 v 6.92 c 0,1.662 -1.338,3 -3,3 h -32 c -1.662,0 -3,-1.338 -3,-3 v -6.92 c 0,-1.662 1.338,-3 3,-3 z" />
|
||||
<path
|
||||
id="rect38"
|
||||
style="opacity:1;fill:#263238;fill-opacity:0.2;stroke-width:3;stroke-linecap:round;stroke-miterlimit:3"
|
||||
d="m -34,1013.279 h 32 c 1.662,0 3,1.338 3,3 v 4 c 0,1.662 -1.338,3 -3,3 h -32 c -1.662,0 -3,-1.338 -3,-3 v -4 c 0,-1.662 1.338,-3 3,-3 z" />
|
||||
<path
|
||||
id="rect40"
|
||||
style="opacity:1;fill:#ffffff;fill-opacity:0.298039;stroke-width:3;stroke-linecap:round;stroke-miterlimit:3"
|
||||
d="m -34,1010.362 h 32 c 1.662,0 3,1.338 3,3 v 4 c 0,1.662 -1.338,3 -3,3 h -32 c -1.662,0 -3,-1.338 -3,-3 v -4 c 0,-1.662 1.338,-3 3,-3 z" />
|
||||
<path
|
||||
id="rect42"
|
||||
style="opacity:1;fill:#aeea00;stroke-width:3;stroke-linecap:round;stroke-miterlimit:3"
|
||||
d="m -34,1011.5 h 32 c 1.662,0 3,1.0954 3,2.456 v 5.729 c 0,1.3606 -1.338,2.456 -3,2.456 h -32 c -1.662,0 -3,-1.0954 -3,-2.456 v -5.729 c 0,-1.3606 1.338,-2.456 3,-2.456 z" />
|
||||
</g>
|
||||
<g
|
||||
transform="matrix(2.63159,0,0,2.63157,356.842,-2270.745)"
|
||||
id="g54">
|
||||
<path
|
||||
id="rect46"
|
||||
style="opacity:1;fill:#1976d2;stroke-width:3;stroke-linecap:round;stroke-miterlimit:3"
|
||||
d="m 8,1024.522 h 32 c 1.662,0 3,1.338 3,3 v 19.84 c 0,1.662 -1.338,3 -3,3 H 8 c -1.662,0 -3,-1.338 -3,-3 v -19.84 c 0,-1.662 1.338,-3 3,-3 z" />
|
||||
<path
|
||||
id="rect48"
|
||||
style="opacity:1;fill:#263238;fill-opacity:0.2;stroke-width:3;stroke-linecap:round;stroke-miterlimit:3"
|
||||
d="m 8,1037.3621 h 32 c 1.662,0 3,1.338 3,3 v 7 c 0,1.662 -1.338,3 -3,3 H 8 c -1.662,0 -3,-1.338 -3,-3 v -7 c 0,-1.662 1.338,-3 3,-3 z" />
|
||||
<path
|
||||
id="rect50"
|
||||
style="opacity:1;fill:#ffffff;fill-opacity:0.2;stroke-width:3;stroke-linecap:round;stroke-miterlimit:3"
|
||||
d="m 8,1024.442 h 32 c 1.662,0 3,1.338 3,3 v 7 c 0,1.662 -1.338,3 -3,3 H 8 c -1.662,0 -3,-1.338 -3,-3 v -7 c 0,-1.662 1.338,-3 3,-3 z" />
|
||||
<path
|
||||
id="rect52"
|
||||
style="opacity:1;fill:#1976d2;stroke-width:3;stroke-linecap:round;stroke-miterlimit:3"
|
||||
d="m 8,1025.662 h 32 c 1.662,0 3,1.2122 3,2.718 v 18.124 c 0,1.5058 -1.338,2.718 -3,2.718 H 8 c -1.662,0 -3,-1.2122 -3,-2.718 v -18.124 c 0,-1.5058 1.338,-2.718 3,-2.718 z" />
|
||||
</g>
|
||||
<g
|
||||
transform="matrix(2.63159,0,0,2.63157,356.842,396.264)"
|
||||
id="g60">
|
||||
<path
|
||||
d="m 24,17.75 c -2.88,0 -5.32,1.985 -6.033,4.65 H 21.18 A 3.22,3.22 0 0 1 24,20.75 3.23,3.23 0 0 1 27.25,24 3.23,3.23 0 0 1 24,27.25 3.22,3.22 0 0 1 21.07,25.4 h -3.154 c 0.642,2.766 3.132,4.85 6.084,4.85 3.434,0 6.25,-2.816 6.25,-6.25 0,-3.434 -2.816,-6.25 -6.25,-6.25"
|
||||
style="color:#000000;font-style:normal;font-variant:normal;font-weight:400;font-stretch:normal;font-size:medium;line-height:normal;font-family:sans-serif;text-indent:0;text-align:start;text-decoration:none;text-decoration-line:none;text-decoration-style:solid;text-decoration-color:#000000;letter-spacing:normal;word-spacing:normal;text-transform:none;writing-mode:lr-tb;direction:ltr;baseline-shift:baseline;text-anchor:start;white-space:normal;clip-rule:nonzero;display:inline;overflow:visible;visibility:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#0d47a1;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:3;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto"
|
||||
id="path56" />
|
||||
<path
|
||||
id="circle58"
|
||||
style="opacity:1;fill:none;fill-opacity:0.403922;stroke:#0d47a1;stroke-width:1.9;stroke-linecap:round"
|
||||
d="M 33.55,24 A 9.5500002,9.5500002 0 0 1 24,33.55 9.5500002,9.5500002 0 0 1 14.45,24 9.5500002,9.5500002 0 0 1 24,14.45 9.5500002,9.5500002 0 0 1 33.55,24 Z" />
|
||||
</g>
|
||||
<g
|
||||
transform="matrix(2.63159,0,0,2.63157,356.842,-2269.159)"
|
||||
id="g66">
|
||||
<path
|
||||
id="ellipse62"
|
||||
style="opacity:1;fill:#263238;fill-opacity:0.2;stroke-width:1.9;stroke-linecap:round;stroke-opacity:0.697211"
|
||||
d="m 17.75,1016.487 a 3.375,3.875 0 0 1 -3.375,3.875 3.375,3.875 0 0 1 -3.375,-3.875 3.375,3.875 0 0 1 3.375,-3.875 3.375,3.875 0 0 1 3.375,3.875 z" />
|
||||
<path
|
||||
id="circle64"
|
||||
style="opacity:1;fill:#ffffff;stroke-width:1.9;stroke-linecap:round;stroke-opacity:0.697211"
|
||||
d="m 17.75,1016.987 a 3.375,3.375 0 0 1 -3.375,3.375 3.375,3.375 0 0 1 -3.375,-3.375 3.375,3.375 0 0 1 3.375,-3.375 3.375,3.375 0 0 1 3.375,3.375 z" />
|
||||
</g>
|
||||
<g
|
||||
transform="matrix(2.63159,0,0,2.63157,408.158,-2269.159)"
|
||||
id="g72">
|
||||
<path
|
||||
id="ellipse68"
|
||||
style="opacity:1;fill:#263238;fill-opacity:0.2;stroke-width:1.9;stroke-linecap:round;stroke-opacity:0.697211"
|
||||
d="m 17.75,1016.487 a 3.375,3.875 0 0 1 -3.375,3.875 3.375,3.875 0 0 1 -3.375,-3.875 3.375,3.875 0 0 1 3.375,-3.875 3.375,3.875 0 0 1 3.375,3.875 z" />
|
||||
<path
|
||||
id="circle70"
|
||||
style="opacity:1;fill:#ffffff;stroke-width:1.9;stroke-linecap:round;stroke-opacity:0.697211"
|
||||
d="m 17.75,1016.987 a 3.375,3.375 0 0 1 -3.375,3.375 3.375,3.375 0 0 1 -3.375,-3.375 3.375,3.375 0 0 1 3.375,-3.375 3.375,3.375 0 0 1 3.375,3.375 z" />
|
||||
</g>
|
||||
<path
|
||||
d="m 282.715,299.835 a 3.29,3.29 0 0 0 -2.662,5.336 l 9.474,12.261 A 7.9,7.9 0 0 0 289,320.257 v 18.21 a 7.877,7.877 0 0 0 7.895,7.895 h 84.21 A 7.877,7.877 0 0 0 389,338.468 v -18.211 c 0,-0.999 -0.19,-1.949 -0.525,-2.826 l 9.472,-12.26 a 3.29,3.29 0 0 0 -2.433,-5.334 3.29,3.29 0 0 0 -2.772,1.31 l -9.013,11.666 a 7.9,7.9 0 0 0 -2.624,-0.45 h -84.21 c -0.922,0 -1.8,0.163 -2.622,0.45 l -9.015,-11.666 a 3.29,3.29 0 0 0 -2.543,-1.312 m 14.18,49.527 A 7.877,7.877 0 0 0 289,357.257 v 52.21 a 7.877,7.877 0 0 0 7.895,7.895 h 84.21 A 7.877,7.877 0 0 0 389,409.468 v -52.211 a 7.877,7.877 0 0 0 -7.895,-7.895 z"
|
||||
style="color:#000000;font-style:normal;font-variant:normal;font-weight:400;font-stretch:normal;font-size:medium;line-height:normal;font-family:sans-serif;text-indent:0;text-align:start;text-decoration:none;text-decoration-line:none;text-decoration-style:solid;text-decoration-color:#000000;letter-spacing:normal;word-spacing:normal;text-transform:none;writing-mode:lr-tb;direction:ltr;baseline-shift:baseline;text-anchor:start;white-space:normal;clip-rule:nonzero;display:inline;overflow:visible;visibility:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:url(#b);fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:6.57895;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto"
|
||||
transform="translate(81,76)"
|
||||
id="path74" />
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 23 KiB |
@@ -295,7 +295,7 @@ Available filters (all case-insensitive):
|
||||
| `message` | `X-Message`, `m` | `ntfy.sh/mytopic/json?message=lalala` | Only return messages that match this exact message string |
|
||||
| `title` | `X-Title`, `t` | `ntfy.sh/mytopic/json?title=some+title` | Only return messages that match this exact title string |
|
||||
| `priority` | `X-Priority`, `prio`, `p` | `ntfy.sh/mytopic/json?p=high,urgent` | Only return messages that match *any priority listed* (comma-separated) |
|
||||
| `tags` | `X-Tags`, `tag`, `ta` | `ntfy.sh/mytopic?/jsontags=error,alert` | Only return messages that match *all listed tags* (comma-separated) |
|
||||
| `tags` | `X-Tags`, `tag`, `ta` | `ntfy.sh/mytopic/json?tags=error,alert` | Only return messages that match *all listed tags* (comma-separated) |
|
||||
|
||||
### Subscribe to multiple topics
|
||||
It's possible to subscribe to multiple topics in one HTTP call by providing a comma-separated list of topics
|
||||
@@ -324,20 +324,21 @@ format of the message. It's very straight forward:
|
||||
|
||||
**Message**:
|
||||
|
||||
| Field | Required | Type | Example | Description |
|
||||
|--------------|----------|---------------------------------------------------|-------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| `id` | ✔️ | *string* | `hwQ2YpKdmg` | Randomly chosen message identifier |
|
||||
| `time` | ✔️ | *number* | `1635528741` | Message date time, as Unix time stamp |
|
||||
| `expires` | (✔)️ | *number* | `1673542291` | Unix time stamp indicating when the message will be deleted, not set if `Cache: no` is sent |
|
||||
| `event` | ✔️ | `open`, `keepalive`, `message`, or `poll_request` | `message` | Message type, typically you'd be only interested in `message` |
|
||||
| `topic` | ✔️ | *string* | `topic1,topic2` | Comma-separated list of topics the message is associated with; only one for all `message` events, but may be a list in `open` events |
|
||||
| `message` | - | *string* | `Some message` | Message body; always present in `message` events |
|
||||
| `title` | - | *string* | `Some title` | Message [title](../publish.md#message-title); if not set defaults to `ntfy.sh/<topic>` |
|
||||
| `tags` | - | *string array* | `["tag1","tag2"]` | List of [tags](../publish.md#tags-emojis) that may or not map to emojis |
|
||||
| `priority` | - | *1, 2, 3, 4, or 5* | `4` | Message [priority](../publish.md#message-priority) with 1=min, 3=default and 5=max |
|
||||
| `click` | - | *URL* | `https://example.com` | Website opened when notification is [clicked](../publish.md#click-action) |
|
||||
| `actions` | - | *JSON array* | *see [actions buttons](../publish.md#action-buttons)* | [Action buttons](../publish.md#action-buttons) that can be displayed in the notification |
|
||||
| `attachment` | - | *JSON object* | *see below* | Details about an attachment (name, URL, size, ...) |
|
||||
| Field | Required | Type | Example | Description |
|
||||
|---------------|----------|---------------------------------------------------------------------------------|-------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| `id` | ✔️ | *string* | `hwQ2YpKdmg` | Randomly chosen message identifier |
|
||||
| `time` | ✔️ | *number* | `1635528741` | Message date time, as Unix time stamp |
|
||||
| `expires` | (✔)️ | *number* | `1673542291` | Unix time stamp indicating when the message will be deleted, not set if `Cache: no` is sent |
|
||||
| `event` | ✔️ | `open`, `keepalive`, `message`, `message_delete`, `message_clear`, `poll_request` | `message` | Message type, typically you'd be only interested in `message` |
|
||||
| `topic` | ✔️ | *string* | `topic1,topic2` | Comma-separated list of topics the message is associated with; only one for all `message` events, but may be a list in `open` events |
|
||||
| `sequence_id` | - | *string* | `my-sequence-123` | Sequence ID for [updating/deleting notifications](../publish.md#updating-deleting-notifications) |
|
||||
| `message` | - | *string* | `Some message` | Message body; always present in `message` events |
|
||||
| `title` | - | *string* | `Some title` | Message [title](../publish.md#message-title); if not set defaults to `ntfy.sh/<topic>` |
|
||||
| `tags` | - | *string array* | `["tag1","tag2"]` | List of [tags](../publish.md#tags-emojis) that may or not map to emojis |
|
||||
| `priority` | - | *1, 2, 3, 4, or 5* | `4` | Message [priority](../publish.md#message-priority) with 1=min, 3=default and 5=max |
|
||||
| `click` | - | *URL* | `https://example.com` | Website opened when notification is [clicked](../publish.md#click-action) |
|
||||
| `actions` | - | *JSON array* | *see [actions buttons](../publish.md#action-buttons)* | [Action buttons](../publish.md#action-buttons) that can be displayed in the notification |
|
||||
| `attachment` | - | *JSON object* | *see below* | Details about an attachment (name, URL, size, ...) |
|
||||
|
||||
**Attachment** (part of the message, see [attachments](../publish.md#attachments) for details):
|
||||
|
||||
|
||||
@@ -5,16 +5,21 @@ on GitHub ([Android](https://github.com/binwiederhier/ntfy-android), [iOS](https
|
||||
contribute, or [build your own](../develop.md).
|
||||
|
||||
<a href="https://play.google.com/store/apps/details?id=io.heckel.ntfy"><img width="170" src="../../static/img/badge-googleplay.png"></a>
|
||||
<a href="https://f-droid.org/en/packages/io.heckel.ntfy/"><img width="170" src="../../static/img/badge-fdroid.png"></a>
|
||||
<a href="https://f-droid.org/en/packages/io.heckel.ntfy/"><img width="170" src="../../static/img/badge-fdroid.svg"></a>
|
||||
<a href="https://apps.apple.com/us/app/ntfy/id1625396347"><img width="150" src="../../static/img/badge-appstore.png"></a>
|
||||
|
||||
You can get the Android app from both [Google Play](https://play.google.com/store/apps/details?id=io.heckel.ntfy) and
|
||||
from [F-Droid](https://f-droid.org/en/packages/io.heckel.ntfy/). Both are largely identical, with the one exception that
|
||||
the F-Droid flavor does not use Firebase. The iOS app can be downloaded from the [App Store](https://apps.apple.com/us/app/ntfy/id1625396347).
|
||||
You can get the Android app from [Google Play](https://play.google.com/store/apps/details?id=io.heckel.ntfy),
|
||||
[F-Droid](https://f-droid.org/en/packages/io.heckel.ntfy/), or via the APKs from [GitHub Releases](https://github.com/binwiederhier/ntfy-android/releases).
|
||||
The Google Play and F-Droid releases are largely identical, with the one exception that the F-Droid flavor does not use Firebase.
|
||||
The iOS app can be downloaded from the [App Store](https://apps.apple.com/us/app/ntfy/id1625396347).
|
||||
|
||||
Alternatively, you may also want to consider using the **[progressive web app (PWA)](pwa.md)** instead of the native app.
|
||||
The PWA is a website that you can add to your home screen, and it will behave just like a native app.
|
||||
|
||||
If you're downloading the APKs from [GitHub](https://github.com/binwiederhier/ntfy-android/releases), they are signed with
|
||||
a certificate with the following SHA-256 fingerprint: `6e145d7ae685eff75468e5067e03a6c3645453343e4e181dac8b6b17ff67489d`.
|
||||
You can also query the DNS TXT records for `ntfy.sh` to find this fingerprint.
|
||||
|
||||
## Overview
|
||||
A picture is worth a thousand words. Here are a few screenshots showing what the app looks like. It's all pretty
|
||||
straight forward. You can add topics and as soon as you add them, you can [publish messages](../publish.md) to them.
|
||||
@@ -77,9 +82,8 @@ you'll see as a permanent notification that looks like this:
|
||||
<figcaption>Instant delivery foreground notification</figcaption>
|
||||
</figure>
|
||||
|
||||
Android does not allow you to dismiss this notification, unless you turn off the notification channel in the settings.
|
||||
To do so, long-press on the foreground notification (screenshot above) and navigate to the settings. Then toggle the
|
||||
"Subscription Service" off:
|
||||
To turn off this notification, long-press on the foreground notification (screenshot above) and navigate to the
|
||||
settings. Then toggle the "Subscription Service" off:
|
||||
|
||||
<figure markdown>
|
||||
{ width=500 }
|
||||
@@ -95,7 +99,29 @@ The reason for this is [Firebase Cloud Messaging (FCM)](https://firebase.google.
|
||||
notifications. Firebase is overall pretty bad at delivering messages in time, but on Android, most apps are stuck with it.
|
||||
|
||||
The ntfy Android app uses Firebase only for the main host `ntfy.sh`, and only in the Google Play flavor of the app.
|
||||
It won't use Firebase for any self-hosted servers, and not at all in the the F-Droid flavor.
|
||||
It won't use Firebase for any self-hosted servers, and not at all in the F-Droid flavor.
|
||||
|
||||
!!! info "F-Droid: Always instant delivery"
|
||||
Since the F-Droid build does not include Firebase, **all subscriptions use instant delivery by default**, and
|
||||
there is no option to disable it. The F-Droid app hides all mentions of "instant delivery" in the UI, since
|
||||
showing options that can't be changed would only be confusing.
|
||||
|
||||
## Publishing messages
|
||||
_Supported on:_ :material-android:
|
||||
|
||||
The Android app allows you to **publish messages directly from the app**, without needing to use curl or any other
|
||||
tool. When enabled in the settings (Settings → General → Show message bar), a **message bar** appears at the bottom
|
||||
of the topic view (it's enabled by default). You can type a message and tap the send button to publish it instantly.
|
||||
If the message bar is disabled, you can tap the floating action button (FAB) at the bottom right instead.
|
||||
|
||||
For more options, tap the expand button next to the send button to open the full **publish dialog**. The dialog lets
|
||||
you compose a full notification with all available options, including title, tags, priority, click URL, email
|
||||
forwarding, delayed delivery, attachments, Markdown formatting, and phone calls.
|
||||
|
||||
<div id="publish-screenshots" class="screenshots">
|
||||
<a href="../../static/img/android-screenshot-publish-message-bar.jpg"><img src="../../static/img/android-screenshot-publish-message-bar.jpg"/></a>
|
||||
<a href="../../static/img/android-screenshot-publish-dialog.jpg"><img src="../../static/img/android-screenshot-publish-dialog.jpg"/></a>
|
||||
</div>
|
||||
|
||||
## Share to topic
|
||||
_Supported on:_ :material-android:
|
||||
@@ -124,10 +150,72 @@ or to simply directly link to a topic from a mobile website.
|
||||
|
||||
**Supported link formats:**
|
||||
|
||||
| Link format | Example | Description |
|
||||
|-------------------------------------------------------------------------------|-------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| <span style="white-space: nowrap">`ntfy://<host>/<topic>`</span> | `ntfy://ntfy.sh/mytopic` | Directly opens the Android app detail view for the given topic and server. Subscribes to the topic if not already subscribed. This is equivalent to the web view `https://ntfy.sh/mytopic` (HTTPS!) |
|
||||
| <span style="white-space: nowrap">`ntfy://<host>/<topic>?secure=false`</span> | `ntfy://example.com/mytopic?secure=false` | Same as above, except that this will use HTTP instead of HTTPS as topic URL. This is equivalent to the web view `http://example.com/mytopic` (HTTP!) |
|
||||
| Link format | Example | Description |
|
||||
|---------------------------------------------------------------------------------|-------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| <span style="white-space: nowrap">`ntfy://<host>/<topic>`</span> | `ntfy://ntfy.sh/mytopic` | Directly opens the Android app detail view for the given topic and server. Subscribes to the topic if not already subscribed. This is equivalent to the web view `https://ntfy.sh/mytopic` (HTTPS!) |
|
||||
| <span style="white-space: nowrap">`ntfy://<host>/<topic>?display=<name>`</span> | `ntfy://ntfy.sh/mytopic?display=My+Topic` | Same as above, but also defines a display name for the topic. |
|
||||
| <span style="white-space: nowrap">`ntfy://<host>/<topic>?secure=false`</span> | `ntfy://example.com/mytopic?secure=false` | Same as above, except that this will use HTTP instead of HTTPS as topic URL. This is equivalent to the web view `http://example.com/mytopic` (HTTP!) |
|
||||
|
||||
## Advanced settings
|
||||
|
||||
### Custom headers
|
||||
_Supported on:_ :material-android:
|
||||
|
||||
If your ntfy server is behind an **authenticated proxy or tunnel** (e.g., Cloudflare Access, Tailscale Funnel, or
|
||||
a reverse proxy with basic auth), you can configure custom HTTP headers that will be sent with every request to
|
||||
that server. You could set headers such as `Authorization`, `CF-Access-Client-Id`, or any other headers required by
|
||||
your setup. To add custom headers, go to **Settings → Advanced → Custom headers**.
|
||||
|
||||
<div id="custom-headers-screenshots" class="screenshots">
|
||||
<a href="../../static/img/android-screenshot-custom-headers.jpg"><img src="../../static/img/android-screenshot-custom-headers.jpg"/></a>
|
||||
<a href="../../static/img/android-screenshot-custom-headers-add.jpg"><img src="../../static/img/android-screenshot-custom-headers-add.jpg"/></a>
|
||||
</div>
|
||||
|
||||
!!! warning
|
||||
If you have a user configured for a server, you cannot add an `Authorization` header for that server, as ntfy
|
||||
sets this header automatically. Similarly, if you have a custom `Authorization` header, you cannot add a user
|
||||
for that server.
|
||||
|
||||
### Manage certificates
|
||||
_Supported on:_ :material-android:
|
||||
|
||||
If you're running a self-hosted ntfy server with a **self-signed certificate** or need to use **mutual TLS (mTLS)**
|
||||
for client authentication, you can manage certificates in the app settings.
|
||||
|
||||
Go to **Settings → Advanced → Manage certificates** to:
|
||||
|
||||
- **Add trusted certificates**: Import a server certificate (PEM format) to trust when connecting to your ntfy server.
|
||||
This is useful for self-signed certificates that are not trusted by the Android system.
|
||||
- **Add client certificates**: Import a client certificate (PKCS#12 format) for mutual TLS authentication. This
|
||||
certificate will be presented to the server when connecting.
|
||||
|
||||
When you subscribe to a topic on a server with an untrusted certificate, the app will show a security warning and
|
||||
allow you to review and trust the certificate.
|
||||
|
||||
<div id="certificates-screenshots" class="screenshots">
|
||||
<a href="../../static/img/android-screenshot-certs-manage.jpg"><img src="../../static/img/android-screenshot-certs-manage.jpg"/></a>
|
||||
<a href="../../static/img/android-screenshot-certs-warning-dialog.jpg"><img src="../../static/img/android-screenshot-certs-warning-dialog.jpg"/></a>
|
||||
</div>
|
||||
|
||||
### Language
|
||||
_Supported on:_ :material-android:
|
||||
|
||||
The Android app supports many languages and uses the **system language by default**. If you'd like to use the app in
|
||||
a different language than your system, you can override it in **Settings → General → Language**.
|
||||
|
||||
<div id="language-screenshots" class="screenshots">
|
||||
<a href="../../static/img/android-screenshot-language-selection.jpg"><img src="../../static/img/android-screenshot-language-selection.jpg"/></a>
|
||||
<a href="../../static/img/android-screenshot-language-german.jpg"><img src="../../static/img/android-screenshot-language-german.jpg"/></a>
|
||||
<a href="../../static/img/android-screenshot-language-hebrew.jpg"><img src="../../static/img/android-screenshot-language-hebrew.jpg"/></a>
|
||||
<a href="../../static/img/android-screenshot-language-chinese.jpg"><img src="../../static/img/android-screenshot-language-chinese.jpg"/></a>
|
||||
</div>
|
||||
|
||||
The app currently supports over 30 languages, including English, German, French, Spanish, Chinese, Japanese, and many
|
||||
more. Languages with more than 80% of strings translated are shown in the language picker.
|
||||
|
||||
!!! tip "Help translate ntfy"
|
||||
If you'd like to help translate ntfy into your language or improve existing translations, please visit the
|
||||
[ntfy Weblate project](https://hosted.weblate.org/projects/ntfy/). Contributions are very welcome!
|
||||
|
||||
## Integrations
|
||||
|
||||
@@ -162,10 +250,13 @@ Here's an example using [MacroDroid](https://play.google.com/store/apps/details?
|
||||
and [Tasker](https://play.google.com/store/apps/details?id=net.dinglisch.android.taskerm), but any app that can catch
|
||||
broadcasts is supported:
|
||||
|
||||
<div id="integration-screenshots-receive" class="screenshots">
|
||||
<div id="integration-screenshots-receive-1" class="screenshots">
|
||||
<a href="../../static/img/android-screenshot-macrodroid-overview.png"><img src="../../static/img/android-screenshot-macrodroid-overview.png"/></a>
|
||||
<a href="../../static/img/android-screenshot-macrodroid-trigger.png"><img src="../../static/img/android-screenshot-macrodroid-trigger.png"/></a>
|
||||
<a href="../../static/img/android-screenshot-macrodroid-action.png"><img src="../../static/img/android-screenshot-macrodroid-action.png"/></a>
|
||||
</div>
|
||||
|
||||
<div id="integration-screenshots-receive-2" class="screenshots">
|
||||
<a href="../../static/img/android-screenshot-tasker-profiles.png"><img src="../../static/img/android-screenshot-tasker-profiles.png"/></a>
|
||||
<a href="../../static/img/android-screenshot-tasker-event-edit.png"><img src="../../static/img/android-screenshot-tasker-event-edit.png"/></a>
|
||||
<a href="../../static/img/android-screenshot-tasker-task-edit.png"><img src="../../static/img/android-screenshot-tasker-task-edit.png"/></a>
|
||||
@@ -233,3 +324,29 @@ The following intent extras are supported when for the intent with the `io.hecke
|
||||
| `message` ❤️ | ✔ | *String* | `Some message` | Message body; **you must set this** |
|
||||
| `tags` | - | *String* | `tag1,tag2,..` | Comma-separated list of [tags](../publish.md#tags-emojis) |
|
||||
| `priority` | - | *String or Int (between 1-5)* | `4` | Message [priority](../publish.md#message-priority) with 1=min, 3=default and 5=max |
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Connection error dialog
|
||||
_Supported on:_ :material-android:
|
||||
|
||||
If the app has trouble connecting to a ntfy server, a **warning icon** will appear in the app bar. Tapping it opens
|
||||
the **connection error dialog**, which shows detailed information about the connection problem and helps you diagnose
|
||||
the issue.
|
||||
|
||||
<div id="connection-error-screenshots" class="screenshots">
|
||||
<a href="../../static/img/android-screenshot-connection-error-warning.jpg"><img src="../../static/img/android-screenshot-connection-error-warning.jpg"/></a>
|
||||
<a href="../../static/img/android-screenshot-connection-error-dialog.jpg"><img src="../../static/img/android-screenshot-connection-error-dialog.jpg"/></a>
|
||||
</div>
|
||||
|
||||
Common connection errors include:
|
||||
|
||||
| Error | Description |
|
||||
|-------|-------------|
|
||||
| Connection refused | The server may be down or the address may be incorrect |
|
||||
| WebSocket not supported | The server may not support WebSocket connections, or a proxy is blocking them |
|
||||
| Not authorized (401/403) | Username/password may be incorrect, or access credentials have expired |
|
||||
| Certificate not trusted | The server is using a self-signed certificate (see [Manage certificates](#manage-certificates)) |
|
||||
|
||||
If you're having persistent connection issues, you can also check the app logs under **Settings → Advanced → Record logs**
|
||||
and share them for debugging.
|
||||
|
||||
257
docs/terms.md
Normal file
@@ -0,0 +1,257 @@
|
||||
# Terms of Service
|
||||
|
||||
**Last updated:** January 26, 2026
|
||||
|
||||
Please read these Terms of Service ("Terms") carefully before using the ntfy.sh website and service (the "Service")
|
||||
operated by ntfy LLC ("us", "we", or "our").
|
||||
|
||||
Your access to and use of the Service is conditioned on your acceptance of and compliance with these Terms. These
|
||||
Terms apply to all visitors, users, and others who access or use the Service.
|
||||
|
||||
**By accessing or using the Service, you agree to be bound by these Terms. If you disagree with any part of the
|
||||
Terms, you may not access the Service.**
|
||||
|
||||
## Service description
|
||||
|
||||
ntfy (pronounced "notify") is a simple HTTP-based pub-sub notification service. It allows you to send push
|
||||
notifications to your phone or desktop via scripts from any computer, using a REST API. The Service includes:
|
||||
|
||||
- The ntfy.sh hosted server
|
||||
- The ntfy web application
|
||||
- The ntfy mobile applications (Android and iOS)
|
||||
- The ntfy command-line interface (CLI)
|
||||
|
||||
The server software and mobile applications are open source and can be [self-hosted](install.md). These Terms
|
||||
apply specifically to the ntfy.sh hosted service.
|
||||
|
||||
## Subscriptions and billing
|
||||
|
||||
### Free tier
|
||||
|
||||
You may use the Service without creating an account or subscribing to a paid plan. Free usage is subject to
|
||||
rate limits and other restrictions as described in our documentation.
|
||||
|
||||
### Paid plans
|
||||
|
||||
Some features of the Service are available only through paid subscription plans ("Subscriptions"). You will
|
||||
be billed in advance on a recurring basis ("Billing Cycle"). Billing cycles are available on a monthly or
|
||||
annual basis.
|
||||
|
||||
At the end of each Billing Cycle, your Subscription will automatically renew under the same conditions unless
|
||||
you cancel it or we cancel it. You may cancel your Subscription renewal through your account settings in the
|
||||
web application.
|
||||
|
||||
A valid payment method is required to process payment for your Subscription. You shall provide us with accurate
|
||||
and complete billing information. By submitting such payment information, you authorize us to charge all
|
||||
Subscription fees incurred through your account to your payment method.
|
||||
|
||||
Payment processing is handled by Stripe. Your payment information is subject to Stripe's
|
||||
[privacy policy](https://stripe.com/privacy) and [terms of service](https://stripe.com/legal).
|
||||
|
||||
Should automatic billing fail to occur for any reason, we will retry the payment according to Stripe's retry
|
||||
schedule. If payment continues to fail after multiple attempts, your Subscription will be canceled and your
|
||||
account will revert to the free tier.
|
||||
|
||||
### Fee changes
|
||||
|
||||
We may, in our sole discretion and at any time, modify the Subscription fees for paid plans. Any fee change
|
||||
will become effective at the end of the then-current Billing Cycle.
|
||||
|
||||
We will provide you with reasonable prior notice of any change in Subscription fees to give you an opportunity
|
||||
to cancel your Subscription before such change becomes effective.
|
||||
|
||||
Your continued use of the Service after a fee change comes into effect constitutes your agreement to pay the
|
||||
modified Subscription fee.
|
||||
|
||||
## Refunds
|
||||
|
||||
Refund requests for Subscriptions may be considered on a case-by-case basis and granted at the sole discretion
|
||||
of ntfy LLC. To request a refund, please contact us at [billing@mail.ntfy.sh](mailto:billing@mail.ntfy.sh).
|
||||
|
||||
## User accounts
|
||||
|
||||
When you create an account with us, you must provide information that is accurate, complete, and current at
|
||||
all times. Failure to do so constitutes a breach of the Terms, which may result in immediate termination of
|
||||
your account.
|
||||
|
||||
You are responsible for:
|
||||
|
||||
- Safeguarding the password that you use to access the Service
|
||||
- Any activities or actions under your account, whether your password is with our Service or a third-party service
|
||||
- Keeping your account credentials confidential
|
||||
|
||||
You agree not to disclose your password to any third party. You must notify us immediately upon becoming aware
|
||||
of any breach of security or unauthorized use of your account.
|
||||
|
||||
You represent that you are at least 18 years old, or that you are at least the minimum age required to form
|
||||
a binding contract in your jurisdiction, and have the legal authority to enter into these Terms.
|
||||
|
||||
## Acceptable use
|
||||
|
||||
You agree not to use the Service to:
|
||||
|
||||
- Send spam, unsolicited messages, or messages to recipients who have not consented to receive them
|
||||
- Distribute malware, viruses, or any other malicious software
|
||||
- Transmit illegal content or content that violates the rights of others
|
||||
- Harass, abuse, or harm another person or group
|
||||
- Impersonate any person or entity, or falsely state or misrepresent your affiliation with a person or entity
|
||||
- Interfere with or disrupt the Service or servers or networks connected to the Service
|
||||
- Attempt to gain unauthorized access to the Service, other accounts, or computer systems
|
||||
- Use the Service for any illegal purpose or in violation of any applicable laws or regulations
|
||||
- Circumvent rate limits or other technical restrictions
|
||||
- Use the Service in a manner that could reasonably be expected to impose an unreasonable or disproportionately
|
||||
large load on our infrastructure
|
||||
|
||||
We reserve the right to investigate and take appropriate action against anyone who, in our sole discretion,
|
||||
violates this provision, including removing content, terminating accounts, and reporting to law enforcement.
|
||||
|
||||
### Topic names
|
||||
|
||||
Topic names on ntfy.sh are public. If you use the Service without access controls, your topic name functions
|
||||
as a password. You are responsible for choosing topic names that cannot be easily guessed. We are not responsible
|
||||
for any unauthorized access to messages published to easily guessable topic names.
|
||||
|
||||
For reserved topics and access control features, consider subscribing to a paid plan.
|
||||
|
||||
## Intellectual property
|
||||
|
||||
### Open source software
|
||||
|
||||
The ntfy server, web application, and mobile applications are open source software, dual-licensed under the
|
||||
[Apache License 2.0](https://github.com/binwiederhier/ntfy/blob/main/LICENSE) and
|
||||
[GPLv2](https://github.com/binwiederhier/ntfy/blob/main/LICENSE.GPLv2). You are free to use, modify, and
|
||||
distribute the software in accordance with these licenses.
|
||||
|
||||
### Trademarks
|
||||
|
||||
The ntfy name, logo, and branding are trademarks of ntfy LLC. Our trademarks may not be used in connection
|
||||
with any product or service without our prior written consent.
|
||||
|
||||
### Your content
|
||||
|
||||
You retain ownership of any content you transmit through the Service. By using the Service, you grant us a
|
||||
limited license to process and transmit your content solely for the purpose of providing the Service.
|
||||
|
||||
## Service availability
|
||||
|
||||
The Service is provided on a "best effort" basis. We do not guarantee any specific uptime or availability.
|
||||
|
||||
We strive to maintain high availability, but the Service may be interrupted for maintenance, updates, or
|
||||
due to circumstances beyond our control. We will make reasonable efforts to notify users of planned
|
||||
maintenance when possible.
|
||||
|
||||
For applications requiring guaranteed uptime or specific service level agreements, we recommend
|
||||
[self-hosting your own ntfy server](install.md).
|
||||
|
||||
A [status page](https://ntfy.statuspage.io/) is available to check the current operational status of the Service.
|
||||
|
||||
## Third-party services
|
||||
|
||||
The Service relies on third-party services to provide certain functionality:
|
||||
|
||||
- **Firebase Cloud Messaging (FCM)** - For push notifications to Android and iOS devices
|
||||
- **Twilio** - For phone call notifications
|
||||
- **Amazon SES** - For email notifications
|
||||
- **Stripe** - For payment processing
|
||||
|
||||
Your use of these features is subject to the respective third-party terms and privacy policies. For more
|
||||
details, see our [privacy policy](privacy.md).
|
||||
|
||||
## Links to other websites
|
||||
|
||||
Our Service may contain links to third-party websites or services that are not owned or controlled by us.
|
||||
|
||||
We have no control over, and assume no responsibility for, the content, privacy policies, or practices of
|
||||
any third-party websites or services. You acknowledge and agree that we shall not be responsible or liable,
|
||||
directly or indirectly, for any damage or loss caused by or in connection with the use of any such content,
|
||||
goods, or services available through any such websites or services.
|
||||
|
||||
## Termination
|
||||
|
||||
We may terminate or suspend your account immediately, without prior notice or liability, for any reason
|
||||
whatsoever, including without limitation if you breach these Terms.
|
||||
|
||||
Upon termination, your right to use the Service will immediately cease. If you wish to terminate your account,
|
||||
you may do so through your account settings or by simply discontinuing use of the Service.
|
||||
|
||||
Termination of your account will result in the deletion of your account data in accordance with our
|
||||
[privacy policy](privacy.md).
|
||||
|
||||
We may retain certain data as required to comply with legal obligations, resolve disputes, and enforce our
|
||||
agreements, as described in our privacy policy.
|
||||
|
||||
## Limitation of liability
|
||||
|
||||
In no event shall ntfy LLC, nor its owner, employees, partners, agents, suppliers, or affiliates, be liable
|
||||
for any indirect, incidental, special, consequential, or punitive damages, including without limitation:
|
||||
|
||||
- Loss of profits, data, use, goodwill, or other intangible losses
|
||||
- Damages resulting from your access to, use of, or inability to access or use the Service
|
||||
- Damages resulting from any conduct or content of any third party on the Service
|
||||
- Damages resulting from any content obtained from the Service
|
||||
- Damages resulting from unauthorized access, use, or alteration of your transmissions or content
|
||||
|
||||
This limitation applies whether based on warranty, contract, tort (including negligence), or any other legal
|
||||
theory, whether or not we have been informed of the possibility of such damage, and even if a remedy set
|
||||
forth herein is found to have failed of its essential purpose.
|
||||
|
||||
## Indemnification
|
||||
|
||||
You agree to defend, indemnify, and hold harmless ntfy LLC and its owner, employees, partners, agents, suppliers,
|
||||
and affiliates from and against any claims, damages, obligations, losses, liabilities, costs, or debt, and
|
||||
expenses (including but not limited to attorney's fees) arising from:
|
||||
|
||||
- Your use of and access to the Service
|
||||
- Your violation of any term of these Terms
|
||||
- Your violation of any applicable law or regulation
|
||||
- Your content, including any claim that your content infringes or misappropriates the rights of any third party
|
||||
|
||||
## Disclaimer
|
||||
|
||||
Your use of the Service is at your sole risk. The Service is provided on an "AS IS" and "AS AVAILABLE" basis,
|
||||
without warranties of any kind, whether express or implied, including but not limited to implied warranties of
|
||||
merchantability, fitness for a particular purpose, non-infringement, or course of performance.
|
||||
|
||||
ntfy LLC does not warrant that:
|
||||
|
||||
- The Service will function uninterrupted, secure, or available at any particular time or location
|
||||
- Any errors or defects will be corrected
|
||||
- The Service is free of viruses or other harmful components
|
||||
- The results of using the Service will meet your requirements
|
||||
- Messages will be delivered successfully or in a timely manner
|
||||
|
||||
If your use case requires guaranteed message delivery, high availability, or handling of sensitive data, we
|
||||
strongly recommend [self-hosting your own ntfy server](install.md) where you have full control over the
|
||||
infrastructure and data.
|
||||
|
||||
## Governing law
|
||||
|
||||
These Terms shall be governed and construed in accordance with the laws of the State of Connecticut, United States,
|
||||
without regard to its conflict of law provisions.
|
||||
|
||||
Any legal action or proceeding arising under these Terms shall be brought exclusively in the federal or state
|
||||
courts located in Connecticut, and the parties hereby consent to personal jurisdiction and venue therein.
|
||||
|
||||
Our failure to enforce any right or provision of these Terms will not be considered a waiver of those rights.
|
||||
If any provision of these Terms is held to be invalid or unenforceable by a court, the remaining provisions
|
||||
of these Terms will remain in effect.
|
||||
|
||||
These Terms constitute the entire agreement between us regarding our Service and supersede any prior agreements
|
||||
we might have had regarding the Service.
|
||||
|
||||
## Changes to these Terms
|
||||
|
||||
We reserve the right, at our sole discretion, to modify or replace these Terms at any time. If a revision is
|
||||
material, we will try to provide at least 30 days' notice prior to any new terms taking effect.
|
||||
|
||||
What constitutes a material change will be determined at our sole discretion. Changes will be posted on this
|
||||
page with an updated "Last updated" date. You may also review all changes in the
|
||||
[Git history](https://github.com/binwiederhier/ntfy/commits/main/docs/terms.md).
|
||||
|
||||
By continuing to access or use our Service after those revisions become effective, you agree to be bound by
|
||||
the revised Terms. If you do not agree to the new Terms, please stop using the Service.
|
||||
|
||||
## Contact
|
||||
|
||||
If you have any questions about these Terms, please see our [contact page](contact.md) or email us at
|
||||
[support@mail.ntfy.sh](mailto:support@mail.ntfy.sh).
|
||||
@@ -1,7 +1,7 @@
|
||||
# Troubleshooting
|
||||
This page lists a few suggestions of what to do when things don't work as expected. This is not a complete list.
|
||||
If this page does not help, feel free to drop by the [Discord](https://discord.gg/cT7ECsZj9w) or [Matrix](https://matrix.to/#/#ntfy:matrix.org)
|
||||
and ask there. We're happy to help.
|
||||
If this page does not help, feel free to reach out via one of the channels listed on the [contact page](contact.md).
|
||||
We're happy to help.
|
||||
|
||||
## ntfy server
|
||||
If you host your own ntfy server, and you're having issues with any component, it is always helpful to enable debugging/tracing
|
||||
@@ -129,3 +129,15 @@ keyboard.
|
||||
|
||||
## iOS app
|
||||
Sorry, there is no way to debug or get the logs from the iOS app (yet), outside of running the app in Xcode.
|
||||
|
||||
## Other
|
||||
|
||||
### "Reconnecting..." / Late notifications on mobile (self-hosted)
|
||||
|
||||
If all of your topics are showing as "Reconnecting" and notifications are taking a long time (30+ minutes) to come in, or if you're only getting new pushes with a manual refresh, double-check your configuration:
|
||||
|
||||
* If ntfy is behind a reverse proxy (such as Nginx):
|
||||
* Make sure `behind-proxy` is enabled in ntfy's config.
|
||||
* Make sure WebSockets are enabled in the reverse proxy config.
|
||||
* Make sure you have granted permission to access all of your topics, either to a logged-in user account or to `everyone`. All subscribed topics are joined into a single WebSocket/JSON request, so a single topic that receives `403 Forbidden` will prevent the entire request from going through.
|
||||
* In particular, double-check that `everyone` has permission to write to `up*` and your user has permission to read `up*` if you are using UnifiedPush.
|
||||
|
||||
112
go.mod
@@ -1,27 +1,25 @@
|
||||
module heckel.io/ntfy/v2
|
||||
|
||||
go 1.24
|
||||
|
||||
toolchain go1.24.0
|
||||
go 1.24.6
|
||||
|
||||
require (
|
||||
cloud.google.com/go/firestore v1.18.0 // indirect
|
||||
cloud.google.com/go/storage v1.56.0 // indirect
|
||||
github.com/BurntSushi/toml v1.5.0 // indirect
|
||||
cloud.google.com/go/firestore v1.21.0 // indirect
|
||||
cloud.google.com/go/storage v1.59.2 // 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.18.0
|
||||
github.com/gabriel-vasile/mimetype v1.4.9
|
||||
github.com/gabriel-vasile/mimetype v1.4.13
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/mattn/go-sqlite3 v1.14.30
|
||||
github.com/mattn/go-sqlite3 v1.14.33
|
||||
github.com/olebedev/when v1.1.0
|
||||
github.com/stretchr/testify v1.10.0
|
||||
github.com/stretchr/testify v1.11.1
|
||||
github.com/urfave/cli/v2 v2.27.7
|
||||
golang.org/x/crypto v0.40.0
|
||||
golang.org/x/oauth2 v0.30.0 // indirect
|
||||
golang.org/x/sync v0.16.0
|
||||
golang.org/x/term v0.33.0
|
||||
golang.org/x/time v0.12.0
|
||||
google.golang.org/api v0.244.0
|
||||
golang.org/x/crypto v0.47.0
|
||||
golang.org/x/oauth2 v0.34.0 // indirect
|
||||
golang.org/x/sync v0.19.0
|
||||
golang.org/x/term v0.39.0
|
||||
golang.org/x/time v0.14.0
|
||||
google.golang.org/api v0.265.0
|
||||
gopkg.in/yaml.v2 v2.4.0
|
||||
)
|
||||
|
||||
@@ -30,75 +28,79 @@ 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.18.0
|
||||
firebase.google.com/go/v4 v4.19.0
|
||||
github.com/SherClockHolmes/webpush-go v1.4.0
|
||||
github.com/jackc/pgx/v5 v5.8.0
|
||||
github.com/microcosm-cc/bluemonday v1.0.27
|
||||
github.com/prometheus/client_golang v1.23.0
|
||||
github.com/prometheus/client_golang v1.23.2
|
||||
github.com/stripe/stripe-go/v74 v74.30.0
|
||||
golang.org/x/text v0.27.0
|
||||
golang.org/x/sys v0.40.0
|
||||
golang.org/x/text v0.33.0
|
||||
)
|
||||
|
||||
require (
|
||||
cel.dev/expr v0.24.0 // indirect
|
||||
cloud.google.com/go v0.121.4 // indirect
|
||||
cloud.google.com/go/auth v0.16.3 // indirect
|
||||
cel.dev/expr v0.25.1 // indirect
|
||||
cloud.google.com/go v0.123.0 // indirect
|
||||
cloud.google.com/go/auth v0.18.1 // indirect
|
||||
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
|
||||
cloud.google.com/go/compute/metadata v0.7.0 // indirect
|
||||
cloud.google.com/go/iam v1.5.2 // indirect
|
||||
cloud.google.com/go/longrunning v0.6.7 // indirect
|
||||
cloud.google.com/go/monitoring v1.24.2 // indirect
|
||||
cloud.google.com/go/compute/metadata v0.9.0 // indirect
|
||||
cloud.google.com/go/iam v1.5.3 // indirect
|
||||
cloud.google.com/go/longrunning v0.8.0 // indirect
|
||||
cloud.google.com/go/monitoring v1.24.3 // indirect
|
||||
github.com/AlekSi/pointer v1.2.0 // indirect
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.29.0 // indirect
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.53.0 // indirect
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.53.0 // indirect
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.31.0 // indirect
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.55.0 // indirect
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.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-20250501225837-2ac532fd4443 // 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.32.4 // indirect
|
||||
github.com/envoyproxy/protoc-gen-validate v1.2.1 // indirect
|
||||
github.com/envoyproxy/go-control-plane/envoy v1.36.0 // indirect
|
||||
github.com/envoyproxy/protoc-gen-validate v1.3.0 // indirect
|
||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||
github.com/go-jose/go-jose/v4 v4.1.2 // indirect
|
||||
github.com/go-jose/go-jose/v4 v4.1.3 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // 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.0 // 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.6 // indirect
|
||||
github.com/googleapis/gax-go/v2 v2.15.0 // indirect
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.11 // indirect
|
||||
github.com/googleapis/gax-go/v2 v2.17.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/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.65.0 // indirect
|
||||
github.com/prometheus/procfs v0.17.0 // indirect
|
||||
github.com/prometheus/common v0.67.5 // indirect
|
||||
github.com/prometheus/procfs v0.19.2 // indirect
|
||||
github.com/russross/blackfriday/v2 v2.1.0 // indirect
|
||||
github.com/spiffe/go-spiffe/v2 v2.5.0 // indirect
|
||||
github.com/spiffe/go-spiffe/v2 v2.6.0 // indirect
|
||||
github.com/stretchr/objx v0.5.2 // indirect
|
||||
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 // indirect
|
||||
github.com/zeebo/errs v1.4.0 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
|
||||
go.opentelemetry.io/contrib/detectors/gcp v1.37.0 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.62.0 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0 // indirect
|
||||
go.opentelemetry.io/otel v1.37.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.37.0 // indirect
|
||||
go.opentelemetry.io/otel/sdk v1.37.0 // indirect
|
||||
go.opentelemetry.io/otel/sdk/metric v1.37.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.37.0 // indirect
|
||||
golang.org/x/net v0.42.0 // indirect
|
||||
golang.org/x/sys v0.34.0 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||
go.opentelemetry.io/contrib/detectors/gcp v1.40.0 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.65.0 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0 // indirect
|
||||
go.opentelemetry.io/otel v1.40.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.40.0 // indirect
|
||||
go.opentelemetry.io/otel/sdk v1.40.0 // indirect
|
||||
go.opentelemetry.io/otel/sdk/metric v1.40.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.40.0 // indirect
|
||||
go.yaml.in/yaml/v2 v2.4.3 // indirect
|
||||
golang.org/x/net v0.49.0 // indirect
|
||||
google.golang.org/appengine/v2 v2.0.6 // indirect
|
||||
google.golang.org/genproto v0.0.0-20250804133106-a7a43d27e69b // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20250804133106-a7a43d27e69b // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250804133106-a7a43d27e69b // indirect
|
||||
google.golang.org/grpc v1.74.2 // indirect
|
||||
google.golang.org/protobuf v1.36.6 // indirect
|
||||
google.golang.org/genproto v0.0.0-20260203192932-546029d2fa20 // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260203192932-546029d2fa20 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260203192932-546029d2fa20 // indirect
|
||||
google.golang.org/grpc v1.78.0 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
243
go.sum
@@ -1,41 +1,41 @@
|
||||
cel.dev/expr v0.24.0 h1:56OvJKSH3hDGL0ml5uSxZmz3/3Pq4tJ+fb1unVLAFcY=
|
||||
cel.dev/expr v0.24.0/go.mod h1:hLPLo1W4QUmuYdA72RBX06QTs6MXw941piREPl3Yfiw=
|
||||
cloud.google.com/go v0.121.4 h1:cVvUiY0sX0xwyxPwdSU2KsF9knOVmtRyAMt8xou0iTs=
|
||||
cloud.google.com/go v0.121.4/go.mod h1:XEBchUiHFJbz4lKBZwYBDHV/rSyfFktk737TLDU089s=
|
||||
cloud.google.com/go/auth v0.16.3 h1:kabzoQ9/bobUmnseYnBO6qQG7q4a/CffFRlJSxv2wCc=
|
||||
cloud.google.com/go/auth v0.16.3/go.mod h1:NucRGjaXfzP1ltpcQ7On/VTZ0H4kWB5Jy+Y9Dnm76fA=
|
||||
cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4=
|
||||
cel.dev/expr v0.25.1/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.18.1 h1:IwTEx92GFUo2pJ6Qea0EU3zYvKnTAeRCODxfA/G5UWs=
|
||||
cloud.google.com/go/auth v0.18.1/go.mod h1:GfTYoS9G3CWpRA3Va9doKN9mjPGRS+v41jmZAhBzbrA=
|
||||
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.7.0 h1:PBWF+iiAerVNe8UCHxdOt6eHLVc3ydFeOCw78U8ytSU=
|
||||
cloud.google.com/go/compute/metadata v0.7.0/go.mod h1:j5MvL9PprKL39t166CoB1uVHfQMs4tFQZZcKwksXUjo=
|
||||
cloud.google.com/go/firestore v1.18.0 h1:cuydCaLS7Vl2SatAeivXyhbhDEIR8BDmtn4egDhIn2s=
|
||||
cloud.google.com/go/firestore v1.18.0/go.mod h1:5ye0v48PhseZBdcl0qbl3uttu7FIEwEYVaWm0UIEOEU=
|
||||
cloud.google.com/go/iam v1.5.2 h1:qgFRAGEmd8z6dJ/qyEchAuL9jpswyODjA2lS+w234g8=
|
||||
cloud.google.com/go/iam v1.5.2/go.mod h1:SE1vg0N81zQqLzQEwxL2WI6yhetBdbNQuTvIKCSkUHE=
|
||||
cloud.google.com/go/logging v1.13.0 h1:7j0HgAp0B94o1YRDqiqm26w4q1rDMH7XNRU34lJXHYc=
|
||||
cloud.google.com/go/logging v1.13.0/go.mod h1:36CoKh6KA/M0PbhPKMq6/qety2DCAErbhXT62TuXALA=
|
||||
cloud.google.com/go/longrunning v0.6.7 h1:IGtfDWHhQCgCjwQjV9iiLnUta9LBCo8R9QmAFsS/PrE=
|
||||
cloud.google.com/go/longrunning v0.6.7/go.mod h1:EAFV3IZAKmM56TyiE6VAP3VoTzhZzySwI/YI1s/nRsY=
|
||||
cloud.google.com/go/monitoring v1.24.2 h1:5OTsoJ1dXYIiMiuL+sYscLc9BumrL3CarVLL7dd7lHM=
|
||||
cloud.google.com/go/monitoring v1.24.2/go.mod h1:x7yzPWcgDRnPEv3sI+jJGBkwl5qINf+6qY4eq0I9B4U=
|
||||
cloud.google.com/go/storage v1.56.0 h1:iixmq2Fse2tqxMbWhLWC9HfBj1qdxqAmiK8/eqtsLxI=
|
||||
cloud.google.com/go/storage v1.56.0/go.mod h1:Tpuj6t4NweCLzlNbw9Z9iwxEkrSem20AetIeH/shgVU=
|
||||
cloud.google.com/go/trace v1.11.6 h1:2O2zjPzqPYAHrn3OKl029qlqG6W8ZdYaOWRyr8NgMT4=
|
||||
cloud.google.com/go/trace v1.11.6/go.mod h1:GA855OeDEBiBMzcckLPE2kDunIpC72N+Pq8WFieFjnI=
|
||||
firebase.google.com/go/v4 v4.18.0 h1:S+g0P72oDGqOaG4wlLErX3zQmU9plVdu7j+Bc3R1qFw=
|
||||
firebase.google.com/go/v4 v4.18.0/go.mod h1:P7UfBpzc8+Z3MckX79+zsWzKVfpGryr6HLbAe7gCWfs=
|
||||
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.21.0 h1:BhopUsx7kh6NFx77ccRsHhrtkbJUmDAxNY3uapWdjcM=
|
||||
cloud.google.com/go/firestore v1.21.0/go.mod h1:1xH6HNcnkf/gGyR8udd6pFO4Z7GWJSwLKQMx/u6UrP4=
|
||||
cloud.google.com/go/iam v1.5.3 h1:+vMINPiDF2ognBJ97ABAYYwRgsaqxPbQDlMnbHMjolc=
|
||||
cloud.google.com/go/iam v1.5.3/go.mod h1:MR3v9oLkZCTlaqljW6Eb2d3HGDGK5/bDv93jhfISFvU=
|
||||
cloud.google.com/go/logging v1.13.1 h1:O7LvmO0kGLaHY/gq8cV7T0dyp6zJhYAOtZPX4TF3QtY=
|
||||
cloud.google.com/go/logging v1.13.1/go.mod h1:XAQkfkMBxQRjQek96WLPNze7vsOmay9H5PqfsNYDqvw=
|
||||
cloud.google.com/go/longrunning v0.8.0 h1:LiKK77J3bx5gDLi4SMViHixjD2ohlkwBi+mKA7EhfW8=
|
||||
cloud.google.com/go/longrunning v0.8.0/go.mod h1:UmErU2Onzi+fKDg2gR7dusz11Pe26aknR4kHmJJqIfk=
|
||||
cloud.google.com/go/monitoring v1.24.3 h1:dde+gMNc0UhPZD1Azu6at2e79bfdztVDS5lvhOdsgaE=
|
||||
cloud.google.com/go/monitoring v1.24.3/go.mod h1:nYP6W0tm3N9H/bOw8am7t62YTzZY+zUeQ+Bi6+2eonI=
|
||||
cloud.google.com/go/storage v1.59.2 h1:gmOAuG1opU8YvycMNpP+DvHfT9BfzzK5Cy+arP+Nocw=
|
||||
cloud.google.com/go/storage v1.59.2/go.mod h1:cMWbtM+anpC74gn6qjLh+exqYcfmB9Hqe5z6adx+CLI=
|
||||
cloud.google.com/go/trace v1.11.7 h1:kDNDX8JkaAG3R2nq1lIdkb7FCSi1rCmsEtKVsty7p+U=
|
||||
cloud.google.com/go/trace v1.11.7/go.mod h1:TNn9d5V3fQVf6s4SCveVMIBS2LJUqo73GACmq/Tky0s=
|
||||
firebase.google.com/go/v4 v4.19.0 h1:f5NMlC2YHFsncz00c2+ecBr+ZYlRMhKIhj1z8Iz0lD8=
|
||||
firebase.google.com/go/v4 v4.19.0/go.mod h1:P7UfBpzc8+Z3MckX79+zsWzKVfpGryr6HLbAe7gCWfs=
|
||||
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.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg=
|
||||
github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.29.0 h1:UQUsRi8WTzhZntp5313l+CHIAT95ojUI2lpP/ExlZa4=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.29.0/go.mod h1:Cz6ft6Dkn3Et6l2v2a9/RpN7epQ1GtDlO6lj8bEcOvw=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.53.0 h1:owcC2UnmsZycprQ5RfRgjydWhuoxg71LUfyiQdijZuM=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.53.0/go.mod h1:ZPpqegjbE99EPKsu3iUWV22A04wzGPcAY/ziSIQEEgs=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.53.0 h1:4LP6hvB4I5ouTbGgWtixJhgED6xdf67twf9PoY96Tbg=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.53.0/go.mod h1:jUZ5LYlw40WMd07qxcQJD5M40aUxrfwqQX1g7zxYnrQ=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.53.0 h1:Ron4zCA/yk6U7WOBXhTJcDpsUBG9npumK6xw2auFltQ=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.53.0/go.mod h1:cSgYe11MCNYunTnRXrKiR/tHc0eoKjICUuWpNZoVCOo=
|
||||
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.31.0 h1:DHa2U07rk8syqvCge0QIGMCE1WxGj9njT44GH7zNJLQ=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.31.0/go.mod h1:P4WPRUkOhJC13W//jWpyfJNDAIpvRbAUIYLX/4jtlE0=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.55.0 h1:UnDZ/zFfG1JhH/DqxIZYU/1CUAlTUScoXD/LcM2Ykk8=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.55.0/go.mod h1:IA1C1U7jO/ENqm/vhi7V9YYpBsp+IMyqNrEN94N7tVc=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.55.0 h1:7t/qx5Ost0s0wbA/VDrByOooURhp+ikYwv20i9Y07TQ=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.55.0/go.mod h1:vB2GH9GAYYJTO3mEn8oYwzEdhlayZIdQz6zdzgUIRvA=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0 h1:0s6TxfCu2KHkkZPnBfsQ2y5qia0jl3MMrmBhu3nCOYk=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0/go.mod h1:Mf6O40IAyB9zR/1J8nGDDPirZQQPbYJni8Yisy7NTMc=
|
||||
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=
|
||||
@@ -46,8 +46,8 @@ github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/cncf/xds/go v0.0.0-20250501225837-2ac532fd4443 h1:aQ3y1lwWyqYPiWZThqv1aFbZMiM9vblcSArJRf2Irls=
|
||||
github.com/cncf/xds/go v0.0.0-20250501225837-2ac532fd4443/go.mod h1:W+zGtBO5Y1IgJhy4+A9GOqVhqLpfZi+vwmdNXUehLA8=
|
||||
github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 h1:aBangftG7EVZoUb69Os8IaYg++6uMOdKK83QtkkvJik=
|
||||
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/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
@@ -58,20 +58,20 @@ github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 h1:oP4q0fw+fOSWn3
|
||||
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
|
||||
github.com/emersion/go-smtp v0.17.0 h1:tq90evlrcyqRfE6DSXaWVH54oX6OuZOQECEmhWBMEtI=
|
||||
github.com/emersion/go-smtp v0.17.0/go.mod h1:qm27SGYgoIPRot6ubfQ/GpiPy/g3PaZAVRxiO/sDUgQ=
|
||||
github.com/envoyproxy/go-control-plane v0.13.4 h1:zEqyPVyku6IvWCFwux4x9RxkLOMUL+1vC9xUFv5l2/M=
|
||||
github.com/envoyproxy/go-control-plane v0.13.4/go.mod h1:kDfuBlDVsSj2MjrLEtRWtHlsWIFcGyB2RMO44Dc5GZA=
|
||||
github.com/envoyproxy/go-control-plane/envoy v1.32.4 h1:jb83lalDRZSpPWW2Z7Mck/8kXZ5CQAFYVjQcdVIr83A=
|
||||
github.com/envoyproxy/go-control-plane/envoy v1.32.4/go.mod h1:Gzjc5k8JcJswLjAx1Zm+wSYE20UrLtt7JZMWiWQXQEw=
|
||||
github.com/envoyproxy/go-control-plane v0.13.5-0.20251024222203-75eaa193e329 h1:K+fnvUM0VZ7ZFJf0n4L/BRlnsb9pL/GuDG6FqaH+PwM=
|
||||
github.com/envoyproxy/go-control-plane v0.13.5-0.20251024222203-75eaa193e329/go.mod h1:Alz8LEClvR7xKsrq3qzoc4N0guvVNSS8KmSChGYr9hs=
|
||||
github.com/envoyproxy/go-control-plane/envoy v1.36.0 h1:yg/JjO5E7ubRyKX3m07GF3reDNEnfOboJ0QySbH736g=
|
||||
github.com/envoyproxy/go-control-plane/envoy v1.36.0/go.mod h1:ty89S1YCCVruQAm9OtKeEkQLTb+Lkz0k8v9W0Oxsv98=
|
||||
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.2.1 h1:DEo3O99U8j4hBFwbJfrz9VtgcDfUKS7KJ7spH3d86P8=
|
||||
github.com/envoyproxy/protoc-gen-validate v1.2.1/go.mod h1:d/C80l/jxXLdfEIhX1W2TmLfsJ31lvEjwamM4DxlWXU=
|
||||
github.com/envoyproxy/protoc-gen-validate v1.3.0 h1:TvGH1wof4H33rezVKWSpqKz5NXWg5VPuZ0uONDT6eb4=
|
||||
github.com/envoyproxy/protoc-gen-validate v1.3.0/go.mod h1:HvYl7zwPa5mffgyeTUHA9zHIH36nmrm7oCbo4YKoSWA=
|
||||
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.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY=
|
||||
github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok=
|
||||
github.com/go-jose/go-jose/v4 v4.1.2 h1:TK/7NqRQZfgAh+Td8AlsrvtPoUyiHh0LqVvokh+1vHI=
|
||||
github.com/go-jose/go-jose/v4 v4.1.2/go.mod h1:22cg9HWM1pOlnRiY+9cQYJ9XHmya1bYW8OeDM6Ku6Oo=
|
||||
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/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs=
|
||||
github.com/go-jose/go-jose/v4 v4.1.3/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=
|
||||
@@ -81,8 +81,8 @@ github.com/golang-jwt/jwt/v4 v4.4.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w
|
||||
github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI=
|
||||
github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||
@@ -96,14 +96,22 @@ 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.6 h1:GW/XbdyBFQ8Qe+YAmFU9uHLo7OnF5tL52HFAgMmyrf4=
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.6/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA=
|
||||
github.com/googleapis/gax-go/v2 v2.15.0 h1:SyjDc1mGgZU5LncH8gimWo9lW1DtIfPibOG81vgd/bo=
|
||||
github.com/googleapis/gax-go/v2 v2.15.0/go.mod h1:zVVkkxAQHa1RQpg9z2AUCMnKhi0Qld9rcmyfL1OZhoc=
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.11 h1:vAe81Msw+8tKUxi2Dqh/NZMz7475yUvmRIkXr4oN2ao=
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.11/go.mod h1:RFV7MUdlb7AgEq2v7FmMCfeSMCllAzWxFgRdusoGks8=
|
||||
github.com/googleapis/gax-go/v2 v2.17.0 h1:RksgfBpxqff0EZkDWYuz9q/uWsTVz+kf43LsZ1J6SMc=
|
||||
github.com/googleapis/gax-go/v2 v2.17.0/go.mod h1:mzaqghpQp4JDh3HvADwrat+6M3MOIDp5YKHhb9PAgDY=
|
||||
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=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgx/v5 v5.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo=
|
||||
github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw=
|
||||
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/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
@@ -112,8 +120,8 @@ 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.30 h1:bVreufq3EAIG1Quvws73du3/QgdeZ3myglJlrzSYYCY=
|
||||
github.com/mattn/go-sqlite3 v1.14.30/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/mattn/go-sqlite3 v1.14.33 h1:A5blZ5ulQo2AtayQ9/limgHEkFreKj1Dv226a1K73s0=
|
||||
github.com/mattn/go-sqlite3 v1.14.33/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
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=
|
||||
@@ -127,26 +135,27 @@ github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1
|
||||
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.0 h1:ust4zpdl9r4trLY/gSjlm07PuiBq2ynaXXlptpfy8Uc=
|
||||
github.com/prometheus/client_golang v1.23.0/go.mod h1:i/o0R9ByOnHX0McrTMTyhYvKE4haaf2mW08I+jGAjEE=
|
||||
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_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.65.0 h1:QDwzd+G1twt//Kwj/Ww6E9FQq1iVMmODnILtW1t2VzE=
|
||||
github.com/prometheus/common v0.65.0/go.mod h1:0gZns+BLRQ3V6NdaerOhMbwwRbNh9hkGINtQAsP5GS8=
|
||||
github.com/prometheus/procfs v0.17.0 h1:FuLQ+05u4ZI+SS/w9+BWEM2TXiHKsUQ9TADiRH7DuK0=
|
||||
github.com/prometheus/procfs v0.17.0/go.mod h1:oPQLaDAMRbA+u8H5Pbfq+dl3VDAvHxMUOVhe0wYB2zw=
|
||||
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
|
||||
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
|
||||
github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4=
|
||||
github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw=
|
||||
github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws=
|
||||
github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw=
|
||||
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.5.0 h1:N2I01KCUkv1FAjZXJMwh95KK1ZIQLYbPfhaxw8WS0hE=
|
||||
github.com/spiffe/go-spiffe/v2 v2.5.0/go.mod h1:P+NxobPc6wXhVtINNtFjNWGBTreew1GBUCwT2wPmb7g=
|
||||
github.com/spiffe/go-spiffe/v2 v2.6.0 h1:l+DolpxNWYgruGQVV0xsfeya3CsC7m8iBzDnMpsbLuo=
|
||||
github.com/spiffe/go-spiffe/v2 v2.6.0/go.mod h1:gm2SeUoMZEtpnzPNs2Csc0D/gX33k1xIx7lEzqblHEs=
|
||||
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/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.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
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/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=
|
||||
@@ -154,38 +163,38 @@ github.com/urfave/cli/v2 v2.27.7/go.mod h1:CyNAG/xg+iAOg0N4MPGZqVmv2rCoP267496AO
|
||||
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 h1:FnBeRrxr7OU4VvAzt5X7s6266i6cSVkkFPS0TuXWbIg=
|
||||
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
github.com/zeebo/errs v1.4.0 h1:XNdoD/RRMKP7HD0UhJnIzUy74ISdGGxURlYG8HSWSfM=
|
||||
github.com/zeebo/errs v1.4.0/go.mod h1:sgbWHsvVuTPHcqJJGQ1WhI5KbWlHYz+2+2C/LSEtCw4=
|
||||
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
|
||||
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
|
||||
go.opentelemetry.io/contrib/detectors/gcp v1.37.0 h1:B+WbN9RPsvobe6q4vP6KgM8/9plR/HNjgGBrfcOlweA=
|
||||
go.opentelemetry.io/contrib/detectors/gcp v1.37.0/go.mod h1:K5zQ3TT7p2ru9Qkzk0bKtCql0RGkPj9pRjpXgZJZ+rU=
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.62.0 h1:rbRJ8BBoVMsQShESYZ0FkvcITu8X8QNwJogcLUmDNNw=
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.62.0/go.mod h1:ru6KHrNtNHxM4nD/vd6QrLVWgKhxPYgblq4VAtNawTQ=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0 h1:Hf9xI/XLML9ElpiHVDNwvqI0hIFlzV8dgIr35kV1kRU=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0/go.mod h1:NfchwuyNoMcZ5MLHwPrODwUF1HWCXWrL31s8gSAdIKY=
|
||||
go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ=
|
||||
go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I=
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.36.0 h1:rixTyDGXFxRy1xzhKrotaHy3/KXdPhlWARrCgK+eqUY=
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.36.0/go.mod h1:dowW6UsM9MKbJq5JTz2AMVp3/5iW5I/TStsk8S+CfHw=
|
||||
go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE=
|
||||
go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E=
|
||||
go.opentelemetry.io/otel/sdk v1.37.0 h1:ItB0QUqnjesGRvNcmAcU0LyvkVyGJ2xftD29bWdDvKI=
|
||||
go.opentelemetry.io/otel/sdk v1.37.0/go.mod h1:VredYzxUvuo2q3WRcDnKDjbdvmO0sCzOvVAiY+yUkAg=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.37.0 h1:90lI228XrB9jCMuSdA0673aubgRobVZFhbjxHHspCPc=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.37.0/go.mod h1:cNen4ZWfiD37l5NhS+Keb5RXVWZWpRE+9WyVCpbo5ps=
|
||||
go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4=
|
||||
go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
|
||||
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.40.0 h1:Awaf8gmW99tZTOWqkLCOl6aw1/rxAWVlHsHIZ3fT2sA=
|
||||
go.opentelemetry.io/contrib/detectors/gcp v1.40.0/go.mod h1:99OY9ZCqyLkzJLTh5XhECpLRSxcZl+ZDKBEO+jMBFR4=
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.65.0 h1:XmiuHzgJt067+a6kwyAzkhXooYVv3/TOw9cM2VfJgUM=
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.65.0/go.mod h1:KDgtbWKTQs4bM+VPUr6WlL9m/WXcmkCcBlIzqxPGzmI=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0 h1:7iP2uCb7sGddAr30RRS6xjKy7AZ2JtTOPA3oolgVSw8=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0/go.mod h1:c7hN3ddxs/z6q9xwvfLPk+UHlWRQyaeR1LdgfL/66l0=
|
||||
go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms=
|
||||
go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g=
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.38.0 h1:wm/Q0GAAykXv83wzcKzGGqAnnfLFyFe7RslekZuv+VI=
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.38.0/go.mod h1:ra3Pa40+oKjvYh+ZD3EdxFZZB0xdMfuileHAm4nNN7w=
|
||||
go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g=
|
||||
go.opentelemetry.io/otel/metric v1.40.0/go.mod h1:ib/crwQH7N3r5kfiBZQbwrTge743UDc7DTFVZrrXnqc=
|
||||
go.opentelemetry.io/otel/sdk v1.40.0 h1:KHW/jUzgo6wsPh9At46+h4upjtccTmuZCFAc9OJ71f8=
|
||||
go.opentelemetry.io/otel/sdk v1.40.0/go.mod h1:Ph7EFdYvxq72Y8Li9q8KebuYUr2KoeyHx0DRMKrYBUE=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.40.0 h1:mtmdVqgQkeRxHgRv4qhyJduP3fYJRMX4AtAlbuWdCYw=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.40.0/go.mod h1:4Z2bGMf0KSK3uRjlczMOeMhKU2rhUqdWNoKcYrtcBPg=
|
||||
go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw=
|
||||
go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA=
|
||||
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.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0=
|
||||
go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
|
||||
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.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM=
|
||||
golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY=
|
||||
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
|
||||
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
|
||||
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=
|
||||
@@ -200,10 +209,10 @@ 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.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs=
|
||||
golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8=
|
||||
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
|
||||
golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
|
||||
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
|
||||
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
|
||||
golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
|
||||
golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
@@ -211,8 +220,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.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
|
||||
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
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=
|
||||
@@ -225,8 +234,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.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
|
||||
golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
|
||||
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
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=
|
||||
@@ -236,8 +245,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.33.0 h1:NuFncQrRcaRvVmgRkvM3j/F00gWIAlcmlB8ACEKmGIg=
|
||||
golang.org/x/term v0.33.0/go.mod h1:s18+ql9tYWp1IfpV9DmCtQDDSRBUjKaw9M1eAv5UeF0=
|
||||
golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY=
|
||||
golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww=
|
||||
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=
|
||||
@@ -249,10 +258,10 @@ 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.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4=
|
||||
golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU=
|
||||
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
|
||||
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
|
||||
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
|
||||
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
|
||||
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
|
||||
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
@@ -261,22 +270,24 @@ golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58
|
||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/api v0.244.0 h1:lpkP8wVibSKr++NCD36XzTk/IzeKJ3klj7vbj+XU5pE=
|
||||
google.golang.org/api v0.244.0/go.mod h1:dMVhVcylamkirHdzEBAIQWUCgqY885ivNeZYd7VAVr8=
|
||||
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||
google.golang.org/api v0.265.0 h1:FZvfUdI8nfmuNrE34aOWFPmLC+qRBEiNm3JdivTvAAU=
|
||||
google.golang.org/api v0.265.0/go.mod h1:uAvfEl3SLUj/7n6k+lJutcswVojHPp2Sp08jWCu8hLY=
|
||||
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-20250804133106-a7a43d27e69b h1:eZTgydvqZO44zyTZAvMaSyAxccZZdraiSAGvqOczVvk=
|
||||
google.golang.org/genproto v0.0.0-20250804133106-a7a43d27e69b/go.mod h1:suyz2QBHQKlGIF92HEEsCfO1SwxXdk7PFLz+Zd9Uah4=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20250804133106-a7a43d27e69b h1:ULiyYQ0FdsJhwwZUwbaXpZF5yUE3h+RA+gxvBu37ucc=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20250804133106-a7a43d27e69b/go.mod h1:oDOGiMSXHL4sDTJvFvIB9nRQCGdLP1o/iVaqQK8zB+M=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250804133106-a7a43d27e69b h1:zPKJod4w6F1+nRGDI9ubnXYhU9NSWoFAijkHkUXeTK8=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250804133106-a7a43d27e69b/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=
|
||||
google.golang.org/grpc v1.74.2 h1:WoosgB65DlWVC9FqI82dGsZhWFNBSLjQ84bjROOpMu4=
|
||||
google.golang.org/grpc v1.74.2/go.mod h1:CtQ+BGjaAIXHs/5YS3i473GqwBBa1zGQNevxdeBEXrM=
|
||||
google.golang.org/genproto v0.0.0-20260203192932-546029d2fa20 h1:/CU1zrxTpGylJJbe3Ru94yy6sZRbzALq2/oxl3pGB3U=
|
||||
google.golang.org/genproto v0.0.0-20260203192932-546029d2fa20/go.mod h1:Tt+08/KdKEt3l8x3Pby3HLQxMB3uk/MzaQ4ZIv0ORTs=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260203192932-546029d2fa20 h1:7ei4lp52gK1uSejlA8AZl5AJjeLUOHBQscRQZUgAcu0=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260203192932-546029d2fa20/go.mod h1:ZdbssH/1SOVnjnDlXzxDHK2MCidiqXtbYccJNzNYPEE=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260203192932-546029d2fa20 h1:Jr5R2J6F6qWyzINc+4AM8t5pfUz6beZpHp678GNrMbE=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260203192932-546029d2fa20/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
|
||||
google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc=
|
||||
google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U=
|
||||
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.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
|
||||
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/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=
|
||||
|
||||
19
main.go
@@ -2,12 +2,14 @@ package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/urfave/cli/v2"
|
||||
"heckel.io/ntfy/v2/cmd"
|
||||
"os"
|
||||
"runtime"
|
||||
|
||||
"github.com/urfave/cli/v2"
|
||||
"heckel.io/ntfy/v2/cmd"
|
||||
)
|
||||
|
||||
// These variables are set during build time using -ldflags
|
||||
var (
|
||||
version = "dev"
|
||||
commit = "unknown"
|
||||
@@ -24,13 +26,24 @@ the Matrix room (https://matrix.to/#/#ntfy:matrix.org).
|
||||
|
||||
ntfy %s (%s), runtime %s, built at %s
|
||||
Copyright (C) Philipp C. Heckel, licensed under Apache License 2.0 & GPLv2
|
||||
`, version, commit[:7], runtime.Version(), date)
|
||||
`, version, maybeShortCommit(commit), runtime.Version(), date)
|
||||
|
||||
app := cmd.New()
|
||||
app.Version = version
|
||||
app.Metadata = map[string]any{
|
||||
cmd.MetadataKeyDate: date,
|
||||
cmd.MetadataKeyCommit: commit,
|
||||
}
|
||||
|
||||
if err := app.Run(os.Args); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err.Error())
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func maybeShortCommit(commit string) string {
|
||||
if len(commit) > 7 {
|
||||
return commit[:7]
|
||||
}
|
||||
return commit
|
||||
}
|
||||
|
||||
628
message/store.go
Normal file
@@ -0,0 +1,628 @@
|
||||
package message
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"heckel.io/ntfy/v2/log"
|
||||
"heckel.io/ntfy/v2/model"
|
||||
"heckel.io/ntfy/v2/util"
|
||||
)
|
||||
|
||||
const (
|
||||
tagMessageCache = "message_cache"
|
||||
)
|
||||
|
||||
var errNoRows = errors.New("no rows found")
|
||||
|
||||
// Store is the interface for a message cache store
|
||||
type Store interface {
|
||||
AddMessage(m *model.Message) error
|
||||
AddMessages(ms []*model.Message) error
|
||||
DB() *sql.DB
|
||||
Message(id string) (*model.Message, error)
|
||||
MessageCounts() (map[string]int, error)
|
||||
Messages(topic string, since model.SinceMarker, scheduled bool) ([]*model.Message, error)
|
||||
MessagesDue() ([]*model.Message, error)
|
||||
MessagesExpired() ([]string, error)
|
||||
MarkPublished(m *model.Message) error
|
||||
UpdateMessageTime(messageID string, timestamp int64) error
|
||||
Topics() ([]string, error)
|
||||
DeleteMessages(ids ...string) error
|
||||
DeleteScheduledBySequenceID(topic, sequenceID string) ([]string, error)
|
||||
ExpireMessages(topics ...string) error
|
||||
AttachmentsExpired() ([]string, error)
|
||||
MarkAttachmentsDeleted(ids ...string) error
|
||||
AttachmentBytesUsedBySender(sender string) (int64, error)
|
||||
AttachmentBytesUsedByUser(userID string) (int64, error)
|
||||
UpdateStats(messages int64) error
|
||||
Stats() (int64, error)
|
||||
Close() error
|
||||
}
|
||||
|
||||
// storeQueries holds the database-specific SQL queries
|
||||
type storeQueries struct {
|
||||
insertMessage string
|
||||
deleteMessage string
|
||||
selectScheduledMessageIDsBySeqID string
|
||||
deleteScheduledBySequenceID string
|
||||
updateMessagesForTopicExpiry string
|
||||
selectRowIDFromMessageID string
|
||||
selectMessagesByID string
|
||||
selectMessagesSinceTime string
|
||||
selectMessagesSinceTimeScheduled string
|
||||
selectMessagesSinceID string
|
||||
selectMessagesSinceIDScheduled string
|
||||
selectMessagesLatest string
|
||||
selectMessagesDue string
|
||||
selectMessagesExpired string
|
||||
updateMessagePublished string
|
||||
selectMessagesCount string
|
||||
selectMessageCountPerTopic string
|
||||
selectTopics string
|
||||
updateAttachmentDeleted string
|
||||
selectAttachmentsExpired string
|
||||
selectAttachmentsSizeBySender string
|
||||
selectAttachmentsSizeByUserID string
|
||||
selectStats string
|
||||
updateStats string
|
||||
updateMessageTime string
|
||||
}
|
||||
|
||||
// commonStore implements store operations that are identical across database backends
|
||||
type commonStore struct {
|
||||
db *sql.DB
|
||||
queue *util.BatchingQueue[*model.Message]
|
||||
nop bool
|
||||
mu sync.Mutex
|
||||
queries storeQueries
|
||||
}
|
||||
|
||||
func newCommonStore(db *sql.DB, queries storeQueries, batchSize int, batchTimeout time.Duration, nop bool) *commonStore {
|
||||
var queue *util.BatchingQueue[*model.Message]
|
||||
if batchSize > 0 || batchTimeout > 0 {
|
||||
queue = util.NewBatchingQueue[*model.Message](batchSize, batchTimeout)
|
||||
}
|
||||
c := &commonStore{
|
||||
db: db,
|
||||
queue: queue,
|
||||
nop: nop,
|
||||
queries: queries,
|
||||
}
|
||||
go c.processMessageBatches()
|
||||
return c
|
||||
}
|
||||
|
||||
// DB returns the underlying database connection
|
||||
func (c *commonStore) DB() *sql.DB {
|
||||
return c.db
|
||||
}
|
||||
|
||||
// AddMessage stores a message to the message cache synchronously, or queues it to be stored at a later date asynchronously.
|
||||
func (c *commonStore) AddMessage(m *model.Message) error {
|
||||
if c.queue != nil {
|
||||
c.queue.Enqueue(m)
|
||||
return nil
|
||||
}
|
||||
return c.addMessages([]*model.Message{m})
|
||||
}
|
||||
|
||||
// AddMessages synchronously stores a batch of messages to the message cache
|
||||
func (c *commonStore) AddMessages(ms []*model.Message) error {
|
||||
return c.addMessages(ms)
|
||||
}
|
||||
|
||||
func (c *commonStore) addMessages(ms []*model.Message) error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if c.nop {
|
||||
return nil
|
||||
}
|
||||
if len(ms) == 0 {
|
||||
return nil
|
||||
}
|
||||
start := time.Now()
|
||||
tx, err := c.db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
stmt, err := tx.Prepare(c.queries.insertMessage)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer stmt.Close()
|
||||
for _, m := range ms {
|
||||
if m.Event != model.MessageEvent && m.Event != model.MessageDeleteEvent && m.Event != model.MessageClearEvent {
|
||||
return model.ErrUnexpectedMessageType
|
||||
}
|
||||
published := m.Time <= time.Now().Unix()
|
||||
tags := strings.Join(m.Tags, ",")
|
||||
var attachmentName, attachmentType, attachmentURL string
|
||||
var attachmentSize, attachmentExpires int64
|
||||
var attachmentDeleted bool
|
||||
if m.Attachment != nil {
|
||||
attachmentName = m.Attachment.Name
|
||||
attachmentType = m.Attachment.Type
|
||||
attachmentSize = m.Attachment.Size
|
||||
attachmentExpires = m.Attachment.Expires
|
||||
attachmentURL = m.Attachment.URL
|
||||
}
|
||||
var actionsStr string
|
||||
if len(m.Actions) > 0 {
|
||||
actionsBytes, err := json.Marshal(m.Actions)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
actionsStr = string(actionsBytes)
|
||||
}
|
||||
var sender string
|
||||
if m.Sender.IsValid() {
|
||||
sender = m.Sender.String()
|
||||
}
|
||||
_, err := stmt.Exec(
|
||||
m.ID,
|
||||
m.SequenceID,
|
||||
m.Time,
|
||||
m.Event,
|
||||
m.Expires,
|
||||
m.Topic,
|
||||
m.Message,
|
||||
m.Title,
|
||||
m.Priority,
|
||||
tags,
|
||||
m.Click,
|
||||
m.Icon,
|
||||
actionsStr,
|
||||
attachmentName,
|
||||
attachmentType,
|
||||
attachmentSize,
|
||||
attachmentExpires,
|
||||
attachmentURL,
|
||||
attachmentDeleted, // Always zero
|
||||
sender,
|
||||
m.User,
|
||||
m.ContentType,
|
||||
m.Encoding,
|
||||
published,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
log.Tag(tagMessageCache).Err(err).Error("Writing %d message(s) failed (took %v)", len(ms), time.Since(start))
|
||||
return err
|
||||
}
|
||||
log.Tag(tagMessageCache).Debug("Wrote %d message(s) in %v", len(ms), time.Since(start))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *commonStore) 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)
|
||||
}
|
||||
|
||||
func (c *commonStore) messagesSinceTime(topic string, since model.SinceMarker, scheduled bool) ([]*model.Message, error) {
|
||||
var rows *sql.Rows
|
||||
var err error
|
||||
if scheduled {
|
||||
rows, err = c.db.Query(c.queries.selectMessagesSinceTimeScheduled, topic, since.Time().Unix())
|
||||
} else {
|
||||
rows, err = c.db.Query(c.queries.selectMessagesSinceTime, topic, since.Time().Unix())
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return readMessages(rows)
|
||||
}
|
||||
|
||||
func (c *commonStore) messagesSinceID(topic string, since model.SinceMarker, scheduled bool) ([]*model.Message, error) {
|
||||
idrows, err := c.db.Query(c.queries.selectRowIDFromMessageID, since.ID())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer idrows.Close()
|
||||
if !idrows.Next() {
|
||||
return c.messagesSinceTime(topic, model.SinceAllMessages, scheduled)
|
||||
}
|
||||
var rowID int64
|
||||
if err := idrows.Scan(&rowID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
idrows.Close()
|
||||
var rows *sql.Rows
|
||||
if scheduled {
|
||||
rows, err = c.db.Query(c.queries.selectMessagesSinceIDScheduled, topic, rowID)
|
||||
} else {
|
||||
rows, err = c.db.Query(c.queries.selectMessagesSinceID, topic, rowID)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return readMessages(rows)
|
||||
}
|
||||
|
||||
func (c *commonStore) messagesLatest(topic string) ([]*model.Message, error) {
|
||||
rows, err := c.db.Query(c.queries.selectMessagesLatest, topic)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return readMessages(rows)
|
||||
}
|
||||
|
||||
func (c *commonStore) MessagesDue() ([]*model.Message, error) {
|
||||
rows, err := c.db.Query(c.queries.selectMessagesDue, time.Now().Unix())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return readMessages(rows)
|
||||
}
|
||||
|
||||
// MessagesExpired returns a list of IDs for messages that have expired (should be deleted)
|
||||
func (c *commonStore) MessagesExpired() ([]string, error) {
|
||||
rows, err := c.db.Query(c.queries.selectMessagesExpired, time.Now().Unix())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
ids := make([]string, 0)
|
||||
for rows.Next() {
|
||||
var id string
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
func (c *commonStore) Message(id string) (*model.Message, error) {
|
||||
rows, err := c.db.Query(c.queries.selectMessagesByID, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !rows.Next() {
|
||||
return nil, model.ErrMessageNotFound
|
||||
}
|
||||
defer rows.Close()
|
||||
return readMessage(rows)
|
||||
}
|
||||
|
||||
// UpdateMessageTime updates the time column for a message by ID. This is only used for testing.
|
||||
func (c *commonStore) UpdateMessageTime(messageID string, timestamp int64) error {
|
||||
_, err := c.db.Exec(c.queries.updateMessageTime, timestamp, messageID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *commonStore) MarkPublished(m *model.Message) error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
_, err := c.db.Exec(c.queries.updateMessagePublished, m.ID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *commonStore) MessageCounts() (map[string]int, error) {
|
||||
rows, err := c.db.Query(c.queries.selectMessageCountPerTopic)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var topic string
|
||||
var count int
|
||||
counts := make(map[string]int)
|
||||
for rows.Next() {
|
||||
if err := rows.Scan(&topic, &count); err != nil {
|
||||
return nil, err
|
||||
} else if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
counts[topic] = count
|
||||
}
|
||||
return counts, nil
|
||||
}
|
||||
|
||||
func (c *commonStore) Topics() ([]string, error) {
|
||||
rows, err := c.db.Query(c.queries.selectTopics)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
topics := make([]string, 0)
|
||||
for rows.Next() {
|
||||
var id string
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
topics = append(topics, id)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return topics, nil
|
||||
}
|
||||
|
||||
func (c *commonStore) DeleteMessages(ids ...string) error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
tx, err := c.db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
for _, id := range ids {
|
||||
if _, err := tx.Exec(c.queries.deleteMessage, id); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// DeleteScheduledBySequenceID deletes unpublished (scheduled) messages with the given topic and sequence ID.
|
||||
// It returns the message IDs of the deleted messages, which can be used to clean up attachment files.
|
||||
func (c *commonStore) DeleteScheduledBySequenceID(topic, sequenceID string) ([]string, error) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
tx, err := c.db.Begin()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
rows, err := tx.Query(c.queries.selectScheduledMessageIDsBySeqID, topic, sequenceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
ids := make([]string, 0)
|
||||
for rows.Next() {
|
||||
var id string
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows.Close()
|
||||
if _, err := tx.Exec(c.queries.deleteScheduledBySequenceID, topic, sequenceID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
func (c *commonStore) ExpireMessages(topics ...string) error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
tx, err := c.db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
for _, t := range topics {
|
||||
if _, err := tx.Exec(c.queries.updateMessagesForTopicExpiry, time.Now().Unix()-1, t); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (c *commonStore) AttachmentsExpired() ([]string, error) {
|
||||
rows, err := c.db.Query(c.queries.selectAttachmentsExpired, time.Now().Unix())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
ids := make([]string, 0)
|
||||
for rows.Next() {
|
||||
var id string
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
func (c *commonStore) MarkAttachmentsDeleted(ids ...string) error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
tx, err := c.db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
for _, id := range ids {
|
||||
if _, err := tx.Exec(c.queries.updateAttachmentDeleted, id); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (c *commonStore) AttachmentBytesUsedBySender(sender string) (int64, error) {
|
||||
rows, err := c.db.Query(c.queries.selectAttachmentsSizeBySender, sender, time.Now().Unix())
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return c.readAttachmentBytesUsed(rows)
|
||||
}
|
||||
|
||||
func (c *commonStore) AttachmentBytesUsedByUser(userID string) (int64, error) {
|
||||
rows, err := c.db.Query(c.queries.selectAttachmentsSizeByUserID, userID, time.Now().Unix())
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return c.readAttachmentBytesUsed(rows)
|
||||
}
|
||||
|
||||
func (c *commonStore) readAttachmentBytesUsed(rows *sql.Rows) (int64, error) {
|
||||
defer rows.Close()
|
||||
var size int64
|
||||
if !rows.Next() {
|
||||
return 0, errors.New("no rows found")
|
||||
}
|
||||
if err := rows.Scan(&size); err != nil {
|
||||
return 0, err
|
||||
} else if err := rows.Err(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return size, nil
|
||||
}
|
||||
|
||||
func (c *commonStore) UpdateStats(messages int64) error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
_, err := c.db.Exec(c.queries.updateStats, messages)
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *commonStore) Stats() (messages int64, err error) {
|
||||
rows, err := c.db.Query(c.queries.selectStats)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer rows.Close()
|
||||
if !rows.Next() {
|
||||
return 0, errNoRows
|
||||
}
|
||||
if err := rows.Scan(&messages); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return messages, nil
|
||||
}
|
||||
|
||||
func (c *commonStore) Close() error {
|
||||
return c.db.Close()
|
||||
}
|
||||
|
||||
func (c *commonStore) processMessageBatches() {
|
||||
if c.queue == nil {
|
||||
return
|
||||
}
|
||||
for messages := range c.queue.Dequeue() {
|
||||
if err := c.addMessages(messages); err != nil {
|
||||
log.Tag(tagMessageCache).Err(err).Error("Cannot write message batch")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func readMessages(rows *sql.Rows) ([]*model.Message, error) {
|
||||
defer rows.Close()
|
||||
messages := make([]*model.Message, 0)
|
||||
for rows.Next() {
|
||||
m, err := readMessage(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
messages = append(messages, m)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return messages, nil
|
||||
}
|
||||
|
||||
func readMessage(rows *sql.Rows) (*model.Message, error) {
|
||||
var timestamp, expires, attachmentSize, attachmentExpires int64
|
||||
var priority int
|
||||
var id, sequenceID, event, topic, msg, title, tagsStr, click, icon, actionsStr, attachmentName, attachmentType, attachmentURL, sender, user, contentType, encoding string
|
||||
err := rows.Scan(
|
||||
&id,
|
||||
&sequenceID,
|
||||
×tamp,
|
||||
&event,
|
||||
&expires,
|
||||
&topic,
|
||||
&msg,
|
||||
&title,
|
||||
&priority,
|
||||
&tagsStr,
|
||||
&click,
|
||||
&icon,
|
||||
&actionsStr,
|
||||
&attachmentName,
|
||||
&attachmentType,
|
||||
&attachmentSize,
|
||||
&attachmentExpires,
|
||||
&attachmentURL,
|
||||
&sender,
|
||||
&user,
|
||||
&contentType,
|
||||
&encoding,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var tags []string
|
||||
if tagsStr != "" {
|
||||
tags = strings.Split(tagsStr, ",")
|
||||
}
|
||||
var actions []*model.Action
|
||||
if actionsStr != "" {
|
||||
if err := json.Unmarshal([]byte(actionsStr), &actions); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
senderIP, err := netip.ParseAddr(sender)
|
||||
if err != nil {
|
||||
senderIP = netip.Addr{} // if no IP stored in database, return invalid address
|
||||
}
|
||||
var att *model.Attachment
|
||||
if attachmentName != "" && attachmentURL != "" {
|
||||
att = &model.Attachment{
|
||||
Name: attachmentName,
|
||||
Type: attachmentType,
|
||||
Size: attachmentSize,
|
||||
Expires: attachmentExpires,
|
||||
URL: attachmentURL,
|
||||
}
|
||||
}
|
||||
return &model.Message{
|
||||
ID: id,
|
||||
SequenceID: sequenceID,
|
||||
Time: timestamp,
|
||||
Expires: expires,
|
||||
Event: event,
|
||||
Topic: topic,
|
||||
Message: msg,
|
||||
Title: title,
|
||||
Priority: priority,
|
||||
Tags: tags,
|
||||
Click: click,
|
||||
Icon: icon,
|
||||
Actions: actions,
|
||||
Attachment: att,
|
||||
Sender: senderIP,
|
||||
User: user,
|
||||
ContentType: contentType,
|
||||
Encoding: encoding,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Ensure commonStore implements Store
|
||||
var _ Store = (*commonStore)(nil)
|
||||
|
||||
// Needed by store.go but not part of Store interface; unused import guard
|
||||
var _ = fmt.Sprintf
|
||||
120
message/store_postgres.go
Normal file
@@ -0,0 +1,120 @@
|
||||
package message
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"time"
|
||||
|
||||
_ "github.com/jackc/pgx/v5/stdlib" // PostgreSQL driver
|
||||
)
|
||||
|
||||
// PostgreSQL runtime query constants
|
||||
const (
|
||||
pgInsertMessageQuery = `
|
||||
INSERT INTO message (mid, sequence_id, time, event, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, attachment_deleted, sender, user_id, content_type, encoding, published)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24)
|
||||
`
|
||||
pgDeleteMessageQuery = `DELETE FROM message WHERE mid = $1`
|
||||
pgSelectScheduledMessageIDsBySeqIDQuery = `SELECT mid FROM message WHERE topic = $1 AND sequence_id = $2 AND published = FALSE`
|
||||
pgDeleteScheduledBySequenceIDQuery = `DELETE FROM message WHERE topic = $1 AND sequence_id = $2 AND published = FALSE`
|
||||
pgUpdateMessagesForTopicExpiryQuery = `UPDATE message SET expires = $1 WHERE topic = $2`
|
||||
pgSelectRowIDFromMessageID = `SELECT id FROM message WHERE mid = $1`
|
||||
pgSelectMessagesByIDQuery = `
|
||||
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 mid = $1
|
||||
`
|
||||
pgSelectMessagesSinceTimeQuery = `
|
||||
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
|
||||
`
|
||||
pgSelectMessagesSinceTimeIncludeScheduledQuery = `
|
||||
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
|
||||
`
|
||||
pgSelectMessagesSinceIDQuery = `
|
||||
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 > $2 AND published = TRUE
|
||||
ORDER BY time, id
|
||||
`
|
||||
pgSelectMessagesSinceIDIncludeScheduledQuery = `
|
||||
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 > $2 OR published = FALSE)
|
||||
ORDER BY time, id
|
||||
`
|
||||
pgSelectMessagesLatestQuery = `
|
||||
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 published = TRUE
|
||||
ORDER BY time DESC, id DESC
|
||||
LIMIT 1
|
||||
`
|
||||
pgSelectMessagesDueQuery = `
|
||||
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 time <= $1 AND published = FALSE
|
||||
ORDER BY time, id
|
||||
`
|
||||
pgSelectMessagesExpiredQuery = `SELECT mid FROM message WHERE expires <= $1 AND published = TRUE`
|
||||
pgUpdateMessagePublishedQuery = `UPDATE message SET published = TRUE WHERE mid = $1`
|
||||
pgSelectMessagesCountQuery = `SELECT COUNT(*) FROM message`
|
||||
pgSelectMessageCountPerTopicQuery = `SELECT topic, COUNT(*) FROM message GROUP BY topic`
|
||||
pgSelectTopicsQuery = `SELECT topic FROM message GROUP BY topic`
|
||||
|
||||
pgUpdateAttachmentDeleted = `UPDATE message SET attachment_deleted = TRUE WHERE mid = $1`
|
||||
pgSelectAttachmentsExpiredQuery = `SELECT mid FROM message WHERE attachment_expires > 0 AND attachment_expires <= $1 AND attachment_deleted = FALSE`
|
||||
pgSelectAttachmentsSizeBySenderQuery = `SELECT COALESCE(SUM(attachment_size), 0) FROM message WHERE user_id = '' AND sender = $1 AND attachment_expires >= $2`
|
||||
pgSelectAttachmentsSizeByUserIDQuery = `SELECT COALESCE(SUM(attachment_size), 0) FROM message WHERE user_id = $1 AND attachment_expires >= $2`
|
||||
|
||||
pgSelectStatsQuery = `SELECT value FROM message_stats WHERE key = 'messages'`
|
||||
pgUpdateStatsQuery = `UPDATE message_stats SET value = $1 WHERE key = 'messages'`
|
||||
pgUpdateMessageTimesQuery = `UPDATE message SET time = $1 WHERE mid = $2`
|
||||
)
|
||||
|
||||
var pgQueries = storeQueries{
|
||||
insertMessage: pgInsertMessageQuery,
|
||||
deleteMessage: pgDeleteMessageQuery,
|
||||
selectScheduledMessageIDsBySeqID: pgSelectScheduledMessageIDsBySeqIDQuery,
|
||||
deleteScheduledBySequenceID: pgDeleteScheduledBySequenceIDQuery,
|
||||
updateMessagesForTopicExpiry: pgUpdateMessagesForTopicExpiryQuery,
|
||||
selectRowIDFromMessageID: pgSelectRowIDFromMessageID,
|
||||
selectMessagesByID: pgSelectMessagesByIDQuery,
|
||||
selectMessagesSinceTime: pgSelectMessagesSinceTimeQuery,
|
||||
selectMessagesSinceTimeScheduled: pgSelectMessagesSinceTimeIncludeScheduledQuery,
|
||||
selectMessagesSinceID: pgSelectMessagesSinceIDQuery,
|
||||
selectMessagesSinceIDScheduled: pgSelectMessagesSinceIDIncludeScheduledQuery,
|
||||
selectMessagesLatest: pgSelectMessagesLatestQuery,
|
||||
selectMessagesDue: pgSelectMessagesDueQuery,
|
||||
selectMessagesExpired: pgSelectMessagesExpiredQuery,
|
||||
updateMessagePublished: pgUpdateMessagePublishedQuery,
|
||||
selectMessagesCount: pgSelectMessagesCountQuery,
|
||||
selectMessageCountPerTopic: pgSelectMessageCountPerTopicQuery,
|
||||
selectTopics: pgSelectTopicsQuery,
|
||||
updateAttachmentDeleted: pgUpdateAttachmentDeleted,
|
||||
selectAttachmentsExpired: pgSelectAttachmentsExpiredQuery,
|
||||
selectAttachmentsSizeBySender: pgSelectAttachmentsSizeBySenderQuery,
|
||||
selectAttachmentsSizeByUserID: pgSelectAttachmentsSizeByUserIDQuery,
|
||||
selectStats: pgSelectStatsQuery,
|
||||
updateStats: pgUpdateStatsQuery,
|
||||
updateMessageTime: pgUpdateMessageTimesQuery,
|
||||
}
|
||||
|
||||
// NewPostgresStore creates a new PostgreSQL-backed message cache store.
|
||||
func NewPostgresStore(dsn string, batchSize int, batchTimeout time.Duration) (Store, error) {
|
||||
db, err := sql.Open("pgx", dsn)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := db.Ping(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := setupPostgresDB(db); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return newCommonStore(db, pgQueries, batchSize, batchTimeout, false), nil
|
||||
}
|
||||
90
message/store_postgres_schema.go
Normal file
@@ -0,0 +1,90 @@
|
||||
package message
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// Initial PostgreSQL schema
|
||||
const (
|
||||
pgCreateTablesQuery = `
|
||||
CREATE TABLE IF NOT EXISTS 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 IF NOT EXISTS idx_message_mid ON message (mid);
|
||||
CREATE INDEX IF NOT EXISTS idx_message_sequence_id ON message (sequence_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_message_time ON message (time);
|
||||
CREATE INDEX IF NOT EXISTS idx_message_topic ON message (topic);
|
||||
CREATE INDEX IF NOT EXISTS idx_message_expires ON message (expires);
|
||||
CREATE INDEX IF NOT EXISTS idx_message_sender ON message (sender);
|
||||
CREATE INDEX IF NOT EXISTS idx_message_user_id ON message (user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_message_attachment_expires ON message (attachment_expires);
|
||||
CREATE TABLE IF NOT EXISTS message_stats (
|
||||
key TEXT PRIMARY KEY,
|
||||
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 (
|
||||
pgCurrentSchemaVersion = 14
|
||||
pgInsertSchemaVersion = `INSERT INTO schema_version (store, version) VALUES ('message', $1)`
|
||||
pgSelectSchemaVersionQuery = `SELECT version FROM schema_version WHERE store = 'message'`
|
||||
)
|
||||
|
||||
func setupPostgresDB(db *sql.DB) error {
|
||||
var schemaVersion int
|
||||
err := db.QueryRow(pgSelectSchemaVersionQuery).Scan(&schemaVersion)
|
||||
if err != nil {
|
||||
return setupNewPostgresDB(db)
|
||||
}
|
||||
if schemaVersion > pgCurrentSchemaVersion {
|
||||
return fmt.Errorf("unexpected schema version: version %d is higher than current version %d", schemaVersion, pgCurrentSchemaVersion)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func setupNewPostgresDB(db *sql.DB) error {
|
||||
tx, err := db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if _, err := tx.Exec(pgCreateTablesQuery); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(pgInsertSchemaVersion, pgCurrentSchemaVersion); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
120
message/store_postgres_test.go
Normal file
@@ -0,0 +1,120 @@
|
||||
package message_test
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"heckel.io/ntfy/v2/message"
|
||||
"heckel.io/ntfy/v2/util"
|
||||
)
|
||||
|
||||
func newTestPostgresStore(t *testing.T) message.Store {
|
||||
dsn := os.Getenv("NTFY_TEST_DATABASE_URL")
|
||||
if dsn == "" {
|
||||
t.Skip("NTFY_TEST_DATABASE_URL not set, skipping PostgreSQL tests")
|
||||
}
|
||||
// Create a unique schema for this test
|
||||
schema := fmt.Sprintf("test_%s", util.RandomString(10))
|
||||
setupDB, err := sql.Open("pgx", dsn)
|
||||
require.Nil(t, err)
|
||||
_, err = setupDB.Exec(fmt.Sprintf("CREATE SCHEMA %s", schema))
|
||||
require.Nil(t, err)
|
||||
require.Nil(t, setupDB.Close())
|
||||
// Open store with search_path set to the new schema
|
||||
u, err := url.Parse(dsn)
|
||||
require.Nil(t, err)
|
||||
q := u.Query()
|
||||
q.Set("search_path", schema)
|
||||
u.RawQuery = q.Encode()
|
||||
store, err := message.NewPostgresStore(u.String(), 0, 0)
|
||||
require.Nil(t, err)
|
||||
t.Cleanup(func() {
|
||||
store.Close()
|
||||
cleanDB, err := sql.Open("pgx", dsn)
|
||||
if err == nil {
|
||||
cleanDB.Exec(fmt.Sprintf("DROP SCHEMA %s CASCADE", schema))
|
||||
cleanDB.Close()
|
||||
}
|
||||
})
|
||||
return store
|
||||
}
|
||||
|
||||
func TestPostgresStore_Messages(t *testing.T) {
|
||||
testCacheMessages(t, newTestPostgresStore(t))
|
||||
}
|
||||
|
||||
func TestPostgresStore_MessagesLock(t *testing.T) {
|
||||
testCacheMessagesLock(t, newTestPostgresStore(t))
|
||||
}
|
||||
|
||||
func TestPostgresStore_MessagesScheduled(t *testing.T) {
|
||||
testCacheMessagesScheduled(t, newTestPostgresStore(t))
|
||||
}
|
||||
|
||||
func TestPostgresStore_Topics(t *testing.T) {
|
||||
testCacheTopics(t, newTestPostgresStore(t))
|
||||
}
|
||||
|
||||
func TestPostgresStore_MessagesTagsPrioAndTitle(t *testing.T) {
|
||||
testCacheMessagesTagsPrioAndTitle(t, newTestPostgresStore(t))
|
||||
}
|
||||
|
||||
func TestPostgresStore_MessagesSinceID(t *testing.T) {
|
||||
testCacheMessagesSinceID(t, newTestPostgresStore(t))
|
||||
}
|
||||
|
||||
func TestPostgresStore_Prune(t *testing.T) {
|
||||
testCachePrune(t, newTestPostgresStore(t))
|
||||
}
|
||||
|
||||
func TestPostgresStore_Attachments(t *testing.T) {
|
||||
testCacheAttachments(t, newTestPostgresStore(t))
|
||||
}
|
||||
|
||||
func TestPostgresStore_AttachmentsExpired(t *testing.T) {
|
||||
testCacheAttachmentsExpired(t, newTestPostgresStore(t))
|
||||
}
|
||||
|
||||
func TestPostgresStore_Sender(t *testing.T) {
|
||||
testSender(t, newTestPostgresStore(t))
|
||||
}
|
||||
|
||||
func TestPostgresStore_DeleteScheduledBySequenceID(t *testing.T) {
|
||||
testDeleteScheduledBySequenceID(t, newTestPostgresStore(t))
|
||||
}
|
||||
|
||||
func TestPostgresStore_MessageByID(t *testing.T) {
|
||||
testMessageByID(t, newTestPostgresStore(t))
|
||||
}
|
||||
|
||||
func TestPostgresStore_MarkPublished(t *testing.T) {
|
||||
testMarkPublished(t, newTestPostgresStore(t))
|
||||
}
|
||||
|
||||
func TestPostgresStore_ExpireMessages(t *testing.T) {
|
||||
testExpireMessages(t, newTestPostgresStore(t))
|
||||
}
|
||||
|
||||
func TestPostgresStore_MarkAttachmentsDeleted(t *testing.T) {
|
||||
testMarkAttachmentsDeleted(t, newTestPostgresStore(t))
|
||||
}
|
||||
|
||||
func TestPostgresStore_Stats(t *testing.T) {
|
||||
testStats(t, newTestPostgresStore(t))
|
||||
}
|
||||
|
||||
func TestPostgresStore_AddMessages(t *testing.T) {
|
||||
testAddMessages(t, newTestPostgresStore(t))
|
||||
}
|
||||
|
||||
func TestPostgresStore_MessagesDue(t *testing.T) {
|
||||
testMessagesDue(t, newTestPostgresStore(t))
|
||||
}
|
||||
|
||||
func TestPostgresStore_MessageFieldRoundTrip(t *testing.T) {
|
||||
testMessageFieldRoundTrip(t, newTestPostgresStore(t))
|
||||
}
|
||||
140
message/store_sqlite.go
Normal file
@@ -0,0 +1,140 @@
|
||||
package message
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
_ "github.com/mattn/go-sqlite3" // SQLite driver
|
||||
"heckel.io/ntfy/v2/util"
|
||||
)
|
||||
|
||||
// SQLite runtime query constants
|
||||
const (
|
||||
sqliteInsertMessageQuery = `
|
||||
INSERT INTO messages (mid, sequence_id, time, event, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, attachment_deleted, sender, user, content_type, encoding, published)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`
|
||||
sqliteDeleteMessageQuery = `DELETE FROM messages WHERE mid = ?`
|
||||
sqliteSelectScheduledMessageIDsBySeqIDQuery = `SELECT mid FROM messages WHERE topic = ? AND sequence_id = ? AND published = 0`
|
||||
sqliteDeleteScheduledBySequenceIDQuery = `DELETE FROM messages WHERE topic = ? AND sequence_id = ? AND published = 0`
|
||||
sqliteUpdateMessagesForTopicExpiryQuery = `UPDATE messages SET expires = ? WHERE topic = ?`
|
||||
sqliteSelectRowIDFromMessageID = `SELECT id FROM messages WHERE mid = ?`
|
||||
sqliteSelectMessagesByIDQuery = `
|
||||
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 mid = ?
|
||||
`
|
||||
sqliteSelectMessagesSinceTimeQuery = `
|
||||
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
|
||||
`
|
||||
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
|
||||
`
|
||||
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 > ? AND published = 1
|
||||
ORDER BY time, id
|
||||
`
|
||||
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 > ? OR published = 0)
|
||||
ORDER BY time, id
|
||||
`
|
||||
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
|
||||
FROM messages
|
||||
WHERE topic = ? AND published = 1
|
||||
ORDER BY time DESC, id DESC
|
||||
LIMIT 1
|
||||
`
|
||||
sqliteSelectMessagesDueQuery = `
|
||||
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 time <= ? AND published = 0
|
||||
ORDER BY time, id
|
||||
`
|
||||
sqliteSelectMessagesExpiredQuery = `SELECT mid FROM messages WHERE expires <= ? AND published = 1`
|
||||
sqliteUpdateMessagePublishedQuery = `UPDATE messages SET published = 1 WHERE mid = ?`
|
||||
sqliteSelectMessagesCountQuery = `SELECT COUNT(*) FROM messages`
|
||||
sqliteSelectMessageCountPerTopicQuery = `SELECT topic, COUNT(*) FROM messages GROUP BY topic`
|
||||
sqliteSelectTopicsQuery = `SELECT topic FROM messages GROUP BY topic`
|
||||
|
||||
sqliteUpdateAttachmentDeleted = `UPDATE messages SET attachment_deleted = 1 WHERE mid = ?`
|
||||
sqliteSelectAttachmentsExpiredQuery = `SELECT mid FROM messages WHERE attachment_expires > 0 AND attachment_expires <= ? AND attachment_deleted = 0`
|
||||
sqliteSelectAttachmentsSizeBySenderQuery = `SELECT IFNULL(SUM(attachment_size), 0) FROM messages WHERE user = '' AND sender = ? AND attachment_expires >= ?`
|
||||
sqliteSelectAttachmentsSizeByUserIDQuery = `SELECT IFNULL(SUM(attachment_size), 0) FROM messages WHERE user = ? AND attachment_expires >= ?`
|
||||
|
||||
sqliteSelectStatsQuery = `SELECT value FROM stats WHERE key = 'messages'`
|
||||
sqliteUpdateStatsQuery = `UPDATE stats SET value = ? WHERE key = 'messages'`
|
||||
sqliteUpdateMessageTimeQuery = `UPDATE messages SET time = ? WHERE mid = ?`
|
||||
)
|
||||
|
||||
var sqliteQueries = storeQueries{
|
||||
insertMessage: sqliteInsertMessageQuery,
|
||||
deleteMessage: sqliteDeleteMessageQuery,
|
||||
selectScheduledMessageIDsBySeqID: sqliteSelectScheduledMessageIDsBySeqIDQuery,
|
||||
deleteScheduledBySequenceID: sqliteDeleteScheduledBySequenceIDQuery,
|
||||
updateMessagesForTopicExpiry: sqliteUpdateMessagesForTopicExpiryQuery,
|
||||
selectRowIDFromMessageID: sqliteSelectRowIDFromMessageID,
|
||||
selectMessagesByID: sqliteSelectMessagesByIDQuery,
|
||||
selectMessagesSinceTime: sqliteSelectMessagesSinceTimeQuery,
|
||||
selectMessagesSinceTimeScheduled: sqliteSelectMessagesSinceTimeIncludeScheduledQuery,
|
||||
selectMessagesSinceID: sqliteSelectMessagesSinceIDQuery,
|
||||
selectMessagesSinceIDScheduled: sqliteSelectMessagesSinceIDIncludeScheduledQuery,
|
||||
selectMessagesLatest: sqliteSelectMessagesLatestQuery,
|
||||
selectMessagesDue: sqliteSelectMessagesDueQuery,
|
||||
selectMessagesExpired: sqliteSelectMessagesExpiredQuery,
|
||||
updateMessagePublished: sqliteUpdateMessagePublishedQuery,
|
||||
selectMessagesCount: sqliteSelectMessagesCountQuery,
|
||||
selectMessageCountPerTopic: sqliteSelectMessageCountPerTopicQuery,
|
||||
selectTopics: sqliteSelectTopicsQuery,
|
||||
updateAttachmentDeleted: sqliteUpdateAttachmentDeleted,
|
||||
selectAttachmentsExpired: sqliteSelectAttachmentsExpiredQuery,
|
||||
selectAttachmentsSizeBySender: sqliteSelectAttachmentsSizeBySenderQuery,
|
||||
selectAttachmentsSizeByUserID: sqliteSelectAttachmentsSizeByUserIDQuery,
|
||||
selectStats: sqliteSelectStatsQuery,
|
||||
updateStats: sqliteUpdateStatsQuery,
|
||||
updateMessageTime: sqliteUpdateMessageTimeQuery,
|
||||
}
|
||||
|
||||
// NewSQLiteStore creates a SQLite file-backed cache
|
||||
func NewSQLiteStore(filename, startupQueries string, cacheDuration time.Duration, batchSize int, batchTimeout time.Duration, nop bool) (Store, error) {
|
||||
parentDir := filepath.Dir(filename)
|
||||
if !util.FileExists(parentDir) {
|
||||
return nil, fmt.Errorf("cache database directory %s does not exist or is not accessible", parentDir)
|
||||
}
|
||||
db, err := sql.Open("sqlite3", filename)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := setupSQLite(db, startupQueries, cacheDuration); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return newCommonStore(db, sqliteQueries, batchSize, batchTimeout, nop), nil
|
||||
}
|
||||
|
||||
// NewMemStore creates an in-memory cache
|
||||
func NewMemStore() (Store, error) {
|
||||
return NewSQLiteStore(createMemoryFilename(), "", 0, 0, 0, false)
|
||||
}
|
||||
|
||||
// NewNopStore creates an in-memory cache that discards all messages;
|
||||
// it is always empty and can be used if caching is entirely disabled
|
||||
func NewNopStore() (Store, error) {
|
||||
return NewSQLiteStore(createMemoryFilename(), "", 0, 0, 0, true)
|
||||
}
|
||||
|
||||
// createMemoryFilename creates a unique memory filename to use for the SQLite backend.
|
||||
func createMemoryFilename() string {
|
||||
return fmt.Sprintf("file:%s?mode=memory&cache=shared", util.RandomString(10))
|
||||
}
|
||||
466
message/store_sqlite_schema.go
Normal file
@@ -0,0 +1,466 @@
|
||||
package message
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"heckel.io/ntfy/v2/log"
|
||||
)
|
||||
|
||||
// Initial SQLite schema
|
||||
const (
|
||||
sqliteCreateMessagesTableQuery = `
|
||||
BEGIN;
|
||||
CREATE TABLE IF NOT EXISTS messages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
mid TEXT NOT NULL,
|
||||
sequence_id TEXT NOT NULL,
|
||||
time INT NOT NULL,
|
||||
event TEXT NOT NULL,
|
||||
expires INT 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 INT NOT NULL,
|
||||
attachment_expires INT NOT NULL,
|
||||
attachment_url TEXT NOT NULL,
|
||||
attachment_deleted INT NOT NULL,
|
||||
sender TEXT NOT NULL,
|
||||
user TEXT NOT NULL,
|
||||
content_type TEXT NOT NULL,
|
||||
encoding TEXT NOT NULL,
|
||||
published INT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_mid ON messages (mid);
|
||||
CREATE INDEX IF NOT EXISTS idx_sequence_id ON messages (sequence_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_time ON messages (time);
|
||||
CREATE INDEX IF NOT EXISTS idx_topic ON messages (topic);
|
||||
CREATE INDEX IF NOT EXISTS idx_expires ON messages (expires);
|
||||
CREATE INDEX IF NOT EXISTS idx_sender ON messages (sender);
|
||||
CREATE INDEX IF NOT EXISTS idx_user ON messages (user);
|
||||
CREATE INDEX IF NOT EXISTS idx_attachment_expires ON messages (attachment_expires);
|
||||
CREATE TABLE IF NOT EXISTS stats (
|
||||
key TEXT PRIMARY KEY,
|
||||
value INT
|
||||
);
|
||||
INSERT INTO stats (key, value) VALUES ('messages', 0);
|
||||
COMMIT;
|
||||
`
|
||||
)
|
||||
|
||||
// Schema version management for SQLite
|
||||
const (
|
||||
sqliteCurrentSchemaVersion = 14
|
||||
sqliteCreateSchemaVersionTableQuery = `
|
||||
CREATE TABLE IF NOT EXISTS schemaVersion (
|
||||
id INT PRIMARY KEY,
|
||||
version INT NOT NULL
|
||||
);
|
||||
`
|
||||
sqliteInsertSchemaVersion = `INSERT INTO schemaVersion VALUES (1, ?)`
|
||||
sqliteUpdateSchemaVersion = `UPDATE schemaVersion SET version = ? WHERE id = 1`
|
||||
sqliteSelectSchemaVersionQuery = `SELECT version FROM schemaVersion WHERE id = 1`
|
||||
)
|
||||
|
||||
// Schema migrations for SQLite
|
||||
const (
|
||||
// 0 -> 1
|
||||
sqliteMigrate0To1AlterMessagesTableQuery = `
|
||||
BEGIN;
|
||||
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('');
|
||||
COMMIT;
|
||||
`
|
||||
|
||||
// 1 -> 2
|
||||
sqliteMigrate1To2AlterMessagesTableQuery = `
|
||||
ALTER TABLE messages ADD COLUMN published INT NOT NULL DEFAULT(1);
|
||||
`
|
||||
|
||||
// 2 -> 3
|
||||
sqliteMigrate2To3AlterMessagesTableQuery = `
|
||||
BEGIN;
|
||||
ALTER TABLE messages ADD COLUMN click TEXT NOT NULL DEFAULT('');
|
||||
ALTER TABLE messages ADD COLUMN attachment_name TEXT NOT NULL DEFAULT('');
|
||||
ALTER TABLE messages ADD COLUMN attachment_type TEXT NOT NULL DEFAULT('');
|
||||
ALTER TABLE messages ADD COLUMN attachment_size INT NOT NULL DEFAULT('0');
|
||||
ALTER TABLE messages ADD COLUMN attachment_expires INT NOT NULL DEFAULT('0');
|
||||
ALTER TABLE messages ADD COLUMN attachment_owner TEXT NOT NULL DEFAULT('');
|
||||
ALTER TABLE messages ADD COLUMN attachment_url TEXT NOT NULL DEFAULT('');
|
||||
COMMIT;
|
||||
`
|
||||
// 3 -> 4
|
||||
sqliteMigrate3To4AlterMessagesTableQuery = `
|
||||
ALTER TABLE messages ADD COLUMN encoding TEXT NOT NULL DEFAULT('');
|
||||
`
|
||||
|
||||
// 4 -> 5
|
||||
sqliteMigrate4To5AlterMessagesTableQuery = `
|
||||
BEGIN;
|
||||
CREATE TABLE IF NOT EXISTS messages_new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
mid TEXT NOT NULL,
|
||||
time INT 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,
|
||||
attachment_name TEXT NOT NULL,
|
||||
attachment_type TEXT NOT NULL,
|
||||
attachment_size INT NOT NULL,
|
||||
attachment_expires INT NOT NULL,
|
||||
attachment_url TEXT NOT NULL,
|
||||
attachment_owner TEXT NOT NULL,
|
||||
encoding TEXT NOT NULL,
|
||||
published INT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_mid ON messages_new (mid);
|
||||
CREATE INDEX IF NOT EXISTS idx_topic ON messages_new (topic);
|
||||
INSERT
|
||||
INTO messages_new (
|
||||
mid, time, topic, message, title, priority, tags, click, attachment_name, attachment_type,
|
||||
attachment_size, attachment_expires, attachment_url, attachment_owner, encoding, published)
|
||||
SELECT
|
||||
id, time, topic, message, title, priority, tags, click, attachment_name, attachment_type,
|
||||
attachment_size, attachment_expires, attachment_url, attachment_owner, encoding, published
|
||||
FROM messages;
|
||||
DROP TABLE messages;
|
||||
ALTER TABLE messages_new RENAME TO messages;
|
||||
COMMIT;
|
||||
`
|
||||
|
||||
// 5 -> 6
|
||||
sqliteMigrate5To6AlterMessagesTableQuery = `
|
||||
ALTER TABLE messages ADD COLUMN actions TEXT NOT NULL DEFAULT('');
|
||||
`
|
||||
|
||||
// 6 -> 7
|
||||
sqliteMigrate6To7AlterMessagesTableQuery = `
|
||||
ALTER TABLE messages RENAME COLUMN attachment_owner TO sender;
|
||||
`
|
||||
|
||||
// 7 -> 8
|
||||
sqliteMigrate7To8AlterMessagesTableQuery = `
|
||||
ALTER TABLE messages ADD COLUMN icon TEXT NOT NULL DEFAULT('');
|
||||
`
|
||||
|
||||
// 8 -> 9
|
||||
sqliteMigrate8To9AlterMessagesTableQuery = `
|
||||
CREATE INDEX IF NOT EXISTS idx_time ON messages (time);
|
||||
`
|
||||
|
||||
// 9 -> 10
|
||||
sqliteMigrate9To10AlterMessagesTableQuery = `
|
||||
ALTER TABLE messages ADD COLUMN user TEXT NOT NULL DEFAULT('');
|
||||
ALTER TABLE messages ADD COLUMN attachment_deleted INT NOT NULL DEFAULT('0');
|
||||
ALTER TABLE messages ADD COLUMN expires INT NOT NULL DEFAULT('0');
|
||||
CREATE INDEX IF NOT EXISTS idx_expires ON messages (expires);
|
||||
CREATE INDEX IF NOT EXISTS idx_sender ON messages (sender);
|
||||
CREATE INDEX IF NOT EXISTS idx_user ON messages (user);
|
||||
CREATE INDEX IF NOT EXISTS idx_attachment_expires ON messages (attachment_expires);
|
||||
`
|
||||
sqliteMigrate9To10UpdateMessageExpiryQuery = `UPDATE messages SET expires = time + ?`
|
||||
|
||||
// 10 -> 11
|
||||
sqliteMigrate10To11AlterMessagesTableQuery = `
|
||||
CREATE TABLE IF NOT EXISTS stats (
|
||||
key TEXT PRIMARY KEY,
|
||||
value INT
|
||||
);
|
||||
INSERT INTO stats (key, value) VALUES ('messages', 0);
|
||||
`
|
||||
|
||||
// 11 -> 12
|
||||
sqliteMigrate11To12AlterMessagesTableQuery = `
|
||||
ALTER TABLE messages ADD COLUMN content_type TEXT NOT NULL DEFAULT('');
|
||||
`
|
||||
|
||||
// 12 -> 13
|
||||
sqliteMigrate12To13AlterMessagesTableQuery = `
|
||||
CREATE INDEX IF NOT EXISTS idx_topic ON messages (topic);
|
||||
`
|
||||
|
||||
// 13 -> 14
|
||||
sqliteMigrate13To14AlterMessagesTableQuery = `
|
||||
ALTER TABLE messages ADD COLUMN sequence_id TEXT NOT NULL DEFAULT('');
|
||||
ALTER TABLE messages ADD COLUMN event TEXT NOT NULL DEFAULT('message');
|
||||
CREATE INDEX IF NOT EXISTS idx_sequence_id ON messages (sequence_id);
|
||||
`
|
||||
)
|
||||
|
||||
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,
|
||||
}
|
||||
)
|
||||
|
||||
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
|
||||
rowsMC, err := db.Query(sqliteSelectMessagesCountQuery)
|
||||
if err != nil {
|
||||
return setupNewSQLite(db)
|
||||
}
|
||||
rowsMC.Close()
|
||||
// If 'messages' table exists, check 'schemaVersion' table
|
||||
schemaVersion := 0
|
||||
rowsSV, err := db.Query(sqliteSelectSchemaVersionQuery)
|
||||
if err == nil {
|
||||
defer rowsSV.Close()
|
||||
if !rowsSV.Next() {
|
||||
return fmt.Errorf("cannot determine schema version: cache file may be corrupt")
|
||||
}
|
||||
if err := rowsSV.Scan(&schemaVersion); err != nil {
|
||||
return err
|
||||
}
|
||||
rowsSV.Close()
|
||||
}
|
||||
// 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 {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func setupNewSQLite(db *sql.DB) error {
|
||||
if _, err := db.Exec(sqliteCreateMessagesTableQuery); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := db.Exec(sqliteCreateSchemaVersionTableQuery); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := db.Exec(sqliteInsertSchemaVersion, sqliteCurrentSchemaVersion); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func runSQLiteStartupQueries(db *sql.DB, startupQueries string) error {
|
||||
if startupQueries != "" {
|
||||
if _, err := db.Exec(startupQueries); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func sqliteMigrateFrom0(db *sql.DB, _ time.Duration) error {
|
||||
log.Tag(tagMessageCache).Info("Migrating cache database schema: from 0 to 1")
|
||||
if _, err := db.Exec(sqliteMigrate0To1AlterMessagesTableQuery); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := db.Exec(sqliteCreateSchemaVersionTableQuery); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := db.Exec(sqliteInsertSchemaVersion, 1); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func sqliteMigrateFrom1(db *sql.DB, _ time.Duration) error {
|
||||
log.Tag(tagMessageCache).Info("Migrating cache database schema: from 1 to 2")
|
||||
if _, err := db.Exec(sqliteMigrate1To2AlterMessagesTableQuery); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := db.Exec(sqliteUpdateSchemaVersion, 2); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func sqliteMigrateFrom2(db *sql.DB, _ time.Duration) error {
|
||||
log.Tag(tagMessageCache).Info("Migrating cache database schema: from 2 to 3")
|
||||
if _, err := db.Exec(sqliteMigrate2To3AlterMessagesTableQuery); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := db.Exec(sqliteUpdateSchemaVersion, 3); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func sqliteMigrateFrom3(db *sql.DB, _ time.Duration) error {
|
||||
log.Tag(tagMessageCache).Info("Migrating cache database schema: from 3 to 4")
|
||||
if _, err := db.Exec(sqliteMigrate3To4AlterMessagesTableQuery); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := db.Exec(sqliteUpdateSchemaVersion, 4); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func sqliteMigrateFrom4(db *sql.DB, _ time.Duration) error {
|
||||
log.Tag(tagMessageCache).Info("Migrating cache database schema: from 4 to 5")
|
||||
if _, err := db.Exec(sqliteMigrate4To5AlterMessagesTableQuery); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := db.Exec(sqliteUpdateSchemaVersion, 5); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func sqliteMigrateFrom5(db *sql.DB, _ time.Duration) error {
|
||||
log.Tag(tagMessageCache).Info("Migrating cache database schema: from 5 to 6")
|
||||
if _, err := db.Exec(sqliteMigrate5To6AlterMessagesTableQuery); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := db.Exec(sqliteUpdateSchemaVersion, 6); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func sqliteMigrateFrom6(db *sql.DB, _ time.Duration) error {
|
||||
log.Tag(tagMessageCache).Info("Migrating cache database schema: from 6 to 7")
|
||||
if _, err := db.Exec(sqliteMigrate6To7AlterMessagesTableQuery); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := db.Exec(sqliteUpdateSchemaVersion, 7); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func sqliteMigrateFrom7(db *sql.DB, _ time.Duration) error {
|
||||
log.Tag(tagMessageCache).Info("Migrating cache database schema: from 7 to 8")
|
||||
if _, err := db.Exec(sqliteMigrate7To8AlterMessagesTableQuery); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := db.Exec(sqliteUpdateSchemaVersion, 8); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func sqliteMigrateFrom8(db *sql.DB, _ time.Duration) error {
|
||||
log.Tag(tagMessageCache).Info("Migrating cache database schema: from 8 to 9")
|
||||
if _, err := db.Exec(sqliteMigrate8To9AlterMessagesTableQuery); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := db.Exec(sqliteUpdateSchemaVersion, 9); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func sqliteMigrateFrom9(db *sql.DB, cacheDuration time.Duration) error {
|
||||
log.Tag(tagMessageCache).Info("Migrating cache database schema: from 9 to 10")
|
||||
tx, err := db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
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(sqliteUpdateSchemaVersion, 10); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func sqliteMigrateFrom10(db *sql.DB, _ time.Duration) error {
|
||||
log.Tag(tagMessageCache).Info("Migrating cache database schema: from 10 to 11")
|
||||
tx, err := db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if _, err := tx.Exec(sqliteMigrate10To11AlterMessagesTableQuery); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(sqliteUpdateSchemaVersion, 11); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func sqliteMigrateFrom11(db *sql.DB, _ time.Duration) error {
|
||||
log.Tag(tagMessageCache).Info("Migrating cache database schema: from 11 to 12")
|
||||
tx, err := db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if _, err := tx.Exec(sqliteMigrate11To12AlterMessagesTableQuery); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(sqliteUpdateSchemaVersion, 12); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func sqliteMigrateFrom12(db *sql.DB, _ time.Duration) error {
|
||||
log.Tag(tagMessageCache).Info("Migrating cache database schema: from 12 to 13")
|
||||
tx, err := db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if _, err := tx.Exec(sqliteMigrate12To13AlterMessagesTableQuery); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(sqliteUpdateSchemaVersion, 13); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func sqliteMigrateFrom13(db *sql.DB, _ time.Duration) error {
|
||||
log.Tag(tagMessageCache).Info("Migrating cache database schema: from 13 to 14")
|
||||
tx, err := db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if _, err := tx.Exec(sqliteMigrate13To14AlterMessagesTableQuery); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(sqliteUpdateSchemaVersion, 14); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
459
message/store_sqlite_test.go
Normal file
@@ -0,0 +1,459 @@
|
||||
package message_test
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
_ "github.com/mattn/go-sqlite3" // SQLite driver
|
||||
"github.com/stretchr/testify/require"
|
||||
"heckel.io/ntfy/v2/message"
|
||||
"heckel.io/ntfy/v2/model"
|
||||
)
|
||||
|
||||
func TestSqliteStore_Messages(t *testing.T) {
|
||||
testCacheMessages(t, newSqliteTestStore(t))
|
||||
}
|
||||
|
||||
func TestMemStore_Messages(t *testing.T) {
|
||||
testCacheMessages(t, newMemTestStore(t))
|
||||
}
|
||||
|
||||
func TestSqliteStore_MessagesLock(t *testing.T) {
|
||||
testCacheMessagesLock(t, newSqliteTestStore(t))
|
||||
}
|
||||
|
||||
func TestMemStore_MessagesLock(t *testing.T) {
|
||||
testCacheMessagesLock(t, newMemTestStore(t))
|
||||
}
|
||||
|
||||
func TestSqliteStore_MessagesScheduled(t *testing.T) {
|
||||
testCacheMessagesScheduled(t, newSqliteTestStore(t))
|
||||
}
|
||||
|
||||
func TestMemStore_MessagesScheduled(t *testing.T) {
|
||||
testCacheMessagesScheduled(t, newMemTestStore(t))
|
||||
}
|
||||
|
||||
func TestSqliteStore_Topics(t *testing.T) {
|
||||
testCacheTopics(t, newSqliteTestStore(t))
|
||||
}
|
||||
|
||||
func TestMemStore_Topics(t *testing.T) {
|
||||
testCacheTopics(t, newMemTestStore(t))
|
||||
}
|
||||
|
||||
func TestSqliteStore_MessagesTagsPrioAndTitle(t *testing.T) {
|
||||
testCacheMessagesTagsPrioAndTitle(t, newSqliteTestStore(t))
|
||||
}
|
||||
|
||||
func TestMemStore_MessagesTagsPrioAndTitle(t *testing.T) {
|
||||
testCacheMessagesTagsPrioAndTitle(t, newMemTestStore(t))
|
||||
}
|
||||
|
||||
func TestSqliteStore_MessagesSinceID(t *testing.T) {
|
||||
testCacheMessagesSinceID(t, newSqliteTestStore(t))
|
||||
}
|
||||
|
||||
func TestMemStore_MessagesSinceID(t *testing.T) {
|
||||
testCacheMessagesSinceID(t, newMemTestStore(t))
|
||||
}
|
||||
|
||||
func TestSqliteStore_Prune(t *testing.T) {
|
||||
testCachePrune(t, newSqliteTestStore(t))
|
||||
}
|
||||
|
||||
func TestMemStore_Prune(t *testing.T) {
|
||||
testCachePrune(t, newMemTestStore(t))
|
||||
}
|
||||
|
||||
func TestSqliteStore_Attachments(t *testing.T) {
|
||||
testCacheAttachments(t, newSqliteTestStore(t))
|
||||
}
|
||||
|
||||
func TestMemStore_Attachments(t *testing.T) {
|
||||
testCacheAttachments(t, newMemTestStore(t))
|
||||
}
|
||||
|
||||
func TestSqliteStore_AttachmentsExpired(t *testing.T) {
|
||||
testCacheAttachmentsExpired(t, newSqliteTestStore(t))
|
||||
}
|
||||
|
||||
func TestMemStore_AttachmentsExpired(t *testing.T) {
|
||||
testCacheAttachmentsExpired(t, newMemTestStore(t))
|
||||
}
|
||||
|
||||
func TestSqliteStore_Sender(t *testing.T) {
|
||||
testSender(t, newSqliteTestStore(t))
|
||||
}
|
||||
|
||||
func TestMemStore_Sender(t *testing.T) {
|
||||
testSender(t, newMemTestStore(t))
|
||||
}
|
||||
|
||||
func TestSqliteStore_DeleteScheduledBySequenceID(t *testing.T) {
|
||||
testDeleteScheduledBySequenceID(t, newSqliteTestStore(t))
|
||||
}
|
||||
|
||||
func TestMemStore_DeleteScheduledBySequenceID(t *testing.T) {
|
||||
testDeleteScheduledBySequenceID(t, newMemTestStore(t))
|
||||
}
|
||||
|
||||
func TestSqliteStore_MessageByID(t *testing.T) {
|
||||
testMessageByID(t, newSqliteTestStore(t))
|
||||
}
|
||||
|
||||
func TestMemStore_MessageByID(t *testing.T) {
|
||||
testMessageByID(t, newMemTestStore(t))
|
||||
}
|
||||
|
||||
func TestSqliteStore_MarkPublished(t *testing.T) {
|
||||
testMarkPublished(t, newSqliteTestStore(t))
|
||||
}
|
||||
|
||||
func TestMemStore_MarkPublished(t *testing.T) {
|
||||
testMarkPublished(t, newMemTestStore(t))
|
||||
}
|
||||
|
||||
func TestSqliteStore_ExpireMessages(t *testing.T) {
|
||||
testExpireMessages(t, newSqliteTestStore(t))
|
||||
}
|
||||
|
||||
func TestMemStore_ExpireMessages(t *testing.T) {
|
||||
testExpireMessages(t, newMemTestStore(t))
|
||||
}
|
||||
|
||||
func TestSqliteStore_MarkAttachmentsDeleted(t *testing.T) {
|
||||
testMarkAttachmentsDeleted(t, newSqliteTestStore(t))
|
||||
}
|
||||
|
||||
func TestMemStore_MarkAttachmentsDeleted(t *testing.T) {
|
||||
testMarkAttachmentsDeleted(t, newMemTestStore(t))
|
||||
}
|
||||
|
||||
func TestSqliteStore_Stats(t *testing.T) {
|
||||
testStats(t, newSqliteTestStore(t))
|
||||
}
|
||||
|
||||
func TestMemStore_Stats(t *testing.T) {
|
||||
testStats(t, newMemTestStore(t))
|
||||
}
|
||||
|
||||
func TestSqliteStore_AddMessages(t *testing.T) {
|
||||
testAddMessages(t, newSqliteTestStore(t))
|
||||
}
|
||||
|
||||
func TestMemStore_AddMessages(t *testing.T) {
|
||||
testAddMessages(t, newMemTestStore(t))
|
||||
}
|
||||
|
||||
func TestSqliteStore_MessagesDue(t *testing.T) {
|
||||
testMessagesDue(t, newSqliteTestStore(t))
|
||||
}
|
||||
|
||||
func TestMemStore_MessagesDue(t *testing.T) {
|
||||
testMessagesDue(t, newMemTestStore(t))
|
||||
}
|
||||
|
||||
func TestSqliteStore_MessageFieldRoundTrip(t *testing.T) {
|
||||
testMessageFieldRoundTrip(t, newSqliteTestStore(t))
|
||||
}
|
||||
|
||||
func TestMemStore_MessageFieldRoundTrip(t *testing.T) {
|
||||
testMessageFieldRoundTrip(t, newMemTestStore(t))
|
||||
}
|
||||
|
||||
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)
|
||||
require.Nil(t, err)
|
||||
|
||||
// Create "version 1" schema
|
||||
_, err = db.Exec(`
|
||||
CREATE TABLE IF NOT EXISTS messages (
|
||||
id VARCHAR(20) PRIMARY KEY,
|
||||
time INT NOT NULL,
|
||||
topic VARCHAR(64) NOT NULL,
|
||||
message VARCHAR(512) NOT NULL,
|
||||
title VARCHAR(256) NOT NULL,
|
||||
priority INT NOT NULL,
|
||||
tags VARCHAR(256) NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_topic ON messages (topic);
|
||||
CREATE TABLE IF NOT EXISTS schemaVersion (
|
||||
id INT PRIMARY KEY,
|
||||
version INT NOT NULL
|
||||
);
|
||||
INSERT INTO schemaVersion (id, version) VALUES (1, 1);
|
||||
`)
|
||||
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, title, priority, tags) VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
fmt.Sprintf("abcd%d", i), time.Now().Unix(), "mytopic", fmt.Sprintf("some message %d", i), "", 0, "")
|
||||
require.Nil(t, err)
|
||||
}
|
||||
require.Nil(t, db.Close())
|
||||
|
||||
// Create store to trigger migration
|
||||
s := newSqliteTestStoreFromFile(t, filename, "")
|
||||
checkSqliteSchemaVersion(t, filename)
|
||||
|
||||
// Add delayed message
|
||||
delayedMessage := model.NewDefaultMessage("mytopic", "some delayed message")
|
||||
delayedMessage.Time = time.Now().Add(time.Minute).Unix()
|
||||
require.Nil(t, s.AddMessage(delayedMessage))
|
||||
|
||||
// 10, not 11!
|
||||
messages, err := s.Messages("mytopic", model.SinceAllMessages, false)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 10, len(messages))
|
||||
|
||||
// 11!
|
||||
messages, err = s.Messages("mytopic", model.SinceAllMessages, true)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 11, len(messages))
|
||||
|
||||
// Check that index "idx_topic" exists
|
||||
verifyDB, err := sql.Open("sqlite3", filename)
|
||||
require.Nil(t, err)
|
||||
defer verifyDB.Close()
|
||||
rows, err := verifyDB.Query(`SELECT name FROM sqlite_master WHERE type='index' AND name='idx_topic'`)
|
||||
require.Nil(t, err)
|
||||
require.True(t, rows.Next())
|
||||
var indexName string
|
||||
require.Nil(t, rows.Scan(&indexName))
|
||||
require.Equal(t, "idx_topic", indexName)
|
||||
require.Nil(t, rows.Close())
|
||||
}
|
||||
|
||||
func TestSqliteStore_Migration_From9(t *testing.T) {
|
||||
// This primarily tests the awkward migration that introduces the "expires" column.
|
||||
// The migration logic has to update the column, using the existing "cache-duration" value.
|
||||
|
||||
filename := newSqliteTestStoreFile(t)
|
||||
db, err := sql.Open("sqlite3", filename)
|
||||
require.Nil(t, err)
|
||||
|
||||
// Create "version 9" schema
|
||||
_, err = db.Exec(`
|
||||
BEGIN;
|
||||
CREATE TABLE IF NOT EXISTS messages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
mid TEXT NOT NULL,
|
||||
time INT 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 INT NOT NULL,
|
||||
attachment_expires INT NOT NULL,
|
||||
attachment_url TEXT NOT NULL,
|
||||
sender TEXT NOT NULL,
|
||||
encoding TEXT NOT NULL,
|
||||
published INT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_mid ON messages (mid);
|
||||
CREATE INDEX IF NOT EXISTS idx_time ON messages (time);
|
||||
CREATE INDEX IF NOT EXISTS idx_topic ON messages (topic);
|
||||
CREATE TABLE IF NOT EXISTS schemaVersion (
|
||||
id INT PRIMARY KEY,
|
||||
version INT NOT NULL
|
||||
);
|
||||
INSERT INTO schemaVersion (id, version) VALUES (1, 9);
|
||||
COMMIT;
|
||||
`)
|
||||
require.Nil(t, err)
|
||||
|
||||
// Insert a bunch of messages
|
||||
insertQuery := `
|
||||
INSERT INTO messages (mid, time, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, encoding, published)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`
|
||||
for i := 0; i < 10; i++ {
|
||||
_, err = db.Exec(
|
||||
insertQuery,
|
||||
fmt.Sprintf("abcd%d", i),
|
||||
time.Now().Unix(),
|
||||
"mytopic",
|
||||
fmt.Sprintf("some message %d", i),
|
||||
"", // title
|
||||
0, // priority
|
||||
"", // tags
|
||||
"", // click
|
||||
"", // icon
|
||||
"", // actions
|
||||
"", // attachment_name
|
||||
"", // attachment_type
|
||||
0, // attachment_size
|
||||
0, // attachment_expires
|
||||
"", // attachment_url
|
||||
"9.9.9.9", // sender
|
||||
"", // encoding
|
||||
1, // published
|
||||
)
|
||||
require.Nil(t, err)
|
||||
}
|
||||
require.Nil(t, db.Close())
|
||||
|
||||
// Create store to trigger migration
|
||||
cacheDuration := 17 * time.Hour
|
||||
s, err := message.NewSQLiteStore(filename, "", cacheDuration, 0, 0, false)
|
||||
require.Nil(t, err)
|
||||
t.Cleanup(func() { s.Close() })
|
||||
checkSqliteSchemaVersion(t, filename)
|
||||
|
||||
// Check version
|
||||
verifyDB, err := sql.Open("sqlite3", filename)
|
||||
require.Nil(t, err)
|
||||
defer verifyDB.Close()
|
||||
rows, err := verifyDB.Query(`SELECT version FROM schemaVersion WHERE id = 1`)
|
||||
require.Nil(t, err)
|
||||
require.True(t, rows.Next())
|
||||
var version int
|
||||
require.Nil(t, rows.Scan(&version))
|
||||
require.Equal(t, 14, version)
|
||||
require.Nil(t, rows.Close())
|
||||
|
||||
messages, err := s.Messages("mytopic", model.SinceAllMessages, false)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 10, len(messages))
|
||||
for _, m := range messages {
|
||||
require.True(t, m.Expires > time.Now().Add(cacheDuration-5*time.Second).Unix())
|
||||
require.True(t, m.Expires < time.Now().Add(cacheDuration+5*time.Second).Unix())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSqliteStore_StartupQueries_WAL(t *testing.T) {
|
||||
filename := newSqliteTestStoreFile(t)
|
||||
startupQueries := `pragma journal_mode = WAL;
|
||||
pragma synchronous = normal;
|
||||
pragma temp_store = memory;`
|
||||
s, err := message.NewSQLiteStore(filename, startupQueries, time.Hour, 0, 0, false)
|
||||
require.Nil(t, err)
|
||||
t.Cleanup(func() { s.Close() })
|
||||
require.Nil(t, s.AddMessage(model.NewDefaultMessage("mytopic", "some message")))
|
||||
require.FileExists(t, filename)
|
||||
require.FileExists(t, filename+"-wal")
|
||||
require.FileExists(t, filename+"-shm")
|
||||
}
|
||||
|
||||
func TestSqliteStore_StartupQueries_None(t *testing.T) {
|
||||
filename := newSqliteTestStoreFile(t)
|
||||
s, err := message.NewSQLiteStore(filename, "", time.Hour, 0, 0, false)
|
||||
require.Nil(t, err)
|
||||
t.Cleanup(func() { s.Close() })
|
||||
require.Nil(t, s.AddMessage(model.NewDefaultMessage("mytopic", "some message")))
|
||||
require.FileExists(t, filename)
|
||||
require.NoFileExists(t, filename+"-wal")
|
||||
require.NoFileExists(t, filename+"-shm")
|
||||
}
|
||||
|
||||
func TestSqliteStore_StartupQueries_Fail(t *testing.T) {
|
||||
filename := newSqliteTestStoreFile(t)
|
||||
_, err := message.NewSQLiteStore(filename, `xx error`, time.Hour, 0, 0, false)
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestNopStore(t *testing.T) {
|
||||
s, err := message.NewNopStore()
|
||||
require.Nil(t, err)
|
||||
t.Cleanup(func() { s.Close() })
|
||||
require.Nil(t, s.AddMessage(model.NewDefaultMessage("mytopic", "my message")))
|
||||
|
||||
messages, err := s.Messages("mytopic", model.SinceAllMessages, false)
|
||||
require.Nil(t, err)
|
||||
require.Empty(t, messages)
|
||||
|
||||
topics, err := s.Topics()
|
||||
require.Nil(t, err)
|
||||
require.Empty(t, topics)
|
||||
}
|
||||
|
||||
func newSqliteTestStore(t *testing.T) message.Store {
|
||||
filename := filepath.Join(t.TempDir(), "cache.db")
|
||||
s, err := message.NewSQLiteStore(filename, "", time.Hour, 0, 0, false)
|
||||
require.Nil(t, err)
|
||||
t.Cleanup(func() { s.Close() })
|
||||
return s
|
||||
}
|
||||
|
||||
func newSqliteTestStoreFile(t *testing.T) string {
|
||||
return filepath.Join(t.TempDir(), "cache.db")
|
||||
}
|
||||
|
||||
func newSqliteTestStoreFromFile(t *testing.T, filename, startupQueries string) message.Store {
|
||||
s, err := message.NewSQLiteStore(filename, startupQueries, time.Hour, 0, 0, false)
|
||||
require.Nil(t, err)
|
||||
t.Cleanup(func() { s.Close() })
|
||||
return s
|
||||
}
|
||||
|
||||
func newMemTestStore(t *testing.T) message.Store {
|
||||
s, err := message.NewMemStore()
|
||||
require.Nil(t, err)
|
||||
t.Cleanup(func() { s.Close() })
|
||||
return s
|
||||
}
|
||||
|
||||
func checkSqliteSchemaVersion(t *testing.T, filename string) {
|
||||
db, err := sql.Open("sqlite3", filename)
|
||||
require.Nil(t, err)
|
||||
defer db.Close()
|
||||
rows, err := db.Query(`SELECT version FROM schemaVersion`)
|
||||
require.Nil(t, err)
|
||||
require.True(t, rows.Next())
|
||||
var schemaVersion int
|
||||
require.Nil(t, rows.Scan(&schemaVersion))
|
||||
require.Equal(t, 14, schemaVersion)
|
||||
require.Nil(t, rows.Close())
|
||||
}
|
||||
767
message/store_test.go
Normal file
@@ -0,0 +1,767 @@
|
||||
package message_test
|
||||
|
||||
import (
|
||||
"net/netip"
|
||||
"sort"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"heckel.io/ntfy/v2/message"
|
||||
"heckel.io/ntfy/v2/model"
|
||||
)
|
||||
|
||||
func testCacheMessages(t *testing.T, s message.Store) {
|
||||
m1 := model.NewDefaultMessage("mytopic", "my message")
|
||||
m1.Time = 1
|
||||
|
||||
m2 := model.NewDefaultMessage("mytopic", "my other message")
|
||||
m2.Time = 2
|
||||
|
||||
require.Nil(t, s.AddMessage(m1))
|
||||
require.Nil(t, s.AddMessage(model.NewDefaultMessage("example", "my example message")))
|
||||
require.Nil(t, s.AddMessage(m2))
|
||||
|
||||
// Adding invalid
|
||||
require.Equal(t, model.ErrUnexpectedMessageType, s.AddMessage(model.NewKeepaliveMessage("mytopic"))) // These should not be added!
|
||||
require.Equal(t, model.ErrUnexpectedMessageType, s.AddMessage(model.NewOpenMessage("example"))) // These should not be added!
|
||||
|
||||
// mytopic: count
|
||||
counts, err := s.MessageCounts()
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 2, counts["mytopic"])
|
||||
|
||||
// mytopic: since all
|
||||
messages, _ := s.Messages("mytopic", model.SinceAllMessages, false)
|
||||
require.Equal(t, 2, len(messages))
|
||||
require.Equal(t, "my message", messages[0].Message)
|
||||
require.Equal(t, "mytopic", messages[0].Topic)
|
||||
require.Equal(t, model.MessageEvent, messages[0].Event)
|
||||
require.Equal(t, "", messages[0].Title)
|
||||
require.Equal(t, 0, messages[0].Priority)
|
||||
require.Nil(t, messages[0].Tags)
|
||||
require.Equal(t, "my other message", messages[1].Message)
|
||||
|
||||
// mytopic: since none
|
||||
messages, _ = s.Messages("mytopic", model.SinceNoMessages, false)
|
||||
require.Empty(t, messages)
|
||||
|
||||
// mytopic: since m1 (by ID)
|
||||
messages, _ = s.Messages("mytopic", model.NewSinceID(m1.ID), false)
|
||||
require.Equal(t, 1, len(messages))
|
||||
require.Equal(t, m2.ID, messages[0].ID)
|
||||
require.Equal(t, "my other message", messages[0].Message)
|
||||
require.Equal(t, "mytopic", messages[0].Topic)
|
||||
|
||||
// mytopic: since 2
|
||||
messages, _ = s.Messages("mytopic", model.NewSinceTime(2), false)
|
||||
require.Equal(t, 1, len(messages))
|
||||
require.Equal(t, "my other message", messages[0].Message)
|
||||
|
||||
// mytopic: latest
|
||||
messages, _ = s.Messages("mytopic", model.SinceLatestMessage, false)
|
||||
require.Equal(t, 1, len(messages))
|
||||
require.Equal(t, "my other message", messages[0].Message)
|
||||
|
||||
// example: count
|
||||
counts, err = s.MessageCounts()
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 1, counts["example"])
|
||||
|
||||
// example: since all
|
||||
messages, _ = s.Messages("example", model.SinceAllMessages, false)
|
||||
require.Equal(t, "my example message", messages[0].Message)
|
||||
|
||||
// non-existing: count
|
||||
counts, err = s.MessageCounts()
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 0, counts["doesnotexist"])
|
||||
|
||||
// non-existing: since all
|
||||
messages, _ = s.Messages("doesnotexist", model.SinceAllMessages, false)
|
||||
require.Empty(t, messages)
|
||||
}
|
||||
|
||||
func testCacheMessagesLock(t *testing.T, s message.Store) {
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 5000; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
assert.Nil(t, s.AddMessage(model.NewDefaultMessage("mytopic", "test message")))
|
||||
wg.Done()
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
func testCacheMessagesScheduled(t *testing.T, s message.Store) {
|
||||
m1 := model.NewDefaultMessage("mytopic", "message 1")
|
||||
m2 := model.NewDefaultMessage("mytopic", "message 2")
|
||||
m2.Time = time.Now().Add(time.Hour).Unix()
|
||||
m3 := model.NewDefaultMessage("mytopic", "message 3")
|
||||
m3.Time = time.Now().Add(time.Minute).Unix() // earlier than m2!
|
||||
m4 := model.NewDefaultMessage("mytopic2", "message 4")
|
||||
m4.Time = time.Now().Add(time.Minute).Unix()
|
||||
require.Nil(t, s.AddMessage(m1))
|
||||
require.Nil(t, s.AddMessage(m2))
|
||||
require.Nil(t, s.AddMessage(m3))
|
||||
|
||||
messages, _ := s.Messages("mytopic", model.SinceAllMessages, false) // exclude scheduled
|
||||
require.Equal(t, 1, len(messages))
|
||||
require.Equal(t, "message 1", messages[0].Message)
|
||||
|
||||
messages, _ = s.Messages("mytopic", model.SinceAllMessages, true) // include scheduled
|
||||
require.Equal(t, 3, len(messages))
|
||||
require.Equal(t, "message 1", messages[0].Message)
|
||||
require.Equal(t, "message 3", messages[1].Message) // Order!
|
||||
require.Equal(t, "message 2", messages[2].Message)
|
||||
|
||||
messages, _ = s.MessagesDue()
|
||||
require.Empty(t, messages)
|
||||
}
|
||||
|
||||
func testCacheTopics(t *testing.T, s message.Store) {
|
||||
require.Nil(t, s.AddMessage(model.NewDefaultMessage("topic1", "my example message")))
|
||||
require.Nil(t, s.AddMessage(model.NewDefaultMessage("topic2", "message 1")))
|
||||
require.Nil(t, s.AddMessage(model.NewDefaultMessage("topic2", "message 2")))
|
||||
require.Nil(t, s.AddMessage(model.NewDefaultMessage("topic2", "message 3")))
|
||||
|
||||
topics, err := s.Topics()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
require.Equal(t, 2, len(topics))
|
||||
require.Contains(t, topics, "topic1")
|
||||
require.Contains(t, topics, "topic2")
|
||||
}
|
||||
|
||||
func testCacheMessagesTagsPrioAndTitle(t *testing.T, s message.Store) {
|
||||
m := model.NewDefaultMessage("mytopic", "some message")
|
||||
m.Tags = []string{"tag1", "tag2"}
|
||||
m.Priority = 5
|
||||
m.Title = "some title"
|
||||
require.Nil(t, s.AddMessage(m))
|
||||
|
||||
messages, _ := s.Messages("mytopic", model.SinceAllMessages, false)
|
||||
require.Equal(t, []string{"tag1", "tag2"}, messages[0].Tags)
|
||||
require.Equal(t, 5, messages[0].Priority)
|
||||
require.Equal(t, "some title", messages[0].Title)
|
||||
}
|
||||
|
||||
func testCacheMessagesSinceID(t *testing.T, s message.Store) {
|
||||
m1 := model.NewDefaultMessage("mytopic", "message 1")
|
||||
m1.Time = 100
|
||||
m2 := model.NewDefaultMessage("mytopic", "message 2")
|
||||
m2.Time = 200
|
||||
m3 := model.NewDefaultMessage("mytopic", "message 3")
|
||||
m3.Time = time.Now().Add(time.Hour).Unix() // Scheduled, in the future, later than m7 and m5
|
||||
m4 := model.NewDefaultMessage("mytopic", "message 4")
|
||||
m4.Time = 400
|
||||
m5 := model.NewDefaultMessage("mytopic", "message 5")
|
||||
m5.Time = time.Now().Add(time.Minute).Unix() // Scheduled, in the future, later than m7
|
||||
m6 := model.NewDefaultMessage("mytopic", "message 6")
|
||||
m6.Time = 600
|
||||
m7 := model.NewDefaultMessage("mytopic", "message 7")
|
||||
m7.Time = 700
|
||||
|
||||
require.Nil(t, s.AddMessage(m1))
|
||||
require.Nil(t, s.AddMessage(m2))
|
||||
require.Nil(t, s.AddMessage(m3))
|
||||
require.Nil(t, s.AddMessage(m4))
|
||||
require.Nil(t, s.AddMessage(m5))
|
||||
require.Nil(t, s.AddMessage(m6))
|
||||
require.Nil(t, s.AddMessage(m7))
|
||||
|
||||
// Case 1: Since ID exists, exclude scheduled
|
||||
messages, _ := s.Messages("mytopic", model.NewSinceID(m2.ID), false)
|
||||
require.Equal(t, 3, len(messages))
|
||||
require.Equal(t, "message 4", messages[0].Message)
|
||||
require.Equal(t, "message 6", messages[1].Message) // Not scheduled m3/m5!
|
||||
require.Equal(t, "message 7", messages[2].Message)
|
||||
|
||||
// Case 2: Since ID exists, include scheduled
|
||||
messages, _ = s.Messages("mytopic", model.NewSinceID(m2.ID), true)
|
||||
require.Equal(t, 5, len(messages))
|
||||
require.Equal(t, "message 4", messages[0].Message)
|
||||
require.Equal(t, "message 6", messages[1].Message)
|
||||
require.Equal(t, "message 7", messages[2].Message)
|
||||
require.Equal(t, "message 5", messages[3].Message) // Order!
|
||||
require.Equal(t, "message 3", messages[4].Message) // Order!
|
||||
|
||||
// Case 3: Since ID does not exist (-> Return all messages), include scheduled
|
||||
messages, _ = s.Messages("mytopic", model.NewSinceID("doesntexist"), true)
|
||||
require.Equal(t, 7, len(messages))
|
||||
require.Equal(t, "message 1", messages[0].Message)
|
||||
require.Equal(t, "message 2", messages[1].Message)
|
||||
require.Equal(t, "message 4", messages[2].Message)
|
||||
require.Equal(t, "message 6", messages[3].Message)
|
||||
require.Equal(t, "message 7", messages[4].Message)
|
||||
require.Equal(t, "message 5", messages[5].Message) // Order!
|
||||
require.Equal(t, "message 3", messages[6].Message) // Order!
|
||||
|
||||
// Case 4: Since ID exists and is last message (-> Return no messages), exclude scheduled
|
||||
messages, _ = s.Messages("mytopic", model.NewSinceID(m7.ID), false)
|
||||
require.Equal(t, 0, len(messages))
|
||||
|
||||
// Case 5: Since ID exists and is last message (-> Return no messages), include scheduled
|
||||
messages, _ = s.Messages("mytopic", model.NewSinceID(m7.ID), true)
|
||||
require.Equal(t, 2, len(messages))
|
||||
require.Equal(t, "message 5", messages[0].Message)
|
||||
require.Equal(t, "message 3", messages[1].Message)
|
||||
}
|
||||
|
||||
func testCachePrune(t *testing.T, s message.Store) {
|
||||
now := time.Now().Unix()
|
||||
|
||||
m1 := model.NewDefaultMessage("mytopic", "my message")
|
||||
m1.Time = now - 10
|
||||
m1.Expires = now - 5
|
||||
|
||||
m2 := model.NewDefaultMessage("mytopic", "my other message")
|
||||
m2.Time = now - 5
|
||||
m2.Expires = now + 5 // In the future
|
||||
|
||||
m3 := model.NewDefaultMessage("another_topic", "and another one")
|
||||
m3.Time = now - 12
|
||||
m3.Expires = now - 2
|
||||
|
||||
require.Nil(t, s.AddMessage(m1))
|
||||
require.Nil(t, s.AddMessage(m2))
|
||||
require.Nil(t, s.AddMessage(m3))
|
||||
|
||||
counts, err := s.MessageCounts()
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 2, counts["mytopic"])
|
||||
require.Equal(t, 1, counts["another_topic"])
|
||||
|
||||
expiredMessageIDs, err := s.MessagesExpired()
|
||||
require.Nil(t, err)
|
||||
require.Nil(t, s.DeleteMessages(expiredMessageIDs...))
|
||||
|
||||
counts, err = s.MessageCounts()
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 1, counts["mytopic"])
|
||||
require.Equal(t, 0, counts["another_topic"])
|
||||
|
||||
messages, err := s.Messages("mytopic", model.SinceAllMessages, false)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 1, len(messages))
|
||||
require.Equal(t, "my other message", messages[0].Message)
|
||||
}
|
||||
|
||||
func testCacheAttachments(t *testing.T, s message.Store) {
|
||||
expires1 := time.Now().Add(-4 * time.Hour).Unix() // Expired
|
||||
m := model.NewDefaultMessage("mytopic", "flower for you")
|
||||
m.ID = "m1"
|
||||
m.SequenceID = "m1"
|
||||
m.Sender = netip.MustParseAddr("1.2.3.4")
|
||||
m.Attachment = &model.Attachment{
|
||||
Name: "flower.jpg",
|
||||
Type: "image/jpeg",
|
||||
Size: 5000,
|
||||
Expires: expires1,
|
||||
URL: "https://ntfy.sh/file/AbDeFgJhal.jpg",
|
||||
}
|
||||
require.Nil(t, s.AddMessage(m))
|
||||
|
||||
expires2 := time.Now().Add(2 * time.Hour).Unix() // Future
|
||||
m = model.NewDefaultMessage("mytopic", "sending you a car")
|
||||
m.ID = "m2"
|
||||
m.SequenceID = "m2"
|
||||
m.Sender = netip.MustParseAddr("1.2.3.4")
|
||||
m.Attachment = &model.Attachment{
|
||||
Name: "car.jpg",
|
||||
Type: "image/jpeg",
|
||||
Size: 10000,
|
||||
Expires: expires2,
|
||||
URL: "https://ntfy.sh/file/aCaRURL.jpg",
|
||||
}
|
||||
require.Nil(t, s.AddMessage(m))
|
||||
|
||||
expires3 := time.Now().Add(1 * time.Hour).Unix() // Future
|
||||
m = model.NewDefaultMessage("another-topic", "sending you another car")
|
||||
m.ID = "m3"
|
||||
m.SequenceID = "m3"
|
||||
m.User = "u_BAsbaAa"
|
||||
m.Sender = netip.MustParseAddr("5.6.7.8")
|
||||
m.Attachment = &model.Attachment{
|
||||
Name: "another-car.jpg",
|
||||
Type: "image/jpeg",
|
||||
Size: 20000,
|
||||
Expires: expires3,
|
||||
URL: "https://ntfy.sh/file/zakaDHFW.jpg",
|
||||
}
|
||||
require.Nil(t, s.AddMessage(m))
|
||||
|
||||
messages, err := s.Messages("mytopic", model.SinceAllMessages, false)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 2, len(messages))
|
||||
|
||||
require.Equal(t, "flower for you", messages[0].Message)
|
||||
require.Equal(t, "flower.jpg", messages[0].Attachment.Name)
|
||||
require.Equal(t, "image/jpeg", messages[0].Attachment.Type)
|
||||
require.Equal(t, int64(5000), messages[0].Attachment.Size)
|
||||
require.Equal(t, expires1, messages[0].Attachment.Expires)
|
||||
require.Equal(t, "https://ntfy.sh/file/AbDeFgJhal.jpg", messages[0].Attachment.URL)
|
||||
require.Equal(t, "1.2.3.4", messages[0].Sender.String())
|
||||
|
||||
require.Equal(t, "sending you a car", messages[1].Message)
|
||||
require.Equal(t, "car.jpg", messages[1].Attachment.Name)
|
||||
require.Equal(t, "image/jpeg", messages[1].Attachment.Type)
|
||||
require.Equal(t, int64(10000), messages[1].Attachment.Size)
|
||||
require.Equal(t, expires2, messages[1].Attachment.Expires)
|
||||
require.Equal(t, "https://ntfy.sh/file/aCaRURL.jpg", messages[1].Attachment.URL)
|
||||
require.Equal(t, "1.2.3.4", messages[1].Sender.String())
|
||||
|
||||
size, err := s.AttachmentBytesUsedBySender("1.2.3.4")
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, int64(10000), size)
|
||||
|
||||
size, err = s.AttachmentBytesUsedBySender("5.6.7.8")
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, int64(0), size) // Accounted to the user, not the IP!
|
||||
|
||||
size, err = s.AttachmentBytesUsedByUser("u_BAsbaAa")
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, int64(20000), size)
|
||||
}
|
||||
|
||||
func testCacheAttachmentsExpired(t *testing.T, s message.Store) {
|
||||
m := model.NewDefaultMessage("mytopic", "flower for you")
|
||||
m.ID = "m1"
|
||||
m.SequenceID = "m1"
|
||||
m.Expires = time.Now().Add(time.Hour).Unix()
|
||||
require.Nil(t, s.AddMessage(m))
|
||||
|
||||
m = model.NewDefaultMessage("mytopic", "message with attachment")
|
||||
m.ID = "m2"
|
||||
m.SequenceID = "m2"
|
||||
m.Expires = time.Now().Add(2 * time.Hour).Unix()
|
||||
m.Attachment = &model.Attachment{
|
||||
Name: "car.jpg",
|
||||
Type: "image/jpeg",
|
||||
Size: 10000,
|
||||
Expires: time.Now().Add(2 * time.Hour).Unix(),
|
||||
URL: "https://ntfy.sh/file/aCaRURL.jpg",
|
||||
}
|
||||
require.Nil(t, s.AddMessage(m))
|
||||
|
||||
m = model.NewDefaultMessage("mytopic", "message with external attachment")
|
||||
m.ID = "m3"
|
||||
m.SequenceID = "m3"
|
||||
m.Expires = time.Now().Add(2 * time.Hour).Unix()
|
||||
m.Attachment = &model.Attachment{
|
||||
Name: "car.jpg",
|
||||
Type: "image/jpeg",
|
||||
Expires: 0, // Unknown!
|
||||
URL: "https://somedomain.com/car.jpg",
|
||||
}
|
||||
require.Nil(t, s.AddMessage(m))
|
||||
|
||||
m = model.NewDefaultMessage("mytopic2", "message with expired attachment")
|
||||
m.ID = "m4"
|
||||
m.SequenceID = "m4"
|
||||
m.Expires = time.Now().Add(2 * time.Hour).Unix()
|
||||
m.Attachment = &model.Attachment{
|
||||
Name: "expired-car.jpg",
|
||||
Type: "image/jpeg",
|
||||
Size: 20000,
|
||||
Expires: time.Now().Add(-1 * time.Hour).Unix(),
|
||||
URL: "https://ntfy.sh/file/aCaRURL.jpg",
|
||||
}
|
||||
require.Nil(t, s.AddMessage(m))
|
||||
|
||||
ids, err := s.AttachmentsExpired()
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 1, len(ids))
|
||||
require.Equal(t, "m4", ids[0])
|
||||
}
|
||||
|
||||
func testSender(t *testing.T, s message.Store) {
|
||||
m1 := model.NewDefaultMessage("mytopic", "mymessage")
|
||||
m1.Sender = netip.MustParseAddr("1.2.3.4")
|
||||
require.Nil(t, s.AddMessage(m1))
|
||||
|
||||
m2 := model.NewDefaultMessage("mytopic", "mymessage without sender")
|
||||
require.Nil(t, s.AddMessage(m2))
|
||||
|
||||
messages, err := s.Messages("mytopic", model.SinceAllMessages, false)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 2, len(messages))
|
||||
require.Equal(t, messages[0].Sender, netip.MustParseAddr("1.2.3.4"))
|
||||
require.Equal(t, messages[1].Sender, netip.Addr{})
|
||||
}
|
||||
|
||||
func testDeleteScheduledBySequenceID(t *testing.T, s message.Store) {
|
||||
// Create a scheduled (unpublished) message
|
||||
scheduledMsg := model.NewDefaultMessage("mytopic", "scheduled message")
|
||||
scheduledMsg.ID = "scheduled1"
|
||||
scheduledMsg.SequenceID = "seq123"
|
||||
scheduledMsg.Time = time.Now().Add(time.Hour).Unix() // Future time makes it scheduled
|
||||
require.Nil(t, s.AddMessage(scheduledMsg))
|
||||
|
||||
// Create a published message with different sequence ID
|
||||
publishedMsg := model.NewDefaultMessage("mytopic", "published message")
|
||||
publishedMsg.ID = "published1"
|
||||
publishedMsg.SequenceID = "seq456"
|
||||
publishedMsg.Time = time.Now().Add(-time.Hour).Unix() // Past time makes it published
|
||||
require.Nil(t, s.AddMessage(publishedMsg))
|
||||
|
||||
// Create a scheduled message in a different topic
|
||||
otherTopicMsg := model.NewDefaultMessage("othertopic", "other scheduled")
|
||||
otherTopicMsg.ID = "other1"
|
||||
otherTopicMsg.SequenceID = "seq123" // Same sequence ID as scheduledMsg
|
||||
otherTopicMsg.Time = time.Now().Add(time.Hour).Unix()
|
||||
require.Nil(t, s.AddMessage(otherTopicMsg))
|
||||
|
||||
// Verify all messages exist (including scheduled)
|
||||
messages, err := s.Messages("mytopic", model.SinceAllMessages, true)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 2, len(messages))
|
||||
|
||||
messages, err = s.Messages("othertopic", model.SinceAllMessages, true)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 1, len(messages))
|
||||
|
||||
// Delete scheduled message by sequence ID and verify returned IDs
|
||||
deletedIDs, err := s.DeleteScheduledBySequenceID("mytopic", "seq123")
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 1, len(deletedIDs))
|
||||
require.Equal(t, "scheduled1", deletedIDs[0])
|
||||
|
||||
// Verify scheduled message is deleted
|
||||
messages, err = s.Messages("mytopic", model.SinceAllMessages, true)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 1, len(messages))
|
||||
require.Equal(t, "published message", messages[0].Message)
|
||||
|
||||
// Verify other topic's message still exists (topic-scoped deletion)
|
||||
messages, err = s.Messages("othertopic", model.SinceAllMessages, true)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 1, len(messages))
|
||||
require.Equal(t, "other scheduled", messages[0].Message)
|
||||
|
||||
// Deleting non-existent sequence ID should return empty list
|
||||
deletedIDs, err = s.DeleteScheduledBySequenceID("mytopic", "nonexistent")
|
||||
require.Nil(t, err)
|
||||
require.Empty(t, deletedIDs)
|
||||
|
||||
// Deleting published message should not affect it (only deletes unpublished)
|
||||
deletedIDs, err = s.DeleteScheduledBySequenceID("mytopic", "seq456")
|
||||
require.Nil(t, err)
|
||||
require.Empty(t, deletedIDs)
|
||||
|
||||
messages, err = s.Messages("mytopic", model.SinceAllMessages, true)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 1, len(messages))
|
||||
require.Equal(t, "published message", messages[0].Message)
|
||||
}
|
||||
|
||||
func testMessageByID(t *testing.T, s message.Store) {
|
||||
// Add a message
|
||||
m := model.NewDefaultMessage("mytopic", "some message")
|
||||
m.Title = "some title"
|
||||
m.Priority = 4
|
||||
m.Tags = []string{"tag1", "tag2"}
|
||||
require.Nil(t, s.AddMessage(m))
|
||||
|
||||
// Retrieve by ID
|
||||
retrieved, err := s.Message(m.ID)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, m.ID, retrieved.ID)
|
||||
require.Equal(t, "mytopic", retrieved.Topic)
|
||||
require.Equal(t, "some message", retrieved.Message)
|
||||
require.Equal(t, "some title", retrieved.Title)
|
||||
require.Equal(t, 4, retrieved.Priority)
|
||||
require.Equal(t, []string{"tag1", "tag2"}, retrieved.Tags)
|
||||
|
||||
// Non-existent ID returns ErrMessageNotFound
|
||||
_, err = s.Message("doesnotexist")
|
||||
require.Equal(t, model.ErrMessageNotFound, err)
|
||||
}
|
||||
|
||||
func testMarkPublished(t *testing.T, s message.Store) {
|
||||
// Add a scheduled message (future time → unpublished)
|
||||
m := model.NewDefaultMessage("mytopic", "scheduled message")
|
||||
m.Time = time.Now().Add(time.Hour).Unix()
|
||||
require.Nil(t, s.AddMessage(m))
|
||||
|
||||
// Verify it does not appear in non-scheduled queries
|
||||
messages, err := s.Messages("mytopic", model.SinceAllMessages, false)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 0, len(messages))
|
||||
|
||||
// Verify it does appear in scheduled queries
|
||||
messages, err = s.Messages("mytopic", model.SinceAllMessages, true)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 1, len(messages))
|
||||
|
||||
// Mark as published
|
||||
require.Nil(t, s.MarkPublished(m))
|
||||
|
||||
// Now it should appear in non-scheduled queries too
|
||||
messages, err = s.Messages("mytopic", model.SinceAllMessages, false)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 1, len(messages))
|
||||
require.Equal(t, "scheduled message", messages[0].Message)
|
||||
}
|
||||
|
||||
func testExpireMessages(t *testing.T, s message.Store) {
|
||||
// Add messages to two topics
|
||||
m1 := model.NewDefaultMessage("topic1", "message 1")
|
||||
m1.Expires = time.Now().Add(time.Hour).Unix()
|
||||
m2 := model.NewDefaultMessage("topic1", "message 2")
|
||||
m2.Expires = time.Now().Add(time.Hour).Unix()
|
||||
m3 := model.NewDefaultMessage("topic2", "message 3")
|
||||
m3.Expires = time.Now().Add(time.Hour).Unix()
|
||||
require.Nil(t, s.AddMessage(m1))
|
||||
require.Nil(t, s.AddMessage(m2))
|
||||
require.Nil(t, s.AddMessage(m3))
|
||||
|
||||
// Verify all messages exist
|
||||
messages, err := s.Messages("topic1", model.SinceAllMessages, false)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 2, len(messages))
|
||||
messages, err = s.Messages("topic2", model.SinceAllMessages, false)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 1, len(messages))
|
||||
|
||||
// Expire topic1 messages
|
||||
require.Nil(t, s.ExpireMessages("topic1"))
|
||||
|
||||
// topic1 messages should now be expired (expires set to past)
|
||||
expiredIDs, err := s.MessagesExpired()
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 2, len(expiredIDs))
|
||||
sort.Strings(expiredIDs)
|
||||
expectedIDs := []string{m1.ID, m2.ID}
|
||||
sort.Strings(expectedIDs)
|
||||
require.Equal(t, expectedIDs, expiredIDs)
|
||||
|
||||
// topic2 should be unaffected
|
||||
messages, err = s.Messages("topic2", model.SinceAllMessages, false)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 1, len(messages))
|
||||
require.Equal(t, "message 3", messages[0].Message)
|
||||
}
|
||||
|
||||
func testMarkAttachmentsDeleted(t *testing.T, s message.Store) {
|
||||
// Add a message with an expired attachment (file needs cleanup)
|
||||
m1 := model.NewDefaultMessage("mytopic", "old file")
|
||||
m1.ID = "msg1"
|
||||
m1.SequenceID = "msg1"
|
||||
m1.Expires = time.Now().Add(time.Hour).Unix()
|
||||
m1.Attachment = &model.Attachment{
|
||||
Name: "old.pdf",
|
||||
Type: "application/pdf",
|
||||
Size: 50000,
|
||||
Expires: time.Now().Add(-time.Hour).Unix(), // Expired
|
||||
URL: "https://ntfy.sh/file/old.pdf",
|
||||
}
|
||||
require.Nil(t, s.AddMessage(m1))
|
||||
|
||||
// Add a message with another expired attachment
|
||||
m2 := model.NewDefaultMessage("mytopic", "another old file")
|
||||
m2.ID = "msg2"
|
||||
m2.SequenceID = "msg2"
|
||||
m2.Expires = time.Now().Add(time.Hour).Unix()
|
||||
m2.Attachment = &model.Attachment{
|
||||
Name: "another.pdf",
|
||||
Type: "application/pdf",
|
||||
Size: 30000,
|
||||
Expires: time.Now().Add(-time.Hour).Unix(), // Expired
|
||||
URL: "https://ntfy.sh/file/another.pdf",
|
||||
}
|
||||
require.Nil(t, s.AddMessage(m2))
|
||||
|
||||
// Both should show as expired attachments needing cleanup
|
||||
ids, err := s.AttachmentsExpired()
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 2, len(ids))
|
||||
|
||||
// Mark msg1's attachment as deleted (file cleaned up)
|
||||
require.Nil(t, s.MarkAttachmentsDeleted("msg1"))
|
||||
|
||||
// Now only msg2 should show as needing cleanup
|
||||
ids, err = s.AttachmentsExpired()
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 1, len(ids))
|
||||
require.Equal(t, "msg2", ids[0])
|
||||
|
||||
// Mark msg2 too
|
||||
require.Nil(t, s.MarkAttachmentsDeleted("msg2"))
|
||||
|
||||
// No more expired attachments to clean up
|
||||
ids, err = s.AttachmentsExpired()
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 0, len(ids))
|
||||
|
||||
// Messages themselves still exist
|
||||
messages, err := s.Messages("mytopic", model.SinceAllMessages, false)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 2, len(messages))
|
||||
}
|
||||
|
||||
func testStats(t *testing.T, s message.Store) {
|
||||
// Initial stats should be zero
|
||||
messages, err := s.Stats()
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, int64(0), messages)
|
||||
|
||||
// Update stats
|
||||
require.Nil(t, s.UpdateStats(42))
|
||||
messages, err = s.Stats()
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, int64(42), messages)
|
||||
|
||||
// Update again (overwrites)
|
||||
require.Nil(t, s.UpdateStats(100))
|
||||
messages, err = s.Stats()
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, int64(100), messages)
|
||||
}
|
||||
|
||||
func testAddMessages(t *testing.T, s message.Store) {
|
||||
// Batch add multiple messages
|
||||
msgs := []*model.Message{
|
||||
model.NewDefaultMessage("mytopic", "batch 1"),
|
||||
model.NewDefaultMessage("mytopic", "batch 2"),
|
||||
model.NewDefaultMessage("othertopic", "batch 3"),
|
||||
}
|
||||
require.Nil(t, s.AddMessages(msgs))
|
||||
|
||||
// Verify all were inserted
|
||||
messages, err := s.Messages("mytopic", model.SinceAllMessages, false)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 2, len(messages))
|
||||
|
||||
messages, err = s.Messages("othertopic", model.SinceAllMessages, false)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 1, len(messages))
|
||||
require.Equal(t, "batch 3", messages[0].Message)
|
||||
|
||||
// Empty batch should succeed
|
||||
require.Nil(t, s.AddMessages([]*model.Message{}))
|
||||
|
||||
// Batch with invalid event type should fail
|
||||
badMsgs := []*model.Message{
|
||||
model.NewKeepaliveMessage("mytopic"),
|
||||
}
|
||||
require.NotNil(t, s.AddMessages(badMsgs))
|
||||
}
|
||||
|
||||
func testMessagesDue(t *testing.T, s message.Store) {
|
||||
// Add a message scheduled in the past (i.e. it's due now)
|
||||
m1 := model.NewDefaultMessage("mytopic", "due message")
|
||||
m1.Time = time.Now().Add(-time.Second).Unix()
|
||||
// Set expires in the future so it doesn't get pruned
|
||||
m1.Expires = time.Now().Add(time.Hour).Unix()
|
||||
require.Nil(t, s.AddMessage(m1))
|
||||
|
||||
// Add a message scheduled in the future (not due)
|
||||
m2 := model.NewDefaultMessage("mytopic", "future message")
|
||||
m2.Time = time.Now().Add(time.Hour).Unix()
|
||||
require.Nil(t, s.AddMessage(m2))
|
||||
|
||||
// Mark m1 as published so it won't be "due"
|
||||
// (MessagesDue returns unpublished messages whose time <= now)
|
||||
// m1 is auto-published (time <= now), so it should not be due
|
||||
// m2 is unpublished (time in future), not due yet
|
||||
due, err := s.MessagesDue()
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 0, len(due))
|
||||
|
||||
// Add a message that was explicitly scheduled in the past but time has "arrived"
|
||||
// We need to manipulate the database to create a truly "due" message:
|
||||
// a message with published=false and time <= now
|
||||
m3 := model.NewDefaultMessage("mytopic", "truly due message")
|
||||
m3.Time = time.Now().Add(2 * time.Second).Unix() // 2 seconds from now
|
||||
require.Nil(t, s.AddMessage(m3))
|
||||
|
||||
// Not due yet
|
||||
due, err = s.MessagesDue()
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 0, len(due))
|
||||
|
||||
// Wait for it to become due
|
||||
time.Sleep(3 * time.Second)
|
||||
|
||||
due, err = s.MessagesDue()
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 1, len(due))
|
||||
require.Equal(t, "truly due message", due[0].Message)
|
||||
}
|
||||
|
||||
func testMessageFieldRoundTrip(t *testing.T, s message.Store) {
|
||||
// Create a message with all fields populated
|
||||
m := model.NewDefaultMessage("mytopic", "hello world")
|
||||
m.SequenceID = "custom_seq_id"
|
||||
m.Title = "A Title"
|
||||
m.Priority = 4
|
||||
m.Tags = []string{"warning", "srv01"}
|
||||
m.Click = "https://example.com/click"
|
||||
m.Icon = "https://example.com/icon.png"
|
||||
m.Actions = []*model.Action{
|
||||
{
|
||||
ID: "action1",
|
||||
Action: "view",
|
||||
Label: "Open Site",
|
||||
URL: "https://example.com",
|
||||
Clear: true,
|
||||
},
|
||||
{
|
||||
ID: "action2",
|
||||
Action: "http",
|
||||
Label: "Call Webhook",
|
||||
URL: "https://example.com/hook",
|
||||
Method: "PUT",
|
||||
Headers: map[string]string{"X-Token": "secret"},
|
||||
Body: `{"key":"value"}`,
|
||||
},
|
||||
}
|
||||
m.ContentType = "text/markdown"
|
||||
m.Encoding = "base64"
|
||||
m.Sender = netip.MustParseAddr("9.8.7.6")
|
||||
m.User = "u_TestUser123"
|
||||
require.Nil(t, s.AddMessage(m))
|
||||
|
||||
// Retrieve and verify every field
|
||||
retrieved, err := s.Message(m.ID)
|
||||
require.Nil(t, err)
|
||||
|
||||
require.Equal(t, m.ID, retrieved.ID)
|
||||
require.Equal(t, "custom_seq_id", retrieved.SequenceID)
|
||||
require.Equal(t, m.Time, retrieved.Time)
|
||||
require.Equal(t, m.Expires, retrieved.Expires)
|
||||
require.Equal(t, model.MessageEvent, retrieved.Event)
|
||||
require.Equal(t, "mytopic", retrieved.Topic)
|
||||
require.Equal(t, "hello world", retrieved.Message)
|
||||
require.Equal(t, "A Title", retrieved.Title)
|
||||
require.Equal(t, 4, retrieved.Priority)
|
||||
require.Equal(t, []string{"warning", "srv01"}, retrieved.Tags)
|
||||
require.Equal(t, "https://example.com/click", retrieved.Click)
|
||||
require.Equal(t, "https://example.com/icon.png", retrieved.Icon)
|
||||
require.Equal(t, "text/markdown", retrieved.ContentType)
|
||||
require.Equal(t, "base64", retrieved.Encoding)
|
||||
require.Equal(t, netip.MustParseAddr("9.8.7.6"), retrieved.Sender)
|
||||
require.Equal(t, "u_TestUser123", retrieved.User)
|
||||
|
||||
// Verify actions round-trip
|
||||
require.Equal(t, 2, len(retrieved.Actions))
|
||||
|
||||
require.Equal(t, "action1", retrieved.Actions[0].ID)
|
||||
require.Equal(t, "view", retrieved.Actions[0].Action)
|
||||
require.Equal(t, "Open Site", retrieved.Actions[0].Label)
|
||||
require.Equal(t, "https://example.com", retrieved.Actions[0].URL)
|
||||
require.Equal(t, true, retrieved.Actions[0].Clear)
|
||||
|
||||
require.Equal(t, "action2", retrieved.Actions[1].ID)
|
||||
require.Equal(t, "http", retrieved.Actions[1].Action)
|
||||
require.Equal(t, "Call Webhook", retrieved.Actions[1].Label)
|
||||
require.Equal(t, "https://example.com/hook", retrieved.Actions[1].URL)
|
||||
require.Equal(t, "PUT", retrieved.Actions[1].Method)
|
||||
require.Equal(t, "secret", retrieved.Actions[1].Headers["X-Token"])
|
||||
require.Equal(t, `{"key":"value"}`, retrieved.Actions[1].Body)
|
||||
}
|
||||
@@ -99,6 +99,9 @@ nav:
|
||||
- "Known issues": known-issues.md
|
||||
- "Deprecation notices": deprecations.md
|
||||
- "Development": develop.md
|
||||
- "Contributing": contributing.md
|
||||
- "Privacy policy": privacy.md
|
||||
- "Terms of Service": terms.md
|
||||
- "Contact": contact.md
|
||||
|
||||
|
||||
|
||||
205
model/model.go
Normal file
@@ -0,0 +1,205 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/netip"
|
||||
"time"
|
||||
|
||||
"heckel.io/ntfy/v2/log"
|
||||
"heckel.io/ntfy/v2/util"
|
||||
)
|
||||
|
||||
// List of possible events
|
||||
const (
|
||||
OpenEvent = "open"
|
||||
KeepaliveEvent = "keepalive"
|
||||
MessageEvent = "message"
|
||||
MessageDeleteEvent = "message_delete"
|
||||
MessageClearEvent = "message_clear"
|
||||
PollRequestEvent = "poll_request"
|
||||
)
|
||||
|
||||
// MessageIDLength is the length of a randomly generated message ID
|
||||
const MessageIDLength = 12
|
||||
|
||||
// Errors for message operations
|
||||
var (
|
||||
ErrUnexpectedMessageType = errors.New("unexpected message type")
|
||||
ErrMessageNotFound = errors.New("message not found")
|
||||
)
|
||||
|
||||
// Message represents a message published to a topic
|
||||
type Message struct {
|
||||
ID string `json:"id"` // Random message ID
|
||||
SequenceID string `json:"sequence_id,omitempty"` // Message sequence ID for updating message contents (omitted if same as ID)
|
||||
Time int64 `json:"time"` // Unix time in seconds
|
||||
Expires int64 `json:"expires,omitempty"` // Unix time in seconds (not required for open/keepalive)
|
||||
Event string `json:"event"` // One of the above
|
||||
Topic string `json:"topic"`
|
||||
Title string `json:"title,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
Priority int `json:"priority,omitempty"`
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
Click string `json:"click,omitempty"`
|
||||
Icon string `json:"icon,omitempty"`
|
||||
Actions []*Action `json:"actions,omitempty"`
|
||||
Attachment *Attachment `json:"attachment,omitempty"`
|
||||
PollID string `json:"poll_id,omitempty"`
|
||||
ContentType string `json:"content_type,omitempty"` // text/plain by default (if empty), or text/markdown
|
||||
Encoding string `json:"encoding,omitempty"` // Empty for raw UTF-8, or "base64" for encoded bytes
|
||||
Sender netip.Addr `json:"-"` // IP address of uploader, used for rate limiting
|
||||
User string `json:"-"` // UserID of the uploader, used to associated attachments
|
||||
}
|
||||
|
||||
// Context returns a log context for the message
|
||||
func (m *Message) Context() log.Context {
|
||||
fields := map[string]any{
|
||||
"topic": m.Topic,
|
||||
"message_id": m.ID,
|
||||
"message_sequence_id": m.SequenceID,
|
||||
"message_time": m.Time,
|
||||
"message_event": m.Event,
|
||||
"message_body_size": len(m.Message),
|
||||
}
|
||||
if m.Sender.IsValid() {
|
||||
fields["message_sender"] = m.Sender.String()
|
||||
}
|
||||
if m.User != "" {
|
||||
fields["message_user"] = m.User
|
||||
}
|
||||
return fields
|
||||
}
|
||||
|
||||
// ForJSON returns a copy of the message suitable for JSON output.
|
||||
// It clears the SequenceID if it equals the ID to reduce redundancy.
|
||||
func (m *Message) ForJSON() *Message {
|
||||
if m.SequenceID == m.ID {
|
||||
clone := *m
|
||||
clone.SequenceID = ""
|
||||
return &clone
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// Attachment represents a file attachment on a message
|
||||
type Attachment struct {
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type,omitempty"`
|
||||
Size int64 `json:"size,omitempty"`
|
||||
Expires int64 `json:"expires,omitempty"`
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
// Action represents a user-defined action on a message
|
||||
type Action struct {
|
||||
ID string `json:"id"`
|
||||
Action string `json:"action"` // "view", "broadcast", "http", or "copy"
|
||||
Label string `json:"label"` // action button label
|
||||
Clear bool `json:"clear"` // clear notification after successful execution
|
||||
URL string `json:"url,omitempty"` // used in "view" and "http" actions
|
||||
Method string `json:"method,omitempty"` // used in "http" action, default is POST (!)
|
||||
Headers map[string]string `json:"headers,omitempty"` // used in "http" action
|
||||
Body string `json:"body,omitempty"` // used in "http" action
|
||||
Intent string `json:"intent,omitempty"` // used in "broadcast" action
|
||||
Extras map[string]string `json:"extras,omitempty"` // used in "broadcast" action
|
||||
Value string `json:"value,omitempty"` // used in "copy" action
|
||||
}
|
||||
|
||||
// NewAction creates a new action with initialized maps
|
||||
func NewAction() *Action {
|
||||
return &Action{
|
||||
Headers: make(map[string]string),
|
||||
Extras: make(map[string]string),
|
||||
}
|
||||
}
|
||||
|
||||
// NewMessage creates a new message with the current timestamp
|
||||
func NewMessage(event, topic, msg string) *Message {
|
||||
return &Message{
|
||||
ID: util.RandomString(MessageIDLength),
|
||||
Time: time.Now().Unix(),
|
||||
Event: event,
|
||||
Topic: topic,
|
||||
Message: msg,
|
||||
}
|
||||
}
|
||||
|
||||
// NewOpenMessage is a convenience method to create an open message
|
||||
func NewOpenMessage(topic string) *Message {
|
||||
return NewMessage(OpenEvent, topic, "")
|
||||
}
|
||||
|
||||
// NewKeepaliveMessage is a convenience method to create a keepalive message
|
||||
func NewKeepaliveMessage(topic string) *Message {
|
||||
return NewMessage(KeepaliveEvent, topic, "")
|
||||
}
|
||||
|
||||
// NewDefaultMessage is a convenience method to create a notification message
|
||||
func NewDefaultMessage(topic, msg string) *Message {
|
||||
return NewMessage(MessageEvent, topic, msg)
|
||||
}
|
||||
|
||||
// NewActionMessage creates a new action message (message_delete or message_clear)
|
||||
func NewActionMessage(event, topic, sequenceID string) *Message {
|
||||
m := NewMessage(event, topic, "")
|
||||
m.SequenceID = sequenceID
|
||||
return m
|
||||
}
|
||||
|
||||
// ValidMessageID returns true if the given string is a valid message ID
|
||||
func ValidMessageID(s string) bool {
|
||||
return util.ValidRandomString(s, MessageIDLength)
|
||||
}
|
||||
|
||||
// SinceMarker represents a point in time or message ID from which to retrieve messages
|
||||
type SinceMarker struct {
|
||||
time time.Time
|
||||
id string
|
||||
}
|
||||
|
||||
// NewSinceTime creates a new SinceMarker from a Unix timestamp
|
||||
func NewSinceTime(timestamp int64) SinceMarker {
|
||||
return SinceMarker{time.Unix(timestamp, 0), ""}
|
||||
}
|
||||
|
||||
// NewSinceID creates a new SinceMarker from a message ID
|
||||
func NewSinceID(id string) SinceMarker {
|
||||
return SinceMarker{time.Unix(0, 0), id}
|
||||
}
|
||||
|
||||
// IsAll returns true if this is the "all messages" marker
|
||||
func (t SinceMarker) IsAll() bool {
|
||||
return t == SinceAllMessages
|
||||
}
|
||||
|
||||
// IsNone returns true if this is the "no messages" marker
|
||||
func (t SinceMarker) IsNone() bool {
|
||||
return t == SinceNoMessages
|
||||
}
|
||||
|
||||
// IsLatest returns true if this is the "latest message" marker
|
||||
func (t SinceMarker) IsLatest() bool {
|
||||
return t == SinceLatestMessage
|
||||
}
|
||||
|
||||
// IsID returns true if this marker references a specific message ID
|
||||
func (t SinceMarker) IsID() bool {
|
||||
return t.id != "" && t.id != "latest"
|
||||
}
|
||||
|
||||
// Time returns the time component of the marker
|
||||
func (t SinceMarker) Time() time.Time {
|
||||
return t.time
|
||||
}
|
||||
|
||||
// ID returns the message ID component of the marker
|
||||
func (t SinceMarker) ID() string {
|
||||
return t.id
|
||||
}
|
||||
|
||||
// Common SinceMarker values for subscribing to messages
|
||||
var (
|
||||
SinceAllMessages = SinceMarker{time.Unix(0, 0), ""}
|
||||
SinceNoMessages = SinceMarker{time.Unix(1, 0), ""}
|
||||
SinceLatestMessage = SinceMarker{time.Unix(0, 0), "latest"}
|
||||
)
|
||||
@@ -4,10 +4,12 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"heckel.io/ntfy/v2/util"
|
||||
"regexp"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"heckel.io/ntfy/v2/model"
|
||||
"heckel.io/ntfy/v2/util"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -20,12 +22,14 @@ const (
|
||||
actionView = "view"
|
||||
actionBroadcast = "broadcast"
|
||||
actionHTTP = "http"
|
||||
actionCopy = "copy"
|
||||
)
|
||||
|
||||
var (
|
||||
actionsAll = []string{actionView, actionBroadcast, actionHTTP}
|
||||
actionsWithURL = []string{actionView, actionHTTP}
|
||||
actionsKeyRegex = regexp.MustCompile(`^([-.\w]+)\s*=\s*`)
|
||||
actionsAll = []string{actionView, actionBroadcast, actionHTTP, actionCopy}
|
||||
actionsWithURL = []string{actionView, actionHTTP} // Must be distinct from actionsWithValue, see populateAction()
|
||||
actionsWithValue = []string{actionCopy} // Must be distinct from actionsWithURL, see populateAction()
|
||||
actionsKeyRegex = regexp.MustCompile(`^([-.\w]+)\s*=\s*`)
|
||||
)
|
||||
|
||||
type actionParser struct {
|
||||
@@ -36,7 +40,7 @@ type actionParser struct {
|
||||
// parseActions 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 []*action, err error) {
|
||||
func parseActions(s string) (actions []*model.Action, err error) {
|
||||
// Parse JSON or simple format
|
||||
s = strings.TrimSpace(s)
|
||||
if strings.HasPrefix(s, "[") {
|
||||
@@ -61,11 +65,13 @@ func parseActions(s string) (actions []*action, err error) {
|
||||
}
|
||||
for _, action := range actions {
|
||||
if !util.Contains(actionsAll, action.Action) {
|
||||
return nil, fmt.Errorf("parameter 'action' cannot be '%s', valid values are 'view', 'broadcast' and 'http'", action.Action)
|
||||
return nil, fmt.Errorf("parameter 'action' cannot be '%s', valid values are 'view', 'broadcast', 'http' and 'copy'", action.Action)
|
||||
} else if action.Label == "" {
|
||||
return nil, fmt.Errorf("parameter 'label' is required")
|
||||
} else if util.Contains(actionsWithURL, action.Action) && action.URL == "" {
|
||||
return nil, fmt.Errorf("parameter 'url' is required for action '%s'", action.Action)
|
||||
} else if util.Contains(actionsWithValue, action.Action) && action.Value == "" {
|
||||
return nil, fmt.Errorf("parameter 'value' is required for action '%s'", action.Action)
|
||||
} else if action.Action == actionHTTP && util.Contains([]string{"GET", "HEAD"}, action.Method) && action.Body != "" {
|
||||
return nil, fmt.Errorf("parameter 'body' cannot be set if method is %s", action.Method)
|
||||
}
|
||||
@@ -75,8 +81,8 @@ func parseActions(s string) (actions []*action, err error) {
|
||||
}
|
||||
|
||||
// parseActionsFromJSON converts a JSON array into an array of actions
|
||||
func parseActionsFromJSON(s string) ([]*action, error) {
|
||||
actions := make([]*action, 0)
|
||||
func parseActionsFromJSON(s string) ([]*model.Action, error) {
|
||||
actions := make([]*model.Action, 0)
|
||||
if err := json.Unmarshal([]byte(s), &actions); err != nil {
|
||||
return nil, fmt.Errorf("JSON error: %w", err)
|
||||
}
|
||||
@@ -102,7 +108,7 @@ func parseActionsFromJSON(s string) ([]*action, error) {
|
||||
// https://github.com/adampresley/sample-ini-parser/blob/master/services/lexer/lexer/Lexer.go
|
||||
// https://github.com/benbjohnson/sql-parser/blob/master/scanner.go
|
||||
// https://blog.gopheracademy.com/advent-2014/parsers-lexers/
|
||||
func parseActionsFromSimple(s string) ([]*action, error) {
|
||||
func parseActionsFromSimple(s string) ([]*model.Action, error) {
|
||||
if !utf8.ValidString(s) {
|
||||
return nil, errors.New("invalid utf-8 string")
|
||||
}
|
||||
@@ -114,8 +120,8 @@ func parseActionsFromSimple(s string) ([]*action, error) {
|
||||
}
|
||||
|
||||
// Parse loops trough parseAction() until the end of the string is reached
|
||||
func (p *actionParser) Parse() ([]*action, error) {
|
||||
actions := make([]*action, 0)
|
||||
func (p *actionParser) Parse() ([]*model.Action, error) {
|
||||
actions := make([]*model.Action, 0)
|
||||
for !p.eof() {
|
||||
a, err := p.parseAction()
|
||||
if err != nil {
|
||||
@@ -129,7 +135,7 @@ func (p *actionParser) Parse() ([]*action, error) {
|
||||
// parseAction parses the individual sections of an action using parseSection into key/value pairs,
|
||||
// and then uses populateAction to interpret the keys/values. The function terminates
|
||||
// when EOF or ";" is reached.
|
||||
func (p *actionParser) parseAction() (*action, error) {
|
||||
func (p *actionParser) parseAction() (*model.Action, error) {
|
||||
a := newAction()
|
||||
section := 0
|
||||
for {
|
||||
@@ -150,7 +156,7 @@ func (p *actionParser) parseAction() (*action, error) {
|
||||
|
||||
// populateAction is the "business logic" of the parser. It applies the key/value
|
||||
// pair to the action instance.
|
||||
func populateAction(newAction *action, section int, key, value string) error {
|
||||
func populateAction(newAction *model.Action, section int, key, value string) error {
|
||||
// Auto-expand keys based on their index
|
||||
if key == "" && section == 0 {
|
||||
key = "action"
|
||||
@@ -158,6 +164,8 @@ func populateAction(newAction *action, section int, key, value string) error {
|
||||
key = "label"
|
||||
} else if key == "" && section == 2 && util.Contains(actionsWithURL, newAction.Action) {
|
||||
key = "url"
|
||||
} else if key == "" && section == 2 && util.Contains(actionsWithValue, newAction.Action) {
|
||||
key = "value"
|
||||
}
|
||||
|
||||
// Validate
|
||||
@@ -188,6 +196,8 @@ func populateAction(newAction *action, section int, key, value string) error {
|
||||
newAction.Method = value
|
||||
case "body":
|
||||
newAction.Body = value
|
||||
case "value":
|
||||
newAction.Value = value
|
||||
case "intent":
|
||||
newAction.Intent = value
|
||||
default:
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"github.com/stretchr/testify/require"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestParseActions(t *testing.T) {
|
||||
@@ -132,6 +133,44 @@ func TestParseActions(t *testing.T) {
|
||||
require.Equal(t, `https://x.org`, actions[1].URL)
|
||||
require.Equal(t, true, actions[1].Clear)
|
||||
|
||||
// Copy action (simple format)
|
||||
actions, err = parseActions("copy, Copy code, 1234")
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 1, len(actions))
|
||||
require.Equal(t, "copy", actions[0].Action)
|
||||
require.Equal(t, "Copy code", actions[0].Label)
|
||||
require.Equal(t, "1234", actions[0].Value)
|
||||
|
||||
// Copy action (JSON)
|
||||
actions, err = parseActions(`[{"action":"copy","label":"Copy OTP","value":"567890"}]`)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 1, len(actions))
|
||||
require.Equal(t, "copy", actions[0].Action)
|
||||
require.Equal(t, "Copy OTP", actions[0].Label)
|
||||
require.Equal(t, "567890", actions[0].Value)
|
||||
|
||||
// Copy action with clear
|
||||
actions, err = parseActions("copy, Copy code, 1234, clear=true")
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 1, len(actions))
|
||||
require.Equal(t, "copy", actions[0].Action)
|
||||
require.Equal(t, "Copy code", actions[0].Label)
|
||||
require.Equal(t, "1234", actions[0].Value)
|
||||
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")
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 1, len(actions))
|
||||
require.Equal(t, "copy", actions[0].Action)
|
||||
require.Equal(t, "Copy token", actions[0].Label)
|
||||
require.Equal(t, "abc-123-def", actions[0].Value)
|
||||
require.True(t, actions[0].Clear)
|
||||
|
||||
// Copy action without value (error)
|
||||
_, err = parseActions("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`)
|
||||
require.EqualError(t, err, "unexpected character 'x' at position 22")
|
||||
@@ -146,7 +185,7 @@ func TestParseActions(t *testing.T) {
|
||||
require.EqualError(t, err, "term 'what is this anyway' unknown")
|
||||
|
||||
_, err = parseActions(`fdsfdsf`)
|
||||
require.EqualError(t, err, "parameter 'action' cannot be 'fdsfdsf', valid values are 'view', 'broadcast' and 'http'")
|
||||
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 "`)
|
||||
require.EqualError(t, err, "key 'aaa' unknown")
|
||||
@@ -173,7 +212,7 @@ func TestParseActions(t *testing.T) {
|
||||
require.EqualError(t, err, "JSON error: invalid character 'i' looking for beginning of value")
|
||||
|
||||
_, err = parseActions(`[ { "some": "object" } ]`)
|
||||
require.EqualError(t, err, "parameter 'action' cannot be '', valid values are 'view', 'broadcast' and 'http'")
|
||||
require.EqualError(t, err, "parameter 'action' cannot be '', valid values are 'view', 'broadcast', 'http' and 'copy'")
|
||||
|
||||
_, err = parseActions("\x00\x01\xFFx\xFE")
|
||||
require.EqualError(t, err, "invalid utf-8 string")
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"net/netip"
|
||||
"reflect"
|
||||
"text/template"
|
||||
"time"
|
||||
|
||||
"heckel.io/ntfy/v2/user"
|
||||
@@ -11,8 +16,6 @@ import (
|
||||
// Defines default config settings (excluding limits, see below)
|
||||
const (
|
||||
DefaultListenHTTP = ":80"
|
||||
DefaultConfigFile = "/etc/ntfy/server.yml"
|
||||
DefaultTemplateDir = "/etc/ntfy/templates"
|
||||
DefaultCacheDuration = 12 * time.Hour
|
||||
DefaultCacheBatchTimeout = time.Duration(0)
|
||||
DefaultKeepaliveInterval = 45 * time.Second // Not too frequently to save battery (Android read timeout used to be 77s!)
|
||||
@@ -26,6 +29,12 @@ const (
|
||||
DefaultStripePriceCacheDuration = 3 * time.Hour // Time to keep Stripe prices cached in memory before a refresh is needed
|
||||
)
|
||||
|
||||
// Platform-specific default paths (set in config_unix.go or config_windows.go)
|
||||
var (
|
||||
DefaultConfigFile string
|
||||
DefaultTemplateDir string
|
||||
)
|
||||
|
||||
// Defines default Web Push settings
|
||||
const (
|
||||
DefaultWebPushExpiryWarningDuration = 55 * 24 * time.Hour
|
||||
@@ -79,6 +88,7 @@ var (
|
||||
// Config is the main config struct for the application. Use New to instantiate a default config struct.
|
||||
type Config struct {
|
||||
File string // Config file, only used for testing
|
||||
DatabaseURL string // PostgreSQL connection string (e.g. "postgres://user:pass@host:5432/ntfy")
|
||||
BaseURL string
|
||||
ListenHTTP string
|
||||
ListenHTTPS string
|
||||
@@ -128,6 +138,7 @@ type Config struct {
|
||||
TwilioCallsBaseURL string
|
||||
TwilioVerifyBaseURL string
|
||||
TwilioVerifyService string
|
||||
TwilioCallFormat *template.Template
|
||||
MetricsEnable bool
|
||||
MetricsListenHTTP string
|
||||
ProfileListenHTTP string
|
||||
@@ -162,6 +173,7 @@ type Config struct {
|
||||
BillingContact string
|
||||
EnableSignup bool // Enable creation of accounts via API and UI
|
||||
EnableLogin bool
|
||||
RequireLogin bool
|
||||
EnableReservations bool // Allow users with role "user" to own/reserve topics
|
||||
EnableMetrics bool
|
||||
AccessControlAllowOrigin string // CORS header field to restrict access from web clients
|
||||
@@ -172,13 +184,16 @@ type Config struct {
|
||||
WebPushStartupQueries string
|
||||
WebPushExpiryDuration time.Duration
|
||||
WebPushExpiryWarningDuration time.Duration
|
||||
Version string // injected by App
|
||||
BuildVersion string // Injected by App
|
||||
BuildDate string // Injected by App
|
||||
BuildCommit string // Injected by App
|
||||
}
|
||||
|
||||
// NewConfig instantiates a default new server config
|
||||
func NewConfig() *Config {
|
||||
return &Config{
|
||||
File: DefaultConfigFile, // Only used for testing
|
||||
DatabaseURL: "",
|
||||
BaseURL: "",
|
||||
ListenHTTP: DefaultListenHTTP,
|
||||
ListenHTTPS: "",
|
||||
@@ -225,6 +240,7 @@ func NewConfig() *Config {
|
||||
TwilioPhoneNumber: "",
|
||||
TwilioVerifyBaseURL: "https://verify.twilio.com", // Override for tests
|
||||
TwilioVerifyService: "",
|
||||
TwilioCallFormat: nil,
|
||||
MessageSizeLimit: DefaultMessageSizeLimit,
|
||||
MessageDelayMin: DefaultMessageDelayMin,
|
||||
MessageDelayMax: DefaultMessageDelayMax,
|
||||
@@ -256,13 +272,34 @@ func NewConfig() *Config {
|
||||
EnableSignup: false,
|
||||
EnableLogin: false,
|
||||
EnableReservations: false,
|
||||
RequireLogin: false,
|
||||
AccessControlAllowOrigin: "*",
|
||||
Version: "",
|
||||
WebPushPrivateKey: "",
|
||||
WebPushPublicKey: "",
|
||||
WebPushFile: "",
|
||||
WebPushEmailAddress: "",
|
||||
WebPushExpiryDuration: DefaultWebPushExpiryDuration,
|
||||
WebPushExpiryWarningDuration: DefaultWebPushExpiryWarningDuration,
|
||||
BuildVersion: "",
|
||||
BuildDate: "",
|
||||
BuildCommit: "",
|
||||
}
|
||||
}
|
||||
|
||||
// Hash computes an SHA-256 hash of the configuration. This is used to detect
|
||||
// configuration changes for the web app version check feature. It uses reflection
|
||||
// to include all JSON-serializable fields automatically.
|
||||
func (c *Config) Hash() string {
|
||||
v := reflect.ValueOf(*c)
|
||||
t := v.Type()
|
||||
var result string
|
||||
for i := 0; i < v.NumField(); i++ {
|
||||
field := v.Field(i)
|
||||
fieldName := t.Field(i).Name
|
||||
// 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))
|
||||
}
|
||||
}
|
||||
return fmt.Sprintf("%x", sha256.Sum256([]byte(result)))
|
||||
}
|
||||
|
||||
8
server/config_unix.go
Normal file
@@ -0,0 +1,8 @@
|
||||
//go:build !windows
|
||||
|
||||
package server
|
||||
|
||||
func init() {
|
||||
DefaultConfigFile = "/etc/ntfy/server.yml"
|
||||
DefaultTemplateDir = "/etc/ntfy/templates"
|
||||
}
|
||||
17
server/config_windows.go
Normal file
@@ -0,0 +1,17 @@
|
||||
//go:build windows
|
||||
|
||||
package server
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
func init() {
|
||||
programData := os.Getenv("ProgramData")
|
||||
if programData == "" {
|
||||
programData = `C:\ProgramData`
|
||||
}
|
||||
DefaultConfigFile = filepath.Join(programData, "ntfy", "server.yml")
|
||||
DefaultTemplateDir = filepath.Join(programData, "ntfy", "templates")
|
||||
}
|
||||
@@ -3,8 +3,9 @@ package server
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"heckel.io/ntfy/v2/log"
|
||||
"net/http"
|
||||
|
||||
"heckel.io/ntfy/v2/log"
|
||||
)
|
||||
|
||||
// errHTTP is a generic HTTP error for any non-200 HTTP error
|
||||
@@ -77,6 +78,21 @@ func (e errHTTP) clone() errHTTP {
|
||||
}
|
||||
}
|
||||
|
||||
// errWebSocketPostUpgrade is a wrapper error indicating an error occurred after the WebSocket
|
||||
// upgrade completed (i.e., the connection was hijacked). This is used to avoid calling
|
||||
// WriteHeader on hijacked connections, which causes log spam.
|
||||
type errWebSocketPostUpgrade struct {
|
||||
err error
|
||||
}
|
||||
|
||||
func (e *errWebSocketPostUpgrade) Error() string {
|
||||
return e.err.Error()
|
||||
}
|
||||
|
||||
func (e *errWebSocketPostUpgrade) Unwrap() error {
|
||||
return e.err
|
||||
}
|
||||
|
||||
var (
|
||||
errHTTPBadRequest = &errHTTP{40000, http.StatusBadRequest, "invalid request", "", nil}
|
||||
errHTTPBadRequestEmailDisabled = &errHTTP{40001, http.StatusBadRequest, "e-mail notifications are not enabled", "https://ntfy.sh/docs/config/#e-mail-notifications", nil}
|
||||
@@ -125,6 +141,7 @@ var (
|
||||
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}
|
||||
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}
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/emersion/go-smtp"
|
||||
"github.com/gorilla/websocket"
|
||||
"heckel.io/ntfy/v2/log"
|
||||
"heckel.io/ntfy/v2/util"
|
||||
"net/http"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/emersion/go-smtp"
|
||||
"github.com/gorilla/websocket"
|
||||
"heckel.io/ntfy/v2/log"
|
||||
"heckel.io/ntfy/v2/model"
|
||||
"heckel.io/ntfy/v2/util"
|
||||
)
|
||||
|
||||
// Log tags
|
||||
@@ -53,12 +56,12 @@ func logvr(v *visitor, r *http.Request) *log.Event {
|
||||
}
|
||||
|
||||
// logvrm creates a new log event with HTTP request, visitor fields and message fields
|
||||
func logvrm(v *visitor, r *http.Request, m *message) *log.Event {
|
||||
func logvrm(v *visitor, r *http.Request, m *model.Message) *log.Event {
|
||||
return logvr(v, r).With(m)
|
||||
}
|
||||
|
||||
// logvrm creates a new log event with visitor fields and message fields
|
||||
func logvm(v *visitor, m *message) *log.Event {
|
||||
func logvm(v *visitor, m *model.Message) *log.Event {
|
||||
return logv(v).With(m)
|
||||
}
|
||||
|
||||
@@ -83,7 +86,8 @@ func httpContext(r *http.Request) log.Context {
|
||||
}
|
||||
|
||||
func websocketErrorContext(err error) log.Context {
|
||||
if c, ok := err.(*websocket.CloseError); ok {
|
||||
var c *websocket.CloseError
|
||||
if errors.As(err, &c) {
|
||||
return log.Context{
|
||||
"error": c.Error(),
|
||||
"error_code": c.Code,
|
||||
|
||||
@@ -1,745 +0,0 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"net/netip"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestSqliteCache_Messages(t *testing.T) {
|
||||
testCacheMessages(t, newSqliteTestCache(t))
|
||||
}
|
||||
|
||||
func TestMemCache_Messages(t *testing.T) {
|
||||
testCacheMessages(t, newMemTestCache(t))
|
||||
}
|
||||
|
||||
func testCacheMessages(t *testing.T, c *messageCache) {
|
||||
m1 := newDefaultMessage("mytopic", "my message")
|
||||
m1.Time = 1
|
||||
|
||||
m2 := newDefaultMessage("mytopic", "my other message")
|
||||
m2.Time = 2
|
||||
|
||||
require.Nil(t, c.AddMessage(m1))
|
||||
require.Nil(t, c.AddMessage(newDefaultMessage("example", "my example message")))
|
||||
require.Nil(t, c.AddMessage(m2))
|
||||
|
||||
// Adding invalid
|
||||
require.Equal(t, errUnexpectedMessageType, c.AddMessage(newKeepaliveMessage("mytopic"))) // These should not be added!
|
||||
require.Equal(t, errUnexpectedMessageType, c.AddMessage(newOpenMessage("example"))) // These should not be added!
|
||||
|
||||
// mytopic: count
|
||||
counts, err := c.MessageCounts()
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 2, counts["mytopic"])
|
||||
|
||||
// mytopic: since all
|
||||
messages, _ := c.Messages("mytopic", sinceAllMessages, false)
|
||||
require.Equal(t, 2, len(messages))
|
||||
require.Equal(t, "my message", messages[0].Message)
|
||||
require.Equal(t, "mytopic", messages[0].Topic)
|
||||
require.Equal(t, messageEvent, messages[0].Event)
|
||||
require.Equal(t, "", messages[0].Title)
|
||||
require.Equal(t, 0, messages[0].Priority)
|
||||
require.Nil(t, messages[0].Tags)
|
||||
require.Equal(t, "my other message", messages[1].Message)
|
||||
|
||||
// mytopic: since none
|
||||
messages, _ = c.Messages("mytopic", sinceNoMessages, false)
|
||||
require.Empty(t, messages)
|
||||
|
||||
// mytopic: since m1 (by ID)
|
||||
messages, _ = c.Messages("mytopic", newSinceID(m1.ID), false)
|
||||
require.Equal(t, 1, len(messages))
|
||||
require.Equal(t, m2.ID, messages[0].ID)
|
||||
require.Equal(t, "my other message", messages[0].Message)
|
||||
require.Equal(t, "mytopic", messages[0].Topic)
|
||||
|
||||
// mytopic: since 2
|
||||
messages, _ = c.Messages("mytopic", newSinceTime(2), false)
|
||||
require.Equal(t, 1, len(messages))
|
||||
require.Equal(t, "my other message", messages[0].Message)
|
||||
|
||||
// mytopic: latest
|
||||
messages, _ = c.Messages("mytopic", sinceLatestMessage, false)
|
||||
require.Equal(t, 1, len(messages))
|
||||
require.Equal(t, "my other message", messages[0].Message)
|
||||
|
||||
// example: count
|
||||
counts, err = c.MessageCounts()
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 1, counts["example"])
|
||||
|
||||
// example: since all
|
||||
messages, _ = c.Messages("example", sinceAllMessages, false)
|
||||
require.Equal(t, "my example message", messages[0].Message)
|
||||
|
||||
// non-existing: count
|
||||
counts, err = c.MessageCounts()
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 0, counts["doesnotexist"])
|
||||
|
||||
// non-existing: since all
|
||||
messages, _ = c.Messages("doesnotexist", sinceAllMessages, false)
|
||||
require.Empty(t, messages)
|
||||
}
|
||||
|
||||
func TestSqliteCache_MessagesLock(t *testing.T) {
|
||||
testCacheMessagesLock(t, newSqliteTestCache(t))
|
||||
}
|
||||
|
||||
func TestMemCache_MessagesLock(t *testing.T) {
|
||||
testCacheMessagesLock(t, newMemTestCache(t))
|
||||
}
|
||||
|
||||
func testCacheMessagesLock(t *testing.T, c *messageCache) {
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 5000; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
assert.Nil(t, c.AddMessage(newDefaultMessage("mytopic", "test message")))
|
||||
wg.Done()
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
func TestSqliteCache_MessagesScheduled(t *testing.T) {
|
||||
testCacheMessagesScheduled(t, newSqliteTestCache(t))
|
||||
}
|
||||
|
||||
func TestMemCache_MessagesScheduled(t *testing.T) {
|
||||
testCacheMessagesScheduled(t, newMemTestCache(t))
|
||||
}
|
||||
|
||||
func testCacheMessagesScheduled(t *testing.T, c *messageCache) {
|
||||
m1 := newDefaultMessage("mytopic", "message 1")
|
||||
m2 := newDefaultMessage("mytopic", "message 2")
|
||||
m2.Time = time.Now().Add(time.Hour).Unix()
|
||||
m3 := newDefaultMessage("mytopic", "message 3")
|
||||
m3.Time = time.Now().Add(time.Minute).Unix() // earlier than m2!
|
||||
m4 := newDefaultMessage("mytopic2", "message 4")
|
||||
m4.Time = time.Now().Add(time.Minute).Unix()
|
||||
require.Nil(t, c.AddMessage(m1))
|
||||
require.Nil(t, c.AddMessage(m2))
|
||||
require.Nil(t, c.AddMessage(m3))
|
||||
|
||||
messages, _ := c.Messages("mytopic", sinceAllMessages, false) // exclude scheduled
|
||||
require.Equal(t, 1, len(messages))
|
||||
require.Equal(t, "message 1", messages[0].Message)
|
||||
|
||||
messages, _ = c.Messages("mytopic", sinceAllMessages, true) // include scheduled
|
||||
require.Equal(t, 3, len(messages))
|
||||
require.Equal(t, "message 1", messages[0].Message)
|
||||
require.Equal(t, "message 3", messages[1].Message) // Order!
|
||||
require.Equal(t, "message 2", messages[2].Message)
|
||||
|
||||
messages, _ = c.MessagesDue()
|
||||
require.Empty(t, messages)
|
||||
}
|
||||
|
||||
func TestSqliteCache_Topics(t *testing.T) {
|
||||
testCacheTopics(t, newSqliteTestCache(t))
|
||||
}
|
||||
|
||||
func TestMemCache_Topics(t *testing.T) {
|
||||
testCacheTopics(t, newMemTestCache(t))
|
||||
}
|
||||
|
||||
func testCacheTopics(t *testing.T, c *messageCache) {
|
||||
require.Nil(t, c.AddMessage(newDefaultMessage("topic1", "my example message")))
|
||||
require.Nil(t, c.AddMessage(newDefaultMessage("topic2", "message 1")))
|
||||
require.Nil(t, c.AddMessage(newDefaultMessage("topic2", "message 2")))
|
||||
require.Nil(t, c.AddMessage(newDefaultMessage("topic2", "message 3")))
|
||||
|
||||
topics, err := c.Topics()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
require.Equal(t, 2, len(topics))
|
||||
require.Equal(t, "topic1", topics["topic1"].ID)
|
||||
require.Equal(t, "topic2", topics["topic2"].ID)
|
||||
}
|
||||
|
||||
func TestSqliteCache_MessagesTagsPrioAndTitle(t *testing.T) {
|
||||
testCacheMessagesTagsPrioAndTitle(t, newSqliteTestCache(t))
|
||||
}
|
||||
|
||||
func TestMemCache_MessagesTagsPrioAndTitle(t *testing.T) {
|
||||
testCacheMessagesTagsPrioAndTitle(t, newMemTestCache(t))
|
||||
}
|
||||
|
||||
func testCacheMessagesTagsPrioAndTitle(t *testing.T, c *messageCache) {
|
||||
m := newDefaultMessage("mytopic", "some message")
|
||||
m.Tags = []string{"tag1", "tag2"}
|
||||
m.Priority = 5
|
||||
m.Title = "some title"
|
||||
require.Nil(t, c.AddMessage(m))
|
||||
|
||||
messages, _ := c.Messages("mytopic", sinceAllMessages, false)
|
||||
require.Equal(t, []string{"tag1", "tag2"}, messages[0].Tags)
|
||||
require.Equal(t, 5, messages[0].Priority)
|
||||
require.Equal(t, "some title", messages[0].Title)
|
||||
}
|
||||
|
||||
func TestSqliteCache_MessagesSinceID(t *testing.T) {
|
||||
testCacheMessagesSinceID(t, newSqliteTestCache(t))
|
||||
}
|
||||
|
||||
func TestMemCache_MessagesSinceID(t *testing.T) {
|
||||
testCacheMessagesSinceID(t, newMemTestCache(t))
|
||||
}
|
||||
|
||||
func testCacheMessagesSinceID(t *testing.T, c *messageCache) {
|
||||
m1 := newDefaultMessage("mytopic", "message 1")
|
||||
m1.Time = 100
|
||||
m2 := newDefaultMessage("mytopic", "message 2")
|
||||
m2.Time = 200
|
||||
m3 := newDefaultMessage("mytopic", "message 3")
|
||||
m3.Time = time.Now().Add(time.Hour).Unix() // Scheduled, in the future, later than m7 and m5
|
||||
m4 := newDefaultMessage("mytopic", "message 4")
|
||||
m4.Time = 400
|
||||
m5 := newDefaultMessage("mytopic", "message 5")
|
||||
m5.Time = time.Now().Add(time.Minute).Unix() // Scheduled, in the future, later than m7
|
||||
m6 := newDefaultMessage("mytopic", "message 6")
|
||||
m6.Time = 600
|
||||
m7 := newDefaultMessage("mytopic", "message 7")
|
||||
m7.Time = 700
|
||||
|
||||
require.Nil(t, c.AddMessage(m1))
|
||||
require.Nil(t, c.AddMessage(m2))
|
||||
require.Nil(t, c.AddMessage(m3))
|
||||
require.Nil(t, c.AddMessage(m4))
|
||||
require.Nil(t, c.AddMessage(m5))
|
||||
require.Nil(t, c.AddMessage(m6))
|
||||
require.Nil(t, c.AddMessage(m7))
|
||||
|
||||
// Case 1: Since ID exists, exclude scheduled
|
||||
messages, _ := c.Messages("mytopic", newSinceID(m2.ID), false)
|
||||
require.Equal(t, 3, len(messages))
|
||||
require.Equal(t, "message 4", messages[0].Message)
|
||||
require.Equal(t, "message 6", messages[1].Message) // Not scheduled m3/m5!
|
||||
require.Equal(t, "message 7", messages[2].Message)
|
||||
|
||||
// Case 2: Since ID exists, include scheduled
|
||||
messages, _ = c.Messages("mytopic", newSinceID(m2.ID), true)
|
||||
require.Equal(t, 5, len(messages))
|
||||
require.Equal(t, "message 4", messages[0].Message)
|
||||
require.Equal(t, "message 6", messages[1].Message)
|
||||
require.Equal(t, "message 7", messages[2].Message)
|
||||
require.Equal(t, "message 5", messages[3].Message) // Order!
|
||||
require.Equal(t, "message 3", messages[4].Message) // Order!
|
||||
|
||||
// Case 3: Since ID does not exist (-> Return all messages), include scheduled
|
||||
messages, _ = c.Messages("mytopic", newSinceID("doesntexist"), true)
|
||||
require.Equal(t, 7, len(messages))
|
||||
require.Equal(t, "message 1", messages[0].Message)
|
||||
require.Equal(t, "message 2", messages[1].Message)
|
||||
require.Equal(t, "message 4", messages[2].Message)
|
||||
require.Equal(t, "message 6", messages[3].Message)
|
||||
require.Equal(t, "message 7", messages[4].Message)
|
||||
require.Equal(t, "message 5", messages[5].Message) // Order!
|
||||
require.Equal(t, "message 3", messages[6].Message) // Order!
|
||||
|
||||
// Case 4: Since ID exists and is last message (-> Return no messages), exclude scheduled
|
||||
messages, _ = c.Messages("mytopic", newSinceID(m7.ID), false)
|
||||
require.Equal(t, 0, len(messages))
|
||||
|
||||
// Case 5: Since ID exists and is last message (-> Return no messages), include scheduled
|
||||
messages, _ = c.Messages("mytopic", newSinceID(m7.ID), true)
|
||||
require.Equal(t, 2, len(messages))
|
||||
require.Equal(t, "message 5", messages[0].Message)
|
||||
require.Equal(t, "message 3", messages[1].Message)
|
||||
}
|
||||
|
||||
func TestSqliteCache_Prune(t *testing.T) {
|
||||
testCachePrune(t, newSqliteTestCache(t))
|
||||
}
|
||||
|
||||
func TestMemCache_Prune(t *testing.T) {
|
||||
testCachePrune(t, newMemTestCache(t))
|
||||
}
|
||||
|
||||
func testCachePrune(t *testing.T, c *messageCache) {
|
||||
now := time.Now().Unix()
|
||||
|
||||
m1 := newDefaultMessage("mytopic", "my message")
|
||||
m1.Time = now - 10
|
||||
m1.Expires = now - 5
|
||||
|
||||
m2 := newDefaultMessage("mytopic", "my other message")
|
||||
m2.Time = now - 5
|
||||
m2.Expires = now + 5 // In the future
|
||||
|
||||
m3 := newDefaultMessage("another_topic", "and another one")
|
||||
m3.Time = now - 12
|
||||
m3.Expires = now - 2
|
||||
|
||||
require.Nil(t, c.AddMessage(m1))
|
||||
require.Nil(t, c.AddMessage(m2))
|
||||
require.Nil(t, c.AddMessage(m3))
|
||||
|
||||
counts, err := c.MessageCounts()
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 2, counts["mytopic"])
|
||||
require.Equal(t, 1, counts["another_topic"])
|
||||
|
||||
expiredMessageIDs, err := c.MessagesExpired()
|
||||
require.Nil(t, err)
|
||||
require.Nil(t, c.DeleteMessages(expiredMessageIDs...))
|
||||
|
||||
counts, err = c.MessageCounts()
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 1, counts["mytopic"])
|
||||
require.Equal(t, 0, counts["another_topic"])
|
||||
|
||||
messages, err := c.Messages("mytopic", sinceAllMessages, false)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 1, len(messages))
|
||||
require.Equal(t, "my other message", messages[0].Message)
|
||||
}
|
||||
|
||||
func TestSqliteCache_Attachments(t *testing.T) {
|
||||
testCacheAttachments(t, newSqliteTestCache(t))
|
||||
}
|
||||
|
||||
func TestMemCache_Attachments(t *testing.T) {
|
||||
testCacheAttachments(t, newMemTestCache(t))
|
||||
}
|
||||
|
||||
func testCacheAttachments(t *testing.T, c *messageCache) {
|
||||
expires1 := time.Now().Add(-4 * time.Hour).Unix() // Expired
|
||||
m := newDefaultMessage("mytopic", "flower for you")
|
||||
m.ID = "m1"
|
||||
m.Sender = netip.MustParseAddr("1.2.3.4")
|
||||
m.Attachment = &attachment{
|
||||
Name: "flower.jpg",
|
||||
Type: "image/jpeg",
|
||||
Size: 5000,
|
||||
Expires: expires1,
|
||||
URL: "https://ntfy.sh/file/AbDeFgJhal.jpg",
|
||||
}
|
||||
require.Nil(t, c.AddMessage(m))
|
||||
|
||||
expires2 := time.Now().Add(2 * time.Hour).Unix() // Future
|
||||
m = newDefaultMessage("mytopic", "sending you a car")
|
||||
m.ID = "m2"
|
||||
m.Sender = netip.MustParseAddr("1.2.3.4")
|
||||
m.Attachment = &attachment{
|
||||
Name: "car.jpg",
|
||||
Type: "image/jpeg",
|
||||
Size: 10000,
|
||||
Expires: expires2,
|
||||
URL: "https://ntfy.sh/file/aCaRURL.jpg",
|
||||
}
|
||||
require.Nil(t, c.AddMessage(m))
|
||||
|
||||
expires3 := time.Now().Add(1 * time.Hour).Unix() // Future
|
||||
m = newDefaultMessage("another-topic", "sending you another car")
|
||||
m.ID = "m3"
|
||||
m.User = "u_BAsbaAa"
|
||||
m.Sender = netip.MustParseAddr("5.6.7.8")
|
||||
m.Attachment = &attachment{
|
||||
Name: "another-car.jpg",
|
||||
Type: "image/jpeg",
|
||||
Size: 20000,
|
||||
Expires: expires3,
|
||||
URL: "https://ntfy.sh/file/zakaDHFW.jpg",
|
||||
}
|
||||
require.Nil(t, c.AddMessage(m))
|
||||
|
||||
messages, err := c.Messages("mytopic", sinceAllMessages, false)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 2, len(messages))
|
||||
|
||||
require.Equal(t, "flower for you", messages[0].Message)
|
||||
require.Equal(t, "flower.jpg", messages[0].Attachment.Name)
|
||||
require.Equal(t, "image/jpeg", messages[0].Attachment.Type)
|
||||
require.Equal(t, int64(5000), messages[0].Attachment.Size)
|
||||
require.Equal(t, expires1, messages[0].Attachment.Expires)
|
||||
require.Equal(t, "https://ntfy.sh/file/AbDeFgJhal.jpg", messages[0].Attachment.URL)
|
||||
require.Equal(t, "1.2.3.4", messages[0].Sender.String())
|
||||
|
||||
require.Equal(t, "sending you a car", messages[1].Message)
|
||||
require.Equal(t, "car.jpg", messages[1].Attachment.Name)
|
||||
require.Equal(t, "image/jpeg", messages[1].Attachment.Type)
|
||||
require.Equal(t, int64(10000), messages[1].Attachment.Size)
|
||||
require.Equal(t, expires2, messages[1].Attachment.Expires)
|
||||
require.Equal(t, "https://ntfy.sh/file/aCaRURL.jpg", messages[1].Attachment.URL)
|
||||
require.Equal(t, "1.2.3.4", messages[1].Sender.String())
|
||||
|
||||
size, err := c.AttachmentBytesUsedBySender("1.2.3.4")
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, int64(10000), size)
|
||||
|
||||
size, err = c.AttachmentBytesUsedBySender("5.6.7.8")
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, int64(0), size) // Accounted to the user, not the IP!
|
||||
|
||||
size, err = c.AttachmentBytesUsedByUser("u_BAsbaAa")
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, int64(20000), size)
|
||||
}
|
||||
|
||||
func TestSqliteCache_Attachments_Expired(t *testing.T) {
|
||||
testCacheAttachmentsExpired(t, newSqliteTestCache(t))
|
||||
}
|
||||
|
||||
func TestMemCache_Attachments_Expired(t *testing.T) {
|
||||
testCacheAttachmentsExpired(t, newMemTestCache(t))
|
||||
}
|
||||
|
||||
func testCacheAttachmentsExpired(t *testing.T, c *messageCache) {
|
||||
m := newDefaultMessage("mytopic", "flower for you")
|
||||
m.ID = "m1"
|
||||
m.Expires = time.Now().Add(time.Hour).Unix()
|
||||
require.Nil(t, c.AddMessage(m))
|
||||
|
||||
m = newDefaultMessage("mytopic", "message with attachment")
|
||||
m.ID = "m2"
|
||||
m.Expires = time.Now().Add(2 * time.Hour).Unix()
|
||||
m.Attachment = &attachment{
|
||||
Name: "car.jpg",
|
||||
Type: "image/jpeg",
|
||||
Size: 10000,
|
||||
Expires: time.Now().Add(2 * time.Hour).Unix(),
|
||||
URL: "https://ntfy.sh/file/aCaRURL.jpg",
|
||||
}
|
||||
require.Nil(t, c.AddMessage(m))
|
||||
|
||||
m = newDefaultMessage("mytopic", "message with external attachment")
|
||||
m.ID = "m3"
|
||||
m.Expires = time.Now().Add(2 * time.Hour).Unix()
|
||||
m.Attachment = &attachment{
|
||||
Name: "car.jpg",
|
||||
Type: "image/jpeg",
|
||||
Expires: 0, // Unknown!
|
||||
URL: "https://somedomain.com/car.jpg",
|
||||
}
|
||||
require.Nil(t, c.AddMessage(m))
|
||||
|
||||
m = newDefaultMessage("mytopic2", "message with expired attachment")
|
||||
m.ID = "m4"
|
||||
m.Expires = time.Now().Add(2 * time.Hour).Unix()
|
||||
m.Attachment = &attachment{
|
||||
Name: "expired-car.jpg",
|
||||
Type: "image/jpeg",
|
||||
Size: 20000,
|
||||
Expires: time.Now().Add(-1 * time.Hour).Unix(),
|
||||
URL: "https://ntfy.sh/file/aCaRURL.jpg",
|
||||
}
|
||||
require.Nil(t, c.AddMessage(m))
|
||||
|
||||
ids, err := c.AttachmentsExpired()
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 1, len(ids))
|
||||
require.Equal(t, "m4", ids[0])
|
||||
}
|
||||
|
||||
func TestSqliteCache_Migration_From0(t *testing.T) {
|
||||
filename := newSqliteTestCacheFile(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 cache to trigger migration
|
||||
c := newSqliteTestCacheFromFile(t, filename, "")
|
||||
checkSchemaVersion(t, c.db)
|
||||
|
||||
messages, err := c.Messages("mytopic", 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 TestSqliteCache_Migration_From1(t *testing.T) {
|
||||
filename := newSqliteTestCacheFile(t)
|
||||
db, err := sql.Open("sqlite3", filename)
|
||||
require.Nil(t, err)
|
||||
|
||||
// Create "version 1" schema
|
||||
_, err = db.Exec(`
|
||||
CREATE TABLE IF NOT EXISTS messages (
|
||||
id VARCHAR(20) PRIMARY KEY,
|
||||
time INT NOT NULL,
|
||||
topic VARCHAR(64) NOT NULL,
|
||||
message VARCHAR(512) NOT NULL,
|
||||
title VARCHAR(256) NOT NULL,
|
||||
priority INT NOT NULL,
|
||||
tags VARCHAR(256) NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_topic ON messages (topic);
|
||||
CREATE TABLE IF NOT EXISTS schemaVersion (
|
||||
id INT PRIMARY KEY,
|
||||
version INT NOT NULL
|
||||
);
|
||||
INSERT INTO schemaVersion (id, version) VALUES (1, 1);
|
||||
`)
|
||||
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, title, priority, tags) VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
fmt.Sprintf("abcd%d", i), time.Now().Unix(), "mytopic", fmt.Sprintf("some message %d", i), "", 0, "")
|
||||
require.Nil(t, err)
|
||||
}
|
||||
require.Nil(t, db.Close())
|
||||
|
||||
// Create cache to trigger migration
|
||||
c := newSqliteTestCacheFromFile(t, filename, "")
|
||||
checkSchemaVersion(t, c.db)
|
||||
|
||||
// Add delayed message
|
||||
delayedMessage := newDefaultMessage("mytopic", "some delayed message")
|
||||
delayedMessage.Time = time.Now().Add(time.Minute).Unix()
|
||||
require.Nil(t, c.AddMessage(delayedMessage))
|
||||
|
||||
// 10, not 11!
|
||||
messages, err := c.Messages("mytopic", sinceAllMessages, false)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 10, len(messages))
|
||||
|
||||
// 11!
|
||||
messages, err = c.Messages("mytopic", sinceAllMessages, true)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 11, len(messages))
|
||||
|
||||
// Check that index "idx_topic" exists
|
||||
rows, err := c.db.Query(`SELECT name FROM sqlite_master WHERE type='index' AND name='idx_topic'`)
|
||||
require.Nil(t, err)
|
||||
require.True(t, rows.Next())
|
||||
var indexName string
|
||||
require.Nil(t, rows.Scan(&indexName))
|
||||
require.Equal(t, "idx_topic", indexName)
|
||||
}
|
||||
|
||||
func TestSqliteCache_Migration_From9(t *testing.T) {
|
||||
// This primarily tests the awkward migration that introduces the "expires" column.
|
||||
// The migration logic has to update the column, using the existing "cache-duration" value.
|
||||
|
||||
filename := newSqliteTestCacheFile(t)
|
||||
db, err := sql.Open("sqlite3", filename)
|
||||
require.Nil(t, err)
|
||||
|
||||
// Create "version 8" schema
|
||||
_, err = db.Exec(`
|
||||
BEGIN;
|
||||
CREATE TABLE IF NOT EXISTS messages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
mid TEXT NOT NULL,
|
||||
time INT 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 INT NOT NULL,
|
||||
attachment_expires INT NOT NULL,
|
||||
attachment_url TEXT NOT NULL,
|
||||
sender TEXT NOT NULL,
|
||||
encoding TEXT NOT NULL,
|
||||
published INT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_mid ON messages (mid);
|
||||
CREATE INDEX IF NOT EXISTS idx_time ON messages (time);
|
||||
CREATE INDEX IF NOT EXISTS idx_topic ON messages (topic);
|
||||
CREATE TABLE IF NOT EXISTS schemaVersion (
|
||||
id INT PRIMARY KEY,
|
||||
version INT NOT NULL
|
||||
);
|
||||
INSERT INTO schemaVersion (id, version) VALUES (1, 9);
|
||||
COMMIT;
|
||||
`)
|
||||
require.Nil(t, err)
|
||||
|
||||
// Insert a bunch of messages
|
||||
insertQuery := `
|
||||
INSERT INTO messages (mid, time, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, encoding, published)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`
|
||||
for i := 0; i < 10; i++ {
|
||||
_, err = db.Exec(
|
||||
insertQuery,
|
||||
fmt.Sprintf("abcd%d", i),
|
||||
time.Now().Unix(),
|
||||
"mytopic",
|
||||
fmt.Sprintf("some message %d", i),
|
||||
"", // title
|
||||
0, // priority
|
||||
"", // tags
|
||||
"", // click
|
||||
"", // icon
|
||||
"", // actions
|
||||
"", // attachment_name
|
||||
"", // attachment_type
|
||||
0, // attachment_size
|
||||
0, // attachment_type
|
||||
"", // attachment_url
|
||||
"9.9.9.9", // sender
|
||||
"", // encoding
|
||||
1, // published
|
||||
)
|
||||
require.Nil(t, err)
|
||||
}
|
||||
|
||||
// Create cache to trigger migration
|
||||
cacheDuration := 17 * time.Hour
|
||||
c, err := newSqliteCache(filename, "", cacheDuration, 0, 0, false)
|
||||
require.Nil(t, err)
|
||||
checkSchemaVersion(t, c.db)
|
||||
|
||||
// Check version
|
||||
rows, err := db.Query(`SELECT version FROM main.schemaVersion WHERE id = 1`)
|
||||
require.Nil(t, err)
|
||||
require.True(t, rows.Next())
|
||||
var version int
|
||||
require.Nil(t, rows.Scan(&version))
|
||||
require.Equal(t, currentSchemaVersion, version)
|
||||
|
||||
messages, err := c.Messages("mytopic", sinceAllMessages, false)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 10, len(messages))
|
||||
for _, m := range messages {
|
||||
require.True(t, m.Expires > time.Now().Add(cacheDuration-5*time.Second).Unix())
|
||||
require.True(t, m.Expires < time.Now().Add(cacheDuration+5*time.Second).Unix())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSqliteCache_StartupQueries_WAL(t *testing.T) {
|
||||
filename := newSqliteTestCacheFile(t)
|
||||
startupQueries := `pragma journal_mode = WAL;
|
||||
pragma synchronous = normal;
|
||||
pragma temp_store = memory;`
|
||||
db, err := newSqliteCache(filename, startupQueries, time.Hour, 0, 0, false)
|
||||
require.Nil(t, err)
|
||||
require.Nil(t, db.AddMessage(newDefaultMessage("mytopic", "some message")))
|
||||
require.FileExists(t, filename)
|
||||
require.FileExists(t, filename+"-wal")
|
||||
require.FileExists(t, filename+"-shm")
|
||||
}
|
||||
|
||||
func TestSqliteCache_StartupQueries_None(t *testing.T) {
|
||||
filename := newSqliteTestCacheFile(t)
|
||||
startupQueries := ""
|
||||
db, err := newSqliteCache(filename, startupQueries, time.Hour, 0, 0, false)
|
||||
require.Nil(t, err)
|
||||
require.Nil(t, db.AddMessage(newDefaultMessage("mytopic", "some message")))
|
||||
require.FileExists(t, filename)
|
||||
require.NoFileExists(t, filename+"-wal")
|
||||
require.NoFileExists(t, filename+"-shm")
|
||||
}
|
||||
|
||||
func TestSqliteCache_StartupQueries_Fail(t *testing.T) {
|
||||
filename := newSqliteTestCacheFile(t)
|
||||
startupQueries := `xx error`
|
||||
_, err := newSqliteCache(filename, startupQueries, time.Hour, 0, 0, false)
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestSqliteCache_Sender(t *testing.T) {
|
||||
testSender(t, newSqliteTestCache(t))
|
||||
}
|
||||
|
||||
func TestMemCache_Sender(t *testing.T) {
|
||||
testSender(t, newMemTestCache(t))
|
||||
}
|
||||
|
||||
func testSender(t *testing.T, c *messageCache) {
|
||||
m1 := newDefaultMessage("mytopic", "mymessage")
|
||||
m1.Sender = netip.MustParseAddr("1.2.3.4")
|
||||
require.Nil(t, c.AddMessage(m1))
|
||||
|
||||
m2 := newDefaultMessage("mytopic", "mymessage without sender")
|
||||
require.Nil(t, c.AddMessage(m2))
|
||||
|
||||
messages, err := c.Messages("mytopic", sinceAllMessages, false)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 2, len(messages))
|
||||
require.Equal(t, messages[0].Sender, netip.MustParseAddr("1.2.3.4"))
|
||||
require.Equal(t, messages[1].Sender, netip.Addr{})
|
||||
}
|
||||
|
||||
func checkSchemaVersion(t *testing.T, db *sql.DB) {
|
||||
rows, err := db.Query(`SELECT version FROM schemaVersion`)
|
||||
require.Nil(t, err)
|
||||
require.True(t, rows.Next())
|
||||
|
||||
var schemaVersion int
|
||||
require.Nil(t, rows.Scan(&schemaVersion))
|
||||
require.Equal(t, currentSchemaVersion, schemaVersion)
|
||||
require.Nil(t, rows.Close())
|
||||
}
|
||||
|
||||
func TestMemCache_NopCache(t *testing.T) {
|
||||
c, _ := newNopCache()
|
||||
require.Nil(t, c.AddMessage(newDefaultMessage("mytopic", "my message")))
|
||||
|
||||
messages, err := c.Messages("mytopic", sinceAllMessages, false)
|
||||
require.Nil(t, err)
|
||||
require.Empty(t, messages)
|
||||
|
||||
topics, err := c.Topics()
|
||||
require.Nil(t, err)
|
||||
require.Empty(t, topics)
|
||||
}
|
||||
|
||||
func newSqliteTestCache(t *testing.T) *messageCache {
|
||||
c, err := newSqliteCache(newSqliteTestCacheFile(t), "", time.Hour, 0, 0, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func newSqliteTestCacheFile(t *testing.T) string {
|
||||
return filepath.Join(t.TempDir(), "cache.db")
|
||||
}
|
||||
|
||||
func newSqliteTestCacheFromFile(t *testing.T, filename, startupQueries string) *messageCache {
|
||||
c, err := newSqliteCache(filename, startupQueries, time.Hour, 0, 0, false)
|
||||
require.Nil(t, err)
|
||||
return c
|
||||
}
|
||||
|
||||
func newMemTestCache(t *testing.T) *messageCache {
|
||||
c, err := newMemCache()
|
||||
require.Nil(t, err)
|
||||
return c
|
||||
}
|
||||
394
server/server.go
@@ -9,8 +9,6 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"gopkg.in/yaml.v2"
|
||||
"heckel.io/ntfy/v2/payments"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
@@ -33,10 +31,15 @@ import (
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||
"golang.org/x/sync/errgroup"
|
||||
"gopkg.in/yaml.v2"
|
||||
"heckel.io/ntfy/v2/log"
|
||||
"heckel.io/ntfy/v2/message"
|
||||
"heckel.io/ntfy/v2/model"
|
||||
"heckel.io/ntfy/v2/payments"
|
||||
"heckel.io/ntfy/v2/user"
|
||||
"heckel.io/ntfy/v2/util"
|
||||
"heckel.io/ntfy/v2/util/sprig"
|
||||
"heckel.io/ntfy/v2/webpush"
|
||||
)
|
||||
|
||||
// Server is the main server, providing the UI and API for ntfy
|
||||
@@ -56,8 +59,8 @@ type Server struct {
|
||||
messages int64 // Total number of messages (persisted if messageCache enabled)
|
||||
messagesHistory []int64 // Last n values of the messages counter, used to determine rate
|
||||
userManager *user.Manager // Might be nil!
|
||||
messageCache *messageCache // Database that stores the messages
|
||||
webPush *webPushStore // Database that stores web push subscriptions
|
||||
messageCache message.Store // Database that stores the messages
|
||||
webPush webpush.Store // Database that stores web push subscriptions
|
||||
fileCache *fileCache // File system based cache that stores attachments
|
||||
stripe stripeAPI // Stripe API, can be replaced with a mock
|
||||
priceCache *util.LookupCache[map[string]int64] // Stripe price ID -> price as cents (USD implied!)
|
||||
@@ -80,15 +83,18 @@ var (
|
||||
wsPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/ws$`)
|
||||
authPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/auth$`)
|
||||
publishPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}/(publish|send|trigger)$`)
|
||||
updatePathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}/[-_A-Za-z0-9]{1,64}$`)
|
||||
clearPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}/[-_A-Za-z0-9]{1,64}/(read|clear)$`)
|
||||
sequenceIDRegex = topicRegex
|
||||
|
||||
webConfigPath = "/config.js"
|
||||
webManifestPath = "/manifest.webmanifest"
|
||||
webRootHTMLPath = "/app.html"
|
||||
webServiceWorkerPath = "/sw.js"
|
||||
accountPath = "/account"
|
||||
matrixPushPath = "/_matrix/push/v1/notify"
|
||||
metricsPath = "/metrics"
|
||||
apiHealthPath = "/v1/health"
|
||||
apiVersionPath = "/v1/version"
|
||||
apiConfigPath = "/v1/config"
|
||||
apiStatsPath = "/v1/stats"
|
||||
apiWebPushPath = "/v1/webpush"
|
||||
apiTiersPath = "/v1/tiers"
|
||||
@@ -108,7 +114,7 @@ var (
|
||||
apiAccountBillingSubscriptionCheckoutSuccessTemplate = "/v1/account/billing/subscription/success/{CHECKOUT_SESSION_ID}"
|
||||
apiAccountBillingSubscriptionCheckoutSuccessRegex = regexp.MustCompile(`/v1/account/billing/subscription/success/(.+)$`)
|
||||
apiAccountReservationSingleRegex = regexp.MustCompile(`/v1/account/reservation/([-_A-Za-z0-9]{1,64})$`)
|
||||
staticRegex = regexp.MustCompile(`^/static/.+`)
|
||||
staticRegex = regexp.MustCompile(`^/(static/.+|app.html|sw.js|sw.js.map)$`)
|
||||
docsRegex = regexp.MustCompile(`^/docs(|/.*)$`)
|
||||
fileRegex = regexp.MustCompile(`^/file/([-_A-Za-z0-9]{1,64})(?:\.[A-Za-z0-9]{1,16})?$`)
|
||||
urlRegex = regexp.MustCompile(`^https?://`)
|
||||
@@ -137,7 +143,7 @@ var (
|
||||
const (
|
||||
firebaseControlTopic = "~control" // See Android if changed
|
||||
firebasePollTopic = "~poll" // See iOS if changed (DISABLED for now)
|
||||
emptyMessageBody = "triggered" // Used if message body is empty
|
||||
emptyMessageBody = "triggered" // Used when a message body is empty
|
||||
newMessageBody = "New message" // Used in poll requests as generic message
|
||||
defaultAttachmentMessage = "You received a file: %s" // Used if message body is empty, and there is an attachment
|
||||
encodingBase64 = "base64" // Used mainly for binary UnifiedPush messages
|
||||
@@ -173,17 +179,25 @@ func New(conf *Config) (*Server, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var webPush *webPushStore
|
||||
var wp webpush.Store
|
||||
if conf.WebPushPublicKey != "" {
|
||||
webPush, err = newWebPushStore(conf.WebPushFile, conf.WebPushStartupQueries)
|
||||
if conf.DatabaseURL != "" {
|
||||
wp, err = webpush.NewPostgresStore(conf.DatabaseURL)
|
||||
} else {
|
||||
wp, err = webpush.NewSQLiteStore(conf.WebPushFile, conf.WebPushStartupQueries)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
topics, err := messageCache.Topics()
|
||||
topicIDs, err := messageCache.Topics()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
topics := make(map[string]*topic, len(topicIDs))
|
||||
for _, id := range topicIDs {
|
||||
topics[id] = newTopic(id)
|
||||
}
|
||||
messages, err := messageCache.Stats()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -196,9 +210,10 @@ func New(conf *Config) (*Server, error) {
|
||||
}
|
||||
}
|
||||
var userManager *user.Manager
|
||||
if conf.AuthFile != "" {
|
||||
if conf.AuthFile != "" || conf.DatabaseURL != "" {
|
||||
authConfig := &user.Config{
|
||||
Filename: conf.AuthFile,
|
||||
DatabaseURL: conf.DatabaseURL,
|
||||
StartupQueries: conf.AuthStartupQueries,
|
||||
DefaultAccess: conf.AuthDefault,
|
||||
ProvisionEnabled: true, // Enable provisioning of users and access
|
||||
@@ -208,7 +223,16 @@ func New(conf *Config) (*Server, error) {
|
||||
BcryptCost: conf.AuthBcryptCost,
|
||||
QueueWriterInterval: conf.AuthStatsQueueWriterInterval,
|
||||
}
|
||||
userManager, err = user.NewManager(authConfig)
|
||||
var store user.Store
|
||||
if conf.DatabaseURL != "" {
|
||||
store, err = user.NewPostgresStore(conf.DatabaseURL)
|
||||
} else {
|
||||
store, err = user.NewSQLiteStore(conf.AuthFile, conf.AuthStartupQueries)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
userManager, err = user.NewManager(store, authConfig)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -230,7 +254,7 @@ func New(conf *Config) (*Server, error) {
|
||||
s := &Server{
|
||||
config: conf,
|
||||
messageCache: messageCache,
|
||||
webPush: webPush,
|
||||
webPush: wp,
|
||||
fileCache: fileCache,
|
||||
firebaseClient: firebaseClient,
|
||||
smtpSender: mailer,
|
||||
@@ -245,13 +269,15 @@ func New(conf *Config) (*Server, error) {
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func createMessageCache(conf *Config) (*messageCache, error) {
|
||||
func createMessageCache(conf *Config) (message.Store, error) {
|
||||
if conf.CacheDuration == 0 {
|
||||
return newNopCache()
|
||||
return message.NewNopStore()
|
||||
} else if conf.DatabaseURL != "" {
|
||||
return message.NewPostgresStore(conf.DatabaseURL, conf.CacheBatchSize, conf.CacheBatchTimeout)
|
||||
} else if conf.CacheFile != "" {
|
||||
return newSqliteCache(conf.CacheFile, conf.CacheStartupQueries, conf.CacheDuration, conf.CacheBatchSize, conf.CacheBatchTimeout, false)
|
||||
return message.NewSQLiteStore(conf.CacheFile, conf.CacheStartupQueries, conf.CacheDuration, conf.CacheBatchSize, conf.CacheBatchTimeout, false)
|
||||
}
|
||||
return newMemCache()
|
||||
return message.NewMemStore()
|
||||
}
|
||||
|
||||
// Run executes the main server. It listens on HTTP (+ HTTPS, if configured), and starts
|
||||
@@ -276,9 +302,9 @@ func (s *Server) Run() error {
|
||||
if s.config.ProfileListenHTTP != "" {
|
||||
listenStr += fmt.Sprintf(" %s[http/profile]", s.config.ProfileListenHTTP)
|
||||
}
|
||||
log.Tag(tagStartup).Info("Listening on%s, ntfy %s, log level is %s", listenStr, s.config.Version, log.CurrentLevel().String())
|
||||
log.Tag(tagStartup).Info("Listening on%s, ntfy %s, log level is %s", listenStr, s.config.BuildVersion, log.CurrentLevel().String())
|
||||
if log.IsFile() {
|
||||
fmt.Fprintf(os.Stderr, "Listening on%s, ntfy %s\n", listenStr, s.config.Version)
|
||||
fmt.Fprintf(os.Stderr, "Listening on%s, ntfy %s\n", listenStr, s.config.BuildVersion)
|
||||
fmt.Fprintf(os.Stderr, "Logs are written to %s\n", log.File())
|
||||
}
|
||||
mux := http.NewServeMux()
|
||||
@@ -432,8 +458,14 @@ func (s *Server) handleError(w http.ResponseWriter, r *http.Request, v *visitor,
|
||||
} else {
|
||||
ev.Info("WebSocket error: %s", err.Error())
|
||||
}
|
||||
w.WriteHeader(httpErr.HTTPCode)
|
||||
return // Do not attempt to write any body to upgraded connection
|
||||
// Write error response only if the connection was not hijacked yet. Bytes written to hijacked
|
||||
// connections are WebSocket frames, not HTTP, and will cause "http: response.WriteHeader on hijacked
|
||||
// connection" log spam.
|
||||
var postUpgradeErr *errWebSocketPostUpgrade
|
||||
if !errors.As(err, &postUpgradeErr) {
|
||||
w.WriteHeader(httpErr.HTTPCode)
|
||||
}
|
||||
return
|
||||
}
|
||||
if isNormalError {
|
||||
ev.Debug("Connection closed with HTTP %d (ntfy error %d)", httpErr.HTTPCode, httpErr.Code)
|
||||
@@ -459,6 +491,10 @@ func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request, v *visit
|
||||
return s.ensureWebEnabled(s.handleEmpty)(w, r, v)
|
||||
} else if r.Method == http.MethodGet && r.URL.Path == apiHealthPath {
|
||||
return s.handleHealth(w, r, v)
|
||||
} else if r.Method == http.MethodGet && r.URL.Path == apiVersionPath {
|
||||
return s.ensureAdmin(s.handleVersion)(w, r, v)
|
||||
} else if r.Method == http.MethodGet && r.URL.Path == apiConfigPath {
|
||||
return s.handleConfig(w, r, v)
|
||||
} else if r.Method == http.MethodGet && r.URL.Path == webConfigPath {
|
||||
return s.ensureWebEnabled(s.handleWebConfig)(w, r, v)
|
||||
} else if r.Method == http.MethodGet && r.URL.Path == webManifestPath {
|
||||
@@ -531,7 +567,7 @@ func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request, v *visit
|
||||
return s.handleMatrixDiscovery(w)
|
||||
} else if r.Method == http.MethodGet && r.URL.Path == metricsPath && s.metricsHandler != nil {
|
||||
return s.handleMetrics(w, r, v)
|
||||
} else if r.Method == http.MethodGet && (staticRegex.MatchString(r.URL.Path) || r.URL.Path == webServiceWorkerPath || r.URL.Path == webRootHTMLPath) {
|
||||
} else if r.Method == http.MethodGet && staticRegex.MatchString(r.URL.Path) {
|
||||
return s.ensureWebEnabled(s.handleStatic)(w, r, v)
|
||||
} else if r.Method == http.MethodGet && docsRegex.MatchString(r.URL.Path) {
|
||||
return s.ensureWebEnabled(s.handleDocs)(w, r, v)
|
||||
@@ -543,8 +579,12 @@ func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request, v *visit
|
||||
return s.transformBodyJSON(s.limitRequestsWithTopic(s.authorizeTopicWrite(s.handlePublish)))(w, r, v)
|
||||
} else if r.Method == http.MethodPost && r.URL.Path == matrixPushPath {
|
||||
return s.transformMatrixJSON(s.limitRequestsWithTopic(s.authorizeTopicWrite(s.handlePublishMatrix)))(w, r, v)
|
||||
} else if (r.Method == http.MethodPut || r.Method == http.MethodPost) && topicPathRegex.MatchString(r.URL.Path) {
|
||||
} else if (r.Method == http.MethodPut || r.Method == http.MethodPost) && (topicPathRegex.MatchString(r.URL.Path) || updatePathRegex.MatchString(r.URL.Path)) {
|
||||
return s.limitRequestsWithTopic(s.authorizeTopicWrite(s.handlePublish))(w, r, v)
|
||||
} else if r.Method == http.MethodDelete && updatePathRegex.MatchString(r.URL.Path) {
|
||||
return s.limitRequestsWithTopic(s.authorizeTopicWrite(s.handleDelete))(w, r, v)
|
||||
} else if r.Method == http.MethodPut && clearPathRegex.MatchString(r.URL.Path) {
|
||||
return s.limitRequestsWithTopic(s.authorizeTopicWrite(s.handleClear))(w, r, v)
|
||||
} else if r.Method == http.MethodGet && publishPathRegex.MatchString(r.URL.Path) {
|
||||
return s.limitRequestsWithTopic(s.authorizeTopicWrite(s.handlePublish))(w, r, v)
|
||||
} else if r.Method == http.MethodGet && jsonPathRegex.MatchString(r.URL.Path) {
|
||||
@@ -595,22 +635,13 @@ func (s *Server) handleHealth(w http.ResponseWriter, _ *http.Request, _ *visitor
|
||||
return s.writeJSON(w, response)
|
||||
}
|
||||
|
||||
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 {
|
||||
response := &apiConfigResponse{
|
||||
BaseURL: "", // Will translate to window.location.origin
|
||||
AppRoot: s.config.WebRoot,
|
||||
EnableLogin: s.config.EnableLogin,
|
||||
EnableSignup: s.config.EnableSignup,
|
||||
EnablePayments: s.config.StripeSecretKey != "",
|
||||
EnableCalls: s.config.TwilioAccount != "",
|
||||
EnableEmails: s.config.SMTPSenderFrom != "",
|
||||
EnableReservations: s.config.EnableReservations,
|
||||
EnableWebPush: s.config.WebPushPublicKey != "",
|
||||
BillingContact: s.config.BillingContact,
|
||||
WebPushPublicKey: s.config.WebPushPublicKey,
|
||||
DisallowedTopics: s.config.DisallowedTopics,
|
||||
}
|
||||
b, err := json.MarshalIndent(response, "", " ")
|
||||
b, err := json.MarshalIndent(s.configResponse(), "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -620,10 +651,29 @@ func (s *Server) handleWebConfig(w http.ResponseWriter, _ *http.Request, _ *visi
|
||||
return err
|
||||
}
|
||||
|
||||
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 != "",
|
||||
EnableReservations: s.config.EnableReservations,
|
||||
EnableWebPush: s.config.WebPushPublicKey != "",
|
||||
BillingContact: s.config.BillingContact,
|
||||
WebPushPublicKey: s.config.WebPushPublicKey,
|
||||
DisallowedTopics: s.config.DisallowedTopics,
|
||||
ConfigHash: s.config.Hash(),
|
||||
}
|
||||
}
|
||||
|
||||
// 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 web",
|
||||
Name: "ntfy",
|
||||
Description: "ntfy lets you send push notifications via scripts from any computer or phone",
|
||||
ShortName: "ntfy",
|
||||
Scope: "/",
|
||||
@@ -708,7 +758,7 @@ func (s *Server) handleFile(w http.ResponseWriter, r *http.Request, v *visitor)
|
||||
if s.config.CacheBatchTimeout > 0 {
|
||||
// Strange edge case: If we immediately after upload request the file (the web app does this for images),
|
||||
// and messages are persisted asynchronously, retry fetching from the database
|
||||
m, err = util.Retry(func() (*message, error) {
|
||||
m, err = util.Retry(func() (*model.Message, error) {
|
||||
return s.messageCache.Message(messageID)
|
||||
}, s.config.CacheBatchTimeout, 100*time.Millisecond, 300*time.Millisecond, 600*time.Millisecond)
|
||||
}
|
||||
@@ -754,7 +804,7 @@ func (s *Server) handleMatrixDiscovery(w http.ResponseWriter) error {
|
||||
return writeMatrixDiscoveryResponse(w)
|
||||
}
|
||||
|
||||
func (s *Server) handlePublishInternal(r *http.Request, v *visitor) (*message, error) {
|
||||
func (s *Server) handlePublishInternal(r *http.Request, v *visitor) (*model.Message, error) {
|
||||
start := time.Now()
|
||||
t, err := fromContext[*topic](r, contextTopic)
|
||||
if err != nil {
|
||||
@@ -769,7 +819,7 @@ func (s *Server) handlePublishInternal(r *http.Request, v *visitor) (*message, e
|
||||
return nil, err
|
||||
}
|
||||
m := newDefaultMessage(t.ID, "")
|
||||
cache, firebase, email, call, template, unifiedpush, e := s.parsePublishParams(r, m)
|
||||
cache, firebase, email, call, template, unifiedpush, priorityStr, e := s.parsePublishParams(r, m)
|
||||
if e != nil {
|
||||
return nil, e.With(t)
|
||||
}
|
||||
@@ -800,7 +850,7 @@ func (s *Server) handlePublishInternal(r *http.Request, v *visitor) (*message, e
|
||||
if cache {
|
||||
m.Expires = time.Unix(m.Time, 0).Add(v.Limits().MessageExpiryDuration).Unix()
|
||||
}
|
||||
if err := s.handlePublishBody(r, v, m, body, template, unifiedpush); err != nil {
|
||||
if err := s.handlePublishBody(r, v, m, body, template, unifiedpush, priorityStr); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if m.Message == "" {
|
||||
@@ -845,6 +895,17 @@ func (s *Server) handlePublishInternal(r *http.Request, v *visitor) (*message, e
|
||||
logvrm(v, r, m).Tag(tagPublish).Debug("Message delayed, will process later")
|
||||
}
|
||||
if cache {
|
||||
// Delete any existing scheduled message with the same sequence ID
|
||||
deletedIDs, err := s.messageCache.DeleteScheduledBySequenceID(t.ID, m.SequenceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Delete attachment files for deleted scheduled messages
|
||||
if s.fileCache != nil && len(deletedIDs) > 0 {
|
||||
if err := s.fileCache.Remove(deletedIDs...); err != nil {
|
||||
logvrm(v, r, m).Tag(tagPublish).Err(err).Warn("Error removing attachments for deleted scheduled messages")
|
||||
}
|
||||
}
|
||||
logvrm(v, r, m).Tag(tagPublish).Debug("Adding message to cache")
|
||||
if err := s.messageCache.AddMessage(m); err != nil {
|
||||
return nil, err
|
||||
@@ -871,7 +932,7 @@ func (s *Server) handlePublish(w http.ResponseWriter, r *http.Request, v *visito
|
||||
return err
|
||||
}
|
||||
minc(metricMessagesPublishedSuccess)
|
||||
return s.writeJSON(w, m)
|
||||
return s.writeJSON(w, m.ForJSON())
|
||||
}
|
||||
|
||||
func (s *Server) handlePublishMatrix(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||
@@ -899,7 +960,72 @@ func (s *Server) handlePublishMatrix(w http.ResponseWriter, r *http.Request, v *
|
||||
return writeMatrixSuccess(w)
|
||||
}
|
||||
|
||||
func (s *Server) sendToFirebase(v *visitor, m *message) {
|
||||
func (s *Server) handleDelete(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||
return s.handleActionMessage(w, r, v, messageDeleteEvent)
|
||||
}
|
||||
|
||||
func (s *Server) handleClear(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||
return s.handleActionMessage(w, r, v, messageClearEvent)
|
||||
}
|
||||
|
||||
func (s *Server) handleActionMessage(w http.ResponseWriter, r *http.Request, v *visitor, event string) error {
|
||||
t, err := fromContext[*topic](r, contextTopic)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
vrate, err := fromContext[*visitor](r, contextRateVisitor)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !util.ContainsIP(s.config.VisitorRequestExemptPrefixes, v.ip) && !vrate.MessageAllowed() {
|
||||
return errHTTPTooManyRequestsLimitMessages.With(t)
|
||||
}
|
||||
sequenceID, e := s.sequenceIDFromPath(r.URL.Path)
|
||||
if e != nil {
|
||||
return e.With(t)
|
||||
}
|
||||
// Create an action message with the given event type
|
||||
m := newActionMessage(event, t.ID, sequenceID)
|
||||
m.Sender = v.IP()
|
||||
m.User = v.MaybeUserID()
|
||||
m.Expires = time.Unix(m.Time, 0).Add(v.Limits().MessageExpiryDuration).Unix()
|
||||
// Publish to subscribers
|
||||
if err := t.Publish(v, m); err != nil {
|
||||
return err
|
||||
}
|
||||
// Send to Firebase for Android clients
|
||||
if s.firebaseClient != nil {
|
||||
go s.sendToFirebase(v, m)
|
||||
}
|
||||
// Send to web push endpoints
|
||||
if s.config.WebPushPublicKey != "" {
|
||||
go s.publishToWebPushEndpoints(v, m)
|
||||
}
|
||||
if event == messageDeleteEvent {
|
||||
// Delete any existing scheduled message with the same sequence ID
|
||||
deletedIDs, err := s.messageCache.DeleteScheduledBySequenceID(t.ID, sequenceID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Delete attachment files for deleted scheduled messages
|
||||
if s.fileCache != nil && len(deletedIDs) > 0 {
|
||||
if err := s.fileCache.Remove(deletedIDs...); err != nil {
|
||||
logvrm(v, r, m).Tag(tagPublish).Err(err).Warn("Error removing attachments for deleted scheduled messages")
|
||||
}
|
||||
}
|
||||
}
|
||||
// Add to message cache
|
||||
if err := s.messageCache.AddMessage(m); err != nil {
|
||||
return err
|
||||
}
|
||||
logvrm(v, r, m).Tag(tagPublish).Debug("Published %s for sequence ID %s", event, sequenceID)
|
||||
s.mu.Lock()
|
||||
s.messages++
|
||||
s.mu.Unlock()
|
||||
return s.writeJSON(w, m.ForJSON())
|
||||
}
|
||||
|
||||
func (s *Server) sendToFirebase(v *visitor, m *model.Message) {
|
||||
logvm(v, m).Tag(tagFirebase).Debug("Publishing to Firebase")
|
||||
if err := s.firebaseClient.Send(v, m); err != nil {
|
||||
minc(metricFirebasePublishedFailure)
|
||||
@@ -913,7 +1039,7 @@ func (s *Server) sendToFirebase(v *visitor, m *message) {
|
||||
minc(metricFirebasePublishedSuccess)
|
||||
}
|
||||
|
||||
func (s *Server) sendEmail(v *visitor, m *message, email string) {
|
||||
func (s *Server) sendEmail(v *visitor, m *model.Message, email string) {
|
||||
logvm(v, m).Tag(tagEmail).Field("email", email).Debug("Sending email to %s", email)
|
||||
if err := s.smtpSender.Send(v, m, email); err != nil {
|
||||
logvm(v, m).Tag(tagEmail).Field("email", email).Err(err).Warn("Unable to send email to %s: %v", email, err.Error())
|
||||
@@ -923,7 +1049,7 @@ func (s *Server) sendEmail(v *visitor, m *message, email string) {
|
||||
minc(metricEmailsPublishedSuccess)
|
||||
}
|
||||
|
||||
func (s *Server) forwardPollRequest(v *visitor, m *message) {
|
||||
func (s *Server) forwardPollRequest(v *visitor, m *model.Message) {
|
||||
topicURL := fmt.Sprintf("%s/%s", s.config.BaseURL, m.Topic)
|
||||
topicHash := fmt.Sprintf("%x", sha256.Sum256([]byte(topicURL)))
|
||||
forwardURL := fmt.Sprintf("%s/%s", s.config.UpstreamBaseURL, topicHash)
|
||||
@@ -933,7 +1059,7 @@ func (s *Server) forwardPollRequest(v *visitor, m *message) {
|
||||
logvm(v, m).Err(err).Warn("Unable to publish poll request")
|
||||
return
|
||||
}
|
||||
req.Header.Set("User-Agent", "ntfy/"+s.config.Version)
|
||||
req.Header.Set("User-Agent", "ntfy/"+s.config.BuildVersion)
|
||||
req.Header.Set("X-Poll-ID", m.ID)
|
||||
if s.config.UpstreamAccessToken != "" {
|
||||
req.Header.Set("Authorization", util.BearerAuth(s.config.UpstreamAccessToken))
|
||||
@@ -955,7 +1081,25 @@ func (s *Server) forwardPollRequest(v *visitor, m *message) {
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, firebase bool, email, call string, template templateMode, unifiedpush bool, err *errHTTP) {
|
||||
func (s *Server) parsePublishParams(r *http.Request, m *model.Message) (cache bool, firebase bool, email, call string, template templateMode, unifiedpush bool, priorityStr string, err *errHTTP) {
|
||||
if r.Method != http.MethodGet && updatePathRegex.MatchString(r.URL.Path) {
|
||||
pathSequenceID, err := s.sequenceIDFromPath(r.URL.Path)
|
||||
if err != nil {
|
||||
return false, false, "", "", "", false, "", err
|
||||
}
|
||||
m.SequenceID = pathSequenceID
|
||||
} else {
|
||||
sequenceID := readParam(r, "x-sequence-id", "sequence-id", "sid")
|
||||
if sequenceID != "" {
|
||||
if sequenceIDRegex.MatchString(sequenceID) {
|
||||
m.SequenceID = sequenceID
|
||||
} else {
|
||||
return false, false, "", "", "", false, "", errHTTPBadRequestSequenceIDInvalid
|
||||
}
|
||||
} else {
|
||||
m.SequenceID = m.ID
|
||||
}
|
||||
}
|
||||
cache = readBoolParam(r, true, "x-cache", "cache")
|
||||
firebase = readBoolParam(r, true, "x-firebase", "firebase")
|
||||
m.Title = readParam(r, "x-title", "title", "t")
|
||||
@@ -964,14 +1108,14 @@ func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, fi
|
||||
filename := readParam(r, "x-filename", "filename", "file", "f")
|
||||
attach := readParam(r, "x-attach", "attach", "a")
|
||||
if attach != "" || filename != "" {
|
||||
m.Attachment = &attachment{}
|
||||
m.Attachment = &model.Attachment{}
|
||||
}
|
||||
if filename != "" {
|
||||
m.Attachment.Name = filename
|
||||
}
|
||||
if attach != "" {
|
||||
if !urlRegex.MatchString(attach) {
|
||||
return false, false, "", "", "", false, errHTTPBadRequestAttachmentURLInvalid
|
||||
return false, false, "", "", "", false, "", errHTTPBadRequestAttachmentURLInvalid
|
||||
}
|
||||
m.Attachment.URL = attach
|
||||
if m.Attachment.Name == "" {
|
||||
@@ -989,19 +1133,19 @@ func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, fi
|
||||
}
|
||||
if icon != "" {
|
||||
if !urlRegex.MatchString(icon) {
|
||||
return false, false, "", "", "", false, errHTTPBadRequestIconURLInvalid
|
||||
return false, false, "", "", "", false, "", errHTTPBadRequestIconURLInvalid
|
||||
}
|
||||
m.Icon = icon
|
||||
}
|
||||
email = readParam(r, "x-email", "x-e-mail", "email", "e-mail", "mail", "e")
|
||||
if s.smtpSender == nil && email != "" {
|
||||
return false, false, "", "", "", false, errHTTPBadRequestEmailDisabled
|
||||
return false, false, "", "", "", false, "", errHTTPBadRequestEmailDisabled
|
||||
}
|
||||
call = readParam(r, "x-call", "call")
|
||||
if call != "" && (s.config.TwilioAccount == "" || s.userManager == nil) {
|
||||
return false, false, "", "", "", false, errHTTPBadRequestPhoneCallsDisabled
|
||||
return false, false, "", "", "", false, "", errHTTPBadRequestPhoneCallsDisabled
|
||||
} else if call != "" && !isBoolValue(call) && !phoneNumberRegex.MatchString(call) {
|
||||
return false, false, "", "", "", false, errHTTPBadRequestPhoneNumberInvalid
|
||||
return false, false, "", "", "", false, "", errHTTPBadRequestPhoneNumberInvalid
|
||||
}
|
||||
template = templateMode(readParam(r, "x-template", "template", "tpl"))
|
||||
messageStr := readParam(r, "x-message", "message", "m")
|
||||
@@ -1013,29 +1157,33 @@ func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, fi
|
||||
m.Message = messageStr
|
||||
}
|
||||
var e error
|
||||
m.Priority, e = util.ParsePriority(readParam(r, "x-priority", "priority", "prio", "p"))
|
||||
if e != nil {
|
||||
return false, false, "", "", "", false, errHTTPBadRequestPriorityInvalid
|
||||
priorityStr = readParam(r, "x-priority", "priority", "prio", "p")
|
||||
if !template.Enabled() {
|
||||
m.Priority, e = util.ParsePriority(priorityStr)
|
||||
if e != nil {
|
||||
return false, false, "", "", "", false, "", errHTTPBadRequestPriorityInvalid
|
||||
}
|
||||
priorityStr = "" // Clear since it's already parsed
|
||||
}
|
||||
m.Tags = readCommaSeparatedParam(r, "x-tags", "tags", "tag", "ta")
|
||||
delayStr := readParam(r, "x-delay", "delay", "x-at", "at", "x-in", "in")
|
||||
if delayStr != "" {
|
||||
if !cache {
|
||||
return false, false, "", "", "", false, errHTTPBadRequestDelayNoCache
|
||||
return false, false, "", "", "", false, "", errHTTPBadRequestDelayNoCache
|
||||
}
|
||||
if email != "" {
|
||||
return false, false, "", "", "", false, errHTTPBadRequestDelayNoEmail // we cannot store the email address (yet)
|
||||
return false, false, "", "", "", false, "", errHTTPBadRequestDelayNoEmail // we cannot store the email address (yet)
|
||||
}
|
||||
if call != "" {
|
||||
return false, false, "", "", "", false, errHTTPBadRequestDelayNoCall // we cannot store the phone number (yet)
|
||||
return false, false, "", "", "", false, "", errHTTPBadRequestDelayNoCall // we cannot store the phone number (yet)
|
||||
}
|
||||
delay, err := util.ParseFutureTime(delayStr, time.Now())
|
||||
if err != nil {
|
||||
return false, false, "", "", "", false, errHTTPBadRequestDelayCannotParse
|
||||
return false, false, "", "", "", false, "", errHTTPBadRequestDelayCannotParse
|
||||
} else if delay.Unix() < time.Now().Add(s.config.MessageDelayMin).Unix() {
|
||||
return false, false, "", "", "", false, errHTTPBadRequestDelayTooSmall
|
||||
return false, false, "", "", "", false, "", errHTTPBadRequestDelayTooSmall
|
||||
} else if delay.Unix() > time.Now().Add(s.config.MessageDelayMax).Unix() {
|
||||
return false, false, "", "", "", false, errHTTPBadRequestDelayTooLarge
|
||||
return false, false, "", "", "", false, "", errHTTPBadRequestDelayTooLarge
|
||||
}
|
||||
m.Time = delay.Unix()
|
||||
}
|
||||
@@ -1043,7 +1191,7 @@ func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, fi
|
||||
if actionsStr != "" {
|
||||
m.Actions, e = parseActions(actionsStr)
|
||||
if e != nil {
|
||||
return false, false, "", "", "", false, errHTTPBadRequestActionsInvalid.Wrap("%s", e.Error())
|
||||
return false, false, "", "", "", false, "", errHTTPBadRequestActionsInvalid.Wrap("%s", e.Error())
|
||||
}
|
||||
}
|
||||
contentType, markdown := readParam(r, "content-type", "content_type"), readBoolParam(r, false, "x-markdown", "markdown", "md")
|
||||
@@ -1062,7 +1210,7 @@ func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, fi
|
||||
cache = false
|
||||
email = ""
|
||||
}
|
||||
return cache, firebase, email, call, template, unifiedpush, nil
|
||||
return cache, firebase, email, call, template, unifiedpush, priorityStr, nil
|
||||
}
|
||||
|
||||
// handlePublishBody consumes the PUT/POST body and decides whether the body is an attachment or the message.
|
||||
@@ -1081,7 +1229,7 @@ func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, fi
|
||||
// If file.txt is <= 4096 (message limit) and valid UTF-8, treat it as a message
|
||||
// 7. curl -T file.txt ntfy.sh/mytopic
|
||||
// In all other cases, mostly if file.txt is > message limit, treat it as an attachment
|
||||
func (s *Server) handlePublishBody(r *http.Request, v *visitor, m *message, body *util.PeekedReadCloser, template templateMode, unifiedpush bool) error {
|
||||
func (s *Server) handlePublishBody(r *http.Request, v *visitor, m *model.Message, body *util.PeekedReadCloser, template templateMode, unifiedpush bool, priorityStr string) error {
|
||||
if m.Event == pollRequestEvent { // Case 1
|
||||
return s.handleBodyDiscard(body)
|
||||
} else if unifiedpush {
|
||||
@@ -1091,7 +1239,7 @@ func (s *Server) handlePublishBody(r *http.Request, v *visitor, m *message, body
|
||||
} else if m.Attachment != nil && m.Attachment.Name != "" {
|
||||
return s.handleBodyAsAttachment(r, v, m, body) // Case 4
|
||||
} else if template.Enabled() {
|
||||
return s.handleBodyAsTemplatedTextMessage(m, template, body) // Case 5
|
||||
return s.handleBodyAsTemplatedTextMessage(m, template, body, priorityStr) // Case 5
|
||||
} else if !body.LimitReached && utf8.Valid(body.PeekedBytes) {
|
||||
return s.handleBodyAsTextMessage(m, body) // Case 6
|
||||
}
|
||||
@@ -1104,7 +1252,7 @@ func (s *Server) handleBodyDiscard(body *util.PeekedReadCloser) error {
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Server) handleBodyAsMessageAutoDetect(m *message, body *util.PeekedReadCloser) error {
|
||||
func (s *Server) handleBodyAsMessageAutoDetect(m *model.Message, body *util.PeekedReadCloser) error {
|
||||
if utf8.Valid(body.PeekedBytes) {
|
||||
m.Message = string(body.PeekedBytes) // Do not trim
|
||||
} else {
|
||||
@@ -1114,7 +1262,7 @@ func (s *Server) handleBodyAsMessageAutoDetect(m *message, body *util.PeekedRead
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) handleBodyAsTextMessage(m *message, body *util.PeekedReadCloser) error {
|
||||
func (s *Server) handleBodyAsTextMessage(m *model.Message, body *util.PeekedReadCloser) error {
|
||||
if !utf8.Valid(body.PeekedBytes) {
|
||||
return errHTTPBadRequestMessageNotUTF8.With(m)
|
||||
}
|
||||
@@ -1127,7 +1275,7 @@ func (s *Server) handleBodyAsTextMessage(m *message, body *util.PeekedReadCloser
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) handleBodyAsTemplatedTextMessage(m *message, template templateMode, body *util.PeekedReadCloser) error {
|
||||
func (s *Server) handleBodyAsTemplatedTextMessage(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
|
||||
@@ -1140,7 +1288,7 @@ func (s *Server) handleBodyAsTemplatedTextMessage(m *message, template templateM
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
if err := s.renderTemplateFromParams(m, peekedBody); err != nil {
|
||||
if err := s.renderTemplateFromParams(m, peekedBody, priorityStr); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -1152,7 +1300,7 @@ func (s *Server) handleBodyAsTemplatedTextMessage(m *message, template templateM
|
||||
|
||||
// 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(m *message, templateName, peekedBody string) error {
|
||||
func (s *Server) renderTemplateFromFile(m *model.Message, templateName, peekedBody string) error {
|
||||
if !templateNameRegex.MatchString(templateName) {
|
||||
return errHTTPBadRequestTemplateFileNotFound
|
||||
}
|
||||
@@ -1171,33 +1319,51 @@ func (s *Server) renderTemplateFromFile(m *message, templateName, peekedBody str
|
||||
}
|
||||
var err error
|
||||
if tpl.Message != nil {
|
||||
if m.Message, err = s.renderTemplate(*tpl.Message, peekedBody); err != nil {
|
||||
if m.Message, err = s.renderTemplate(templateName+" (message)", *tpl.Message, peekedBody); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if tpl.Title != nil {
|
||||
if m.Title, err = s.renderTemplate(*tpl.Title, peekedBody); err != nil {
|
||||
if m.Title, err = s.renderTemplate(templateName+" (title)", *tpl.Title, peekedBody); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if tpl.Priority != nil {
|
||||
renderedPriority, err := s.renderTemplate(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 and title parameters.
|
||||
func (s *Server) renderTemplateFromParams(m *message, peekedBody string) error {
|
||||
// message, title, and priority parameters.
|
||||
func (s *Server) renderTemplateFromParams(m *model.Message, peekedBody string, priorityStr string) error {
|
||||
var err error
|
||||
if m.Message, err = s.renderTemplate(m.Message, peekedBody); err != nil {
|
||||
if m.Message, err = s.renderTemplate("priority query parameter", m.Message, peekedBody); err != nil {
|
||||
return err
|
||||
}
|
||||
if m.Title, err = s.renderTemplate(m.Title, peekedBody); err != nil {
|
||||
if m.Title, err = s.renderTemplate("title query parameter", m.Title, peekedBody); err != nil {
|
||||
return err
|
||||
}
|
||||
if priorityStr != "" {
|
||||
renderedPriority, err := s.renderTemplate("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(tpl string, source string) (string, error) {
|
||||
func (s *Server) renderTemplate(name, tpl, source string) (string, error) {
|
||||
if templateDisallowedRegex.MatchString(tpl) {
|
||||
return "", errHTTPBadRequestTemplateDisallowedFunctionCalls
|
||||
}
|
||||
@@ -1212,12 +1378,12 @@ func (s *Server) renderTemplate(tpl string, source string) (string, error) {
|
||||
var buf bytes.Buffer
|
||||
limitWriter := util.NewLimitWriter(util.NewTimeoutWriter(&buf, templateMaxExecutionTime), util.NewFixedLimiter(templateMaxOutputBytes))
|
||||
if err := t.Execute(limitWriter, data); err != nil {
|
||||
return "", errHTTPBadRequestTemplateExecuteFailed.Wrap("%s", err.Error())
|
||||
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
|
||||
}
|
||||
|
||||
func (s *Server) handleBodyAsAttachment(r *http.Request, v *visitor, m *message, body *util.PeekedReadCloser) error {
|
||||
func (s *Server) handleBodyAsAttachment(r *http.Request, v *visitor, m *model.Message, body *util.PeekedReadCloser) error {
|
||||
if s.fileCache == nil || s.config.BaseURL == "" || s.config.AttachmentCacheDir == "" {
|
||||
return errHTTPBadRequestAttachmentsDisallowed.With(m)
|
||||
}
|
||||
@@ -1241,7 +1407,7 @@ func (s *Server) handleBodyAsAttachment(r *http.Request, v *visitor, m *message,
|
||||
}
|
||||
}
|
||||
if m.Attachment == nil {
|
||||
m.Attachment = &attachment{}
|
||||
m.Attachment = &model.Attachment{}
|
||||
}
|
||||
var ext string
|
||||
m.Attachment.Expires = attachmentExpiry
|
||||
@@ -1268,9 +1434,9 @@ func (s *Server) handleBodyAsAttachment(r *http.Request, v *visitor, m *message,
|
||||
}
|
||||
|
||||
func (s *Server) handleSubscribeJSON(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||
encoder := func(msg *message) (string, error) {
|
||||
encoder := func(msg *model.Message) (string, error) {
|
||||
var buf bytes.Buffer
|
||||
if err := json.NewEncoder(&buf).Encode(&msg); err != nil {
|
||||
if err := json.NewEncoder(&buf).Encode(msg.ForJSON()); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return buf.String(), nil
|
||||
@@ -1279,12 +1445,12 @@ func (s *Server) handleSubscribeJSON(w http.ResponseWriter, r *http.Request, v *
|
||||
}
|
||||
|
||||
func (s *Server) handleSubscribeSSE(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||
encoder := func(msg *message) (string, error) {
|
||||
encoder := func(msg *model.Message) (string, error) {
|
||||
var buf bytes.Buffer
|
||||
if err := json.NewEncoder(&buf).Encode(&msg); err != nil {
|
||||
if err := json.NewEncoder(&buf).Encode(msg.ForJSON()); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if msg.Event != messageEvent {
|
||||
if msg.Event != messageEvent && msg.Event != messageDeleteEvent && msg.Event != messageClearEvent {
|
||||
return fmt.Sprintf("event: %s\ndata: %s\n", msg.Event, buf.String()), nil // Browser's .onmessage() does not fire on this!
|
||||
}
|
||||
return fmt.Sprintf("data: %s\n", buf.String()), nil
|
||||
@@ -1293,7 +1459,7 @@ func (s *Server) handleSubscribeSSE(w http.ResponseWriter, r *http.Request, v *v
|
||||
}
|
||||
|
||||
func (s *Server) handleSubscribeRaw(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||
encoder := func(msg *message) (string, error) {
|
||||
encoder := func(msg *model.Message) (string, error) {
|
||||
if msg.Event == messageEvent { // only handle default events
|
||||
return strings.ReplaceAll(msg.Message, "\n", " ") + "\n", nil
|
||||
}
|
||||
@@ -1318,14 +1484,18 @@ func (s *Server) handleSubscribeHTTP(w http.ResponseWriter, r *http.Request, v *
|
||||
return err
|
||||
}
|
||||
var wlock sync.Mutex
|
||||
var closed bool
|
||||
defer func() {
|
||||
// Hack: This is the fix for a horrible data race that I have not been able to figure out in quite some time.
|
||||
// It appears to be happening when the Go HTTP code reads from the socket when closing the request (i.e. AFTER
|
||||
// this function returns), and causes a data race with the ResponseWriter. Locking wlock here silences the
|
||||
// data race detector. See https://github.com/binwiederhier/ntfy/issues/338#issuecomment-1163425889.
|
||||
wlock.TryLock()
|
||||
// This blocks until any in-flight sub() call finishes writing/flushing the response writer,
|
||||
// then marks the connection as closed so future sub() calls are no-ops. This prevents a panic
|
||||
// from writing to a response writer that has been cleaned up after the handler returns.
|
||||
// See https://github.com/binwiederhier/ntfy/issues/338#issuecomment-1163425889
|
||||
// and https://github.com/binwiederhier/ntfy/pull/1598.
|
||||
wlock.Lock()
|
||||
closed = true
|
||||
wlock.Unlock()
|
||||
}()
|
||||
sub := func(v *visitor, msg *message) error {
|
||||
sub := func(v *visitor, msg *model.Message) error {
|
||||
if !filters.Pass(msg) {
|
||||
return nil
|
||||
}
|
||||
@@ -1335,6 +1505,9 @@ func (s *Server) handleSubscribeHTTP(w http.ResponseWriter, r *http.Request, v *
|
||||
}
|
||||
wlock.Lock()
|
||||
defer wlock.Unlock()
|
||||
if closed {
|
||||
return nil
|
||||
}
|
||||
if _, err := w.Write([]byte(m)); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -1484,7 +1657,7 @@ func (s *Server) handleSubscribeWS(w http.ResponseWriter, r *http.Request, v *vi
|
||||
}
|
||||
}
|
||||
})
|
||||
sub := func(v *visitor, msg *message) error {
|
||||
sub := func(v *visitor, msg *model.Message) error {
|
||||
if !filters.Pass(msg) {
|
||||
return nil
|
||||
}
|
||||
@@ -1525,10 +1698,13 @@ func (s *Server) handleSubscribeWS(w http.ResponseWriter, r *http.Request, v *vi
|
||||
logvr(v, r).Tag(tagWebsocket).Err(err).Fields(websocketErrorContext(err)).Trace("WebSocket connection closed")
|
||||
return nil // Normal closures are not errors; note: "1006 (abnormal closure)" is treated as normal, because people disconnect a lot
|
||||
}
|
||||
return err
|
||||
if err != nil {
|
||||
return &errWebSocketPostUpgrade{err}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseSubscribeParams(r *http.Request) (poll bool, since sinceMarker, scheduled bool, filters *queryFilter, err error) {
|
||||
func parseSubscribeParams(r *http.Request) (poll bool, since model.SinceMarker, scheduled bool, filters *queryFilter, err error) {
|
||||
poll = readBoolParam(r, false, "x-poll", "poll", "po")
|
||||
scheduled = readBoolParam(r, false, "x-scheduled", "scheduled", "sched")
|
||||
since, err = parseSince(r, poll)
|
||||
@@ -1609,11 +1785,11 @@ func (s *Server) setRateVisitors(r *http.Request, v *visitor, rateTopics []*topi
|
||||
|
||||
// sendOldMessages selects old messages from the messageCache and calls sub for each of them. It uses since as the
|
||||
// marker, returning only messages that are newer than the marker.
|
||||
func (s *Server) sendOldMessages(topics []*topic, since sinceMarker, scheduled bool, v *visitor, sub subscriber) error {
|
||||
func (s *Server) sendOldMessages(topics []*topic, since model.SinceMarker, scheduled bool, v *visitor, sub subscriber) error {
|
||||
if since.IsNone() {
|
||||
return nil
|
||||
}
|
||||
messages := make([]*message, 0)
|
||||
messages := make([]*model.Message, 0)
|
||||
for _, t := range topics {
|
||||
topicMessages, err := s.messageCache.Messages(t.ID, since, scheduled)
|
||||
if err != nil {
|
||||
@@ -1636,7 +1812,7 @@ func (s *Server) sendOldMessages(topics []*topic, since sinceMarker, scheduled b
|
||||
//
|
||||
// Values in the "since=..." parameter can be either a unix timestamp or a duration (e.g. 12h),
|
||||
// "all" for all messages, or "latest" for the most recent message for a topic
|
||||
func parseSince(r *http.Request, poll bool) (sinceMarker, error) {
|
||||
func parseSince(r *http.Request, poll bool) (model.SinceMarker, error) {
|
||||
since := readParam(r, "x-since", "since", "si")
|
||||
|
||||
// Easy cases (empty, all, none)
|
||||
@@ -1694,6 +1870,15 @@ func (s *Server) topicsFromPath(path string) ([]*topic, string, error) {
|
||||
return topics, parts[1], nil
|
||||
}
|
||||
|
||||
// sequenceIDFromPath returns the sequence ID from a path like /mytopic/sequenceIdHere
|
||||
func (s *Server) sequenceIDFromPath(path string) (string, *errHTTP) {
|
||||
parts := strings.Split(path, "/")
|
||||
if len(parts) < 3 {
|
||||
return "", errHTTPBadRequestSequenceIDInvalid
|
||||
}
|
||||
return parts[2], nil
|
||||
}
|
||||
|
||||
// topicsFromIDs returns the topics with the given IDs, creating them if they don't exist.
|
||||
func (s *Server) topicsFromIDs(ids ...string) ([]*topic, error) {
|
||||
s.mu.Lock()
|
||||
@@ -1858,7 +2043,7 @@ func (s *Server) sendDelayedMessages() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) sendDelayedMessage(v *visitor, m *message) error {
|
||||
func (s *Server) sendDelayedMessage(v *visitor, m *model.Message) error {
|
||||
logvm(v, m).Debug("Sending delayed message")
|
||||
s.mu.RLock()
|
||||
t, ok := s.topics[m.Topic] // If no subscribers, just mark message as published
|
||||
@@ -1948,6 +2133,9 @@ func (s *Server) transformBodyJSON(next handleFunc) handleFunc {
|
||||
if m.Firebase != "" {
|
||||
r.Header.Set("X-Firebase", m.Firebase)
|
||||
}
|
||||
if m.SequenceID != "" {
|
||||
r.Header.Set("X-Sequence-ID", m.SequenceID)
|
||||
}
|
||||
return next(w, r, v)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,6 +38,12 @@
|
||||
#
|
||||
# firebase-key-file: <filename>
|
||||
|
||||
# If "database-url" is set, ntfy will use PostgreSQL for database-backed stores instead of SQLite.
|
||||
# Currently this applies to the web push subscription store. Message cache and user manager support
|
||||
# will be added in future releases. When set, the "web-push-file" option is not required.
|
||||
#
|
||||
# database-url: "postgres://user:pass@host:5432/ntfy"
|
||||
|
||||
# If "cache-file" is set, messages are cached in a local SQLite database instead of only in-memory.
|
||||
# This allows for service restarts without losing messages in support of the since= parameter.
|
||||
#
|
||||
@@ -160,7 +166,7 @@
|
||||
# If enabled, allow outgoing e-mail notifications via the 'X-Email' header. If this header is set,
|
||||
# messages will additionally be sent out as e-mail using an external SMTP server.
|
||||
#
|
||||
# As of today, only SMTP servers with plain text auth (or no auth at all), and STARTLS are supported.
|
||||
# As of today, only SMTP servers with plain text auth (or no auth at all), and STARTTLS are supported.
|
||||
# Please also refer to the rate limiting settings below (visitor-email-limit-burst & visitor-email-limit-burst).
|
||||
#
|
||||
# - smtp-sender-addr is the hostname:port of the SMTP server
|
||||
@@ -198,8 +204,8 @@
|
||||
# - web-push-private-key is the generated VAPID private key, e.g. AA2BB1234567890abcdefzxcvbnm1234567890
|
||||
# - web-push-file is a database file to keep track of browser subscription endpoints, e.g. /var/cache/ntfy/webpush.db
|
||||
# - web-push-email-address is the admin email address send to the push provider, e.g. sysadmin@example.com
|
||||
# - web-push-startup-queries is an optional list of queries to run on startup`
|
||||
# - web-push-expiry-warning-duration defines the duration after which unused subscriptions are sent a warning (default is 55d`)
|
||||
# - web-push-startup-queries is an optional list of queries to run on startup
|
||||
# - web-push-expiry-warning-duration defines the duration after which unused subscriptions are sent a warning (default is 55d)
|
||||
# - web-push-expiry-duration defines the duration after which unused subscriptions will expire (default is 60d)
|
||||
#
|
||||
# web-push-public-key:
|
||||
@@ -216,11 +222,13 @@
|
||||
# - twilio-auth-token is the Twilio auth token, e.g. affebeef258625862586258625862586
|
||||
# - twilio-phone-number is the outgoing phone number you purchased, e.g. +18775132586
|
||||
# - twilio-verify-service is the Twilio Verify service SID, e.g. VA12345beefbeef67890beefbeef122586
|
||||
# - twilio-call-format is the custom TwiML send to the Call API (optional, see https://www.twilio.com/docs/voice/twiml)
|
||||
#
|
||||
# twilio-account:
|
||||
# twilio-auth-token:
|
||||
# twilio-phone-number:
|
||||
# twilio-verify-service:
|
||||
# twilio-call-format:
|
||||
|
||||
# Interval in which keepalive messages are sent to the client. This is to prevent
|
||||
# intermediaries closing the connection for inactivity.
|
||||
@@ -258,9 +266,11 @@
|
||||
#
|
||||
# - enable-signup allows users to sign up via the web app, or API
|
||||
# - enable-login allows users to log in via the web app, or API
|
||||
# - require-login redirects users to the login page if they are not logged in (disallows web app access without login)
|
||||
# - enable-reservations allows users to reserve topics (if their tier allows it)
|
||||
#
|
||||
# enable-signup: false
|
||||
# require-login: false
|
||||
# enable-login: false
|
||||
# enable-reservations: false
|
||||
|
||||
@@ -276,7 +286,7 @@
|
||||
#
|
||||
# - upstream-base-url is the base URL of the upstream server. Should be "https://ntfy.sh".
|
||||
# - upstream-access-token is the token used to authenticate with the upstream server. This is only required
|
||||
# if you exceed the upstream rate limits, or the uptream server requires authentication.
|
||||
# if you exceed the upstream rate limits, or the upstream server requires authentication.
|
||||
#
|
||||
# upstream-base-url:
|
||||
# upstream-access-token:
|
||||
|
||||
@@ -6,6 +6,14 @@ import (
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func (s *Server) handleVersion(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||
return s.writeJSON(w, &apiVersionResponse{
|
||||
Version: s.config.BuildVersion,
|
||||
Commit: s.config.BuildCommit,
|
||||
Date: s.config.BuildDate,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleUsersGet(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||
users, err := s.userManager.Users()
|
||||
if err != nil {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"github.com/stretchr/testify/require"
|
||||
"heckel.io/ntfy/v2/user"
|
||||
"heckel.io/ntfy/v2/util"
|
||||
@@ -9,393 +10,452 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestVersion_Admin(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T) {
|
||||
c := newTestConfigWithAuthFile(t)
|
||||
c.BuildVersion = "1.2.3"
|
||||
c.BuildCommit = "abcdef0"
|
||||
c.BuildDate = "2026-02-08T00:00:00Z"
|
||||
s := newTestServer(t, c)
|
||||
defer s.closeDatabases()
|
||||
|
||||
// Create admin and regular user
|
||||
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin, false))
|
||||
require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser, false))
|
||||
|
||||
// Admin can access /v1/version
|
||||
rr := request(t, s, "GET", "/v1/version", "", map[string]string{
|
||||
"Authorization": util.BasicAuth("phil", "phil"),
|
||||
})
|
||||
require.Equal(t, 200, rr.Code)
|
||||
|
||||
var versionResponse apiVersionResponse
|
||||
require.Nil(t, json.NewDecoder(rr.Body).Decode(&versionResponse))
|
||||
require.Equal(t, "1.2.3", versionResponse.Version)
|
||||
require.Equal(t, "abcdef0", versionResponse.Commit)
|
||||
require.Equal(t, "2026-02-08T00:00:00Z", versionResponse.Date)
|
||||
|
||||
// Non-admin user cannot access /v1/version
|
||||
rr = request(t, s, "GET", "/v1/version", "", map[string]string{
|
||||
"Authorization": util.BasicAuth("ben", "ben"),
|
||||
})
|
||||
require.Equal(t, 401, rr.Code)
|
||||
|
||||
// Unauthenticated user cannot access /v1/version
|
||||
rr = request(t, s, "GET", "/v1/version", "", nil)
|
||||
require.Equal(t, 401, rr.Code)
|
||||
})
|
||||
}
|
||||
|
||||
func TestUser_AddRemove(t *testing.T) {
|
||||
s := newTestServer(t, newTestConfigWithAuthFile(t))
|
||||
defer s.closeDatabases()
|
||||
forEachBackend(t, func(t *testing.T) {
|
||||
s := newTestServer(t, newTestConfigWithAuthFile(t))
|
||||
defer s.closeDatabases()
|
||||
|
||||
// Create admin, tier
|
||||
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin, false))
|
||||
require.Nil(t, s.userManager.AddTier(&user.Tier{
|
||||
Code: "tier1",
|
||||
}))
|
||||
// Create admin, tier
|
||||
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin, false))
|
||||
require.Nil(t, s.userManager.AddTier(&user.Tier{
|
||||
Code: "tier1",
|
||||
}))
|
||||
|
||||
// Create user via API
|
||||
rr := request(t, s, "POST", "/v1/users", `{"username": "ben", "password":"ben"}`, map[string]string{
|
||||
"Authorization": util.BasicAuth("phil", "phil"),
|
||||
// Create user via API
|
||||
rr := request(t, s, "POST", "/v1/users", `{"username": "ben", "password":"ben"}`, map[string]string{
|
||||
"Authorization": util.BasicAuth("phil", "phil"),
|
||||
})
|
||||
require.Equal(t, 200, rr.Code)
|
||||
|
||||
// Create user with tier via API
|
||||
rr = request(t, s, "PUT", "/v1/users", `{"username": "emma", "password":"emma", "tier": "tier1"}`, map[string]string{
|
||||
"Authorization": util.BasicAuth("phil", "phil"),
|
||||
})
|
||||
require.Equal(t, 200, rr.Code)
|
||||
|
||||
// Check users
|
||||
users, err := s.userManager.Users()
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 4, len(users))
|
||||
require.Equal(t, "phil", users[0].Name)
|
||||
require.Equal(t, "ben", users[1].Name)
|
||||
require.Equal(t, user.RoleUser, users[1].Role)
|
||||
require.Nil(t, users[1].Tier)
|
||||
require.Equal(t, "emma", users[2].Name)
|
||||
require.Equal(t, user.RoleUser, users[2].Role)
|
||||
require.Equal(t, "tier1", users[2].Tier.Code)
|
||||
require.Equal(t, user.Everyone, users[3].Name)
|
||||
|
||||
// Delete user via API
|
||||
rr = request(t, s, "DELETE", "/v1/users", `{"username": "ben"}`, map[string]string{
|
||||
"Authorization": util.BasicAuth("phil", "phil"),
|
||||
})
|
||||
require.Equal(t, 200, rr.Code)
|
||||
|
||||
// Check user was deleted
|
||||
users, err = s.userManager.Users()
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 3, len(users))
|
||||
require.Equal(t, "phil", users[0].Name)
|
||||
require.Equal(t, "emma", users[1].Name)
|
||||
require.Equal(t, user.Everyone, users[2].Name)
|
||||
|
||||
// Reject invalid user change
|
||||
rr = request(t, s, "PUT", "/v1/users", `{"username": "ben"}`, map[string]string{
|
||||
"Authorization": util.BasicAuth("phil", "phil"),
|
||||
})
|
||||
require.Equal(t, 400, rr.Code)
|
||||
})
|
||||
require.Equal(t, 200, rr.Code)
|
||||
|
||||
// Create user with tier via API
|
||||
rr = request(t, s, "PUT", "/v1/users", `{"username": "emma", "password":"emma", "tier": "tier1"}`, map[string]string{
|
||||
"Authorization": util.BasicAuth("phil", "phil"),
|
||||
})
|
||||
require.Equal(t, 200, rr.Code)
|
||||
|
||||
// Check users
|
||||
users, err := s.userManager.Users()
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 4, len(users))
|
||||
require.Equal(t, "phil", users[0].Name)
|
||||
require.Equal(t, "ben", users[1].Name)
|
||||
require.Equal(t, user.RoleUser, users[1].Role)
|
||||
require.Nil(t, users[1].Tier)
|
||||
require.Equal(t, "emma", users[2].Name)
|
||||
require.Equal(t, user.RoleUser, users[2].Role)
|
||||
require.Equal(t, "tier1", users[2].Tier.Code)
|
||||
require.Equal(t, user.Everyone, users[3].Name)
|
||||
|
||||
// Delete user via API
|
||||
rr = request(t, s, "DELETE", "/v1/users", `{"username": "ben"}`, map[string]string{
|
||||
"Authorization": util.BasicAuth("phil", "phil"),
|
||||
})
|
||||
require.Equal(t, 200, rr.Code)
|
||||
|
||||
// Check user was deleted
|
||||
users, err = s.userManager.Users()
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 3, len(users))
|
||||
require.Equal(t, "phil", users[0].Name)
|
||||
require.Equal(t, "emma", users[1].Name)
|
||||
require.Equal(t, user.Everyone, users[2].Name)
|
||||
|
||||
// Reject invalid user change
|
||||
rr = request(t, s, "PUT", "/v1/users", `{"username": "ben"}`, map[string]string{
|
||||
"Authorization": util.BasicAuth("phil", "phil"),
|
||||
})
|
||||
require.Equal(t, 400, rr.Code)
|
||||
}
|
||||
|
||||
func TestUser_AddWithPasswordHash(t *testing.T) {
|
||||
s := newTestServer(t, newTestConfigWithAuthFile(t))
|
||||
defer s.closeDatabases()
|
||||
forEachBackend(t, func(t *testing.T) {
|
||||
s := newTestServer(t, newTestConfigWithAuthFile(t))
|
||||
defer s.closeDatabases()
|
||||
|
||||
// Create admin
|
||||
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin, false))
|
||||
// Create admin
|
||||
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin, false))
|
||||
|
||||
// Create user via API
|
||||
rr := request(t, s, "POST", "/v1/users", `{"username": "ben", "hash":"$2a$04$2aPIIqPXQU16OfkSUZH1XOzpu1gsPRKkrfVdFLgWQ.tqb.vtTCuVe"}`, map[string]string{
|
||||
"Authorization": util.BasicAuth("phil", "phil"),
|
||||
})
|
||||
require.Equal(t, 200, rr.Code)
|
||||
// Create user via API
|
||||
rr := request(t, s, "POST", "/v1/users", `{"username": "ben", "hash":"$2a$04$2aPIIqPXQU16OfkSUZH1XOzpu1gsPRKkrfVdFLgWQ.tqb.vtTCuVe"}`, map[string]string{
|
||||
"Authorization": util.BasicAuth("phil", "phil"),
|
||||
})
|
||||
require.Equal(t, 200, rr.Code)
|
||||
|
||||
// Check that user can login with password
|
||||
rr = request(t, s, "POST", "/v1/account/token", "", map[string]string{
|
||||
"Authorization": util.BasicAuth("ben", "ben"),
|
||||
})
|
||||
require.Equal(t, 200, rr.Code)
|
||||
|
||||
// Check users
|
||||
users, err := s.userManager.Users()
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 3, len(users))
|
||||
require.Equal(t, "phil", users[0].Name)
|
||||
require.Equal(t, user.RoleAdmin, users[0].Role)
|
||||
require.Equal(t, "ben", users[1].Name)
|
||||
require.Equal(t, user.RoleUser, users[1].Role)
|
||||
}
|
||||
|
||||
func TestUser_ChangeUserPassword(t *testing.T) {
|
||||
s := newTestServer(t, newTestConfigWithAuthFile(t))
|
||||
defer s.closeDatabases()
|
||||
|
||||
// Create admin
|
||||
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin, false))
|
||||
|
||||
// Create user via API
|
||||
rr := request(t, s, "POST", "/v1/users", `{"username": "ben", "password": "ben"}`, map[string]string{
|
||||
"Authorization": util.BasicAuth("phil", "phil"),
|
||||
})
|
||||
require.Equal(t, 200, rr.Code)
|
||||
|
||||
// Try to login with first password
|
||||
rr = request(t, s, "POST", "/v1/account/token", "", map[string]string{
|
||||
"Authorization": util.BasicAuth("ben", "ben"),
|
||||
})
|
||||
require.Equal(t, 200, rr.Code)
|
||||
|
||||
// Change password via API
|
||||
rr = request(t, s, "PUT", "/v1/users", `{"username": "ben", "password": "ben-two"}`, map[string]string{
|
||||
"Authorization": util.BasicAuth("phil", "phil"),
|
||||
})
|
||||
require.Equal(t, 200, rr.Code)
|
||||
|
||||
// Make sure first password fails
|
||||
rr = request(t, s, "POST", "/v1/account/token", "", map[string]string{
|
||||
"Authorization": util.BasicAuth("ben", "ben"),
|
||||
})
|
||||
require.Equal(t, 401, rr.Code)
|
||||
|
||||
// Try to login with second password
|
||||
rr = request(t, s, "POST", "/v1/account/token", "", map[string]string{
|
||||
"Authorization": util.BasicAuth("ben", "ben-two"),
|
||||
})
|
||||
require.Equal(t, 200, rr.Code)
|
||||
}
|
||||
|
||||
func TestUser_ChangeUserTier(t *testing.T) {
|
||||
s := newTestServer(t, newTestConfigWithAuthFile(t))
|
||||
defer s.closeDatabases()
|
||||
|
||||
// Create admin, tier
|
||||
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin, false))
|
||||
require.Nil(t, s.userManager.AddTier(&user.Tier{
|
||||
Code: "tier1",
|
||||
}))
|
||||
require.Nil(t, s.userManager.AddTier(&user.Tier{
|
||||
Code: "tier2",
|
||||
}))
|
||||
|
||||
// Create user with tier via API
|
||||
rr := request(t, s, "POST", "/v1/users", `{"username": "ben", "password":"ben", "tier": "tier1"}`, map[string]string{
|
||||
"Authorization": util.BasicAuth("phil", "phil"),
|
||||
})
|
||||
require.Equal(t, 200, rr.Code)
|
||||
|
||||
// Check users
|
||||
users, err := s.userManager.Users()
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 3, len(users))
|
||||
require.Equal(t, "phil", users[0].Name)
|
||||
require.Equal(t, "ben", users[1].Name)
|
||||
require.Equal(t, user.RoleUser, users[1].Role)
|
||||
require.Equal(t, "tier1", users[1].Tier.Code)
|
||||
|
||||
// Change user tier via API
|
||||
rr = request(t, s, "PUT", "/v1/users", `{"username": "ben", "tier": "tier2"}`, map[string]string{
|
||||
"Authorization": util.BasicAuth("phil", "phil"),
|
||||
})
|
||||
require.Equal(t, 200, rr.Code)
|
||||
|
||||
// Check users again
|
||||
users, err = s.userManager.Users()
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, "tier2", users[1].Tier.Code)
|
||||
}
|
||||
|
||||
func TestUser_ChangeUserPasswordAndTier(t *testing.T) {
|
||||
s := newTestServer(t, newTestConfigWithAuthFile(t))
|
||||
defer s.closeDatabases()
|
||||
|
||||
// Create admin, tier
|
||||
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin, false))
|
||||
require.Nil(t, s.userManager.AddTier(&user.Tier{
|
||||
Code: "tier1",
|
||||
}))
|
||||
require.Nil(t, s.userManager.AddTier(&user.Tier{
|
||||
Code: "tier2",
|
||||
}))
|
||||
|
||||
// Create user with tier via API
|
||||
rr := request(t, s, "POST", "/v1/users", `{"username": "ben", "password":"ben", "tier": "tier1"}`, map[string]string{
|
||||
"Authorization": util.BasicAuth("phil", "phil"),
|
||||
})
|
||||
require.Equal(t, 200, rr.Code)
|
||||
|
||||
// Check users
|
||||
users, err := s.userManager.Users()
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 3, len(users))
|
||||
require.Equal(t, "phil", users[0].Name)
|
||||
require.Equal(t, "ben", users[1].Name)
|
||||
require.Equal(t, user.RoleUser, users[1].Role)
|
||||
require.Equal(t, "tier1", users[1].Tier.Code)
|
||||
|
||||
// Change user password and tier via API
|
||||
rr = request(t, s, "PUT", "/v1/users", `{"username": "ben", "password":"ben-two", "tier": "tier2"}`, map[string]string{
|
||||
"Authorization": util.BasicAuth("phil", "phil"),
|
||||
})
|
||||
require.Equal(t, 200, rr.Code)
|
||||
|
||||
// Make sure first password fails
|
||||
rr = request(t, s, "POST", "/v1/account/token", "", map[string]string{
|
||||
"Authorization": util.BasicAuth("ben", "ben"),
|
||||
})
|
||||
require.Equal(t, 401, rr.Code)
|
||||
|
||||
// Try to login with second password
|
||||
rr = request(t, s, "POST", "/v1/account/token", "", map[string]string{
|
||||
"Authorization": util.BasicAuth("ben", "ben-two"),
|
||||
})
|
||||
require.Equal(t, 200, rr.Code)
|
||||
|
||||
// Check new tier
|
||||
users, err = s.userManager.Users()
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, "tier2", users[1].Tier.Code)
|
||||
}
|
||||
|
||||
func TestUser_ChangeUserPasswordWithHash(t *testing.T) {
|
||||
s := newTestServer(t, newTestConfigWithAuthFile(t))
|
||||
defer s.closeDatabases()
|
||||
|
||||
// Create admin
|
||||
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin, false))
|
||||
|
||||
// Create user with tier via API
|
||||
rr := request(t, s, "POST", "/v1/users", `{"username": "ben", "password":"not-ben"}`, map[string]string{
|
||||
"Authorization": util.BasicAuth("phil", "phil"),
|
||||
})
|
||||
require.Equal(t, 200, rr.Code)
|
||||
|
||||
// Try to login with first password
|
||||
rr = request(t, s, "POST", "/v1/account/token", "", map[string]string{
|
||||
"Authorization": util.BasicAuth("ben", "not-ben"),
|
||||
})
|
||||
require.Equal(t, 200, rr.Code)
|
||||
|
||||
// Change user password and tier via API
|
||||
rr = request(t, s, "PUT", "/v1/users", `{"username": "ben", "hash":"$2a$04$2aPIIqPXQU16OfkSUZH1XOzpu1gsPRKkrfVdFLgWQ.tqb.vtTCuVe"}`, map[string]string{
|
||||
"Authorization": util.BasicAuth("phil", "phil"),
|
||||
})
|
||||
require.Equal(t, 200, rr.Code)
|
||||
|
||||
// Try to login with second password
|
||||
rr = request(t, s, "POST", "/v1/account/token", "", map[string]string{
|
||||
"Authorization": util.BasicAuth("ben", "ben"),
|
||||
})
|
||||
require.Equal(t, 200, rr.Code)
|
||||
}
|
||||
|
||||
func TestUser_DontChangeAdminPassword(t *testing.T) {
|
||||
s := newTestServer(t, newTestConfigWithAuthFile(t))
|
||||
defer s.closeDatabases()
|
||||
|
||||
// Create admin
|
||||
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin, false))
|
||||
require.Nil(t, s.userManager.AddUser("admin", "admin", user.RoleAdmin, false))
|
||||
|
||||
// Try to change password via API
|
||||
rr := request(t, s, "PUT", "/v1/users", `{"username": "admin", "password": "admin-new"}`, map[string]string{
|
||||
"Authorization": util.BasicAuth("phil", "phil"),
|
||||
})
|
||||
require.Equal(t, 403, rr.Code)
|
||||
}
|
||||
|
||||
func TestUser_AddRemove_Failures(t *testing.T) {
|
||||
s := newTestServer(t, newTestConfigWithAuthFile(t))
|
||||
defer s.closeDatabases()
|
||||
|
||||
// Create admin
|
||||
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin, false))
|
||||
require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser, false))
|
||||
|
||||
// Cannot create user with invalid username
|
||||
rr := request(t, s, "POST", "/v1/users", `{"username": "not valid", "password":"ben"}`, map[string]string{
|
||||
"Authorization": util.BasicAuth("phil", "phil"),
|
||||
})
|
||||
require.Equal(t, 400, rr.Code)
|
||||
|
||||
// Cannot create user if user already exists
|
||||
rr = request(t, s, "POST", "/v1/users", `{"username": "phil", "password":"phil"}`, map[string]string{
|
||||
"Authorization": util.BasicAuth("phil", "phil"),
|
||||
})
|
||||
require.Equal(t, 40901, toHTTPError(t, rr.Body.String()).Code)
|
||||
|
||||
// Cannot create user with invalid tier
|
||||
rr = request(t, s, "POST", "/v1/users", `{"username": "emma", "password":"emma", "tier": "invalid"}`, map[string]string{
|
||||
"Authorization": util.BasicAuth("phil", "phil"),
|
||||
})
|
||||
require.Equal(t, 40030, toHTTPError(t, rr.Body.String()).Code)
|
||||
|
||||
// Cannot delete user as non-admin
|
||||
rr = request(t, s, "DELETE", "/v1/users", `{"username": "ben"}`, map[string]string{
|
||||
"Authorization": util.BasicAuth("ben", "ben"),
|
||||
})
|
||||
require.Equal(t, 401, rr.Code)
|
||||
|
||||
// Delete user via API
|
||||
rr = request(t, s, "DELETE", "/v1/users", `{"username": "ben"}`, map[string]string{
|
||||
"Authorization": util.BasicAuth("phil", "phil"),
|
||||
})
|
||||
require.Equal(t, 200, rr.Code)
|
||||
}
|
||||
|
||||
func TestAccess_AllowReset(t *testing.T) {
|
||||
c := newTestConfigWithAuthFile(t)
|
||||
c.AuthDefault = user.PermissionDenyAll
|
||||
s := newTestServer(t, c)
|
||||
defer s.closeDatabases()
|
||||
|
||||
// User and admin
|
||||
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin, false))
|
||||
require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser, false))
|
||||
|
||||
// Subscribing not allowed
|
||||
rr := request(t, s, "GET", "/gold/json?poll=1", "", map[string]string{
|
||||
"Authorization": util.BasicAuth("ben", "ben"),
|
||||
})
|
||||
require.Equal(t, 403, rr.Code)
|
||||
|
||||
// Grant access
|
||||
rr = request(t, s, "POST", "/v1/users/access", `{"username": "ben", "topic":"gold", "permission":"ro"}`, map[string]string{
|
||||
"Authorization": util.BasicAuth("phil", "phil"),
|
||||
})
|
||||
require.Equal(t, 200, rr.Code)
|
||||
|
||||
// Now subscribing is allowed
|
||||
rr = request(t, s, "GET", "/gold/json?poll=1", "", map[string]string{
|
||||
"Authorization": util.BasicAuth("ben", "ben"),
|
||||
})
|
||||
require.Equal(t, 200, rr.Code)
|
||||
|
||||
// Reset access
|
||||
rr = request(t, s, "DELETE", "/v1/users/access", `{"username": "ben", "topic":"gold"}`, map[string]string{
|
||||
"Authorization": util.BasicAuth("phil", "phil"),
|
||||
})
|
||||
require.Equal(t, 200, rr.Code)
|
||||
|
||||
// Subscribing not allowed (again)
|
||||
rr = request(t, s, "GET", "/gold/json?poll=1", "", map[string]string{
|
||||
"Authorization": util.BasicAuth("ben", "ben"),
|
||||
})
|
||||
require.Equal(t, 403, rr.Code)
|
||||
}
|
||||
|
||||
func TestAccess_AllowReset_NonAdminAttempt(t *testing.T) {
|
||||
c := newTestConfigWithAuthFile(t)
|
||||
c.AuthDefault = user.PermissionDenyAll
|
||||
s := newTestServer(t, c)
|
||||
defer s.closeDatabases()
|
||||
|
||||
// User
|
||||
require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser, false))
|
||||
|
||||
// Grant access fails, because non-admin
|
||||
rr := request(t, s, "POST", "/v1/users/access", `{"username": "ben", "topic":"gold", "permission":"ro"}`, map[string]string{
|
||||
"Authorization": util.BasicAuth("ben", "ben"),
|
||||
})
|
||||
require.Equal(t, 401, rr.Code)
|
||||
}
|
||||
|
||||
func TestAccess_AllowReset_KillConnection(t *testing.T) {
|
||||
c := newTestConfigWithAuthFile(t)
|
||||
c.AuthDefault = user.PermissionDenyAll
|
||||
s := newTestServer(t, c)
|
||||
defer s.closeDatabases()
|
||||
|
||||
// User and admin, grant access to "gol*" topics
|
||||
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin, false))
|
||||
require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser, false))
|
||||
require.Nil(t, s.userManager.AllowAccess("ben", "gol*", user.PermissionRead)) // Wildcard!
|
||||
|
||||
start, timeTaken := time.Now(), atomic.Int64{}
|
||||
go func() {
|
||||
rr := request(t, s, "GET", "/gold/json", "", map[string]string{
|
||||
// Check that user can login with password
|
||||
rr = request(t, s, "POST", "/v1/account/token", "", map[string]string{
|
||||
"Authorization": util.BasicAuth("ben", "ben"),
|
||||
})
|
||||
require.Equal(t, 200, rr.Code)
|
||||
timeTaken.Store(time.Since(start).Milliseconds())
|
||||
}()
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
|
||||
// Reset access
|
||||
rr := request(t, s, "DELETE", "/v1/users/access", `{"username": "ben", "topic":"gol*"}`, map[string]string{
|
||||
"Authorization": util.BasicAuth("phil", "phil"),
|
||||
})
|
||||
require.Equal(t, 200, rr.Code)
|
||||
|
||||
// Wait for connection to be killed; this will fail if the connection is never killed
|
||||
waitFor(t, func() bool {
|
||||
return timeTaken.Load() >= 500
|
||||
// Check users
|
||||
users, err := s.userManager.Users()
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 3, len(users))
|
||||
require.Equal(t, "phil", users[0].Name)
|
||||
require.Equal(t, user.RoleAdmin, users[0].Role)
|
||||
require.Equal(t, "ben", users[1].Name)
|
||||
require.Equal(t, user.RoleUser, users[1].Role)
|
||||
})
|
||||
}
|
||||
|
||||
func TestUser_ChangeUserPassword(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T) {
|
||||
s := newTestServer(t, newTestConfigWithAuthFile(t))
|
||||
defer s.closeDatabases()
|
||||
|
||||
// Create admin
|
||||
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin, false))
|
||||
|
||||
// Create user via API
|
||||
rr := request(t, s, "POST", "/v1/users", `{"username": "ben", "password": "ben"}`, map[string]string{
|
||||
"Authorization": util.BasicAuth("phil", "phil"),
|
||||
})
|
||||
require.Equal(t, 200, rr.Code)
|
||||
|
||||
// Try to login with first password
|
||||
rr = request(t, s, "POST", "/v1/account/token", "", map[string]string{
|
||||
"Authorization": util.BasicAuth("ben", "ben"),
|
||||
})
|
||||
require.Equal(t, 200, rr.Code)
|
||||
|
||||
// Change password via API
|
||||
rr = request(t, s, "PUT", "/v1/users", `{"username": "ben", "password": "ben-two"}`, map[string]string{
|
||||
"Authorization": util.BasicAuth("phil", "phil"),
|
||||
})
|
||||
require.Equal(t, 200, rr.Code)
|
||||
|
||||
// Make sure first password fails
|
||||
rr = request(t, s, "POST", "/v1/account/token", "", map[string]string{
|
||||
"Authorization": util.BasicAuth("ben", "ben"),
|
||||
})
|
||||
require.Equal(t, 401, rr.Code)
|
||||
|
||||
// Try to login with second password
|
||||
rr = request(t, s, "POST", "/v1/account/token", "", map[string]string{
|
||||
"Authorization": util.BasicAuth("ben", "ben-two"),
|
||||
})
|
||||
require.Equal(t, 200, rr.Code)
|
||||
})
|
||||
}
|
||||
|
||||
func TestUser_ChangeUserTier(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T) {
|
||||
s := newTestServer(t, newTestConfigWithAuthFile(t))
|
||||
defer s.closeDatabases()
|
||||
|
||||
// Create admin, tier
|
||||
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin, false))
|
||||
require.Nil(t, s.userManager.AddTier(&user.Tier{
|
||||
Code: "tier1",
|
||||
}))
|
||||
require.Nil(t, s.userManager.AddTier(&user.Tier{
|
||||
Code: "tier2",
|
||||
}))
|
||||
|
||||
// Create user with tier via API
|
||||
rr := request(t, s, "POST", "/v1/users", `{"username": "ben", "password":"ben", "tier": "tier1"}`, map[string]string{
|
||||
"Authorization": util.BasicAuth("phil", "phil"),
|
||||
})
|
||||
require.Equal(t, 200, rr.Code)
|
||||
|
||||
// Check users
|
||||
users, err := s.userManager.Users()
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 3, len(users))
|
||||
require.Equal(t, "phil", users[0].Name)
|
||||
require.Equal(t, "ben", users[1].Name)
|
||||
require.Equal(t, user.RoleUser, users[1].Role)
|
||||
require.Equal(t, "tier1", users[1].Tier.Code)
|
||||
|
||||
// Change user tier via API
|
||||
rr = request(t, s, "PUT", "/v1/users", `{"username": "ben", "tier": "tier2"}`, map[string]string{
|
||||
"Authorization": util.BasicAuth("phil", "phil"),
|
||||
})
|
||||
require.Equal(t, 200, rr.Code)
|
||||
|
||||
// Check users again
|
||||
users, err = s.userManager.Users()
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, "tier2", users[1].Tier.Code)
|
||||
})
|
||||
}
|
||||
|
||||
func TestUser_ChangeUserPasswordAndTier(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T) {
|
||||
s := newTestServer(t, newTestConfigWithAuthFile(t))
|
||||
defer s.closeDatabases()
|
||||
|
||||
// Create admin, tier
|
||||
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin, false))
|
||||
require.Nil(t, s.userManager.AddTier(&user.Tier{
|
||||
Code: "tier1",
|
||||
}))
|
||||
require.Nil(t, s.userManager.AddTier(&user.Tier{
|
||||
Code: "tier2",
|
||||
}))
|
||||
|
||||
// Create user with tier via API
|
||||
rr := request(t, s, "POST", "/v1/users", `{"username": "ben", "password":"ben", "tier": "tier1"}`, map[string]string{
|
||||
"Authorization": util.BasicAuth("phil", "phil"),
|
||||
})
|
||||
require.Equal(t, 200, rr.Code)
|
||||
|
||||
// Check users
|
||||
users, err := s.userManager.Users()
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 3, len(users))
|
||||
require.Equal(t, "phil", users[0].Name)
|
||||
require.Equal(t, "ben", users[1].Name)
|
||||
require.Equal(t, user.RoleUser, users[1].Role)
|
||||
require.Equal(t, "tier1", users[1].Tier.Code)
|
||||
|
||||
// Change user password and tier via API
|
||||
rr = request(t, s, "PUT", "/v1/users", `{"username": "ben", "password":"ben-two", "tier": "tier2"}`, map[string]string{
|
||||
"Authorization": util.BasicAuth("phil", "phil"),
|
||||
})
|
||||
require.Equal(t, 200, rr.Code)
|
||||
|
||||
// Make sure first password fails
|
||||
rr = request(t, s, "POST", "/v1/account/token", "", map[string]string{
|
||||
"Authorization": util.BasicAuth("ben", "ben"),
|
||||
})
|
||||
require.Equal(t, 401, rr.Code)
|
||||
|
||||
// Try to login with second password
|
||||
rr = request(t, s, "POST", "/v1/account/token", "", map[string]string{
|
||||
"Authorization": util.BasicAuth("ben", "ben-two"),
|
||||
})
|
||||
require.Equal(t, 200, rr.Code)
|
||||
|
||||
// Check new tier
|
||||
users, err = s.userManager.Users()
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, "tier2", users[1].Tier.Code)
|
||||
})
|
||||
}
|
||||
|
||||
func TestUser_ChangeUserPasswordWithHash(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T) {
|
||||
s := newTestServer(t, newTestConfigWithAuthFile(t))
|
||||
defer s.closeDatabases()
|
||||
|
||||
// Create admin
|
||||
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin, false))
|
||||
|
||||
// Create user with tier via API
|
||||
rr := request(t, s, "POST", "/v1/users", `{"username": "ben", "password":"not-ben"}`, map[string]string{
|
||||
"Authorization": util.BasicAuth("phil", "phil"),
|
||||
})
|
||||
require.Equal(t, 200, rr.Code)
|
||||
|
||||
// Try to login with first password
|
||||
rr = request(t, s, "POST", "/v1/account/token", "", map[string]string{
|
||||
"Authorization": util.BasicAuth("ben", "not-ben"),
|
||||
})
|
||||
require.Equal(t, 200, rr.Code)
|
||||
|
||||
// Change user password and tier via API
|
||||
rr = request(t, s, "PUT", "/v1/users", `{"username": "ben", "hash":"$2a$04$2aPIIqPXQU16OfkSUZH1XOzpu1gsPRKkrfVdFLgWQ.tqb.vtTCuVe"}`, map[string]string{
|
||||
"Authorization": util.BasicAuth("phil", "phil"),
|
||||
})
|
||||
require.Equal(t, 200, rr.Code)
|
||||
|
||||
// Try to login with second password
|
||||
rr = request(t, s, "POST", "/v1/account/token", "", map[string]string{
|
||||
"Authorization": util.BasicAuth("ben", "ben"),
|
||||
})
|
||||
require.Equal(t, 200, rr.Code)
|
||||
})
|
||||
}
|
||||
|
||||
func TestUser_DontChangeAdminPassword(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T) {
|
||||
s := newTestServer(t, newTestConfigWithAuthFile(t))
|
||||
defer s.closeDatabases()
|
||||
|
||||
// Create admin
|
||||
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin, false))
|
||||
require.Nil(t, s.userManager.AddUser("admin", "admin", user.RoleAdmin, false))
|
||||
|
||||
// Try to change password via API
|
||||
rr := request(t, s, "PUT", "/v1/users", `{"username": "admin", "password": "admin-new"}`, map[string]string{
|
||||
"Authorization": util.BasicAuth("phil", "phil"),
|
||||
})
|
||||
require.Equal(t, 403, rr.Code)
|
||||
})
|
||||
}
|
||||
|
||||
func TestUser_AddRemove_Failures(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T) {
|
||||
s := newTestServer(t, newTestConfigWithAuthFile(t))
|
||||
defer s.closeDatabases()
|
||||
|
||||
// Create admin
|
||||
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin, false))
|
||||
require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser, false))
|
||||
|
||||
// Cannot create user with invalid username
|
||||
rr := request(t, s, "POST", "/v1/users", `{"username": "not valid", "password":"ben"}`, map[string]string{
|
||||
"Authorization": util.BasicAuth("phil", "phil"),
|
||||
})
|
||||
require.Equal(t, 400, rr.Code)
|
||||
|
||||
// Cannot create user if user already exists
|
||||
rr = request(t, s, "POST", "/v1/users", `{"username": "phil", "password":"phil"}`, map[string]string{
|
||||
"Authorization": util.BasicAuth("phil", "phil"),
|
||||
})
|
||||
require.Equal(t, 40901, toHTTPError(t, rr.Body.String()).Code)
|
||||
|
||||
// Cannot create user with invalid tier
|
||||
rr = request(t, s, "POST", "/v1/users", `{"username": "emma", "password":"emma", "tier": "invalid"}`, map[string]string{
|
||||
"Authorization": util.BasicAuth("phil", "phil"),
|
||||
})
|
||||
require.Equal(t, 40030, toHTTPError(t, rr.Body.String()).Code)
|
||||
|
||||
// Cannot delete user as non-admin
|
||||
rr = request(t, s, "DELETE", "/v1/users", `{"username": "ben"}`, map[string]string{
|
||||
"Authorization": util.BasicAuth("ben", "ben"),
|
||||
})
|
||||
require.Equal(t, 401, rr.Code)
|
||||
|
||||
// Delete user via API
|
||||
rr = request(t, s, "DELETE", "/v1/users", `{"username": "ben"}`, map[string]string{
|
||||
"Authorization": util.BasicAuth("phil", "phil"),
|
||||
})
|
||||
require.Equal(t, 200, rr.Code)
|
||||
})
|
||||
}
|
||||
|
||||
func TestAccess_AllowReset(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T) {
|
||||
c := newTestConfigWithAuthFile(t)
|
||||
c.AuthDefault = user.PermissionDenyAll
|
||||
s := newTestServer(t, c)
|
||||
defer s.closeDatabases()
|
||||
|
||||
// User and admin
|
||||
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin, false))
|
||||
require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser, false))
|
||||
|
||||
// Subscribing not allowed
|
||||
rr := request(t, s, "GET", "/gold/json?poll=1", "", map[string]string{
|
||||
"Authorization": util.BasicAuth("ben", "ben"),
|
||||
})
|
||||
require.Equal(t, 403, rr.Code)
|
||||
|
||||
// Grant access
|
||||
rr = request(t, s, "POST", "/v1/users/access", `{"username": "ben", "topic":"gold", "permission":"ro"}`, map[string]string{
|
||||
"Authorization": util.BasicAuth("phil", "phil"),
|
||||
})
|
||||
require.Equal(t, 200, rr.Code)
|
||||
|
||||
// Now subscribing is allowed
|
||||
rr = request(t, s, "GET", "/gold/json?poll=1", "", map[string]string{
|
||||
"Authorization": util.BasicAuth("ben", "ben"),
|
||||
})
|
||||
require.Equal(t, 200, rr.Code)
|
||||
|
||||
// Reset access
|
||||
rr = request(t, s, "DELETE", "/v1/users/access", `{"username": "ben", "topic":"gold"}`, map[string]string{
|
||||
"Authorization": util.BasicAuth("phil", "phil"),
|
||||
})
|
||||
require.Equal(t, 200, rr.Code)
|
||||
|
||||
// Subscribing not allowed (again)
|
||||
rr = request(t, s, "GET", "/gold/json?poll=1", "", map[string]string{
|
||||
"Authorization": util.BasicAuth("ben", "ben"),
|
||||
})
|
||||
require.Equal(t, 403, rr.Code)
|
||||
})
|
||||
}
|
||||
|
||||
func TestAccess_AllowReset_NonAdminAttempt(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T) {
|
||||
c := newTestConfigWithAuthFile(t)
|
||||
c.AuthDefault = user.PermissionDenyAll
|
||||
s := newTestServer(t, c)
|
||||
defer s.closeDatabases()
|
||||
|
||||
// User
|
||||
require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser, false))
|
||||
|
||||
// Grant access fails, because non-admin
|
||||
rr := request(t, s, "POST", "/v1/users/access", `{"username": "ben", "topic":"gold", "permission":"ro"}`, map[string]string{
|
||||
"Authorization": util.BasicAuth("ben", "ben"),
|
||||
})
|
||||
require.Equal(t, 401, rr.Code)
|
||||
})
|
||||
}
|
||||
|
||||
func TestAccess_AllowReset_KillConnection(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T) {
|
||||
c := newTestConfigWithAuthFile(t)
|
||||
c.AuthDefault = user.PermissionDenyAll
|
||||
s := newTestServer(t, c)
|
||||
defer s.closeDatabases()
|
||||
|
||||
// User and admin, grant access to "gol*" topics
|
||||
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin, false))
|
||||
require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser, false))
|
||||
require.Nil(t, s.userManager.AllowAccess("ben", "gol*", user.PermissionRead)) // Wildcard!
|
||||
|
||||
start, timeTaken := time.Now(), atomic.Int64{}
|
||||
go func() {
|
||||
rr := request(t, s, "GET", "/gold/json", "", map[string]string{
|
||||
"Authorization": util.BasicAuth("ben", "ben"),
|
||||
})
|
||||
require.Equal(t, 200, rr.Code)
|
||||
timeTaken.Store(time.Since(start).Milliseconds())
|
||||
}()
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
|
||||
// Reset access
|
||||
rr := request(t, s, "DELETE", "/v1/users/access", `{"username": "ben", "topic":"gol*"}`, map[string]string{
|
||||
"Authorization": util.BasicAuth("phil", "phil"),
|
||||
})
|
||||
require.Equal(t, 200, rr.Code)
|
||||
|
||||
// Wait for connection to be killed; this will fail if the connection is never killed
|
||||
waitFor(t, func() bool {
|
||||
return timeTaken.Load() >= 500
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"firebase.google.com/go/v4/messaging"
|
||||
"fmt"
|
||||
"google.golang.org/api/option"
|
||||
"heckel.io/ntfy/v2/model"
|
||||
"heckel.io/ntfy/v2/user"
|
||||
"heckel.io/ntfy/v2/util"
|
||||
"strings"
|
||||
@@ -43,7 +44,7 @@ func newFirebaseClient(sender firebaseSender, auther user.Auther) *firebaseClien
|
||||
}
|
||||
}
|
||||
|
||||
func (c *firebaseClient) Send(v *visitor, m *message) error {
|
||||
func (c *firebaseClient) Send(v *visitor, m *model.Message) error {
|
||||
if !v.FirebaseAllowed() {
|
||||
return errFirebaseTemporarilyBanned
|
||||
}
|
||||
@@ -80,7 +81,7 @@ type firebaseSenderImpl struct {
|
||||
}
|
||||
|
||||
func newFirebaseSender(credentialsFile string) (firebaseSender, error) {
|
||||
fb, err := firebase.NewApp(context.Background(), nil, option.WithCredentialsFile(credentialsFile))
|
||||
fb, err := firebase.NewApp(context.Background(), nil, option.WithAuthCredentialsFile(option.ServiceAccount, credentialsFile))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -121,7 +122,7 @@ func (c *firebaseSenderImpl) Send(m *messaging.Message) error {
|
||||
// On Android, this will trigger the app to poll the topic and thereby displaying new messages.
|
||||
// - If UpstreamBaseURL is set, messages are forwarded as poll requests to an upstream server and then forwarded
|
||||
// to Firebase here. This is mainly for iOS to support self-hosted servers.
|
||||
func toFirebaseMessage(m *message, auther user.Auther) (*messaging.Message, error) {
|
||||
func toFirebaseMessage(m *model.Message, auther user.Auther) (*messaging.Message, error) {
|
||||
var data map[string]string // Mostly matches https://ntfy.sh/docs/subscribe/api/#json-message-format
|
||||
var apnsConfig *messaging.APNSConfig
|
||||
switch m.Event {
|
||||
@@ -143,6 +144,15 @@ func toFirebaseMessage(m *message, auther user.Auther) (*messaging.Message, erro
|
||||
"poll_id": m.PollID,
|
||||
}
|
||||
apnsConfig = createAPNSAlertConfig(m, data)
|
||||
case messageDeleteEvent, messageClearEvent:
|
||||
data = map[string]string{
|
||||
"id": m.ID,
|
||||
"time": fmt.Sprintf("%d", m.Time),
|
||||
"event": m.Event,
|
||||
"topic": m.Topic,
|
||||
"sequence_id": m.SequenceID,
|
||||
}
|
||||
apnsConfig = createAPNSBackgroundConfig(data)
|
||||
case messageEvent:
|
||||
if auther != nil {
|
||||
// If "anonymous read" for a topic is not allowed, we cannot send the message along
|
||||
@@ -161,6 +171,7 @@ func toFirebaseMessage(m *message, auther user.Auther) (*messaging.Message, erro
|
||||
"time": fmt.Sprintf("%d", m.Time),
|
||||
"event": m.Event,
|
||||
"topic": m.Topic,
|
||||
"sequence_id": m.SequenceID,
|
||||
"priority": fmt.Sprintf("%d", m.Priority),
|
||||
"tags": strings.Join(m.Tags, ","),
|
||||
"click": m.Click,
|
||||
@@ -225,7 +236,7 @@ func maybeTruncateFCMMessage(m *messaging.Message) *messaging.Message {
|
||||
// createAPNSAlertConfig creates an APNS config for iOS notifications that show up as an alert (only relevant for iOS).
|
||||
// We must set the Alert struct ("alert"), and we need to set MutableContent ("mutable-content"), so the Notification Service
|
||||
// Extension in iOS can modify the message.
|
||||
func createAPNSAlertConfig(m *message, data map[string]string) *messaging.APNSConfig {
|
||||
func createAPNSAlertConfig(m *model.Message, data map[string]string) *messaging.APNSConfig {
|
||||
apnsData := make(map[string]any)
|
||||
for k, v := range data {
|
||||
apnsData[k] = v
|
||||
@@ -286,7 +297,7 @@ func maybeTruncateAPNSBodyMessage(s string) string {
|
||||
//
|
||||
// This empties all the fields that are not needed for a poll request and just sets the required fields,
|
||||
// most importantly, the PollID.
|
||||
func toPollRequest(m *message) *message {
|
||||
func toPollRequest(m *model.Message) *model.Message {
|
||||
pr := newPollRequestMessage(m.Topic, m.ID)
|
||||
pr.ID = m.ID
|
||||
pr.Time = m.Time
|
||||
|
||||
@@ -4,6 +4,7 @@ package server
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"heckel.io/ntfy/v2/model"
|
||||
"heckel.io/ntfy/v2/user"
|
||||
)
|
||||
|
||||
@@ -21,7 +22,7 @@ var (
|
||||
type firebaseClient struct {
|
||||
}
|
||||
|
||||
func (c *firebaseClient) Send(v *visitor, m *message) error {
|
||||
func (c *firebaseClient) Send(v *visitor, m *model.Message) error {
|
||||
return errFirebaseNotAvailable
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"heckel.io/ntfy/v2/model"
|
||||
"heckel.io/ntfy/v2/user"
|
||||
"net/netip"
|
||||
"strings"
|
||||
@@ -131,7 +132,7 @@ func TestToFirebaseMessage_Message_Normal_Allowed(t *testing.T) {
|
||||
m.Click = "https://google.com"
|
||||
m.Icon = "https://ntfy.sh/static/img/ntfy.png"
|
||||
m.Title = "some title"
|
||||
m.Actions = []*action{
|
||||
m.Actions = []*model.Action{
|
||||
{
|
||||
ID: "123",
|
||||
Action: "view",
|
||||
@@ -150,7 +151,7 @@ func TestToFirebaseMessage_Message_Normal_Allowed(t *testing.T) {
|
||||
},
|
||||
},
|
||||
}
|
||||
m.Attachment = &attachment{
|
||||
m.Attachment = &model.Attachment{
|
||||
Name: "some file.jpg",
|
||||
Type: "image/jpeg",
|
||||
Size: 12345,
|
||||
@@ -177,6 +178,7 @@ func TestToFirebaseMessage_Message_Normal_Allowed(t *testing.T) {
|
||||
"time": fmt.Sprintf("%d", m.Time),
|
||||
"event": "message",
|
||||
"topic": "mytopic",
|
||||
"sequence_id": "",
|
||||
"priority": "4",
|
||||
"tags": strings.Join(m.Tags, ","),
|
||||
"click": "https://google.com",
|
||||
@@ -199,6 +201,7 @@ func TestToFirebaseMessage_Message_Normal_Allowed(t *testing.T) {
|
||||
"time": fmt.Sprintf("%d", m.Time),
|
||||
"event": "message",
|
||||
"topic": "mytopic",
|
||||
"sequence_id": "",
|
||||
"priority": "4",
|
||||
"tags": strings.Join(m.Tags, ","),
|
||||
"click": "https://google.com",
|
||||
@@ -232,6 +235,7 @@ func TestToFirebaseMessage_Message_Normal_Not_Allowed(t *testing.T) {
|
||||
"time": fmt.Sprintf("%d", m.Time),
|
||||
"event": "poll_request",
|
||||
"topic": "mytopic",
|
||||
"sequence_id": "",
|
||||
"message": "New message",
|
||||
"title": "",
|
||||
"tags": "",
|
||||
@@ -343,16 +347,16 @@ func TestToFirebaseSender_Abuse(t *testing.T) {
|
||||
client := newFirebaseClient(sender, &testAuther{})
|
||||
visitor := newVisitor(newTestConfig(t), newMemTestCache(t), nil, netip.MustParseAddr("1.2.3.4"), nil)
|
||||
|
||||
require.Nil(t, client.Send(visitor, &message{Topic: "mytopic"}))
|
||||
require.Nil(t, client.Send(visitor, &model.Message{Topic: "mytopic"}))
|
||||
require.Equal(t, 1, len(sender.Messages()))
|
||||
|
||||
require.Nil(t, client.Send(visitor, &message{Topic: "mytopic"}))
|
||||
require.Nil(t, client.Send(visitor, &model.Message{Topic: "mytopic"}))
|
||||
require.Equal(t, 2, len(sender.Messages()))
|
||||
|
||||
require.Equal(t, errFirebaseQuotaExceeded, client.Send(visitor, &message{Topic: "mytopic"}))
|
||||
require.Equal(t, errFirebaseQuotaExceeded, client.Send(visitor, &model.Message{Topic: "mytopic"}))
|
||||
require.Equal(t, 2, len(sender.Messages()))
|
||||
|
||||
sender.messages = make([]*messaging.Message, 0) // Reset to test that time limit is working
|
||||
require.Equal(t, errFirebaseTemporarilyBanned, client.Send(visitor, &message{Topic: "mytopic"}))
|
||||
require.Equal(t, errFirebaseTemporarilyBanned, client.Send(visitor, &model.Message{Topic: "mytopic"}))
|
||||
require.Equal(t, 0, len(sender.Messages()))
|
||||
}
|
||||
|
||||
@@ -6,23 +6,25 @@ import (
|
||||
)
|
||||
|
||||
func TestServer_Manager_Prune_Messages_Without_Attachments_DoesNotPanic(t *testing.T) {
|
||||
// Tests that the manager runs without attachment-cache-dir set, see #617
|
||||
c := newTestConfig(t)
|
||||
c.AttachmentCacheDir = ""
|
||||
s := newTestServer(t, c)
|
||||
forEachBackend(t, func(t *testing.T) {
|
||||
// Tests that the manager runs without attachment-cache-dir set, see #617
|
||||
c := newTestConfig(t)
|
||||
c.AttachmentCacheDir = ""
|
||||
s := newTestServer(t, c)
|
||||
|
||||
// Publish a message
|
||||
rr := request(t, s, "POST", "/mytopic", "hi", nil)
|
||||
require.Equal(t, 200, rr.Code)
|
||||
m := toMessage(t, rr.Body.String())
|
||||
// Publish a message
|
||||
rr := request(t, s, "POST", "/mytopic", "hi", nil)
|
||||
require.Equal(t, 200, rr.Code)
|
||||
m := toMessage(t, rr.Body.String())
|
||||
|
||||
// Expire message
|
||||
require.Nil(t, s.messageCache.ExpireMessages("mytopic"))
|
||||
// Expire message
|
||||
require.Nil(t, s.messageCache.ExpireMessages("mytopic"))
|
||||
|
||||
// Does not panic
|
||||
s.pruneMessages()
|
||||
// Does not panic
|
||||
s.pruneMessages()
|
||||
|
||||
// Actually deleted
|
||||
_, err := s.messageCache.Message(m.ID)
|
||||
require.Equal(t, errMessageNotFound, err)
|
||||
// Actually deleted
|
||||
_, err := s.messageCache.Message(m.ID)
|
||||
require.Equal(t, errMessageNotFound, err)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -4,33 +4,50 @@ import (
|
||||
"bytes"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"heckel.io/ntfy/v2/log"
|
||||
"heckel.io/ntfy/v2/user"
|
||||
"heckel.io/ntfy/v2/util"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"text/template"
|
||||
|
||||
"heckel.io/ntfy/v2/log"
|
||||
"heckel.io/ntfy/v2/model"
|
||||
"heckel.io/ntfy/v2/user"
|
||||
"heckel.io/ntfy/v2/util"
|
||||
)
|
||||
|
||||
const (
|
||||
twilioCallFormat = `
|
||||
// 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 %s. Message:
|
||||
You have a message from notify on topic {{.Topic}}. Message:
|
||||
<break time="1s"/>
|
||||
%s
|
||||
{{.Message}}
|
||||
<break time="1s"/>
|
||||
End of message.
|
||||
<break time="1s"/>
|
||||
This message was sent by user %s. It will be repeated three times.
|
||||
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>`
|
||||
)
|
||||
</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.
|
||||
@@ -60,12 +77,34 @@ func (s *Server) convertPhoneNumber(u *user.User, phoneNumber string) (string, *
|
||||
|
||||
// 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 *message, to string) {
|
||||
func (s *Server) callPhone(v *visitor, r *http.Request, m *model.Message, to string) {
|
||||
u, sender := v.User(), m.Sender.String()
|
||||
if u != nil {
|
||||
sender = u.Name
|
||||
}
|
||||
body := fmt.Sprintf(twilioCallFormat, xmlEscapeText(m.Topic), xmlEscapeText(m.Message), xmlEscapeText(sender))
|
||||
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),
|
||||
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)
|
||||
return
|
||||
}
|
||||
body := bodyBuf.String()
|
||||
data := url.Values{}
|
||||
data.Set("From", s.config.TwilioPhoneNumber)
|
||||
data.Set("To", to)
|
||||
@@ -87,7 +126,7 @@ func (s *Server) callPhoneInternal(data url.Values) (string, error) {
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("User-Agent", "ntfy/"+s.config.Version)
|
||||
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)
|
||||
@@ -111,7 +150,7 @@ func (s *Server) verifyPhoneNumber(v *visitor, r *http.Request, phoneNumber, cha
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("User-Agent", "ntfy/"+s.config.Version)
|
||||
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)
|
||||
@@ -137,7 +176,7 @@ func (s *Server) verifyPhoneNumberCheck(v *visitor, r *http.Request, phoneNumber
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("User-Agent", "ntfy/"+s.config.Version)
|
||||
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)
|
||||
|
||||
@@ -1,264 +1,343 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"github.com/stretchr/testify/require"
|
||||
"heckel.io/ntfy/v2/user"
|
||||
"heckel.io/ntfy/v2/util"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"text/template"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"heckel.io/ntfy/v2/user"
|
||||
"heckel.io/ntfy/v2/util"
|
||||
)
|
||||
|
||||
func TestServer_Twilio_Call_Add_Verify_Call_Delete_Success(t *testing.T) {
|
||||
var called, verified atomic.Bool
|
||||
var code atomic.Pointer[string]
|
||||
twilioVerifyServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
body, err := io.ReadAll(r.Body)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, "Basic QUMxMjM0NTY3ODkwOkFBRUFBMTIzNDU2Nzg5MA==", r.Header.Get("Authorization"))
|
||||
if r.URL.Path == "/v2/Services/VA1234567890/Verifications" {
|
||||
if code.Load() != nil {
|
||||
forEachBackend(t, func(t *testing.T) {
|
||||
var called, verified atomic.Bool
|
||||
var code atomic.Pointer[string]
|
||||
twilioVerifyServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
body, err := io.ReadAll(r.Body)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, "Basic QUMxMjM0NTY3ODkwOkFBRUFBMTIzNDU2Nzg5MA==", r.Header.Get("Authorization"))
|
||||
if r.URL.Path == "/v2/Services/VA1234567890/Verifications" {
|
||||
if code.Load() != nil {
|
||||
t.Fatal("Should be only called once")
|
||||
}
|
||||
require.Equal(t, "Channel=sms&To=%2B12223334444", string(body))
|
||||
code.Store(util.String("123456"))
|
||||
} else if r.URL.Path == "/v2/Services/VA1234567890/VerificationCheck" {
|
||||
if verified.Load() {
|
||||
t.Fatal("Should be only called once")
|
||||
}
|
||||
require.Equal(t, "Code=123456&To=%2B12223334444", string(body))
|
||||
verified.Store(true)
|
||||
} else {
|
||||
t.Fatal("Unexpected path:", r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer twilioVerifyServer.Close()
|
||||
twilioCallsServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if called.Load() {
|
||||
t.Fatal("Should be only called once")
|
||||
}
|
||||
require.Equal(t, "Channel=sms&To=%2B12223334444", string(body))
|
||||
code.Store(util.String("123456"))
|
||||
} else if r.URL.Path == "/v2/Services/VA1234567890/VerificationCheck" {
|
||||
if verified.Load() {
|
||||
t.Fatal("Should be only called once")
|
||||
}
|
||||
require.Equal(t, "Code=123456&To=%2B12223334444", string(body))
|
||||
verified.Store(true)
|
||||
} else {
|
||||
t.Fatal("Unexpected path:", r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer twilioVerifyServer.Close()
|
||||
twilioCallsServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if called.Load() {
|
||||
t.Fatal("Should be only called once")
|
||||
}
|
||||
body, err := io.ReadAll(r.Body)
|
||||
body, err := io.ReadAll(r.Body)
|
||||
require.Nil(t, err)
|
||||
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, "From=%2B1234567890&To=%2B12223334444&Twiml=%0A%3CResponse%3E%0A%09%3CPause+length%3D%221%22%2F%3E%0A%09%3CSay+loop%3D%223%22%3E%0A%09%09You+have+a+message+from+notify+on+topic+mytopic.+Message%3A%0A%09%09%3Cbreak+time%3D%221s%22%2F%3E%0A%09%09hi+there%0A%09%09%3Cbreak+time%3D%221s%22%2F%3E%0A%09%09End+of+message.%0A%09%09%3Cbreak+time%3D%221s%22%2F%3E%0A%09%09This+message+was+sent+by+user+phil.+It+will+be+repeated+three+times.%0A%09%09To+unsubscribe+from+calls+like+this%2C+remove+your+phone+number+in+the+notify+web+app.%0A%09%09%3Cbreak+time%3D%223s%22%2F%3E%0A%09%3C%2FSay%3E%0A%09%3CSay%3EGoodbye.%3C%2FSay%3E%0A%3C%2FResponse%3E", string(body))
|
||||
called.Store(true)
|
||||
}))
|
||||
defer twilioCallsServer.Close()
|
||||
|
||||
c := newTestConfigWithAuthFile(t)
|
||||
c.TwilioVerifyBaseURL = twilioVerifyServer.URL
|
||||
c.TwilioCallsBaseURL = twilioCallsServer.URL
|
||||
c.TwilioAccount = "AC1234567890"
|
||||
c.TwilioAuthToken = "AAEAA1234567890"
|
||||
c.TwilioPhoneNumber = "+1234567890"
|
||||
c.TwilioVerifyService = "VA1234567890"
|
||||
s := newTestServer(t, c)
|
||||
|
||||
// Add tier and user
|
||||
require.Nil(t, s.userManager.AddTier(&user.Tier{
|
||||
Code: "pro",
|
||||
MessageLimit: 10,
|
||||
CallLimit: 1,
|
||||
}))
|
||||
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false))
|
||||
require.Nil(t, s.userManager.ChangeTier("phil", "pro"))
|
||||
u, err := s.userManager.User("phil")
|
||||
require.Nil(t, err)
|
||||
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, "From=%2B1234567890&To=%2B12223334444&Twiml=%0A%3CResponse%3E%0A%09%3CPause+length%3D%221%22%2F%3E%0A%09%3CSay+loop%3D%223%22%3E%0A%09%09You+have+a+message+from+notify+on+topic+mytopic.+Message%3A%0A%09%09%3Cbreak+time%3D%221s%22%2F%3E%0A%09%09hi+there%0A%09%09%3Cbreak+time%3D%221s%22%2F%3E%0A%09%09End+of+message.%0A%09%09%3Cbreak+time%3D%221s%22%2F%3E%0A%09%09This+message+was+sent+by+user+phil.+It+will+be+repeated+three+times.%0A%09%09To+unsubscribe+from+calls+like+this%2C+remove+your+phone+number+in+the+notify+web+app.%0A%09%09%3Cbreak+time%3D%223s%22%2F%3E%0A%09%3C%2FSay%3E%0A%09%3CSay%3EGoodbye.%3C%2FSay%3E%0A%3C%2FResponse%3E", string(body))
|
||||
called.Store(true)
|
||||
}))
|
||||
defer twilioCallsServer.Close()
|
||||
|
||||
c := newTestConfigWithAuthFile(t)
|
||||
c.TwilioVerifyBaseURL = twilioVerifyServer.URL
|
||||
c.TwilioCallsBaseURL = twilioCallsServer.URL
|
||||
c.TwilioAccount = "AC1234567890"
|
||||
c.TwilioAuthToken = "AAEAA1234567890"
|
||||
c.TwilioPhoneNumber = "+1234567890"
|
||||
c.TwilioVerifyService = "VA1234567890"
|
||||
s := newTestServer(t, c)
|
||||
// Send verification code for phone number
|
||||
response := request(t, s, "PUT", "/v1/account/phone/verify", `{"number":"+12223334444","channel":"sms"}`, map[string]string{
|
||||
"authorization": util.BasicAuth("phil", "phil"),
|
||||
})
|
||||
require.Equal(t, 200, response.Code)
|
||||
waitFor(t, func() bool {
|
||||
return *code.Load() == "123456"
|
||||
})
|
||||
|
||||
// Add tier and user
|
||||
require.Nil(t, s.userManager.AddTier(&user.Tier{
|
||||
Code: "pro",
|
||||
MessageLimit: 10,
|
||||
CallLimit: 1,
|
||||
}))
|
||||
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false))
|
||||
require.Nil(t, s.userManager.ChangeTier("phil", "pro"))
|
||||
u, err := s.userManager.User("phil")
|
||||
require.Nil(t, err)
|
||||
// Add phone number with code
|
||||
response = request(t, s, "PUT", "/v1/account/phone", `{"number":"+12223334444","code":"123456"}`, map[string]string{
|
||||
"authorization": util.BasicAuth("phil", "phil"),
|
||||
})
|
||||
require.Equal(t, 200, response.Code)
|
||||
waitFor(t, func() bool {
|
||||
return verified.Load()
|
||||
})
|
||||
phoneNumbers, err := s.userManager.PhoneNumbers(u.ID)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 1, len(phoneNumbers))
|
||||
require.Equal(t, "+12223334444", phoneNumbers[0])
|
||||
|
||||
// Send verification code for phone number
|
||||
response := request(t, s, "PUT", "/v1/account/phone/verify", `{"number":"+12223334444","channel":"sms"}`, map[string]string{
|
||||
"authorization": util.BasicAuth("phil", "phil"),
|
||||
})
|
||||
require.Equal(t, 200, response.Code)
|
||||
waitFor(t, func() bool {
|
||||
return *code.Load() == "123456"
|
||||
})
|
||||
// Do the thing
|
||||
response = request(t, s, "POST", "/mytopic", "hi there", map[string]string{
|
||||
"authorization": util.BasicAuth("phil", "phil"),
|
||||
"x-call": "yes",
|
||||
})
|
||||
require.Equal(t, "hi there", toMessage(t, response.Body.String()).Message)
|
||||
waitFor(t, func() bool {
|
||||
return called.Load()
|
||||
})
|
||||
|
||||
// Add phone number with code
|
||||
response = request(t, s, "PUT", "/v1/account/phone", `{"number":"+12223334444","code":"123456"}`, map[string]string{
|
||||
"authorization": util.BasicAuth("phil", "phil"),
|
||||
})
|
||||
require.Equal(t, 200, response.Code)
|
||||
waitFor(t, func() bool {
|
||||
return verified.Load()
|
||||
})
|
||||
phoneNumbers, err := s.userManager.PhoneNumbers(u.ID)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 1, len(phoneNumbers))
|
||||
require.Equal(t, "+12223334444", phoneNumbers[0])
|
||||
// Remove the phone number
|
||||
response = request(t, s, "DELETE", "/v1/account/phone", `{"number":"+12223334444"}`, map[string]string{
|
||||
"authorization": util.BasicAuth("phil", "phil"),
|
||||
})
|
||||
require.Equal(t, 200, response.Code)
|
||||
|
||||
// Do the thing
|
||||
response = request(t, s, "POST", "/mytopic", "hi there", map[string]string{
|
||||
"authorization": util.BasicAuth("phil", "phil"),
|
||||
"x-call": "yes",
|
||||
// Verify the phone number is gone from the DB
|
||||
phoneNumbers, err = s.userManager.PhoneNumbers(u.ID)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 0, len(phoneNumbers))
|
||||
})
|
||||
require.Equal(t, "hi there", toMessage(t, response.Body.String()).Message)
|
||||
waitFor(t, func() bool {
|
||||
return called.Load()
|
||||
})
|
||||
|
||||
// Remove the phone number
|
||||
response = request(t, s, "DELETE", "/v1/account/phone", `{"number":"+12223334444"}`, map[string]string{
|
||||
"authorization": util.BasicAuth("phil", "phil"),
|
||||
})
|
||||
require.Equal(t, 200, response.Code)
|
||||
|
||||
// Verify the phone number is gone from the DB
|
||||
phoneNumbers, err = s.userManager.PhoneNumbers(u.ID)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 0, len(phoneNumbers))
|
||||
}
|
||||
|
||||
func TestServer_Twilio_Call_Success(t *testing.T) {
|
||||
var called atomic.Bool
|
||||
twilioServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if called.Load() {
|
||||
t.Fatal("Should be only called once")
|
||||
}
|
||||
body, err := io.ReadAll(r.Body)
|
||||
forEachBackend(t, func(t *testing.T) {
|
||||
var called atomic.Bool
|
||||
twilioServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if called.Load() {
|
||||
t.Fatal("Should be only called once")
|
||||
}
|
||||
body, err := io.ReadAll(r.Body)
|
||||
require.Nil(t, err)
|
||||
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, "From=%2B1234567890&To=%2B11122233344&Twiml=%0A%3CResponse%3E%0A%09%3CPause+length%3D%221%22%2F%3E%0A%09%3CSay+loop%3D%223%22%3E%0A%09%09You+have+a+message+from+notify+on+topic+mytopic.+Message%3A%0A%09%09%3Cbreak+time%3D%221s%22%2F%3E%0A%09%09hi+there%0A%09%09%3Cbreak+time%3D%221s%22%2F%3E%0A%09%09End+of+message.%0A%09%09%3Cbreak+time%3D%221s%22%2F%3E%0A%09%09This+message+was+sent+by+user+phil.+It+will+be+repeated+three+times.%0A%09%09To+unsubscribe+from+calls+like+this%2C+remove+your+phone+number+in+the+notify+web+app.%0A%09%09%3Cbreak+time%3D%223s%22%2F%3E%0A%09%3C%2FSay%3E%0A%09%3CSay%3EGoodbye.%3C%2FSay%3E%0A%3C%2FResponse%3E", string(body))
|
||||
called.Store(true)
|
||||
}))
|
||||
defer twilioServer.Close()
|
||||
|
||||
c := newTestConfigWithAuthFile(t)
|
||||
c.TwilioCallsBaseURL = twilioServer.URL
|
||||
c.TwilioAccount = "AC1234567890"
|
||||
c.TwilioAuthToken = "AAEAA1234567890"
|
||||
c.TwilioPhoneNumber = "+1234567890"
|
||||
s := newTestServer(t, c)
|
||||
|
||||
// Add tier and user
|
||||
require.Nil(t, s.userManager.AddTier(&user.Tier{
|
||||
Code: "pro",
|
||||
MessageLimit: 10,
|
||||
CallLimit: 1,
|
||||
}))
|
||||
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false))
|
||||
require.Nil(t, s.userManager.ChangeTier("phil", "pro"))
|
||||
u, err := s.userManager.User("phil")
|
||||
require.Nil(t, err)
|
||||
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, "From=%2B1234567890&To=%2B11122233344&Twiml=%0A%3CResponse%3E%0A%09%3CPause+length%3D%221%22%2F%3E%0A%09%3CSay+loop%3D%223%22%3E%0A%09%09You+have+a+message+from+notify+on+topic+mytopic.+Message%3A%0A%09%09%3Cbreak+time%3D%221s%22%2F%3E%0A%09%09hi+there%0A%09%09%3Cbreak+time%3D%221s%22%2F%3E%0A%09%09End+of+message.%0A%09%09%3Cbreak+time%3D%221s%22%2F%3E%0A%09%09This+message+was+sent+by+user+phil.+It+will+be+repeated+three+times.%0A%09%09To+unsubscribe+from+calls+like+this%2C+remove+your+phone+number+in+the+notify+web+app.%0A%09%09%3Cbreak+time%3D%223s%22%2F%3E%0A%09%3C%2FSay%3E%0A%09%3CSay%3EGoodbye.%3C%2FSay%3E%0A%3C%2FResponse%3E", string(body))
|
||||
called.Store(true)
|
||||
}))
|
||||
defer twilioServer.Close()
|
||||
require.Nil(t, s.userManager.AddPhoneNumber(u.ID, "+11122233344"))
|
||||
|
||||
c := newTestConfigWithAuthFile(t)
|
||||
c.TwilioCallsBaseURL = twilioServer.URL
|
||||
c.TwilioAccount = "AC1234567890"
|
||||
c.TwilioAuthToken = "AAEAA1234567890"
|
||||
c.TwilioPhoneNumber = "+1234567890"
|
||||
s := newTestServer(t, c)
|
||||
|
||||
// Add tier and user
|
||||
require.Nil(t, s.userManager.AddTier(&user.Tier{
|
||||
Code: "pro",
|
||||
MessageLimit: 10,
|
||||
CallLimit: 1,
|
||||
}))
|
||||
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false))
|
||||
require.Nil(t, s.userManager.ChangeTier("phil", "pro"))
|
||||
u, err := s.userManager.User("phil")
|
||||
require.Nil(t, err)
|
||||
require.Nil(t, s.userManager.AddPhoneNumber(u.ID, "+11122233344"))
|
||||
|
||||
// Do the thing
|
||||
response := request(t, s, "POST", "/mytopic", "hi there", map[string]string{
|
||||
"authorization": util.BasicAuth("phil", "phil"),
|
||||
"x-call": "+11122233344",
|
||||
})
|
||||
require.Equal(t, "hi there", toMessage(t, response.Body.String()).Message)
|
||||
waitFor(t, func() bool {
|
||||
return called.Load()
|
||||
// Do the thing
|
||||
response := request(t, s, "POST", "/mytopic", "hi there", map[string]string{
|
||||
"authorization": util.BasicAuth("phil", "phil"),
|
||||
"x-call": "+11122233344",
|
||||
})
|
||||
require.Equal(t, "hi there", toMessage(t, response.Body.String()).Message)
|
||||
waitFor(t, func() bool {
|
||||
return called.Load()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestServer_Twilio_Call_Success_With_Yes(t *testing.T) {
|
||||
var called atomic.Bool
|
||||
twilioServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if called.Load() {
|
||||
t.Fatal("Should be only called once")
|
||||
}
|
||||
body, err := io.ReadAll(r.Body)
|
||||
forEachBackend(t, func(t *testing.T) {
|
||||
var called atomic.Bool
|
||||
twilioServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if called.Load() {
|
||||
t.Fatal("Should be only called once")
|
||||
}
|
||||
body, err := io.ReadAll(r.Body)
|
||||
require.Nil(t, err)
|
||||
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, "From=%2B1234567890&To=%2B11122233344&Twiml=%0A%3CResponse%3E%0A%09%3CPause+length%3D%221%22%2F%3E%0A%09%3CSay+loop%3D%223%22%3E%0A%09%09You+have+a+message+from+notify+on+topic+mytopic.+Message%3A%0A%09%09%3Cbreak+time%3D%221s%22%2F%3E%0A%09%09hi+there%0A%09%09%3Cbreak+time%3D%221s%22%2F%3E%0A%09%09End+of+message.%0A%09%09%3Cbreak+time%3D%221s%22%2F%3E%0A%09%09This+message+was+sent+by+user+phil.+It+will+be+repeated+three+times.%0A%09%09To+unsubscribe+from+calls+like+this%2C+remove+your+phone+number+in+the+notify+web+app.%0A%09%09%3Cbreak+time%3D%223s%22%2F%3E%0A%09%3C%2FSay%3E%0A%09%3CSay%3EGoodbye.%3C%2FSay%3E%0A%3C%2FResponse%3E", string(body))
|
||||
called.Store(true)
|
||||
}))
|
||||
defer twilioServer.Close()
|
||||
|
||||
c := newTestConfigWithAuthFile(t)
|
||||
c.TwilioCallsBaseURL = twilioServer.URL
|
||||
c.TwilioAccount = "AC1234567890"
|
||||
c.TwilioAuthToken = "AAEAA1234567890"
|
||||
c.TwilioPhoneNumber = "+1234567890"
|
||||
s := newTestServer(t, c)
|
||||
|
||||
// Add tier and user
|
||||
require.Nil(t, s.userManager.AddTier(&user.Tier{
|
||||
Code: "pro",
|
||||
MessageLimit: 10,
|
||||
CallLimit: 1,
|
||||
}))
|
||||
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false))
|
||||
require.Nil(t, s.userManager.ChangeTier("phil", "pro"))
|
||||
u, err := s.userManager.User("phil")
|
||||
require.Nil(t, err)
|
||||
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, "From=%2B1234567890&To=%2B11122233344&Twiml=%0A%3CResponse%3E%0A%09%3CPause+length%3D%221%22%2F%3E%0A%09%3CSay+loop%3D%223%22%3E%0A%09%09You+have+a+message+from+notify+on+topic+mytopic.+Message%3A%0A%09%09%3Cbreak+time%3D%221s%22%2F%3E%0A%09%09hi+there%0A%09%09%3Cbreak+time%3D%221s%22%2F%3E%0A%09%09End+of+message.%0A%09%09%3Cbreak+time%3D%221s%22%2F%3E%0A%09%09This+message+was+sent+by+user+phil.+It+will+be+repeated+three+times.%0A%09%09To+unsubscribe+from+calls+like+this%2C+remove+your+phone+number+in+the+notify+web+app.%0A%09%09%3Cbreak+time%3D%223s%22%2F%3E%0A%09%3C%2FSay%3E%0A%09%3CSay%3EGoodbye.%3C%2FSay%3E%0A%3C%2FResponse%3E", string(body))
|
||||
called.Store(true)
|
||||
}))
|
||||
defer twilioServer.Close()
|
||||
require.Nil(t, s.userManager.AddPhoneNumber(u.ID, "+11122233344"))
|
||||
|
||||
c := newTestConfigWithAuthFile(t)
|
||||
c.TwilioCallsBaseURL = twilioServer.URL
|
||||
c.TwilioAccount = "AC1234567890"
|
||||
c.TwilioAuthToken = "AAEAA1234567890"
|
||||
c.TwilioPhoneNumber = "+1234567890"
|
||||
s := newTestServer(t, c)
|
||||
|
||||
// Add tier and user
|
||||
require.Nil(t, s.userManager.AddTier(&user.Tier{
|
||||
Code: "pro",
|
||||
MessageLimit: 10,
|
||||
CallLimit: 1,
|
||||
}))
|
||||
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false))
|
||||
require.Nil(t, s.userManager.ChangeTier("phil", "pro"))
|
||||
u, err := s.userManager.User("phil")
|
||||
require.Nil(t, err)
|
||||
require.Nil(t, s.userManager.AddPhoneNumber(u.ID, "+11122233344"))
|
||||
|
||||
// Do the thing
|
||||
response := request(t, s, "POST", "/mytopic", "hi there", map[string]string{
|
||||
"authorization": util.BasicAuth("phil", "phil"),
|
||||
"x-call": "yes", // <<<------
|
||||
// Do the thing
|
||||
response := request(t, s, "POST", "/mytopic", "hi there", map[string]string{
|
||||
"authorization": util.BasicAuth("phil", "phil"),
|
||||
"x-call": "yes", // <<<------
|
||||
})
|
||||
require.Equal(t, "hi there", toMessage(t, response.Body.String()).Message)
|
||||
waitFor(t, func() bool {
|
||||
return called.Load()
|
||||
})
|
||||
})
|
||||
require.Equal(t, "hi there", toMessage(t, response.Body.String()).Message)
|
||||
waitFor(t, func() bool {
|
||||
return called.Load()
|
||||
}
|
||||
|
||||
func TestServer_Twilio_Call_Success_with_custom_twiml(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T) {
|
||||
var called atomic.Bool
|
||||
twilioServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if called.Load() {
|
||||
t.Fatal("Should be only called once")
|
||||
}
|
||||
body, err := io.ReadAll(r.Body)
|
||||
require.Nil(t, err)
|
||||
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, "From=%2B1234567890&To=%2B11122233344&Twiml=%0A%3CResponse%3E%0A%09%3CPause+length%3D%221%22%2F%3E%0A%09%3CSay+language%3D%22de-DE%22+loop%3D%223%22%3E%0A%09%09Du+hast+eine+Nachricht+von+notify+im+Thema+mytopic.+Nachricht%3A%0A%09%09%3Cbreak+time%3D%221s%22%2F%3E%0A%09%09hi+there%0A%09%09%3Cbreak+time%3D%221s%22%2F%3E%0A%09%09Ende+der+Nachricht.%0A%09%09%3Cbreak+time%3D%221s%22%2F%3E%0A%09%09Diese+Nachricht+wurde+von+Benutzer+phil+gesendet.+Sie+wird+drei+Mal+wiederholt.%0A%09%09Um+dich+von+Anrufen+wie+diesen+abzumelden%2C+entferne+deine+Telefonnummer+in+der+notify+web+app.%0A%09%09%3Cbreak+time%3D%223s%22%2F%3E%0A%09%3C%2FSay%3E%0A%09%3CSay+language%3D%22de-DE%22%3EAuf+Wiederh%C3%B6ren.%3C%2FSay%3E%0A%3C%2FResponse%3E", string(body))
|
||||
called.Store(true)
|
||||
}))
|
||||
defer twilioServer.Close()
|
||||
|
||||
c := newTestConfigWithAuthFile(t)
|
||||
c.TwilioCallsBaseURL = twilioServer.URL
|
||||
c.TwilioAccount = "AC1234567890"
|
||||
c.TwilioAuthToken = "AAEAA1234567890"
|
||||
c.TwilioPhoneNumber = "+1234567890"
|
||||
c.TwilioCallFormat = template.Must(template.New("twiml").Parse(`
|
||||
<Response>
|
||||
<Pause length="1"/>
|
||||
<Say language="de-DE" loop="3">
|
||||
Du hast eine Nachricht von notify im Thema {{.Topic}}. Nachricht:
|
||||
<break time="1s"/>
|
||||
{{.Message}}
|
||||
<break time="1s"/>
|
||||
Ende der Nachricht.
|
||||
<break time="1s"/>
|
||||
Diese Nachricht wurde von Benutzer {{.Sender}} gesendet. Sie wird drei Mal wiederholt.
|
||||
Um dich von Anrufen wie diesen abzumelden, entferne deine Telefonnummer in der notify web app.
|
||||
<break time="3s"/>
|
||||
</Say>
|
||||
<Say language="de-DE">Auf Wiederhören.</Say>
|
||||
</Response>`))
|
||||
s := newTestServer(t, c)
|
||||
|
||||
// Add tier and user
|
||||
require.Nil(t, s.userManager.AddTier(&user.Tier{
|
||||
Code: "pro",
|
||||
MessageLimit: 10,
|
||||
CallLimit: 1,
|
||||
}))
|
||||
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false))
|
||||
require.Nil(t, s.userManager.ChangeTier("phil", "pro"))
|
||||
u, err := s.userManager.User("phil")
|
||||
require.Nil(t, err)
|
||||
require.Nil(t, s.userManager.AddPhoneNumber(u.ID, "+11122233344"))
|
||||
|
||||
// Do the thing
|
||||
response := request(t, s, "POST", "/mytopic", "hi there", map[string]string{
|
||||
"authorization": util.BasicAuth("phil", "phil"),
|
||||
"x-call": "+11122233344",
|
||||
})
|
||||
require.Equal(t, "hi there", toMessage(t, response.Body.String()).Message)
|
||||
waitFor(t, func() bool {
|
||||
return called.Load()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestServer_Twilio_Call_UnverifiedNumber(t *testing.T) {
|
||||
c := newTestConfigWithAuthFile(t)
|
||||
c.TwilioCallsBaseURL = "http://dummy.invalid"
|
||||
c.TwilioAccount = "AC1234567890"
|
||||
c.TwilioAuthToken = "AAEAA1234567890"
|
||||
c.TwilioPhoneNumber = "+1234567890"
|
||||
s := newTestServer(t, c)
|
||||
forEachBackend(t, func(t *testing.T) {
|
||||
c := newTestConfigWithAuthFile(t)
|
||||
c.TwilioCallsBaseURL = "http://dummy.invalid"
|
||||
c.TwilioAccount = "AC1234567890"
|
||||
c.TwilioAuthToken = "AAEAA1234567890"
|
||||
c.TwilioPhoneNumber = "+1234567890"
|
||||
s := newTestServer(t, c)
|
||||
|
||||
// Add tier and user
|
||||
require.Nil(t, s.userManager.AddTier(&user.Tier{
|
||||
Code: "pro",
|
||||
MessageLimit: 10,
|
||||
CallLimit: 1,
|
||||
}))
|
||||
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false))
|
||||
require.Nil(t, s.userManager.ChangeTier("phil", "pro"))
|
||||
// Add tier and user
|
||||
require.Nil(t, s.userManager.AddTier(&user.Tier{
|
||||
Code: "pro",
|
||||
MessageLimit: 10,
|
||||
CallLimit: 1,
|
||||
}))
|
||||
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false))
|
||||
require.Nil(t, s.userManager.ChangeTier("phil", "pro"))
|
||||
|
||||
// Do the thing
|
||||
response := request(t, s, "POST", "/mytopic", "test", map[string]string{
|
||||
"authorization": util.BasicAuth("phil", "phil"),
|
||||
"x-call": "+11122233344",
|
||||
// Do the thing
|
||||
response := request(t, s, "POST", "/mytopic", "test", map[string]string{
|
||||
"authorization": util.BasicAuth("phil", "phil"),
|
||||
"x-call": "+11122233344",
|
||||
})
|
||||
require.Equal(t, 40034, toHTTPError(t, response.Body.String()).Code)
|
||||
})
|
||||
require.Equal(t, 40034, toHTTPError(t, response.Body.String()).Code)
|
||||
}
|
||||
|
||||
func TestServer_Twilio_Call_InvalidNumber(t *testing.T) {
|
||||
c := newTestConfigWithAuthFile(t)
|
||||
c.TwilioCallsBaseURL = "https://127.0.0.1"
|
||||
c.TwilioAccount = "AC1234567890"
|
||||
c.TwilioAuthToken = "AAEAA1234567890"
|
||||
c.TwilioPhoneNumber = "+1234567890"
|
||||
s := newTestServer(t, c)
|
||||
forEachBackend(t, func(t *testing.T) {
|
||||
c := newTestConfigWithAuthFile(t)
|
||||
c.TwilioCallsBaseURL = "https://127.0.0.1"
|
||||
c.TwilioAccount = "AC1234567890"
|
||||
c.TwilioAuthToken = "AAEAA1234567890"
|
||||
c.TwilioPhoneNumber = "+1234567890"
|
||||
s := newTestServer(t, c)
|
||||
|
||||
response := request(t, s, "POST", "/mytopic", "test", map[string]string{
|
||||
"x-call": "+invalid",
|
||||
response := request(t, s, "POST", "/mytopic", "test", map[string]string{
|
||||
"x-call": "+invalid",
|
||||
})
|
||||
require.Equal(t, 40033, toHTTPError(t, response.Body.String()).Code)
|
||||
})
|
||||
require.Equal(t, 40033, toHTTPError(t, response.Body.String()).Code)
|
||||
}
|
||||
|
||||
func TestServer_Twilio_Call_Anonymous(t *testing.T) {
|
||||
c := newTestConfigWithAuthFile(t)
|
||||
c.TwilioCallsBaseURL = "https://127.0.0.1"
|
||||
c.TwilioAccount = "AC1234567890"
|
||||
c.TwilioAuthToken = "AAEAA1234567890"
|
||||
c.TwilioPhoneNumber = "+1234567890"
|
||||
s := newTestServer(t, c)
|
||||
forEachBackend(t, func(t *testing.T) {
|
||||
c := newTestConfigWithAuthFile(t)
|
||||
c.TwilioCallsBaseURL = "https://127.0.0.1"
|
||||
c.TwilioAccount = "AC1234567890"
|
||||
c.TwilioAuthToken = "AAEAA1234567890"
|
||||
c.TwilioPhoneNumber = "+1234567890"
|
||||
s := newTestServer(t, c)
|
||||
|
||||
response := request(t, s, "POST", "/mytopic", "test", map[string]string{
|
||||
"x-call": "+123123",
|
||||
response := request(t, s, "POST", "/mytopic", "test", map[string]string{
|
||||
"x-call": "+123123",
|
||||
})
|
||||
require.Equal(t, 40035, toHTTPError(t, response.Body.String()).Code)
|
||||
})
|
||||
require.Equal(t, 40035, toHTTPError(t, response.Body.String()).Code)
|
||||
}
|
||||
|
||||
func TestServer_Twilio_Call_Unconfigured(t *testing.T) {
|
||||
s := newTestServer(t, newTestConfig(t))
|
||||
response := request(t, s, "POST", "/mytopic", "test", map[string]string{
|
||||
"x-call": "+1234",
|
||||
forEachBackend(t, func(t *testing.T) {
|
||||
s := newTestServer(t, newTestConfig(t))
|
||||
response := request(t, s, "POST", "/mytopic", "test", map[string]string{
|
||||
"x-call": "+1234",
|
||||
})
|
||||
require.Equal(t, 40032, toHTTPError(t, response.Body.String()).Code)
|
||||
})
|
||||
require.Equal(t, 40032, toHTTPError(t, response.Body.String()).Code)
|
||||
}
|
||||
|
||||
@@ -11,7 +11,9 @@ import (
|
||||
|
||||
"github.com/SherClockHolmes/webpush-go"
|
||||
"heckel.io/ntfy/v2/log"
|
||||
"heckel.io/ntfy/v2/model"
|
||||
"heckel.io/ntfy/v2/user"
|
||||
wpush "heckel.io/ntfy/v2/webpush"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -82,14 +84,14 @@ func (s *Server) handleWebPushDelete(w http.ResponseWriter, r *http.Request, _ *
|
||||
return s.writeJSON(w, newSuccessResponse())
|
||||
}
|
||||
|
||||
func (s *Server) publishToWebPushEndpoints(v *visitor, m *message) {
|
||||
func (s *Server) publishToWebPushEndpoints(v *visitor, m *model.Message) {
|
||||
subscriptions, err := s.webPush.SubscriptionsForTopic(m.Topic)
|
||||
if err != nil {
|
||||
logvm(v, m).Err(err).With(v, m).Warn("Unable to publish web push messages")
|
||||
return
|
||||
}
|
||||
log.Tag(tagWebPush).With(v, m).Debug("Publishing web push message to %d subscribers", len(subscriptions))
|
||||
payload, err := json.Marshal(newWebPushPayload(fmt.Sprintf("%s/%s", s.config.BaseURL, m.Topic), m))
|
||||
payload, err := json.Marshal(newWebPushPayload(fmt.Sprintf("%s/%s", s.config.BaseURL, m.Topic), m.ForJSON()))
|
||||
if err != nil {
|
||||
log.Tag(tagWebPush).Err(err).With(v, m).Warn("Unable to marshal expiring payload")
|
||||
return
|
||||
@@ -128,7 +130,7 @@ func (s *Server) pruneAndNotifyWebPushSubscriptionsInternal() error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
warningSent := make([]*webPushSubscription, 0)
|
||||
warningSent := make([]*wpush.Subscription, 0)
|
||||
for _, subscription := range subscriptions {
|
||||
if err := s.sendWebPushNotification(subscription, payload); err != nil {
|
||||
log.Tag(tagWebPush).Err(err).With(subscription).Warn("Unable to publish expiry imminent warning")
|
||||
@@ -143,7 +145,7 @@ func (s *Server) pruneAndNotifyWebPushSubscriptionsInternal() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) sendWebPushNotification(sub *webPushSubscription, message []byte, contexters ...log.Contexter) error {
|
||||
func (s *Server) sendWebPushNotification(sub *wpush.Subscription, message []byte, contexters ...log.Contexter) error {
|
||||
log.Tag(tagWebPush).With(sub).With(contexters...).Debug("Sending web push message")
|
||||
payload := &webpush.Subscription{
|
||||
Endpoint: sub.Endpoint,
|
||||
|
||||
@@ -4,6 +4,8 @@ package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"heckel.io/ntfy/v2/model"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -20,7 +22,7 @@ func (s *Server) handleWebPushDelete(w http.ResponseWriter, r *http.Request, _ *
|
||||
return errHTTPNotFound
|
||||
}
|
||||
|
||||
func (s *Server) publishToWebPushEndpoints(v *visitor, m *message) {
|
||||
func (s *Server) publishToWebPushEndpoints(v *visitor, m *model.Message) {
|
||||
// Nothing to see here
|
||||
}
|
||||
|
||||
|
||||
@@ -5,10 +5,6 @@ package server
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"github.com/SherClockHolmes/webpush-go"
|
||||
"github.com/stretchr/testify/require"
|
||||
"heckel.io/ntfy/v2/user"
|
||||
"heckel.io/ntfy/v2/util"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@@ -18,6 +14,11 @@ import (
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/SherClockHolmes/webpush-go"
|
||||
"github.com/stretchr/testify/require"
|
||||
"heckel.io/ntfy/v2/user"
|
||||
"heckel.io/ntfy/v2/util"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -25,237 +26,262 @@ const (
|
||||
)
|
||||
|
||||
func TestServer_WebPush_Enabled(t *testing.T) {
|
||||
conf := newTestConfig(t)
|
||||
conf.WebRoot = "" // Disable web app
|
||||
s := newTestServer(t, conf)
|
||||
forEachBackend(t, func(t *testing.T) {
|
||||
conf := newTestConfig(t)
|
||||
conf.WebRoot = "" // Disable web app
|
||||
s := newTestServer(t, conf)
|
||||
|
||||
rr := request(t, s, "GET", "/manifest.webmanifest", "", nil)
|
||||
require.Equal(t, 404, rr.Code)
|
||||
rr := request(t, s, "GET", "/manifest.webmanifest", "", nil)
|
||||
require.Equal(t, 404, rr.Code)
|
||||
|
||||
conf2 := newTestConfig(t)
|
||||
s2 := newTestServer(t, conf2)
|
||||
conf2 := newTestConfig(t)
|
||||
s2 := newTestServer(t, conf2)
|
||||
|
||||
rr = request(t, s2, "GET", "/manifest.webmanifest", "", nil)
|
||||
require.Equal(t, 404, rr.Code)
|
||||
rr = request(t, s2, "GET", "/manifest.webmanifest", "", nil)
|
||||
require.Equal(t, 404, rr.Code)
|
||||
|
||||
conf3 := newTestConfigWithWebPush(t)
|
||||
s3 := newTestServer(t, conf3)
|
||||
conf3 := newTestConfigWithWebPush(t)
|
||||
s3 := newTestServer(t, conf3)
|
||||
|
||||
rr = request(t, s3, "GET", "/manifest.webmanifest", "", nil)
|
||||
require.Equal(t, 200, rr.Code)
|
||||
require.Equal(t, "application/manifest+json", rr.Header().Get("Content-Type"))
|
||||
rr = request(t, s3, "GET", "/manifest.webmanifest", "", nil)
|
||||
require.Equal(t, 200, rr.Code)
|
||||
require.Equal(t, "application/manifest+json", rr.Header().Get("Content-Type"))
|
||||
|
||||
})
|
||||
}
|
||||
func TestServer_WebPush_Disabled(t *testing.T) {
|
||||
s := newTestServer(t, newTestConfig(t))
|
||||
forEachBackend(t, func(t *testing.T) {
|
||||
s := newTestServer(t, newTestConfig(t))
|
||||
|
||||
response := request(t, s, "POST", "/v1/webpush", payloadForTopics(t, []string{"test-topic"}, testWebPushEndpoint), nil)
|
||||
require.Equal(t, 404, response.Code)
|
||||
response := request(t, s, "POST", "/v1/webpush", payloadForTopics(t, []string{"test-topic"}, testWebPushEndpoint), nil)
|
||||
require.Equal(t, 404, response.Code)
|
||||
})
|
||||
}
|
||||
|
||||
func TestServer_WebPush_TopicAdd(t *testing.T) {
|
||||
s := newTestServer(t, newTestConfigWithWebPush(t))
|
||||
forEachBackend(t, func(t *testing.T) {
|
||||
s := newTestServer(t, newTestConfigWithWebPush(t))
|
||||
|
||||
response := request(t, s, "POST", "/v1/webpush", payloadForTopics(t, []string{"test-topic"}, testWebPushEndpoint), nil)
|
||||
require.Equal(t, 200, response.Code)
|
||||
require.Equal(t, `{"success":true}`+"\n", response.Body.String())
|
||||
response := request(t, s, "POST", "/v1/webpush", payloadForTopics(t, []string{"test-topic"}, testWebPushEndpoint), nil)
|
||||
require.Equal(t, 200, response.Code)
|
||||
require.Equal(t, `{"success":true}`+"\n", response.Body.String())
|
||||
|
||||
subs, err := s.webPush.SubscriptionsForTopic("test-topic")
|
||||
require.Nil(t, err)
|
||||
subs, err := s.webPush.SubscriptionsForTopic("test-topic")
|
||||
require.Nil(t, err)
|
||||
|
||||
require.Len(t, subs, 1)
|
||||
require.Equal(t, subs[0].Endpoint, testWebPushEndpoint)
|
||||
require.Equal(t, subs[0].P256dh, "p256dh-key")
|
||||
require.Equal(t, subs[0].Auth, "auth-key")
|
||||
require.Equal(t, subs[0].UserID, "")
|
||||
require.Len(t, subs, 1)
|
||||
require.Equal(t, subs[0].Endpoint, testWebPushEndpoint)
|
||||
require.Equal(t, subs[0].P256dh, "p256dh-key")
|
||||
require.Equal(t, subs[0].Auth, "auth-key")
|
||||
require.Equal(t, subs[0].UserID, "")
|
||||
})
|
||||
}
|
||||
|
||||
func TestServer_WebPush_TopicAdd_InvalidEndpoint(t *testing.T) {
|
||||
s := newTestServer(t, newTestConfigWithWebPush(t))
|
||||
forEachBackend(t, func(t *testing.T) {
|
||||
s := newTestServer(t, newTestConfigWithWebPush(t))
|
||||
|
||||
response := request(t, s, "POST", "/v1/webpush", payloadForTopics(t, []string{"test-topic"}, "https://ddos-target.example.com/webpush"), nil)
|
||||
require.Equal(t, 400, response.Code)
|
||||
require.Equal(t, `{"code":40039,"http":400,"error":"invalid request: web push endpoint unknown"}`+"\n", response.Body.String())
|
||||
response := request(t, s, "POST", "/v1/webpush", payloadForTopics(t, []string{"test-topic"}, "https://ddos-target.example.com/webpush"), nil)
|
||||
require.Equal(t, 400, response.Code)
|
||||
require.Equal(t, `{"code":40039,"http":400,"error":"invalid request: web push endpoint unknown"}`+"\n", response.Body.String())
|
||||
})
|
||||
}
|
||||
|
||||
func TestServer_WebPush_TopicAdd_TooManyTopics(t *testing.T) {
|
||||
s := newTestServer(t, newTestConfigWithWebPush(t))
|
||||
forEachBackend(t, func(t *testing.T) {
|
||||
s := newTestServer(t, newTestConfigWithWebPush(t))
|
||||
|
||||
topicList := make([]string, 51)
|
||||
for i := range topicList {
|
||||
topicList[i] = util.RandomString(5)
|
||||
}
|
||||
topicList := make([]string, 51)
|
||||
for i := range topicList {
|
||||
topicList[i] = util.RandomString(5)
|
||||
}
|
||||
|
||||
response := request(t, s, "POST", "/v1/webpush", payloadForTopics(t, topicList, testWebPushEndpoint), nil)
|
||||
require.Equal(t, 400, response.Code)
|
||||
require.Equal(t, `{"code":40040,"http":400,"error":"invalid request: too many web push topic subscriptions"}`+"\n", response.Body.String())
|
||||
response := request(t, s, "POST", "/v1/webpush", payloadForTopics(t, topicList, testWebPushEndpoint), nil)
|
||||
require.Equal(t, 400, response.Code)
|
||||
require.Equal(t, `{"code":40040,"http":400,"error":"invalid request: too many web push topic subscriptions"}`+"\n", response.Body.String())
|
||||
})
|
||||
}
|
||||
|
||||
func TestServer_WebPush_TopicUnsubscribe(t *testing.T) {
|
||||
s := newTestServer(t, newTestConfigWithWebPush(t))
|
||||
forEachBackend(t, func(t *testing.T) {
|
||||
s := newTestServer(t, newTestConfigWithWebPush(t))
|
||||
|
||||
addSubscription(t, s, testWebPushEndpoint, "test-topic")
|
||||
requireSubscriptionCount(t, s, "test-topic", 1)
|
||||
addSubscription(t, s, testWebPushEndpoint, "test-topic")
|
||||
requireSubscriptionCount(t, s, "test-topic", 1)
|
||||
|
||||
response := request(t, s, "POST", "/v1/webpush", payloadForTopics(t, []string{}, testWebPushEndpoint), nil)
|
||||
require.Equal(t, 200, response.Code)
|
||||
require.Equal(t, `{"success":true}`+"\n", response.Body.String())
|
||||
response := request(t, s, "POST", "/v1/webpush", payloadForTopics(t, []string{}, testWebPushEndpoint), nil)
|
||||
require.Equal(t, 200, response.Code)
|
||||
require.Equal(t, `{"success":true}`+"\n", response.Body.String())
|
||||
|
||||
requireSubscriptionCount(t, s, "test-topic", 0)
|
||||
requireSubscriptionCount(t, s, "test-topic", 0)
|
||||
})
|
||||
}
|
||||
|
||||
func TestServer_WebPush_Delete(t *testing.T) {
|
||||
s := newTestServer(t, newTestConfigWithWebPush(t))
|
||||
forEachBackend(t, func(t *testing.T) {
|
||||
s := newTestServer(t, newTestConfigWithWebPush(t))
|
||||
|
||||
addSubscription(t, s, testWebPushEndpoint, "test-topic")
|
||||
requireSubscriptionCount(t, s, "test-topic", 1)
|
||||
addSubscription(t, s, testWebPushEndpoint, "test-topic")
|
||||
requireSubscriptionCount(t, s, "test-topic", 1)
|
||||
|
||||
response := request(t, s, "DELETE", "/v1/webpush", fmt.Sprintf(`{"endpoint":"%s"}`, testWebPushEndpoint), nil)
|
||||
require.Equal(t, 200, response.Code)
|
||||
require.Equal(t, `{"success":true}`+"\n", response.Body.String())
|
||||
response := request(t, s, "DELETE", "/v1/webpush", fmt.Sprintf(`{"endpoint":"%s"}`, testWebPushEndpoint), nil)
|
||||
require.Equal(t, 200, response.Code)
|
||||
require.Equal(t, `{"success":true}`+"\n", response.Body.String())
|
||||
|
||||
requireSubscriptionCount(t, s, "test-topic", 0)
|
||||
requireSubscriptionCount(t, s, "test-topic", 0)
|
||||
})
|
||||
}
|
||||
|
||||
func TestServer_WebPush_TopicSubscribeProtected_Allowed(t *testing.T) {
|
||||
config := configureAuth(t, newTestConfigWithWebPush(t))
|
||||
config.AuthDefault = user.PermissionDenyAll
|
||||
s := newTestServer(t, config)
|
||||
forEachBackend(t, func(t *testing.T) {
|
||||
config := configureAuth(t, newTestConfigWithWebPush(t))
|
||||
config.AuthDefault = user.PermissionDenyAll
|
||||
s := newTestServer(t, config)
|
||||
|
||||
require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser, false))
|
||||
require.Nil(t, s.userManager.AllowAccess("ben", "test-topic", user.PermissionReadWrite))
|
||||
require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser, false))
|
||||
require.Nil(t, s.userManager.AllowAccess("ben", "test-topic", user.PermissionReadWrite))
|
||||
|
||||
response := request(t, s, "POST", "/v1/webpush", payloadForTopics(t, []string{"test-topic"}, testWebPushEndpoint), map[string]string{
|
||||
"Authorization": util.BasicAuth("ben", "ben"),
|
||||
response := request(t, s, "POST", "/v1/webpush", payloadForTopics(t, []string{"test-topic"}, testWebPushEndpoint), map[string]string{
|
||||
"Authorization": util.BasicAuth("ben", "ben"),
|
||||
})
|
||||
require.Equal(t, 200, response.Code)
|
||||
require.Equal(t, `{"success":true}`+"\n", response.Body.String())
|
||||
|
||||
subs, err := s.webPush.SubscriptionsForTopic("test-topic")
|
||||
require.Nil(t, err)
|
||||
require.Len(t, subs, 1)
|
||||
require.True(t, strings.HasPrefix(subs[0].UserID, "u_"))
|
||||
})
|
||||
require.Equal(t, 200, response.Code)
|
||||
require.Equal(t, `{"success":true}`+"\n", response.Body.String())
|
||||
|
||||
subs, err := s.webPush.SubscriptionsForTopic("test-topic")
|
||||
require.Nil(t, err)
|
||||
require.Len(t, subs, 1)
|
||||
require.True(t, strings.HasPrefix(subs[0].UserID, "u_"))
|
||||
}
|
||||
|
||||
func TestServer_WebPush_TopicSubscribeProtected_Denied(t *testing.T) {
|
||||
config := configureAuth(t, newTestConfigWithWebPush(t))
|
||||
config.AuthDefault = user.PermissionDenyAll
|
||||
s := newTestServer(t, config)
|
||||
forEachBackend(t, func(t *testing.T) {
|
||||
config := configureAuth(t, newTestConfigWithWebPush(t))
|
||||
config.AuthDefault = user.PermissionDenyAll
|
||||
s := newTestServer(t, config)
|
||||
|
||||
response := request(t, s, "POST", "/v1/webpush", payloadForTopics(t, []string{"test-topic"}, testWebPushEndpoint), nil)
|
||||
require.Equal(t, 403, response.Code)
|
||||
response := request(t, s, "POST", "/v1/webpush", payloadForTopics(t, []string{"test-topic"}, testWebPushEndpoint), nil)
|
||||
require.Equal(t, 403, response.Code)
|
||||
|
||||
requireSubscriptionCount(t, s, "test-topic", 0)
|
||||
requireSubscriptionCount(t, s, "test-topic", 0)
|
||||
})
|
||||
}
|
||||
|
||||
func TestServer_WebPush_DeleteAccountUnsubscribe(t *testing.T) {
|
||||
config := configureAuth(t, newTestConfigWithWebPush(t))
|
||||
s := newTestServer(t, config)
|
||||
forEachBackend(t, func(t *testing.T) {
|
||||
config := configureAuth(t, newTestConfigWithWebPush(t))
|
||||
s := newTestServer(t, config)
|
||||
|
||||
require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser, false))
|
||||
require.Nil(t, s.userManager.AllowAccess("ben", "test-topic", user.PermissionReadWrite))
|
||||
require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser, false))
|
||||
require.Nil(t, s.userManager.AllowAccess("ben", "test-topic", user.PermissionReadWrite))
|
||||
|
||||
response := request(t, s, "POST", "/v1/webpush", payloadForTopics(t, []string{"test-topic"}, testWebPushEndpoint), map[string]string{
|
||||
"Authorization": util.BasicAuth("ben", "ben"),
|
||||
response := request(t, s, "POST", "/v1/webpush", payloadForTopics(t, []string{"test-topic"}, testWebPushEndpoint), map[string]string{
|
||||
"Authorization": util.BasicAuth("ben", "ben"),
|
||||
})
|
||||
|
||||
require.Equal(t, 200, response.Code)
|
||||
require.Equal(t, `{"success":true}`+"\n", response.Body.String())
|
||||
|
||||
requireSubscriptionCount(t, s, "test-topic", 1)
|
||||
|
||||
request(t, s, "DELETE", "/v1/account", `{"password":"ben"}`, map[string]string{
|
||||
"Authorization": util.BasicAuth("ben", "ben"),
|
||||
})
|
||||
// should've been deleted with the account
|
||||
requireSubscriptionCount(t, s, "test-topic", 0)
|
||||
})
|
||||
|
||||
require.Equal(t, 200, response.Code)
|
||||
require.Equal(t, `{"success":true}`+"\n", response.Body.String())
|
||||
|
||||
requireSubscriptionCount(t, s, "test-topic", 1)
|
||||
|
||||
request(t, s, "DELETE", "/v1/account", `{"password":"ben"}`, map[string]string{
|
||||
"Authorization": util.BasicAuth("ben", "ben"),
|
||||
})
|
||||
// should've been deleted with the account
|
||||
requireSubscriptionCount(t, s, "test-topic", 0)
|
||||
}
|
||||
|
||||
func TestServer_WebPush_Publish(t *testing.T) {
|
||||
s := newTestServer(t, newTestConfigWithWebPush(t))
|
||||
forEachBackend(t, func(t *testing.T) {
|
||||
s := newTestServer(t, newTestConfigWithWebPush(t))
|
||||
|
||||
var received atomic.Bool
|
||||
pushService := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, err := io.ReadAll(r.Body)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, "/push-receive", r.URL.Path)
|
||||
require.Equal(t, "high", r.Header.Get("Urgency"))
|
||||
require.Equal(t, "", r.Header.Get("Topic"))
|
||||
received.Store(true)
|
||||
}))
|
||||
defer pushService.Close()
|
||||
var received atomic.Bool
|
||||
pushService := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, err := io.ReadAll(r.Body)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, "/push-receive", r.URL.Path)
|
||||
require.Equal(t, "high", r.Header.Get("Urgency"))
|
||||
require.Equal(t, "", r.Header.Get("Topic"))
|
||||
received.Store(true)
|
||||
}))
|
||||
defer pushService.Close()
|
||||
|
||||
addSubscription(t, s, pushService.URL+"/push-receive", "test-topic")
|
||||
request(t, s, "POST", "/test-topic", "web push test", nil)
|
||||
addSubscription(t, s, pushService.URL+"/push-receive", "test-topic")
|
||||
request(t, s, "POST", "/test-topic", "web push test", nil)
|
||||
|
||||
waitFor(t, func() bool {
|
||||
return received.Load()
|
||||
waitFor(t, func() bool {
|
||||
return received.Load()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestServer_WebPush_Publish_RemoveOnError(t *testing.T) {
|
||||
s := newTestServer(t, newTestConfigWithWebPush(t))
|
||||
forEachBackend(t, func(t *testing.T) {
|
||||
s := newTestServer(t, newTestConfigWithWebPush(t))
|
||||
|
||||
var received atomic.Bool
|
||||
pushService := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, err := io.ReadAll(r.Body)
|
||||
require.Nil(t, err)
|
||||
w.WriteHeader(http.StatusGone)
|
||||
received.Store(true)
|
||||
}))
|
||||
defer pushService.Close()
|
||||
var received atomic.Bool
|
||||
pushService := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, err := io.ReadAll(r.Body)
|
||||
require.Nil(t, err)
|
||||
w.WriteHeader(http.StatusGone)
|
||||
received.Store(true)
|
||||
}))
|
||||
defer pushService.Close()
|
||||
|
||||
addSubscription(t, s, pushService.URL+"/push-receive", "test-topic", "test-topic-abc")
|
||||
requireSubscriptionCount(t, s, "test-topic", 1)
|
||||
requireSubscriptionCount(t, s, "test-topic-abc", 1)
|
||||
addSubscription(t, s, pushService.URL+"/push-receive", "test-topic", "test-topic-abc")
|
||||
requireSubscriptionCount(t, s, "test-topic", 1)
|
||||
requireSubscriptionCount(t, s, "test-topic-abc", 1)
|
||||
|
||||
request(t, s, "POST", "/test-topic", "web push test", nil)
|
||||
request(t, s, "POST", "/test-topic", "web push test", nil)
|
||||
|
||||
waitFor(t, func() bool {
|
||||
return received.Load()
|
||||
waitFor(t, func() bool {
|
||||
return received.Load()
|
||||
})
|
||||
|
||||
// Receiving the 410 should've caused the publisher to expire all subscriptions on the endpoint
|
||||
|
||||
requireSubscriptionCount(t, s, "test-topic", 0)
|
||||
requireSubscriptionCount(t, s, "test-topic-abc", 0)
|
||||
})
|
||||
|
||||
// Receiving the 410 should've caused the publisher to expire all subscriptions on the endpoint
|
||||
|
||||
requireSubscriptionCount(t, s, "test-topic", 0)
|
||||
requireSubscriptionCount(t, s, "test-topic-abc", 0)
|
||||
}
|
||||
|
||||
func TestServer_WebPush_Expiry(t *testing.T) {
|
||||
s := newTestServer(t, newTestConfigWithWebPush(t))
|
||||
forEachBackend(t, func(t *testing.T) {
|
||||
s := newTestServer(t, newTestConfigWithWebPush(t))
|
||||
|
||||
var received atomic.Bool
|
||||
var received atomic.Bool
|
||||
|
||||
pushService := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, err := io.ReadAll(r.Body)
|
||||
require.Nil(t, err)
|
||||
w.WriteHeader(200)
|
||||
w.Write([]byte(``))
|
||||
received.Store(true)
|
||||
}))
|
||||
defer pushService.Close()
|
||||
pushService := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, err := io.ReadAll(r.Body)
|
||||
require.Nil(t, err)
|
||||
w.WriteHeader(200)
|
||||
w.Write([]byte(``))
|
||||
received.Store(true)
|
||||
}))
|
||||
defer pushService.Close()
|
||||
|
||||
addSubscription(t, s, pushService.URL+"/push-receive", "test-topic")
|
||||
requireSubscriptionCount(t, s, "test-topic", 1)
|
||||
endpoint := pushService.URL + "/push-receive"
|
||||
addSubscription(t, s, endpoint, "test-topic")
|
||||
requireSubscriptionCount(t, s, "test-topic", 1)
|
||||
|
||||
_, err := s.webPush.db.Exec("UPDATE subscription SET updated_at = ?", time.Now().Add(-55*24*time.Hour).Unix())
|
||||
require.Nil(t, err)
|
||||
require.Nil(t, s.webPush.SetSubscriptionUpdatedAt(endpoint, time.Now().Add(-55*24*time.Hour).Unix()))
|
||||
|
||||
s.pruneAndNotifyWebPushSubscriptions()
|
||||
requireSubscriptionCount(t, s, "test-topic", 1)
|
||||
s.pruneAndNotifyWebPushSubscriptions()
|
||||
requireSubscriptionCount(t, s, "test-topic", 1)
|
||||
|
||||
waitFor(t, func() bool {
|
||||
return received.Load()
|
||||
})
|
||||
waitFor(t, func() bool {
|
||||
return received.Load()
|
||||
})
|
||||
|
||||
_, err = s.webPush.db.Exec("UPDATE subscription SET updated_at = ?", time.Now().Add(-60*24*time.Hour).Unix())
|
||||
require.Nil(t, err)
|
||||
require.Nil(t, s.webPush.SetSubscriptionUpdatedAt(endpoint, time.Now().Add(-60*24*time.Hour).Unix()))
|
||||
|
||||
s.pruneAndNotifyWebPushSubscriptions()
|
||||
waitFor(t, func() bool {
|
||||
subs, err := s.webPush.SubscriptionsForTopic("test-topic")
|
||||
require.Nil(t, err)
|
||||
return len(subs) == 0
|
||||
s.pruneAndNotifyWebPushSubscriptions()
|
||||
waitFor(t, func() bool {
|
||||
subs, err := s.webPush.SubscriptionsForTopic("test-topic")
|
||||
require.Nil(t, err)
|
||||
return len(subs) == 0
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -285,7 +311,9 @@ func newTestConfigWithWebPush(t *testing.T) *Config {
|
||||
conf := newTestConfig(t)
|
||||
privateKey, publicKey, err := webpush.GenerateVAPIDKeys()
|
||||
require.Nil(t, err)
|
||||
conf.WebPushFile = filepath.Join(t.TempDir(), "webpush.db")
|
||||
if conf.DatabaseURL == "" {
|
||||
conf.WebPushFile = filepath.Join(t.TempDir(), "webpush.db")
|
||||
}
|
||||
conf.WebPushEmailAddress = "testing@example.com"
|
||||
conf.WebPushPrivateKey = privateKey
|
||||
conf.WebPushPublicKey = publicKey
|
||||
|
||||
@@ -12,11 +12,12 @@ import (
|
||||
"time"
|
||||
|
||||
"heckel.io/ntfy/v2/log"
|
||||
"heckel.io/ntfy/v2/model"
|
||||
"heckel.io/ntfy/v2/util"
|
||||
)
|
||||
|
||||
type mailer interface {
|
||||
Send(v *visitor, m *message, to string) error
|
||||
Send(v *visitor, m *model.Message, to string) error
|
||||
Counts() (total int64, success int64, failure int64)
|
||||
}
|
||||
|
||||
@@ -27,7 +28,7 @@ type smtpSender struct {
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func (s *smtpSender) Send(v *visitor, m *message, to string) error {
|
||||
func (s *smtpSender) Send(v *visitor, m *model.Message, to string) error {
|
||||
return s.withCount(v, m, func() error {
|
||||
host, _, err := net.SplitHostPort(s.config.SMTPSenderAddr)
|
||||
if err != nil {
|
||||
@@ -63,7 +64,7 @@ func (s *smtpSender) Counts() (total int64, success int64, failure int64) {
|
||||
return s.success + s.failure, s.success, s.failure
|
||||
}
|
||||
|
||||
func (s *smtpSender) withCount(v *visitor, m *message, fn func() error) error {
|
||||
func (s *smtpSender) withCount(v *visitor, m *model.Message, fn func() error) error {
|
||||
err := fn()
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
@@ -76,7 +77,7 @@ func (s *smtpSender) withCount(v *visitor, m *message, fn func() error) error {
|
||||
return err
|
||||
}
|
||||
|
||||
func formatMail(baseURL, senderIP, from, to string, m *message) (string, error) {
|
||||
func formatMail(baseURL, senderIP, from, to string, m *model.Message) (string, error) {
|
||||
topicURL := baseURL + "/" + m.Topic
|
||||
subject := m.Title
|
||||
if subject == "" {
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"github.com/stretchr/testify/require"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"heckel.io/ntfy/v2/model"
|
||||
)
|
||||
|
||||
func TestFormatMail_Basic(t *testing.T) {
|
||||
actual, _ := formatMail("https://ntfy.sh", "1.2.3.4", "ntfy@ntfy.sh", "phil@example.com", &message{
|
||||
actual, _ := formatMail("https://ntfy.sh", "1.2.3.4", "ntfy@ntfy.sh", "phil@example.com", &model.Message{
|
||||
ID: "abc",
|
||||
Time: 1640382204,
|
||||
Event: "message",
|
||||
@@ -27,7 +29,7 @@ This message was sent by 1.2.3.4 at Fri, 24 Dec 2021 21:43:24 UTC via https://nt
|
||||
}
|
||||
|
||||
func TestFormatMail_JustEmojis(t *testing.T) {
|
||||
actual, _ := formatMail("https://ntfy.sh", "1.2.3.4", "ntfy@ntfy.sh", "phil@example.com", &message{
|
||||
actual, _ := formatMail("https://ntfy.sh", "1.2.3.4", "ntfy@ntfy.sh", "phil@example.com", &model.Message{
|
||||
ID: "abc",
|
||||
Time: 1640382204,
|
||||
Event: "message",
|
||||
@@ -49,7 +51,7 @@ This message was sent by 1.2.3.4 at Fri, 24 Dec 2021 21:43:24 UTC via https://nt
|
||||
}
|
||||
|
||||
func TestFormatMail_JustOtherTags(t *testing.T) {
|
||||
actual, _ := formatMail("https://ntfy.sh", "1.2.3.4", "ntfy@ntfy.sh", "phil@example.com", &message{
|
||||
actual, _ := formatMail("https://ntfy.sh", "1.2.3.4", "ntfy@ntfy.sh", "phil@example.com", &model.Message{
|
||||
ID: "abc",
|
||||
Time: 1640382204,
|
||||
Event: "message",
|
||||
@@ -73,7 +75,7 @@ This message was sent by 1.2.3.4 at Fri, 24 Dec 2021 21:43:24 UTC via https://nt
|
||||
}
|
||||
|
||||
func TestFormatMail_JustPriority(t *testing.T) {
|
||||
actual, _ := formatMail("https://ntfy.sh", "1.2.3.4", "ntfy@ntfy.sh", "phil@example.com", &message{
|
||||
actual, _ := formatMail("https://ntfy.sh", "1.2.3.4", "ntfy@ntfy.sh", "phil@example.com", &model.Message{
|
||||
ID: "abc",
|
||||
Time: 1640382204,
|
||||
Event: "message",
|
||||
@@ -97,7 +99,7 @@ This message was sent by 1.2.3.4 at Fri, 24 Dec 2021 21:43:24 UTC via https://nt
|
||||
}
|
||||
|
||||
func TestFormatMail_UTF8Subject(t *testing.T) {
|
||||
actual, _ := formatMail("https://ntfy.sh", "1.2.3.4", "ntfy@ntfy.sh", "phil@example.com", &message{
|
||||
actual, _ := formatMail("https://ntfy.sh", "1.2.3.4", "ntfy@ntfy.sh", "phil@example.com", &model.Message{
|
||||
ID: "abc",
|
||||
Time: 1640382204,
|
||||
Event: "message",
|
||||
@@ -119,7 +121,7 @@ This message was sent by 1.2.3.4 at Fri, 24 Dec 2021 21:43:24 UTC via https://nt
|
||||
}
|
||||
|
||||
func TestFormatMail_WithAllTheThings(t *testing.T) {
|
||||
actual, _ := formatMail("https://ntfy.sh", "1.2.3.4", "ntfy@ntfy.sh", "phil@example.com", &message{
|
||||
actual, _ := formatMail("https://ntfy.sh", "1.2.3.4", "ntfy@ntfy.sh", "phil@example.com", &model.Message{
|
||||
ID: "abc",
|
||||
Time: 1640382204,
|
||||
Event: "message",
|
||||
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
|
||||
"github.com/emersion/go-smtp"
|
||||
"github.com/microcosm-cc/bluemonday"
|
||||
"heckel.io/ntfy/v2/model"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -183,7 +184,7 @@ func (s *smtpSession) Data(r io.Reader) error {
|
||||
})
|
||||
}
|
||||
|
||||
func (s *smtpSession) publishMessage(m *message) error {
|
||||
func (s *smtpSession) publishMessage(m *model.Message) error {
|
||||
// Extract remote address (for rate limiting)
|
||||
remoteAddr, _, err := net.SplitHostPort(s.conn.Conn().RemoteAddr().String())
|
||||
if err != nil {
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"time"
|
||||
|
||||
"heckel.io/ntfy/v2/log"
|
||||
"heckel.io/ntfy/v2/model"
|
||||
"heckel.io/ntfy/v2/util"
|
||||
)
|
||||
|
||||
@@ -33,7 +34,7 @@ type topicSubscriber struct {
|
||||
}
|
||||
|
||||
// subscriber is a function that is called for every new message on a topic
|
||||
type subscriber func(v *visitor, msg *message) error
|
||||
type subscriber func(v *visitor, msg *model.Message) error
|
||||
|
||||
// newTopic creates a new topic
|
||||
func newTopic(id string) *topic {
|
||||
@@ -103,7 +104,7 @@ func (t *topic) Unsubscribe(id int) {
|
||||
}
|
||||
|
||||
// Publish asynchronously publishes to all subscribers
|
||||
func (t *topic) Publish(v *visitor, m *message) error {
|
||||
func (t *topic) Publish(v *visitor, m *model.Message) error {
|
||||
go func() {
|
||||
// We want to lock the topic as short as possible, so we make a shallow copy of the
|
||||
// subscribers map here. Actually sending out the messages then doesn't have to lock.
|
||||
|
||||
@@ -7,10 +7,11 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"heckel.io/ntfy/v2/model"
|
||||
)
|
||||
|
||||
func TestTopic_CancelSubscribersExceptUser(t *testing.T) {
|
||||
subFn := func(v *visitor, msg *message) error {
|
||||
subFn := func(v *visitor, msg *model.Message) error {
|
||||
return nil
|
||||
}
|
||||
canceled1 := atomic.Bool{}
|
||||
@@ -33,7 +34,7 @@ func TestTopic_CancelSubscribersExceptUser(t *testing.T) {
|
||||
func TestTopic_CancelSubscribersUser(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
subFn := func(v *visitor, msg *message) error {
|
||||
subFn := func(v *visitor, msg *model.Message) error {
|
||||
return nil
|
||||
}
|
||||
canceled1 := atomic.Bool{}
|
||||
@@ -76,7 +77,7 @@ func TestTopic_Subscribe_DuplicateID(t *testing.T) {
|
||||
cancel: func() {},
|
||||
}
|
||||
|
||||
subFn := func(v *visitor, msg *message) error {
|
||||
subFn := func(v *visitor, msg *model.Message) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
265
server/types.go
@@ -2,195 +2,78 @@ package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"time"
|
||||
|
||||
"heckel.io/ntfy/v2/log"
|
||||
"heckel.io/ntfy/v2/model"
|
||||
"heckel.io/ntfy/v2/user"
|
||||
"heckel.io/ntfy/v2/util"
|
||||
)
|
||||
|
||||
// List of possible events
|
||||
// Event constants
|
||||
const (
|
||||
openEvent = "open"
|
||||
keepaliveEvent = "keepalive"
|
||||
messageEvent = "message"
|
||||
pollRequestEvent = "poll_request"
|
||||
openEvent = model.OpenEvent
|
||||
keepaliveEvent = model.KeepaliveEvent
|
||||
messageEvent = model.MessageEvent
|
||||
messageDeleteEvent = model.MessageDeleteEvent
|
||||
messageClearEvent = model.MessageClearEvent
|
||||
pollRequestEvent = model.PollRequestEvent
|
||||
messageIDLength = model.MessageIDLength
|
||||
)
|
||||
|
||||
const (
|
||||
messageIDLength = 12
|
||||
// SinceMarker aliases
|
||||
var (
|
||||
sinceAllMessages = model.SinceAllMessages
|
||||
sinceNoMessages = model.SinceNoMessages
|
||||
sinceLatestMessage = model.SinceLatestMessage
|
||||
)
|
||||
|
||||
// message represents a message published to a topic
|
||||
type message struct {
|
||||
ID string `json:"id"` // Random message ID
|
||||
Time int64 `json:"time"` // Unix time in seconds
|
||||
Expires int64 `json:"expires,omitempty"` // Unix time in seconds (not required for open/keepalive)
|
||||
Event string `json:"event"` // One of the above
|
||||
Topic string `json:"topic"`
|
||||
Title string `json:"title,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
Priority int `json:"priority,omitempty"`
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
Click string `json:"click,omitempty"`
|
||||
Icon string `json:"icon,omitempty"`
|
||||
Actions []*action `json:"actions,omitempty"`
|
||||
Attachment *attachment `json:"attachment,omitempty"`
|
||||
PollID string `json:"poll_id,omitempty"`
|
||||
ContentType string `json:"content_type,omitempty"` // text/plain by default (if empty), or text/markdown
|
||||
Encoding string `json:"encoding,omitempty"` // empty for raw UTF-8, or "base64" for encoded bytes
|
||||
Sender netip.Addr `json:"-"` // IP address of uploader, used for rate limiting
|
||||
User string `json:"-"` // UserID of the uploader, used to associated attachments
|
||||
}
|
||||
// Error aliases
|
||||
var (
|
||||
errMessageNotFound = model.ErrMessageNotFound
|
||||
)
|
||||
|
||||
func (m *message) Context() log.Context {
|
||||
fields := map[string]any{
|
||||
"topic": m.Topic,
|
||||
"message_id": m.ID,
|
||||
"message_time": m.Time,
|
||||
"message_event": m.Event,
|
||||
"message_body_size": len(m.Message),
|
||||
}
|
||||
if m.Sender.IsValid() {
|
||||
fields["message_sender"] = m.Sender.String()
|
||||
}
|
||||
if m.User != "" {
|
||||
fields["message_user"] = m.User
|
||||
}
|
||||
return fields
|
||||
}
|
||||
|
||||
type attachment struct {
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type,omitempty"`
|
||||
Size int64 `json:"size,omitempty"`
|
||||
Expires int64 `json:"expires,omitempty"`
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
type action struct {
|
||||
ID string `json:"id"`
|
||||
Action string `json:"action"` // "view", "broadcast", or "http"
|
||||
Label string `json:"label"` // action button label
|
||||
Clear bool `json:"clear"` // clear notification after successful execution
|
||||
URL string `json:"url,omitempty"` // used in "view" and "http" actions
|
||||
Method string `json:"method,omitempty"` // used in "http" action, default is POST (!)
|
||||
Headers map[string]string `json:"headers,omitempty"` // used in "http" action
|
||||
Body string `json:"body,omitempty"` // used in "http" action
|
||||
Intent string `json:"intent,omitempty"` // used in "broadcast" action
|
||||
Extras map[string]string `json:"extras,omitempty"` // used in "broadcast" action
|
||||
}
|
||||
|
||||
func newAction() *action {
|
||||
return &action{
|
||||
Headers: make(map[string]string),
|
||||
Extras: make(map[string]string),
|
||||
}
|
||||
}
|
||||
|
||||
// publishMessage is used as input when publishing as JSON
|
||||
type publishMessage struct {
|
||||
Topic string `json:"topic"`
|
||||
Title string `json:"title"`
|
||||
Message string `json:"message"`
|
||||
Priority int `json:"priority"`
|
||||
Tags []string `json:"tags"`
|
||||
Click string `json:"click"`
|
||||
Icon string `json:"icon"`
|
||||
Actions []action `json:"actions"`
|
||||
Attach string `json:"attach"`
|
||||
Markdown bool `json:"markdown"`
|
||||
Filename string `json:"filename"`
|
||||
Email string `json:"email"`
|
||||
Call string `json:"call"`
|
||||
Cache string `json:"cache"` // use string as it defaults to true (or use &bool instead)
|
||||
Firebase string `json:"firebase"` // use string as it defaults to true (or use &bool instead)
|
||||
Delay string `json:"delay"`
|
||||
}
|
||||
|
||||
// messageEncoder is a function that knows how to encode a message
|
||||
type messageEncoder func(msg *message) (string, error)
|
||||
|
||||
// newMessage creates a new message with the current timestamp
|
||||
func newMessage(event, topic, msg string) *message {
|
||||
return &message{
|
||||
ID: util.RandomString(messageIDLength),
|
||||
Time: time.Now().Unix(),
|
||||
Event: event,
|
||||
Topic: topic,
|
||||
Message: msg,
|
||||
}
|
||||
}
|
||||
|
||||
// newOpenMessage is a convenience method to create an open message
|
||||
func newOpenMessage(topic string) *message {
|
||||
return newMessage(openEvent, topic, "")
|
||||
}
|
||||
|
||||
// newKeepaliveMessage is a convenience method to create a keepalive message
|
||||
func newKeepaliveMessage(topic string) *message {
|
||||
return newMessage(keepaliveEvent, topic, "")
|
||||
}
|
||||
|
||||
// newDefaultMessage is a convenience method to create a notification message
|
||||
func newDefaultMessage(topic, msg string) *message {
|
||||
return newMessage(messageEvent, topic, msg)
|
||||
}
|
||||
// Constructors and helpers
|
||||
var (
|
||||
newMessage = model.NewMessage
|
||||
newDefaultMessage = model.NewDefaultMessage
|
||||
newOpenMessage = model.NewOpenMessage
|
||||
newKeepaliveMessage = model.NewKeepaliveMessage
|
||||
newActionMessage = model.NewActionMessage
|
||||
newAction = model.NewAction
|
||||
newSinceTime = model.NewSinceTime
|
||||
newSinceID = model.NewSinceID
|
||||
validMessageID = model.ValidMessageID
|
||||
)
|
||||
|
||||
// newPollRequestMessage is a convenience method to create a poll request message
|
||||
func newPollRequestMessage(topic, pollID string) *message {
|
||||
func newPollRequestMessage(topic, pollID string) *model.Message {
|
||||
m := newMessage(pollRequestEvent, topic, newMessageBody)
|
||||
m.PollID = pollID
|
||||
return m
|
||||
}
|
||||
|
||||
func validMessageID(s string) bool {
|
||||
return util.ValidRandomString(s, messageIDLength)
|
||||
// publishMessage is used as input when publishing as JSON
|
||||
type publishMessage struct {
|
||||
Topic string `json:"topic"`
|
||||
SequenceID string `json:"sequence_id"`
|
||||
Title string `json:"title"`
|
||||
Message string `json:"message"`
|
||||
Priority int `json:"priority"`
|
||||
Tags []string `json:"tags"`
|
||||
Click string `json:"click"`
|
||||
Icon string `json:"icon"`
|
||||
Actions []model.Action `json:"actions"`
|
||||
Attach string `json:"attach"`
|
||||
Markdown bool `json:"markdown"`
|
||||
Filename string `json:"filename"`
|
||||
Email string `json:"email"`
|
||||
Call string `json:"call"`
|
||||
Cache string `json:"cache"` // use string as it defaults to true (or use &bool instead)
|
||||
Firebase string `json:"firebase"` // use string as it defaults to true (or use &bool instead)
|
||||
Delay string `json:"delay"`
|
||||
}
|
||||
|
||||
type sinceMarker struct {
|
||||
time time.Time
|
||||
id string
|
||||
}
|
||||
|
||||
func newSinceTime(timestamp int64) sinceMarker {
|
||||
return sinceMarker{time.Unix(timestamp, 0), ""}
|
||||
}
|
||||
|
||||
func newSinceID(id string) sinceMarker {
|
||||
return sinceMarker{time.Unix(0, 0), id}
|
||||
}
|
||||
|
||||
func (t sinceMarker) IsAll() bool {
|
||||
return t == sinceAllMessages
|
||||
}
|
||||
|
||||
func (t sinceMarker) IsNone() bool {
|
||||
return t == sinceNoMessages
|
||||
}
|
||||
|
||||
func (t sinceMarker) IsLatest() bool {
|
||||
return t == sinceLatestMessage
|
||||
}
|
||||
|
||||
func (t sinceMarker) IsID() bool {
|
||||
return t.id != "" && t.id != "latest"
|
||||
}
|
||||
|
||||
func (t sinceMarker) Time() time.Time {
|
||||
return t.time
|
||||
}
|
||||
|
||||
func (t sinceMarker) ID() string {
|
||||
return t.id
|
||||
}
|
||||
|
||||
var (
|
||||
sinceAllMessages = sinceMarker{time.Unix(0, 0), ""}
|
||||
sinceNoMessages = sinceMarker{time.Unix(1, 0), ""}
|
||||
sinceLatestMessage = sinceMarker{time.Unix(0, 0), "latest"}
|
||||
)
|
||||
// messageEncoder is a function that knows how to encode a message
|
||||
type messageEncoder func(msg *model.Message) (string, error)
|
||||
|
||||
type queryFilter struct {
|
||||
ID string
|
||||
@@ -222,8 +105,8 @@ func parseQueryFilters(r *http.Request) (*queryFilter, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (q *queryFilter) Pass(msg *message) bool {
|
||||
if msg.Event != messageEvent {
|
||||
func (q *queryFilter) Pass(msg *model.Message) bool {
|
||||
if msg.Event != messageEvent && msg.Event != messageDeleteEvent && msg.Event != messageClearEvent {
|
||||
return true // filters only apply to messages
|
||||
} else if q.ID != "" && msg.ID != q.ID {
|
||||
return false
|
||||
@@ -276,7 +159,7 @@ func (t templateMode) FileName() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// templateFile represents a template file with title and message
|
||||
// templateFile represents a template file with title, message, and priority
|
||||
// It is used for file-based templates, e.g. grafana, influxdb, etc.
|
||||
//
|
||||
// Example YAML:
|
||||
@@ -285,15 +168,23 @@ func (t templateMode) FileName() string {
|
||||
// message: |
|
||||
// This is a {{ .Type }} alert.
|
||||
// It can be multiline.
|
||||
// priority: '{{ if eq .status "Error" }}5{{ else }}3{{ end }}'
|
||||
type templateFile struct {
|
||||
Title *string `yaml:"title"`
|
||||
Message *string `yaml:"message"`
|
||||
Title *string `yaml:"title"`
|
||||
Message *string `yaml:"message"`
|
||||
Priority *string `yaml:"priority"`
|
||||
}
|
||||
|
||||
type apiHealthResponse struct {
|
||||
Healthy bool `json:"healthy"`
|
||||
}
|
||||
|
||||
type apiVersionResponse struct {
|
||||
Version string `json:"version"`
|
||||
Commit string `json:"commit"`
|
||||
Date string `json:"date"`
|
||||
}
|
||||
|
||||
type apiStatsResponse struct {
|
||||
Messages int64 `json:"messages"`
|
||||
MessagesRate float64 `json:"messages_rate"` // Average number of messages per second
|
||||
@@ -449,6 +340,7 @@ 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"`
|
||||
@@ -458,6 +350,7 @@ type apiConfigResponse struct {
|
||||
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 {
|
||||
@@ -536,12 +429,12 @@ const (
|
||||
)
|
||||
|
||||
type webPushPayload struct {
|
||||
Event string `json:"event"`
|
||||
SubscriptionID string `json:"subscription_id"`
|
||||
Message *message `json:"message"`
|
||||
Event string `json:"event"`
|
||||
SubscriptionID string `json:"subscription_id"`
|
||||
Message *model.Message `json:"message"`
|
||||
}
|
||||
|
||||
func newWebPushPayload(subscriptionID string, message *message) *webPushPayload {
|
||||
func newWebPushPayload(subscriptionID string, message *model.Message) *webPushPayload {
|
||||
return &webPushPayload{
|
||||
Event: webPushMessageEvent,
|
||||
SubscriptionID: subscriptionID,
|
||||
@@ -559,22 +452,6 @@ func newWebPushSubscriptionExpiringPayload() *webPushControlMessagePayload {
|
||||
}
|
||||
}
|
||||
|
||||
type webPushSubscription struct {
|
||||
ID string
|
||||
Endpoint string
|
||||
Auth string
|
||||
P256dh string
|
||||
UserID string
|
||||
}
|
||||
|
||||
func (w *webPushSubscription) Context() log.Context {
|
||||
return map[string]any{
|
||||
"web_push_subscription_id": w.ID,
|
||||
"web_push_subscription_user_id": w.UserID,
|
||||
"web_push_subscription_endpoint": w.Endpoint,
|
||||
}
|
||||
}
|
||||
|
||||
// https://developer.mozilla.org/en-US/docs/Web/Manifest
|
||||
type webManifestResponse struct {
|
||||
Name string `json:"name"`
|
||||
|
||||